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