@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.0

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 +824 -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,550 @@
1
+ // Daemon HTTP service.
2
+ //
3
+ // Transport-neutral: construction takes its collaborators, so tests boot the real
4
+ // server against fake mail/source and assert the whole contract over HTTP.
5
+ //
6
+ // Auth: every route except /health requires `Authorization: Bearer <token>`,
7
+ // compared in constant time. Loopback is not authorization.
8
+ //
9
+ // Bounds: request body, SSE clients, registry entries. A local process should not
10
+ // be able to exhaust the daemon.
11
+
12
+ import http from "node:http"
13
+ import { timingSafeEqual } from "node:crypto"
14
+
15
+ import type { ResolvedConfig } from "../core/config.ts"
16
+ import type { ScanSummary } from "../core/status.ts"
17
+
18
+ type ScanSummaryLike = ScanSummary
19
+ import { CAPABILITIES } from "./api.ts"
20
+ import { createSendBudget } from "./send-budget.ts"
21
+ import type { MailSource } from "./process.ts"
22
+ import { ackPending, claimPending, listPending } from "../store/pending-store.ts"
23
+ import { journal } from "../store/journal.ts"
24
+ import { recordOutbound, removeOutbound, sessionThread } from "../store/threads.ts"
25
+ import { generateMessageId } from "../core/mailer.ts"
26
+ import { readMode, writeMode, isMode, type Mode } from "../store/mode-store.ts"
27
+ import { createRegistry, type Registry } from "../store/registry.ts"
28
+
29
+ export const MAX_BODY_BYTES = 1_000_000
30
+ /** A body that is not JSON: the caller's fault, and the only 400 the handler emits. */
31
+ class BadBodyError extends Error {}
32
+
33
+ async function readJson(req: import("node:http").IncomingMessage): Promise<unknown> {
34
+ const text = await readBody(req)
35
+ try {
36
+ return JSON.parse(text)
37
+ } catch {
38
+ throw new BadBodyError("body is not valid JSON")
39
+ }
40
+ }
41
+
42
+ export const MAX_SSE_CLIENTS = 100
43
+ /** Heartbeat entries kept; sessions that vanished are pruned on /health anyway. */
44
+ export const MAX_PINGS = 256
45
+
46
+ export type DaemonEvent =
47
+ | { type: "delivery"; pending: ReturnType<typeof listPending>[number] }
48
+ | { type: "mode"; mode: Mode }
49
+
50
+ export interface DaemonDeps {
51
+ config: ResolvedConfig
52
+ token: string
53
+ source: MailSource
54
+ /** Sends mail. Injected so tests never open a socket. */
55
+ send: (message: {
56
+ to: string
57
+ subject: string
58
+ text: string
59
+ cwd?: string
60
+ sessionId?: string
61
+ /** RFC 5322 threading, derived here from the outbound id registry. */
62
+ inReplyTo?: string
63
+ references?: string[]
64
+ /** Minted and recorded before this is called; the transport must stamp it. */
65
+ messageId: string
66
+ }) => Promise<{ messageId?: string } | void>
67
+ /**
68
+ * Called after a successful send with the assigned Message-ID, so the caller can
69
+ * record it for reply-chain routing. Kept out of this module so the service does
70
+ * not need the routing secret.
71
+ */
72
+ onSent?: (info: { messageId?: string; subject: string; sessionId?: string }) => void
73
+ /**
74
+ * Re-read config and restart the watcher. Called after `/mailbridge setup` or
75
+ * `auth` so a fresh config or token takes effect without killing the daemon.
76
+ */
77
+ reload?: () => Promise<{ ok: boolean; error?: string; warning?: string }>
78
+ /**
79
+ * Ask the daemon to exit so a supervisor can start a fresh process.
80
+ * `/reload` re-reads config but cannot replace running code, so an updated
81
+ * package would otherwise keep executing the previous version until reboot.
82
+ */
83
+ shutdown?: (reason?: string) => void
84
+ /**
85
+ * Called after a mode change. Turning mode on must trigger a scan: the only other
86
+ * triggers are an IMAP `exists` (a NEW arrival) and reconnect, so without this a reply
87
+ * that arrived while the channel was closed would wait for unrelated mail.
88
+ */
89
+ onModeChange?: (mode: Mode) => void
90
+ /**
91
+ * Live config accessor. /reload swaps the config, so anything that reads it per
92
+ * request (recipient, Message-ID domain, device name, listen address) must ask here
93
+ * rather than capture the object this service was built with.
94
+ */
95
+ getConfig?: () => ResolvedConfig
96
+ /** Outcome of the most recent scan, for diagnostics. */
97
+ scanSummary?: () => ScanSummaryLike | undefined
98
+ /** mtime of the config file the daemon is running on (0 when there was no file). */
99
+ configLoadedAt?: () => number
100
+ /** Is the IMAP watcher connected? A revoked credential shows here, not in the process. */
101
+ imapHealthy?: () => boolean
102
+ /** This build's version. Diagnostic only - compatibility is `apiVersion`. */
103
+ version?: string
104
+ /** The wire contract this daemon implements. */
105
+ apiVersion?: number
106
+ /** How long after the last ping to exit. Default 120s. */
107
+ idleTimeoutMs?: number
108
+ env?: NodeJS.ProcessEnv
109
+ now?: () => number
110
+ /** Diagnostics: the daemon's log. */
111
+ log?: (message: string) => void
112
+ registry?: Registry
113
+ }
114
+
115
+ export interface DaemonService {
116
+ server: http.Server
117
+ listen(port?: number, host?: string): Promise<{ port: number; host: string }>
118
+ close(): Promise<void>
119
+ /** Push an event to every connected SSE client. */
120
+ broadcast(event: DaemonEvent): void
121
+ readonly sseClients: number
122
+ }
123
+
124
+ function constantTimeEqual(a: string, b: string): boolean {
125
+ const ab = Buffer.from(a)
126
+ const bb = Buffer.from(b)
127
+ return ab.length === bb.length && timingSafeEqual(ab, bb)
128
+ }
129
+
130
+ function readBody(req: http.IncomingMessage): Promise<string> {
131
+ return new Promise((resolve, reject) => {
132
+ let size = 0
133
+ const chunks: Buffer[] = []
134
+ req.on("data", (chunk: Buffer) => {
135
+ size += chunk.length
136
+ if (size > MAX_BODY_BYTES) {
137
+ reject(new Error("request body too large"))
138
+ req.destroy()
139
+ return
140
+ }
141
+ chunks.push(chunk)
142
+ })
143
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
144
+ req.on("error", reject)
145
+ })
146
+ }
147
+
148
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
149
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
150
+ }
151
+
152
+ export function createDaemonService(deps: DaemonDeps): DaemonService {
153
+ const env = deps.env ?? process.env
154
+ const now = deps.now ?? (() => Date.now())
155
+ const registry = deps.registry ?? createRegistry()
156
+
157
+ /** live SSE response objects */
158
+ const streams = new Set<http.ServerResponse>()
159
+
160
+ // Sliding one-minute budget for outbound mail. Ten notifications a minute is far above any
161
+ // human-answered flow; the point is that a loop cannot spend the account's quota.
162
+ const sendBudget = createSendBudget({ limit: 10, windowMs: 60_000 })
163
+
164
+ // Liveness of pi sessions. A session pings on its poll tick; when every session has gone quiet
165
+ // the daemon stops - always, and by design: an updated package cannot leave an orphan running
166
+ // old code, and nothing reads a mailbox no session will consume. A daemon nobody ever pings
167
+ // (started by hand, and pi never attached) stops on the same clock.
168
+ const pings = new Map<string, number>()
169
+ // Idleness is measured from the daemon's own start when nobody has pinged yet, so a fresh
170
+ // daemon gets the full grace period rather than looking infinitely stale.
171
+ const startedAt = Date.now()
172
+ const idleTimeout = deps.idleTimeoutMs ?? 120_000
173
+
174
+ const idleCheck = setInterval(() => {
175
+ const newest = Math.max(startedAt, ...pings.values())
176
+ if (Date.now() - newest <= idleTimeout) return
177
+ deps.shutdown?.(`idle: no ping for ${Math.round(idleTimeout / 1000)}s`)
178
+ }, Math.min(15_000, Math.max(1_000, Math.floor(idleTimeout / 4))))
179
+ // Never hold the process open just for this timer.
180
+ if (typeof idleCheck.unref === "function") idleCheck.unref()
181
+
182
+ function broadcast(event: DaemonEvent): void {
183
+ const payload = `data: ${JSON.stringify(event)}\n\n`
184
+ for (const res of streams) {
185
+ try {
186
+ res.write(payload)
187
+ } catch {
188
+ streams.delete(res)
189
+ }
190
+ }
191
+ }
192
+
193
+ // The handler is a separate function so a throw anywhere in it - including the URL parse,
194
+ // which runs before authentication - becomes a 500 rather than an unhandled rejection that
195
+ // kills the daemon. Node accepts absolute-form request targets, so `new URL` can throw on a
196
+ // single unauthenticated request.
197
+ const dispatch = async (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse): Promise<void> => {
198
+ const url = new URL(req.url ?? "/", "http://127.0.0.1")
199
+ const send = (status: number, body: unknown) => {
200
+ const payload = JSON.stringify(body)
201
+ res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) })
202
+ res.end(payload)
203
+ }
204
+ const fail = (status: number, code: string, extra?: Record<string, unknown>) =>
205
+ send(status, { error: { code, ...extra } })
206
+
207
+ // Liveness is unauthenticated so a supervisor can probe without a secret.
208
+ if (url.pathname === "/health") {
209
+ // Prune here as well as on /bye: a crashed session never sends one, and an
210
+ // unbounded map plus a spread into Math.max is its own failure mode.
211
+ const nowMs = now()
212
+ const stale = nowMs - (deps.idleTimeoutMs ?? 120_000) * 4
213
+ for (const [id, at] of pings) if (at < stale) pings.delete(id)
214
+ // Track the maximum by iteration: spreading an unbounded map into Math.max throws
215
+ // RangeError (and kills the daemon) long before the map is "large".
216
+ let newestPing = 0
217
+ for (const at of pings.values()) if (at > newestPing) newestPing = at
218
+ return send(200, {
219
+ ok: true,
220
+ mode: readMode(env),
221
+ pending: listPending(env).length,
222
+ // `sessions` is the decision registry (sessions that were told about a
223
+ // notification), not connected clients — a raw payload showed `sessions: 0`
224
+ // next to `clients: 2`. Both keys, so neither reading is wrong.
225
+ sessions: registry.list().length,
226
+ registry: registry.list().length,
227
+ sseClients: streams.size,
228
+ // Liveness: how many sessions are pinging, and how long ago the last one did.
229
+ clients: pings.size,
230
+ lastPingAgoMs: newestPing ? Math.max(0, now() - newestPing) : null,
231
+ lastScan: deps.scanSummary?.() ?? null,
232
+ // Diagnostic (which build), and the contract (which protocol). The session
233
+ // replaces a daemon whose apiVersion differs; the version is for the panel.
234
+ version: deps.version,
235
+ apiVersion: deps.apiVersion,
236
+ capabilities: CAPABILITIES,
237
+ configLoadedAt: deps.configLoadedAt?.() ?? null,
238
+ imap: deps.imapHealthy ? (deps.imapHealthy() ? "connected" : "disconnected") : "unknown",
239
+ })
240
+ }
241
+
242
+ const header = req.headers.authorization ?? ""
243
+ const presented = header.startsWith("Bearer ") ? header.slice(7) : ""
244
+ if (!constantTimeEqual(presented, deps.token)) return fail(401, "unauthorized")
245
+
246
+ const route = `${req.method} ${url.pathname}`
247
+ try {
248
+ switch (route) {
249
+ case "GET /events": {
250
+ if (streams.size >= MAX_SSE_CLIENTS) return fail(503, "too_many_clients")
251
+ res.writeHead(200, {
252
+ "content-type": "text/event-stream",
253
+ "cache-control": "no-cache",
254
+ connection: "keep-alive",
255
+ })
256
+ res.write(": connected\n\n")
257
+ // Tell a fresh client the current mode immediately.
258
+ res.write(`data: ${JSON.stringify({ type: "mode", mode: readMode(env) })}\n\n`)
259
+ streams.add(res)
260
+ req.on("close", () => streams.delete(res))
261
+ return undefined
262
+ }
263
+
264
+ case "GET /pending": {
265
+ // Filtered: a whole-file payload of another machine's mail is both wrong to
266
+ // deliver and wastefully large. The daemon's own live config is the authority -
267
+ // the same one /claim uses - so a client holding a stale device name (renamed,
268
+ // or rewritten by `setup`) cannot ask for records it will then be refused.
269
+ const mine = deps.getConfig?.()?.device.name
270
+ const all = listPending(env)
271
+ const offered = mine ? all.filter((r) => r.device === undefined || r.device === mine) : all
272
+ return send(200, {
273
+ pending: offered,
274
+ // A count, never the content: these are another device's replies, and after a
275
+ // rename they are the user's own mail sitting here invisible.
276
+ withheld: all.length - offered.length,
277
+ })
278
+ }
279
+
280
+ case "GET /mode":
281
+ return send(200, { mode: readMode(env) })
282
+
283
+ case "POST /claim": {
284
+ const body = asRecord(await readJson(req))
285
+ const uid = Number(body?.uid)
286
+ const instanceId = typeof body?.instanceId === "string" ? body.instanceId : ""
287
+ if (!Number.isInteger(uid) || !instanceId) return fail(400, "bad_request")
288
+ const uidValidity =
289
+ typeof body?.uidValidity === "number" && Number.isFinite(body.uidValidity) ? body.uidValidity : undefined
290
+ const result = claimPending(
291
+ env,
292
+ uid,
293
+ instanceId,
294
+ now(),
295
+ uidValidity,
296
+ deps.source.uidValidity?.(),
297
+ deps.getConfig?.()?.device.name,
298
+ )
299
+ if (!result.ok) return fail(result.reason === "not-found" ? 404 : 409, result.reason)
300
+ return send(200, { ok: true })
301
+ }
302
+
303
+ case "POST /ack": {
304
+ const body = asRecord(await readJson(req))
305
+ const uid = Number(body?.uid)
306
+ const instanceId = typeof body?.instanceId === "string" ? body.instanceId : ""
307
+ if (!Number.isInteger(uid) || !instanceId) return fail(400, "bad_request")
308
+
309
+ // Remove the record, THEN journal, then touch the mail. The order looks
310
+ // backwards but is the safe one: the journal is read at scan time, so
311
+ // journalling while the record still exists would leave a window where a
312
+ // polling session claims and injects the same reply twice. Losing the
313
+ // journal write after a successful ack only re-opens dedupe for a UID that
314
+ // is no longer in `pending`, which the scanner never reads again.
315
+ const uidValidity =
316
+ typeof body?.uidValidity === "number" && Number.isFinite(body.uidValidity) ? body.uidValidity : undefined
317
+ const acked = ackPending(env, uid, instanceId, uidValidity, deps.source.uidValidity?.())
318
+ if (!acked.ok) return fail(acked.reason === "not-found" ? 404 : 409, acked.reason)
319
+ // The record's space, never the watcher's: after a renumbering the two differ,
320
+ // and journaling under the live space would suppress a brand-new message at
321
+ // that UID. A record predating UIDVALIDITY carries none, and journal() then
322
+ // keeps the space the file already records.
323
+ journal(env, uid, acked.uidValidity)
324
+ // The record's session, never the request body's: a client that acks
325
+ // another session's mail must not also free its decision slot.
326
+ registry.release(acked.sessionId)
327
+
328
+ // Mailbox hygiene only. A failure here must not resurrect the delivery.
329
+ try {
330
+ await deps.source.markSeen?.(uid)
331
+ } catch {
332
+ /* logged by the caller in production; delivery is already acked */
333
+ }
334
+ return send(200, { ok: true })
335
+ }
336
+
337
+ case "POST /register": {
338
+ const body = asRecord(await readJson(req))
339
+ const sessionId = typeof body?.sessionId === "string" ? body.sessionId : ""
340
+ if (!sessionId) return fail(400, "bad_request")
341
+ const result = registry.register(sessionId)
342
+ if (result.full) return fail(503, "registry_full")
343
+ return send(200, result)
344
+ }
345
+
346
+ case "DELETE /register": {
347
+ const body = asRecord(await readJson(req))
348
+ const sessionId = typeof body?.sessionId === "string" ? body.sessionId : ""
349
+ if (!sessionId) return fail(400, "bad_request")
350
+ registry.release(sessionId)
351
+ return send(200, { ok: true })
352
+ }
353
+
354
+ case "POST /ping": {
355
+ const body = asRecord(await readJson(req))
356
+ const instanceId = typeof body?.instanceId === "string" ? body.instanceId : ""
357
+ if (!instanceId) return fail(400, "bad_request")
358
+ pings.set(instanceId, now())
359
+ // Bounded: a client that keeps changing its id must not grow this map without
360
+ // limit. Oldest entries are dropped first.
361
+ while (pings.size > MAX_PINGS) {
362
+ const oldest = pings.keys().next().value
363
+ if (oldest === undefined) break
364
+ pings.delete(oldest)
365
+ }
366
+ return send(200, { ok: true, clients: pings.size })
367
+ }
368
+
369
+ case "POST /bye": {
370
+ const body = asRecord(await readJson(req))
371
+ const instanceId = typeof body?.instanceId === "string" ? body.instanceId : ""
372
+ if (!instanceId) return fail(400, "bad_request")
373
+ pings.delete(instanceId)
374
+ return send(200, { ok: true, clients: pings.size })
375
+ }
376
+
377
+ case "POST /shutdown": {
378
+ if (!deps.shutdown) return fail(501, "not_supported")
379
+ // Answer first, then exit: a client that got no response would report
380
+ // a failure for a restart that actually worked.
381
+ send(200, { ok: true })
382
+ setTimeout(() => deps.shutdown?.(), 50)
383
+ return undefined
384
+ }
385
+
386
+ case "POST /reload": {
387
+ if (!deps.reload) return fail(501, "not_supported")
388
+ try {
389
+ const result = await deps.reload()
390
+ if (!result.ok) return fail(500, "reload_failed", { message: result.error ?? "unknown" })
391
+ return send(200, { ok: true, ...(result.warning ? { warning: result.warning } : {}) })
392
+ } catch (error) {
393
+ return fail(500, "reload_failed", { message: (error as Error).message })
394
+ }
395
+ }
396
+
397
+ case "POST /mode": {
398
+ const body = asRecord(await readJson(req))
399
+ if (!isMode(body?.mode)) return fail(400, "bad_request")
400
+ const mode = writeMode(env, body.mode, now())
401
+ broadcast({ type: "mode", mode })
402
+ deps.onModeChange?.(mode)
403
+ return send(200, { mode })
404
+ }
405
+
406
+ case "POST /send": {
407
+ const body = asRecord(await readJson(req))
408
+ const subject = typeof body?.subject === "string" ? body.subject : ""
409
+ const text = typeof body?.text === "string" ? body.text : ""
410
+ const config = deps.getConfig?.() ?? deps.config
411
+ const to = typeof body?.to === "string" && body.to ? body.to : config.mail.to
412
+ // cwd lets the mailer resolve relative image paths from the agent's project.
413
+ const cwd = typeof body?.cwd === "string" ? body.cwd : undefined
414
+ // sessionId lets the sender thread this mail into the session's conversation.
415
+ const sessionId = typeof body?.sessionId === "string" ? body.sessionId : undefined
416
+ if (!subject || !text) return fail(400, "bad_request")
417
+ // A budget, not a rate limiter: an injected agent can ask for unbounded mail.
418
+ // A settle notification is exempt - it is the one mail that says the agent is
419
+ // waiting, and a legitimate `notify_user` burst must not silence it.
420
+ const kind =
421
+ body?.kind === "settle" ? "settle" : body?.kind === "decision" ? "decision" : "notification"
422
+ // Only unbounded, model-driven notifications are budgeted: a settle says the
423
+ // agent is waiting, and a decision is already capped at one outstanding per
424
+ // session by the registry.
425
+ if (kind === "notification" && !sendBudget.take(Date.now())) {
426
+ return fail(429, "send_budget")
427
+ }
428
+
429
+ // Thread every notification for a session into one conversation: reply to
430
+ // the previous message, reference the session root. Read BEFORE sending,
431
+ // because recording happens after.
432
+ // Mint and record BEFORE calling deps.send. The scan can see the mail come
433
+ // back into INBOX as soon as SMTP accepts it, so an id recorded after the
434
+ // send leaves the self-echo guard empty for exactly that window.
435
+ // Read the thread BEFORE recording: recording moves lastMessageId to the new
436
+ // id, and a message must answer its predecessor, not itself.
437
+ const thread = sessionId ? sessionThread(env, sessionId) : {}
438
+ const messageId = generateMessageId(config.mail.smtp.user ?? "")
439
+ if (sessionId) {
440
+ recordOutbound(env, {
441
+ messageId,
442
+ sessionId,
443
+ device: config.device.name,
444
+ subject,
445
+ })
446
+ }
447
+ const threading = {
448
+ ...(thread.lastMessageId ? { inReplyTo: `<${thread.lastMessageId}>` } : {}),
449
+ ...(thread.rootMessageId ? { references: [`<${thread.rootMessageId}>`] } : {}),
450
+ }
451
+ try {
452
+ const result = await deps.send({
453
+ to,
454
+ subject,
455
+ text,
456
+ ...(cwd ? { cwd } : {}),
457
+ ...(sessionId ? { sessionId } : {}),
458
+ messageId,
459
+ ...threading,
460
+ })
461
+ // Sends were invisible in daemon.log: "did my notification go out?" had no
462
+ // answer. Kind and size only - never the subject, per AGENTS invariant 8.
463
+ deps.log?.(`send ok (${kind}, ${text.length} chars)`)
464
+ // Record the outbound id so a reply reaches this session even if a
465
+ // client or a human rewrites the subject token away.
466
+ } catch (error) {
467
+ // The id was recorded before the send, because the mail can come back
468
+ // before the transport resolves. It will not come back now, so drop it:
469
+ // otherwise the next notification threads against a parent that never
470
+ // existed.
471
+ removeOutbound(env, messageId)
472
+ return fail(502, "send_failed", { message: (error as Error).message })
473
+ }
474
+ // Bookkeeping only, and outside the failure path: a recording error here must
475
+ // not report a delivered mail as undelivered.
476
+ try {
477
+ deps.onSent?.({ messageId, subject, ...(sessionId ? { sessionId } : {}) })
478
+ } catch {
479
+ /* the thread store is an optimisation; the mail is already gone */
480
+ }
481
+ return send(200, { ok: true })
482
+ }
483
+
484
+ default:
485
+ return fail(404, "not_found")
486
+ }
487
+ } catch (error) {
488
+ const message = (error as Error).message
489
+ if (message === "request body too large") return fail(413, "body_too_large")
490
+ // Classified by cause, not by prose: a store write failure mentions `threads.json`,
491
+ // and a substring match on "json" reported it as the client's fault.
492
+ if (error instanceof BadBodyError) return fail(400, "bad_request", { message })
493
+ return fail(500, "internal", { message })
494
+ }
495
+ }
496
+
497
+ const server = http.createServer((req, res) => {
498
+ void dispatch(req, res).catch(() => {
499
+ // A malformed request must not take the process down with it.
500
+ try {
501
+ if (!res.headersSent) {
502
+ res.writeHead(500, { "content-type": "application/json" })
503
+ res.end(JSON.stringify({ error: { code: "internal" } }))
504
+ } else {
505
+ res.destroy()
506
+ }
507
+ } catch {
508
+ /* the socket is already gone */
509
+ }
510
+ })
511
+ })
512
+ // Unparseable HTTP never reaches the handler at all.
513
+ server.on("clientError", (_error, socket) => {
514
+ socket.destroy()
515
+ })
516
+
517
+ return {
518
+ server,
519
+ get sseClients() {
520
+ return streams.size
521
+ },
522
+ broadcast,
523
+ // Never fall back to the unspecified address: `undefined` binds 0.0.0.0, and the port is
524
+ // what keeps one daemon per machine. loadConfig repairs this, so this is a belt.
525
+ listen(port = deps.config.daemon.port, host = deps.config.daemon.host || "127.0.0.1") {
526
+ return new Promise((resolve, reject) => {
527
+ server.once("error", reject)
528
+ server.listen(port, host, () => {
529
+ server.removeListener("error", reject)
530
+ const address = server.address()
531
+ resolve({ port: typeof address === "object" && address ? address.port : port, host })
532
+ })
533
+ })
534
+ },
535
+ close() {
536
+ clearInterval(idleCheck)
537
+ return new Promise((resolve) => {
538
+ for (const res of streams) {
539
+ try {
540
+ res.end()
541
+ } catch {
542
+ /* already gone */
543
+ }
544
+ }
545
+ streams.clear()
546
+ server.close(() => resolve())
547
+ })
548
+ },
549
+ }
550
+ }
@@ -0,0 +1,88 @@
1
+ // The MailSource the daemon actually hands to the scanner and the HTTP service.
2
+ //
3
+ // This is a seam, not a convenience: it exists so a test can exercise exactly what production
4
+ // wires up. Three rounds of "the guard is inert in production" came from code that was only
5
+ // ever tested through a hand-built fixture, so the proxy and the UIDVALIDITY bookkeeping live
6
+ // here, in one small file, where they can be driven directly.
7
+
8
+ import { dropPendingFromOtherSpaces, listPending } from "../store/pending-store.ts"
9
+ import { syncUidValidity } from "../store/uid-cursor.ts"
10
+ import type { MailSource } from "./process.ts"
11
+
12
+ /** What the proxy needs from the watcher. */
13
+ export interface ProxyTarget extends MailSource {
14
+ uidValidity(): number | undefined
15
+ uidNext(): number | undefined
16
+ }
17
+
18
+ export interface SourceProxyDeps {
19
+ /** The current watcher, or undefined while it is stopped or being replaced. */
20
+ watcher: () => ProxyTarget | undefined
21
+ env?: NodeJS.ProcessEnv
22
+ }
23
+
24
+ export function createSourceProxy(deps: SourceProxyDeps): MailSource {
25
+ const target = (): ProxyTarget => {
26
+ const watcher = deps.watcher()
27
+ if (!watcher) throw new Error("pi-mailbridge: imap watcher not started")
28
+ return watcher
29
+ }
30
+ return {
31
+ searchAfter: (uid) => target().searchAfter(uid),
32
+ fetch: (uid) => target().fetch(uid),
33
+ markSeen: async (uid) => {
34
+ await deps.watcher()?.markSeen?.(uid)
35
+ },
36
+ // Every call reads the live watcher: the daemon swaps watchers on reload, and a
37
+ // captured reference would answer from the old connection.
38
+ uidValidity: () => deps.watcher()?.uidValidity(),
39
+ }
40
+ }
41
+
42
+ export interface UidValidityOutcome {
43
+ reset: boolean
44
+ validity: number | undefined
45
+ dropped: number
46
+ }
47
+
48
+ /**
49
+ * Compare the mailbox's UIDVALIDITY with the recorded one, and reset what a renumbering
50
+ * invalidates.
51
+ *
52
+ * Called on every successful connect. A renumbering cannot be detected by the cursor alone,
53
+ * so this is the only place it is noticed - and the only place its consequences are undone.
54
+ */
55
+ export function applyUidValidity(
56
+ env: NodeJS.ProcessEnv,
57
+ watcher: ProxyTarget | undefined,
58
+ onDropped?: (sessionId: string) => void,
59
+ ): UidValidityOutcome {
60
+ const validity = watcher?.uidValidity()
61
+ if (validity === undefined) return { reset: false, validity, dropped: 0 }
62
+ const reset = syncUidValidity(env, validity, watcher?.uidNext())
63
+
64
+ // Drop on EVERY connect, not only on a reset: a crash between the cursor write and the
65
+ // drop leaves records from another space that nothing can claim (the guard refuses them)
66
+ // and nothing would ever remove, because the cursor already carries the new validity.
67
+ // Queued records from the wrong numbering could collide with new UIDs and be replaced
68
+ // silently. Dropping them is safe: they were never acked, so the mail is still unread.
69
+ // On a reset the cursor re-reads it; on the orphan path (no reset) the message is behind
70
+ // an already-advanced cursor, which is why the drop runs every connect. Their decision
71
+ // slots are freed, or that session would be told it has an outstanding decision for a day.
72
+ // Only records from another UID space are dropped here. Age-based expiry used to run on every
73
+ // connect, which deleted replies for sessions that were merely quiet - a user on `off` for a
74
+ // week came home to nothing to drain, with the cursor already past the mail. Expiry now
75
+ // happens under queue pressure only (see process.ts).
76
+ // On a reset, unknown-space records go too: see dropPendingFromOtherSpaces.
77
+ const dropped = dropPendingFromOtherSpaces(env, validity, { includeUnknown: reset })
78
+ if (dropped.length > 0) {
79
+ // Only free a slot the dropped record was holding: a session that still has a live
80
+ // record in the current space must keep its reservation, or it could be granted a
81
+ // second decision while the first is unanswered.
82
+ const stillQueued = new Set(listPending(env).map((r) => r.sessionId))
83
+ for (const record of dropped) {
84
+ if (!stillQueued.has(record.sessionId)) onDropped?.(record.sessionId)
85
+ }
86
+ }
87
+ return { reset, validity, dropped: dropped.length }
88
+ }