@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +836 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
@@ -0,0 +1,332 @@
1
+ // Daemon supervision from the extension side.
2
+ //
3
+ // The extension must never assume a daemon is running: a session can start on a
4
+ // freshly booted machine. `ensureDaemon` probes /health and, if nothing answers,
5
+ // spawns the daemon detached so it outlives this pi session.
6
+ //
7
+ // Detached, with output appended to daemon.log (not stdio:ignore: a silent daemon made the
8
+ // catch-up failures invisible). The daemon owns the mailbox and must outlive the
9
+ // process that started it so another session (or the phone) can still be served. It is not
10
+ // immortal: it exits once every session has stopped pinging.
11
+
12
+ import { spawn as nodeSpawn, type SpawnOptions } from "node:child_process"
13
+ import { chmodSync, closeSync, mkdirSync, openSync, renameSync, statSync } from "node:fs"
14
+ import { dirname } from "node:path"
15
+
16
+ import type { PendingRecord } from "../store/pending-store.ts"
17
+ import { API_VERSION, apiCompatible } from "./api.ts"
18
+ import { statePath } from "../store/paths.ts"
19
+
20
+ export interface ProbeOptions {
21
+ baseUrl: string
22
+ timeoutMs?: number
23
+ /** Injected for tests. Defaults to global fetch. */
24
+ fetchImpl?: typeof fetch
25
+ }
26
+
27
+ /** True when a daemon answers /health. Never throws. */
28
+ export async function probeHealth(options: ProbeOptions): Promise<boolean> {
29
+ const health = await probeHealthDetail(options)
30
+ return health.ok
31
+ }
32
+
33
+ /**
34
+ * `/health`, with the protocol it speaks.
35
+ *
36
+ * `ok` means "something answered"; `apiVersion` says whether *this* build can use it. A daemon
37
+ * from another protocol is alive but unusable, and reusing it would make a replacement look
38
+ * successful while the mismatch stayed.
39
+ */
40
+ export async function probeHealthDetail(options: ProbeOptions): Promise<{ ok: boolean; apiVersion?: number }> {
41
+ const doFetch = options.fetchImpl ?? fetch
42
+ const controller = new AbortController()
43
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 750)
44
+ try {
45
+ const response = await doFetch(`${options.baseUrl}/health`, { signal: controller.signal })
46
+ if (!response.ok) return { ok: false }
47
+ const body = (await response.json().catch(() => undefined)) as { apiVersion?: unknown } | undefined
48
+ return {
49
+ ok: true,
50
+ ...(typeof body?.apiVersion === "number" ? { apiVersion: body.apiVersion } : {}),
51
+ }
52
+ } catch {
53
+ return { ok: false }
54
+ } finally {
55
+ clearTimeout(timer)
56
+ }
57
+ }
58
+
59
+ export interface EnsureDaemonOptions {
60
+ baseUrl: string
61
+ /** Absolute path to the daemon entry (`.ts` or `.js`). */
62
+ scriptPath: string
63
+ /** Extra argv passed after the script path (e.g. none). */
64
+ args?: string[]
65
+ env?: NodeJS.ProcessEnv
66
+ /** How long to wait for the spawned daemon to answer /health. */
67
+ waitMs?: number
68
+ /** Injected for tests. */
69
+ fetchImpl?: typeof fetch
70
+ spawnImpl?: (
71
+ command: string,
72
+ args: string[],
73
+ options: SpawnOptions,
74
+ ) => {
75
+ unref?: () => void
76
+ pid?: number
77
+ /** Needed to catch a fork failure; without a listener Node throws in *this* process. */
78
+ on?: (event: "error", listener: (error: Error) => void) => unknown
79
+ }
80
+ execPath?: string
81
+ sleep?: (ms: number) => Promise<void>
82
+ }
83
+
84
+ export interface EnsureDaemonResult {
85
+ /** True when this call spawned the process. */
86
+ started: boolean
87
+ /** True when the daemon answers /health afterwards, on this build's protocol. */
88
+ healthy: boolean
89
+ /** Something answered but spoke another protocol; it must be replaced, not reused. */
90
+ incompatible?: boolean
91
+ /** Why the spawn failed, when it did (EAGAIN, ENOENT, a sandbox refusal). */
92
+ error?: string
93
+ /** The protocol the answering daemon spoke, when one answered and was not reused. */
94
+ apiVersion?: number
95
+ }
96
+
97
+ const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
98
+
99
+ /**
100
+ * Make sure a daemon is running.
101
+ *
102
+ * Reuses an existing daemon rather than racing it: `daemon.lock` in the state directory is the
103
+ * lock (the port is a second guard), and a
104
+ * second daemon exits 0 on EADDRINUSE, so the worst case is a wasted spawn rather
105
+ * than two watchers on one mailbox.
106
+ */
107
+ export async function ensureDaemon(options: EnsureDaemonOptions): Promise<EnsureDaemonResult> {
108
+ const doFetch = options.fetchImpl ?? fetch
109
+ const found = await probeHealthDetail({ baseUrl: options.baseUrl, fetchImpl: doFetch })
110
+ if (found.ok) {
111
+ // Reuse only a daemon this build can talk to. An incompatible one is reported so the
112
+ // caller can replace it, instead of being adopted and failing on the first request.
113
+ if (apiCompatible(API_VERSION, found.apiVersion)) return { started: false, healthy: true }
114
+ return { started: false, healthy: false, incompatible: true, apiVersion: found.apiVersion }
115
+ }
116
+
117
+ const spawn = options.spawnImpl ?? nodeSpawn
118
+ const execPath = options.execPath ?? process.execPath
119
+ // The port this process probed, so the child cannot pick a different one from its own env -
120
+ // otherwise two daemons can end up on one state directory, which has no cross-process locks.
121
+ const probedPort = (() => {
122
+ try {
123
+ return new URL(options.baseUrl).port
124
+ } catch {
125
+ return ""
126
+ }
127
+ })()
128
+ // `--experimental-strip-types` only for the TypeScript entry: the published build runs
129
+ // `dist/daemon/main.js`, and Node refuses type stripping under `node_modules` anyway.
130
+ const args = [
131
+ ...(options.scriptPath.endsWith(".ts") ? ["--experimental-strip-types"] : []),
132
+ options.scriptPath,
133
+ ...(probedPort ? ["--port", probedPort] : []),
134
+ ...(options.args ?? []),
135
+ ]
136
+
137
+ // Detached with output ignored would make failures invisible: a catch-up scan could
138
+ // fail on every boot with nothing to show for it. Append to a log file instead.
139
+ const env = options.env ?? process.env
140
+ const logPath = statePath(env, "daemon.log")
141
+ let logHandle: number | undefined
142
+ try {
143
+ mkdirSync(dirname(logPath), { recursive: true, mode: 0o700 })
144
+ // One rotation at 5 MB: the log grows only when mail is seen, but it grows forever.
145
+ try {
146
+ if (statSync(logPath).size > 5 * 1024 * 1024) renameSync(logPath, `${logPath}.1`)
147
+ } catch {
148
+ /* no log yet, or cannot rotate - appending is still better than nothing */
149
+ }
150
+ logHandle = openSync(logPath, "a", 0o600)
151
+ // An older build created it 0644; the log carries addresses and session names.
152
+ try {
153
+ chmodSync(logPath, 0o600)
154
+ } catch {
155
+ /* best effort */
156
+ }
157
+ } catch {
158
+ // A read-only state dir must not stop the daemon from starting.
159
+ }
160
+
161
+ const child = spawn(execPath, args, {
162
+ detached: true,
163
+ stdio: logHandle === undefined ? "ignore" : ["ignore", logHandle, logHandle],
164
+ env,
165
+ } as SpawnOptions)
166
+ // A fork failure (EAGAIN under fd pressure, a sandbox denying exec) arrives as an `error`
167
+ // event. Without a listener Node treats it as an uncaught exception in *pi's* process: the
168
+ // session dies while the daemon it was trying to start never existed.
169
+ let spawnError: Error | undefined
170
+ child.on?.("error", (error) => {
171
+ spawnError = error
172
+ })
173
+ child.unref?.()
174
+ if (logHandle !== undefined) {
175
+ // The child holds its own copy of the descriptor.
176
+ try {
177
+ closeSync(logHandle)
178
+ } catch {
179
+ /* already closed */
180
+ }
181
+ }
182
+
183
+ const sleep = options.sleep ?? defaultSleep
184
+ const deadline = Date.now() + (options.waitMs ?? 5_000)
185
+ while (Date.now() < deadline) {
186
+ if (spawnError) return { started: true, healthy: false, error: spawnError.message }
187
+ await sleep(100)
188
+ if (await probeHealth({ baseUrl: options.baseUrl, fetchImpl: doFetch })) {
189
+ return { started: true, healthy: true }
190
+ }
191
+ }
192
+ return { started: true, healthy: false }
193
+ }
194
+
195
+ /** Default loopback base URL for the configured port. */
196
+ export function daemonBaseUrl(port: number, host = "127.0.0.1"): string {
197
+ return `http://${host}:${port}`
198
+ }
199
+
200
+ /** SMTP transactions with inlined images are slow; be generous rather than lose the mail. */
201
+ const SEND_TIMEOUT_MS = 120_000
202
+ /** Reload tears down and re-dials IMAP, which can involve a token fetch. */
203
+ const RELOAD_TIMEOUT_MS = 120_000
204
+
205
+ export interface DaemonClientOptions {
206
+ baseUrl: string
207
+ token: string
208
+ /** This machine's device name; /pending returns only this device's records. */
209
+ device?: string
210
+ fetchImpl?: typeof fetch
211
+ /**
212
+ * Per-request ceiling. Without one, a daemon that accepts a connection and never
213
+ * answers leaves the caller's promise unsettled forever - and the drain it is inside
214
+ * then wedges every later drain in the process.
215
+ */
216
+ timeoutMs?: number
217
+ }
218
+
219
+ /** Tiny typed client used by the extension and by `/mailbridge`. */
220
+ export type DaemonResult<T> = { ok: true; data: T } | { ok: false; status: number; code?: string }
221
+
222
+ export function createDaemonClient(options: DaemonClientOptions) {
223
+ const doFetch = options.fetchImpl ?? fetch
224
+ const deviceName = options.device ?? ""
225
+ const headers = { authorization: `Bearer ${options.token}`, "content-type": "application/json" }
226
+
227
+ async function call<T>(
228
+ method: string,
229
+ path: string,
230
+ body?: unknown,
231
+ timeoutMs: number = options.timeoutMs ?? 15_000,
232
+ ): Promise<DaemonResult<T>> {
233
+ const controller = new AbortController()
234
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
235
+ try {
236
+ const response = await doFetch(`${options.baseUrl}${path}`, {
237
+ method,
238
+ headers,
239
+ signal: controller.signal,
240
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
241
+ })
242
+ const text = await response.text()
243
+ if (!response.ok) {
244
+ let code: string | undefined
245
+ try {
246
+ code = (JSON.parse(text) as { error?: { code?: string } }).error?.code
247
+ } catch {
248
+ /* non-JSON error body */
249
+ }
250
+ return { ok: false, status: response.status, ...(code ? { code } : {}) }
251
+ }
252
+ return { ok: true, data: (text ? JSON.parse(text) : {}) as T }
253
+ } catch (error) {
254
+ // A timeout is reported distinctly: the outcome is unknown, so callers must
255
+ // treat it like a network failure (retry later) rather than a definite "no".
256
+ if (error instanceof Error && error.name === "AbortError") return { ok: false, status: 0, code: "timeout" }
257
+ return { ok: false, status: 0, code: "network" }
258
+ } finally {
259
+ clearTimeout(timer)
260
+ }
261
+ }
262
+
263
+ return {
264
+ health: () =>
265
+ call<{
266
+ ok: boolean
267
+ mode: string
268
+ pending: number
269
+ lastScan?: {
270
+ at: number
271
+ examined: number
272
+ persisted: number
273
+ rejected: number
274
+ reasons: Record<string, number>
275
+ } | null
276
+ configLoadedAt?: number | null
277
+ /** The daemon's package version. Diagnostic only. */
278
+ version?: string
279
+ /** The wire contract it implements; absent on a build from before this field. */
280
+ apiVersion?: number
281
+ /** Additive features inside that contract. */
282
+ capabilities?: string[]
283
+ /** "connected" | "disconnected" | "unknown" */
284
+ imap?: string
285
+ }>("GET", "/health"),
286
+ pending: () =>
287
+ call<{ pending: PendingRecord[]; withheld?: number }>(
288
+ "GET",
289
+ `/pending?device=${encodeURIComponent(deviceName)}`,
290
+ ),
291
+ mode: () => call<{ mode: string }>("GET", "/mode"),
292
+ setMode: (mode: "on" | "off") => call<{ mode: string }>("POST", "/mode", { mode }),
293
+ claim: (uid: number, instanceId: string, uidValidity?: number) =>
294
+ call<{ ok: boolean }>("POST", "/claim", {
295
+ uid,
296
+ instanceId,
297
+ ...(uidValidity === undefined ? {} : { uidValidity }),
298
+ }),
299
+ ack: (uid: number, instanceId: string, sessionId?: string, uidValidity?: number) =>
300
+ call<{ ok: boolean }>("POST", "/ack", {
301
+ uid,
302
+ instanceId,
303
+ sessionId,
304
+ ...(uidValidity === undefined ? {} : { uidValidity }),
305
+ }),
306
+ register: (sessionId: string) => call<{ alreadyPending: boolean; full: boolean }>("POST", "/register", { sessionId }),
307
+ release: (sessionId: string) => call<{ ok: boolean }>("DELETE", "/register", { sessionId }),
308
+ // A notification can carry a few hundred KB of inlined images and the daemon awaits
309
+ // the whole SMTP transaction, so a slow-but-healthy relay must not be cut off: the
310
+ // generic 15 s ceiling would drop the one mail this product exists to deliver.
311
+ send: (message: {
312
+ /** "settle" and "decision" are exempt from the daemon's notification budget. */
313
+ kind?: "settle" | "decision" | "notification"
314
+ to?: string
315
+ subject: string
316
+ text: string
317
+ cwd?: string
318
+ sessionId?: string
319
+ }) => call<{ ok: boolean }>("POST", "/send", message, SEND_TIMEOUT_MS),
320
+ /** Re-read config and restart the watcher (after setup or auth). */
321
+ // Includes an IMAP logout, an XOAUTH2 token fetch and a fresh dial.
322
+ reload: () => call<{ ok: boolean; warning?: string }>("POST", "/reload", undefined, RELOAD_TIMEOUT_MS),
323
+ /** Ask the daemon to exit, so a fresh process can load updated code. */
324
+ shutdown: () => call<{ ok: boolean }>("POST", "/shutdown"),
325
+ /** Liveness heartbeat; the daemon exits when every client stops pinging. */
326
+ ping: (instanceId: string) => call<{ ok: boolean; clients: number }>("POST", "/ping", { instanceId }),
327
+ /** Tell the daemon this session is gone. */
328
+ bye: (instanceId: string) => call<{ ok: boolean; clients: number }>("POST", "/bye", { instanceId }),
329
+ }
330
+ }
331
+
332
+ export type DaemonClient = ReturnType<typeof createDaemonClient>