@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,802 @@
1
+ // pi-mailbridge daemon — process entry.
2
+ //
3
+ // Boot order matters:
4
+ // 1. load config, surface warnings
5
+ // 2. resolve the shared secret (also the HTTP bearer token)
6
+ // 3. start the HTTP service (so /health answers while IMAP is still dialling)
7
+ // 4. catch-up scan, then the IDLE watcher
8
+ //
9
+ // Single-instance: a lock file in the state directory, not the port. The port is still checked
10
+ // (EADDRINUSE exits 0), but two processes that disagree about `daemon.port` - a per-process
11
+ // PI_MAILBRIDGE_PORT, or the config edited while one is running - would otherwise start two
12
+ // daemons, two IMAP watchers, on one state directory whose stores have no cross-process lock.
13
+ //
14
+ // Modes:
15
+ // (none) run the daemon
16
+ // --check validate config + secrets + token command, touch no mail
17
+ // --setup write a config from an address (headless equivalent of /mailbridge setup)
18
+ // --auth run the OAuth device-code flow
19
+ // --prime move the UID cursor to the newest message
20
+ // --smoke send one real mail, then open the folder and count
21
+ // --print-token print the shared secret (for scripts and tests)
22
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
23
+ import { statePath } from "../store/paths.js";
24
+ import { assertStateReadable } from "../store/schema.js";
25
+ import { dirname } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { loadConfig, hasUsableMailConfig, shortHostname, REPAIRED_PREFIX, } from "../core/config.js";
28
+ import { configPath } from "../store/paths.js";
29
+ import { buildSetupConfig, discoverDomain, domainOf } from "../core/discover.js";
30
+ import { ensureSecret } from "../store/secret.js";
31
+ import { readMode, scanningEnabled } from "../store/mode-store.js";
32
+ import { createScanQueue } from "./scan-queue.js";
33
+ import { sendMail } from "../core/mailer.js";
34
+ import { captureDeliveredMessageId } from "../core/sent-index.js";
35
+ import { createCommandTokenProvider } from "../core/oauth.js";
36
+ import { createOpenidClient } from "../core/oauth-openid.js";
37
+ import { buildTokenProvider } from "../core/token-source.js";
38
+ import { fileTokenStore } from "../store/oauth-store.js";
39
+ import { scanAndProcess } from "./process.js";
40
+ import { applyUidValidity, createSourceProxy } from "./source-proxy.js";
41
+ import { createRegistry } from "../store/registry.js";
42
+ import { createImapWatcher } from "./watcher.js";
43
+ import { createImapflowFactory } from "./imapflow.js";
44
+ import { createDaemonService } from "./service.js";
45
+ import { runSmoke } from "../core/smoke.js";
46
+ import { runSetup } from "../core/setup.js";
47
+ import { listPending } from "../store/pending-store.js";
48
+ import { advanceCursor, isPrimed, markPrimed, readCursor } from "../store/uid-cursor.js";
49
+ import { alreadyPrimedMessage, runPrime } from "../core/prime.js";
50
+ import { packageVersion } from "../core/version.js";
51
+ import { API_VERSION } from "./api.js";
52
+ import { recordOutbound } from "../store/threads.js";
53
+ import { tokenFromSubject } from "../core/token.js";
54
+ const EXIT_CONFIG = 2;
55
+ const EXIT_AUTH = 3;
56
+ const EXIT_NETWORK = 4;
57
+ // ---------------------------------------------------------------------------
58
+ // Token supply
59
+ // ---------------------------------------------------------------------------
60
+ /**
61
+ * Pick the token source for this installation.
62
+ *
63
+ * Precedence: built-in device-code OAuth2 (self-refreshing) -> a configured
64
+ * command -> none (LOGIN auth). Returning `undefined` is not an error: it means
65
+ * the account does not use XOAUTH2.
66
+ */
67
+ /**
68
+ * `--auth`: approve the device grant once and persist the refresh token.
69
+ * Prints the URL and code, waits for approval, then exits.
70
+ */
71
+ async function login(config) {
72
+ if (!config.oauth?.issuer || !config.oauth.clientId) {
73
+ log("oauth.issuer and oauth.clientId are required for device login — see docs/OAUTH.md");
74
+ return EXIT_CONFIG;
75
+ }
76
+ const client = await createOpenidClient(config.oauth);
77
+ // Start the grant and show the human what to do BEFORE waiting on them.
78
+ const authorization = await client.deviceAuthorization();
79
+ process.stdout.write(`\nApprove this login:\n ${authorization.verificationUri}\n`);
80
+ if (authorization.userCode)
81
+ process.stdout.write(`Code: ${authorization.userCode}\n`);
82
+ process.stdout.write("\nWaiting for approval…\n");
83
+ try {
84
+ const tokens = await client.pollDeviceAuthorization();
85
+ if (!tokens.refreshToken) {
86
+ log("the provider returned no refresh_token — add offline_access to oauth.scope");
87
+ return EXIT_AUTH;
88
+ }
89
+ fileTokenStore().write({
90
+ refreshToken: tokens.refreshToken,
91
+ accessToken: tokens.accessToken,
92
+ expiresAt: tokens.expiresAt,
93
+ });
94
+ }
95
+ catch (error) {
96
+ log(`login failed: ${error.message}`);
97
+ return EXIT_AUTH;
98
+ }
99
+ log("authorised; refresh token stored");
100
+ return 0;
101
+ }
102
+ /**
103
+ * `--smoke`: prove the real account works, both directions, without pi.
104
+ *
105
+ * 1. resolve a token (LOGIN or OAuth2)
106
+ * 2. send one mail over SMTP
107
+ * 3. open the IMAP folder and count messages
108
+ *
109
+ * Deliberately does NOT touch the cursor, the journal, or any pending record, so a
110
+ * smoke test can never disturb real deliveries.
111
+ */
112
+ async function smoke(config) {
113
+ const tokenProvider = await buildTokenProvider(config);
114
+ const result = await runSmoke(config, {
115
+ ...(tokenProvider ? { getAccessToken: () => tokenProvider.getToken() } : {}),
116
+ });
117
+ for (const step of result.steps) {
118
+ log(`${step.step} ${step.ok ? "ok" : "FAILED"}: ${step.detail}`);
119
+ if (step.hint)
120
+ log(` hint: ${step.hint}`);
121
+ }
122
+ if (!result.ok)
123
+ return EXIT_AUTH;
124
+ log("smoke ok: both directions work");
125
+ return 0;
126
+ }
127
+ /**
128
+ * `--prime`: move the UID cursor to the newest message without reading anything.
129
+ *
130
+ * Without this, the first scan walks the entire existing mailbox: it fetches and
131
+ * parses every message looking for a routing token. Nothing is stored and nothing
132
+ * is marked seen, but on a mailbox that also carries personal mail that is both
133
+ * slow and more exposure than a notification bridge needs. Priming says "watch
134
+ * from now on".
135
+ *
136
+ * First run only: after that the cursor is the thing that makes mail visible, and
137
+ * moving it forward would silently skip replies that arrived while nothing scanned.
138
+ */
139
+ async function prime(config) {
140
+ const tokenProvider = await buildTokenProvider(config);
141
+ const getAccessToken = tokenProvider ? () => tokenProvider.getToken() : undefined;
142
+ const client = await createImapflowFactory({ ...(getAccessToken ? { getAccessToken } : {}) })(config, { idleRenewMs: config.tuning.idleRenewMs });
143
+ try {
144
+ const plan = await runPrime(config, {
145
+ openMailbox: async (cfg) => {
146
+ await client.connect();
147
+ await client.mailboxOpen(cfg.mail.imap.folder || "INBOX");
148
+ return {
149
+ search: () => client.search({ uid: "1:*" }, { uid: true }),
150
+ close: async () => client.logout(),
151
+ };
152
+ },
153
+ readCursor: () => readCursor(process.env),
154
+ isPrimed: () => isPrimed(process.env),
155
+ markPrimed: () => markPrimed(process.env),
156
+ advanceCursor: (uid) => {
157
+ advanceCursor(process.env, uid);
158
+ },
159
+ });
160
+ if (!plan.ok) {
161
+ log(alreadyPrimedMessage(plan.currentUid));
162
+ return EXIT_CONFIG;
163
+ }
164
+ log(`primed: cursor set to uid ${plan.highest} (${plan.count} existing message(s) will not be read)`);
165
+ return 0;
166
+ }
167
+ catch (error) {
168
+ log(`prime failed: ${error.message}`);
169
+ return EXIT_AUTH;
170
+ }
171
+ finally {
172
+ try {
173
+ await client.logout();
174
+ }
175
+ catch {
176
+ /* already gone */
177
+ }
178
+ }
179
+ }
180
+ /**
181
+ * One daemon per state directory, whatever port it thinks it owns.
182
+ *
183
+ * `wx` makes the create atomic; the pid inside lets a later process tell a live holder from a
184
+ * crash. A holder that is gone is stolen, so a killed daemon does not lock the machine out.
185
+ */
186
+ function acquireDaemonLock(env) {
187
+ const path = statePath(env, "daemon.lock");
188
+ const take = () => {
189
+ try {
190
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
191
+ writeFileSync(path, String(process.pid), { flag: "wx", mode: 0o600 });
192
+ return {
193
+ ok: true,
194
+ release: () => {
195
+ // Only remove our own: a stolen lock belongs to someone else now.
196
+ try {
197
+ if (readFileSync(path, "utf8").trim() === String(process.pid))
198
+ unlinkSync(path);
199
+ }
200
+ catch {
201
+ /* already gone */
202
+ }
203
+ },
204
+ };
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ };
210
+ const first = take();
211
+ if (first)
212
+ return first;
213
+ let holder = 0;
214
+ try {
215
+ holder = Number(readFileSync(path, "utf8").trim());
216
+ }
217
+ catch {
218
+ /* unreadable: treat as stale */
219
+ }
220
+ const alive = holder > 0 && holder !== process.pid && (() => {
221
+ try {
222
+ process.kill(holder, 0);
223
+ return true;
224
+ }
225
+ catch (error) {
226
+ return error.code === "EPERM";
227
+ }
228
+ })();
229
+ if (alive)
230
+ return { ok: false, pid: holder };
231
+ // Stale: the holder is gone (a crash, or a reboot with a leftover file). Steal it by
232
+ // RENAMING it aside, not unlinking it: an unlink plus a retake lets two stealers both win -
233
+ // B unlinks the corpse, creates its own lock, then C unlinks *B's live lock* and creates
234
+ // its own. A rename is atomic, so exactly one stealer gets the file; the winner verifies it
235
+ // took the corpse it judged dead, and puts back anything that is not.
236
+ const aside = `${path}.${process.pid}.stale`;
237
+ try {
238
+ renameSync(path, aside);
239
+ }
240
+ catch {
241
+ // Someone else moved it first; try again from the top once.
242
+ const retry = take();
243
+ return retry ?? { ok: false, pid: holder };
244
+ }
245
+ let moved = Number.NaN;
246
+ try {
247
+ moved = Number(readFileSync(aside, "utf8").trim());
248
+ }
249
+ catch {
250
+ /* unreadable */
251
+ }
252
+ if (moved !== holder) {
253
+ // The file changed between the liveness check and our rename: restore it untouched and
254
+ // report the current holder rather than stealing a live lock.
255
+ try {
256
+ renameSync(aside, path);
257
+ }
258
+ catch {
259
+ /* if we cannot put it back, leave it aside; the next start will judge it */
260
+ }
261
+ const retry = take();
262
+ return retry ?? { ok: false, pid: Number.isFinite(moved) ? moved : holder };
263
+ }
264
+ try {
265
+ unlinkSync(aside);
266
+ }
267
+ catch {
268
+ /* already gone */
269
+ }
270
+ const second = take();
271
+ return second ?? { ok: false, pid: holder };
272
+ }
273
+ function argValue(argv, flag) {
274
+ const index = argv.indexOf(flag);
275
+ if (index < 0)
276
+ return undefined;
277
+ const next = argv[index + 1];
278
+ return next && !next.startsWith("--") ? next : undefined;
279
+ }
280
+ /**
281
+ * `--setup <pi-address> [--to <your-address>] [--force]` — the headless twin of
282
+ * `/mailbridge setup`. Shares core/setup.ts, so behaviour cannot drift.
283
+ */
284
+ async function setup(argv) {
285
+ const address = argValue(argv, "--setup");
286
+ if (!address) {
287
+ log("usage: --setup <pi-address> [--to <your-address>] [--force]");
288
+ return EXIT_CONFIG;
289
+ }
290
+ const to = argValue(argv, "--to");
291
+ const result = await runSetup({ address, ...(to ? { to } : {}), force: argv.includes("--force") });
292
+ for (const step of result.steps)
293
+ log(step);
294
+ if (result.next)
295
+ log(`next: ${result.next}`);
296
+ return result.ok ? 0 : EXIT_CONFIG;
297
+ }
298
+ function log(message) {
299
+ // Timestamped: a live review had to read TCP counters to prove the poll was running, because
300
+ // the log carried no time and empty scans are silent.
301
+ console.error(`pi-mailbridge: ${new Date().toISOString().slice(0, 19)}Z ${message}`);
302
+ }
303
+ /**
304
+ * `--check`: report every problem distinctly and exit with a code a supervisor
305
+ * can act on. Never touches mail.
306
+ */
307
+ /**
308
+ * Problems `--check` reports, without the network or the log side-effects.
309
+ * Exported so the combination rules can be tested.
310
+ */
311
+ export function checkProblems(config) {
312
+ const problems = [];
313
+ const add = (problem) => {
314
+ if (!problems.includes(problem))
315
+ problems.push(problem);
316
+ };
317
+ // config.warnings already covers missing host/user/to and an empty allowlist. A repaired
318
+ // malformed section is reported but is not a reason to refuse to start: the daemon runs.
319
+ for (const warning of config.warnings)
320
+ if (!warning.startsWith(REPAIRED_PREFIX))
321
+ add(warning);
322
+ if (!hasUsableMailConfig(config)) {
323
+ add("mail.imap.host / mail.imap.user / mail.smtp.host / mail.to must all be set");
324
+ }
325
+ // Same check as /mailbridge check: one address plus DMARC means every reply is rejected
326
+ // on providers that stamp no Authentication-Results on self-addressed mail. This is the
327
+ // headless path a supervisor runs, so it must not stay silent.
328
+ if (config.security.allowSelfReplies && config.security.requireDmarcPass) {
329
+ add("security.allowSelfReplies is on but security.requireDmarcPass is too — providers that do not " +
330
+ "stamp Authentication-Results on self-addressed mail will reject every reply (dmarc-missing)");
331
+ }
332
+ return problems;
333
+ }
334
+ async function check(config) {
335
+ let code = 0;
336
+ const problems = checkProblems(config);
337
+ const add = (problem) => {
338
+ if (!problems.includes(problem))
339
+ problems.push(problem);
340
+ };
341
+ if (config.mail.imap.authType === "XOAUTH2" || config.mail.smtp.authType === "XOAUTH2") {
342
+ const hasOAuth = Boolean(config.oauth?.issuer && config.oauth?.clientId);
343
+ const command = config.mail.smtp.tokenCommand ?? config.mail.imap.tokenCommand ?? [];
344
+ if (hasOAuth) {
345
+ const stored = fileTokenStore().read();
346
+ if (stored?.refreshToken) {
347
+ log("oauth: refresh token present");
348
+ }
349
+ else {
350
+ add("oauth is configured but no refresh token yet — run `/mailbridge auth` in pi (or `pi-mailbridge --auth`)");
351
+ code = EXIT_AUTH;
352
+ }
353
+ }
354
+ else if (command.length === 0) {
355
+ add("XOAUTH2 selected but neither oauth.* nor tokenCommand is configured");
356
+ code = EXIT_AUTH;
357
+ }
358
+ else {
359
+ const provider = createCommandTokenProvider({ command });
360
+ try {
361
+ await provider.getToken();
362
+ log(`token command ok (${command[0]})`);
363
+ }
364
+ catch (error) {
365
+ add(error.message);
366
+ code = EXIT_AUTH;
367
+ }
368
+ }
369
+ }
370
+ if (config.security.requireDmarcPass && !config.security.trustedAuthservId) {
371
+ log("note: security.trustedAuthservId unset — trusting the topmost Authentication-Results header");
372
+ }
373
+ try {
374
+ ensureSecret();
375
+ log("shared secret ok");
376
+ }
377
+ catch (error) {
378
+ add(`secret: ${error.message}`);
379
+ code = code || EXIT_CONFIG;
380
+ }
381
+ for (const problem of problems)
382
+ log(` ! ${problem}`);
383
+ if (problems.length === 0)
384
+ log("config ok");
385
+ return code || (problems.length > 0 ? EXIT_CONFIG : 0);
386
+ }
387
+ /** The config file's mtime, or 0 when there is no file. */
388
+ function configStamp(env = process.env) {
389
+ try {
390
+ return statSync(configPath(env), { throwIfNoEntry: false })?.mtimeMs ?? 0;
391
+ }
392
+ catch {
393
+ return 0;
394
+ }
395
+ }
396
+ async function run(initialConfig, initialStamp) {
397
+ const token = ensureSecret().toString("base64url");
398
+ // Mutable refs: /reload swaps both without recreating the HTTP service.
399
+ let currentConfig = initialConfig;
400
+ let currentWatcher;
401
+ let tokenProvider;
402
+ let service;
403
+ const getAccessToken = () => (tokenProvider ? tokenProvider.getToken() : Promise.reject(new Error("no token provider")));
404
+ // A stable MailSource the service can hold across reloads.
405
+ // One registry for the process: the service uses it for decisions, and the UIDVALIDITY
406
+ // reset releases the slots of records it drops.
407
+ const registry = createRegistry();
408
+ // Built once; every call reads the live watcher, which /reload swaps.
409
+ const sourceProxy = createSourceProxy({ watcher: () => currentWatcher, env: process.env });
410
+ let reportedModeOff = false;
411
+ // Last scan outcome, surfaced on /health so `/mailbridge status` can answer "why did my
412
+ // reply not arrive?" - the reasons were already computed, just never shown.
413
+ // When the running config was read. The extension compares this with the config file's
414
+ // mtime: a session that attaches to an already-running daemon must not inherit a config
415
+ // the daemon read before the user changed it.
416
+ // The mtime of the file this config came from, taken BEFORE it was read, so a write in
417
+ // the gap is still detected as newer.
418
+ let configLoadedAt = initialStamp;
419
+ /** Periodic catch-up timer (armed at the end of run()). */
420
+ let pollHandle;
421
+ let lastCheckedAt;
422
+ let lastScan;
423
+ const scanOnce = createScanQueue((why) => scanNow(why));
424
+ function formatReasons(reasons) {
425
+ const parts = Object.entries(reasons)
426
+ .filter(([, n]) => n > 0)
427
+ .sort((a, b) => b[1] - a[1])
428
+ .map(([reason, n]) => `${reason}=${n}`);
429
+ return parts.length ? parts.join(" ") : "no reasons";
430
+ }
431
+ async function scanNow(why) {
432
+ // `off` must actually close the channel, not just stop the mail coming back.
433
+ // While mode is off nothing is fetched or persisted and the cursor stays put, so
434
+ // turning `/mailbridge on` back on processes what arrived meanwhile.
435
+ // Stamped before the gate: "last checked" answers "is the poll loop alive", and while
436
+ // mode is off it is the only thing it can honestly mean. Leaving it stale made a quiet
437
+ // off period look identical to a dead poll loop - the symptom this line exists to show.
438
+ lastCheckedAt = Date.now();
439
+ if (!scanningEnabled(readMode(process.env))) {
440
+ if (!reportedModeOff) {
441
+ reportedModeOff = true;
442
+ log(`${why}: email mode is off - not scanning`);
443
+ }
444
+ return;
445
+ }
446
+ reportedModeOff = false;
447
+ const before = new Set(listPending().map((r) => r.uid));
448
+ try {
449
+ const result = await scanAndProcess(currentConfig, {
450
+ source: sourceProxy,
451
+ secret: ensureSecret(),
452
+ onDropped: (sessionId) => registry.release(sessionId),
453
+ });
454
+ // Keep the last scan that actually saw mail. Overwriting it with every empty
455
+ // catch-up scan made the "why did my reply not arrive" panel a few seconds old on a
456
+ // mailbox that receives anything else.
457
+ if (result.examined > 0) {
458
+ lastScan = {
459
+ at: Date.now(),
460
+ examined: result.examined,
461
+ persisted: result.persisted,
462
+ rejected: result.rejected,
463
+ reasons: result.reasons,
464
+ };
465
+ }
466
+ if (result.persisted > 0) {
467
+ // Name the reasons even on a partial success: "rejected 1" alone leaves the user
468
+ // unable to tell which reply was dropped and why.
469
+ log(`${why}: persisted ${result.persisted} of ${result.examined} (rejected ${result.rejected}: ${formatReasons(result.reasons)})`);
470
+ for (const record of listPending()) {
471
+ if (!before.has(record.uid))
472
+ service.broadcast({ type: "delivery", pending: record });
473
+ }
474
+ }
475
+ else if (result.examined > 0) {
476
+ // Name the reasons: "nothing for us" is what makes a missing reply undebuggable.
477
+ log(`${why}: examined ${result.examined}, nothing for us (${formatReasons(result.reasons)})`);
478
+ }
479
+ }
480
+ catch (error) {
481
+ // A scan failure must not kill the daemon: the cursor did not advance, so
482
+ // the next IDLE event or catch-up retries the same UIDs.
483
+ log(`${why}: scan failed: ${error.message}`);
484
+ }
485
+ }
486
+ const armPoll = () => {
487
+ // Clearing first makes this callable from /reload: setting the interval to 0 leaves no
488
+ // tick to re-read it, so without an external re-arm a later `/reload` could never turn
489
+ // the safety net back on.
490
+ if (pollHandle !== undefined)
491
+ clearTimeout(pollHandle);
492
+ pollHandle = undefined;
493
+ // Read the interval on every tick so `/reload` can change it, and reuse the same mode
494
+ // rule the scanner uses rather than spelling it out again.
495
+ const scanEveryMs = Math.max(0, currentConfig.daemon.scanIntervalSecs ?? 60) * 1000;
496
+ if (scanEveryMs === 0)
497
+ return;
498
+ pollHandle = setTimeout(() => {
499
+ pollHandle = undefined;
500
+ if (scanningEnabled(readMode(process.env)) && currentWatcher?.isHealthy())
501
+ void scanOnce("poll");
502
+ armPoll();
503
+ }, scanEveryMs);
504
+ pollHandle.unref?.();
505
+ };
506
+ let reloadInFlight;
507
+ async function doReload(pass = 0) {
508
+ const previous = currentConfig;
509
+ const previousProvider = tokenProvider;
510
+ const previousStamp = configLoadedAt;
511
+ // Stamped at entry: if the file changes while this reload runs, the caller(s) joined to
512
+ // it must not be told their newer config is in force.
513
+ const startedFrom = configStamp();
514
+ try {
515
+ await currentWatcher?.stop();
516
+ configLoadedAt = startedFrom;
517
+ currentConfig = loadConfig();
518
+ tokenProvider = await buildTokenProvider(currentConfig);
519
+ const watcher = await startWatcher(currentConfig);
520
+ // start() does not throw on a refused connect - it arms a reconnect - so ask the
521
+ // watcher whether it is actually connected before calling the reload a success.
522
+ if (!watcher.isHealthy()) {
523
+ // The config was re-read; the connection is what failed, and the watcher is
524
+ // retrying. Report it rather than calling the reload clean.
525
+ log("reloaded config, but IMAP did not connect (check the password/host)");
526
+ armPoll();
527
+ return { ok: true, warning: "config reloaded, but IMAP did not connect" };
528
+ }
529
+ // Someone wrote the config while we were re-dialling (a `/mailbridge setup` that
530
+ // joined this reload, for instance). One extra pass so they get a reload that
531
+ // started after their write - otherwise mail keeps going to the address they
532
+ // just replaced until the next session notices.
533
+ if (pass === 0 && configStamp() > startedFrom)
534
+ return doReload(1);
535
+ log("reloaded config and restarted the watcher");
536
+ armPoll();
537
+ return { ok: true };
538
+ }
539
+ catch (error) {
540
+ // Do not leave the daemon holding a config whose watcher never started - nor a token
541
+ // provider built for credentials that were just rolled back.
542
+ currentConfig = previous;
543
+ tokenProvider = previousProvider;
544
+ configLoadedAt = previousStamp;
545
+ try {
546
+ await startWatcher(previous);
547
+ log(`reload failed (${error.message}); kept the previous config`);
548
+ }
549
+ catch {
550
+ log(`reload failed (${error.message}); previous watcher could not restart either`);
551
+ }
552
+ return { ok: false, error: error.message };
553
+ }
554
+ }
555
+ async function startWatcher(config) {
556
+ const watcher = createImapWatcher(config, {
557
+ factory: createImapflowFactory({ getAccessToken: tokenProvider ? getAccessToken : undefined }),
558
+ callbacks: {
559
+ onReady: () => {
560
+ // UIDs are only meaningful within one UIDVALIDITY. A recreated or migrated
561
+ // folder renumbers from 1; without this the high-water cursor would skip
562
+ // every future message, with "examined 0" as the only symptom.
563
+ // `currentWatcher` is assigned before start(), so it is this watcher here.
564
+ const outcome = applyUidValidity(process.env, currentWatcher, (sessionId) => registry.release(sessionId));
565
+ if (outcome.reset) {
566
+ log(`mailbox UIDVALIDITY changed to ${outcome.validity}; cursor reset and ${outcome.dropped} queued record(s) dropped - re-reading from the start`);
567
+ }
568
+ else if (outcome.dropped > 0) {
569
+ // No reset needed, but records from a previous numbering were still stuck in
570
+ // the queue (a crash between the cursor write and the drop). Say so.
571
+ log(`${outcome.dropped} queued record(s) from an earlier UID space dropped`);
572
+ }
573
+ log("imap ready; running catch-up scan");
574
+ void scanOnce("catch-up");
575
+ },
576
+ onMail: () => {
577
+ void scanOnce("idle");
578
+ },
579
+ onError: (error, delay) => log(`imap error (${error.message}); reconnecting in ${delay}ms`),
580
+ },
581
+ });
582
+ // Assign BEFORE starting: `start()` fires onReady, and the catch-up scan it
583
+ // triggers reads through sourceProxy. With the assignment after `await`, every
584
+ // catch-up scan failed with "imap watcher not started" and mail that arrived
585
+ // while the daemon was down was only picked up on the next IDLE event.
586
+ currentWatcher = watcher;
587
+ await watcher.start();
588
+ return watcher;
589
+ }
590
+ tokenProvider = await buildTokenProvider(initialConfig);
591
+ service = createDaemonService({
592
+ // The service must see the CURRENT config: /reload swaps it, and a stale recipient
593
+ // would keep mailing the previous address after a documented re-setup.
594
+ getConfig: () => currentConfig,
595
+ registry,
596
+ config: initialConfig,
597
+ token,
598
+ source: sourceProxy,
599
+ send: async (message) => {
600
+ const provider = tokenProvider;
601
+ return sendWithCapture(message, (current) => sendMail(currentConfig, current, {
602
+ ...(provider ? { getAccessToken: () => provider.getToken() } : {}),
603
+ // Lets a revoked grant be refreshed once instead of failing the notification.
604
+ ...(provider ? { provider } : {}),
605
+ }), {
606
+ sentFolder: currentConfig.mail.imap.sentFolder,
607
+ capture: (mintedId, subject, sessionId) => captureDeliveredMessageId({
608
+ config: currentConfig,
609
+ mintedId,
610
+ subject,
611
+ sessionId,
612
+ ...(provider ? { getAccessToken: () => provider.getToken() } : {}),
613
+ }),
614
+ onMissed: () => log("could not read the sent copy back; the reply chain may not match this mail"),
615
+ });
616
+ },
617
+ onSent: ({ messageId, subject, sessionId }) => {
618
+ if (!messageId)
619
+ return;
620
+ // The sender knows the session; parsing it back out of the subject only works
621
+ // for legacy signed tokens, because production subjects carry a short route id.
622
+ // That bug silently disabled both threading and the reply-chain fallback.
623
+ const parsed = sessionId ? { sessionId, device: currentConfig.device.name } : tokenFromSubject(subject, ensureSecret());
624
+ if (!parsed)
625
+ return;
626
+ recordOutbound(process.env, {
627
+ messageId,
628
+ sessionId: parsed.sessionId,
629
+ ...(parsed.device ? { device: parsed.device } : {}),
630
+ subject,
631
+ });
632
+ },
633
+ // Lets `/mailbridge restart` replace the process, not just its config, so an
634
+ // updated package actually takes effect.
635
+ shutdown: (reason) => {
636
+ void shutdown(reason ?? "shutdown requested");
637
+ },
638
+ // Always carry the freshness stamp: on a quiet mailbox `lastScan` is absent, and the
639
+ // panel then said "(none yet)" while the daemon was happily polling.
640
+ scanSummary: () => ({ ...(lastScan ?? {}), ...(lastCheckedAt ? { checkedAt: lastCheckedAt } : {}) }),
641
+ version: packageVersion(import.meta.url),
642
+ apiVersion: API_VERSION,
643
+ log,
644
+ // The file's mtime, not a clock reading: comparing mtime to mtime has no clock coupling.
645
+ configLoadedAt: () => configLoadedAt,
646
+ imapHealthy: () => currentWatcher?.isHealthy() ?? false,
647
+ // Re-open the channel: process what arrived while mode was off, and retry anything a
648
+ // full queue refused. Safe to call when nothing changed (an empty scan is cheap).
649
+ onModeChange: (mode) => {
650
+ // Only "on" needs work; "off" simply stops the next scan from doing anything.
651
+ if (mode === "on" && currentWatcher)
652
+ void scanOnce("mode-on");
653
+ },
654
+ idleTimeoutMs: (currentConfig.daemon.idleTimeoutSecs ?? 120) * 1000,
655
+ // `/mailbridge setup` / `auth` write new config or tokens; pick them up here.
656
+ // One reload at a time: two sessions noticing the same stale config must not each
657
+ // stop and redial the watcher.
658
+ reload: () => (reloadInFlight ??= doReload().finally(() => (reloadInFlight = undefined))),
659
+ });
660
+ service.server.on("error", (error) => {
661
+ if (error.code === "EADDRINUSE") {
662
+ log(`another daemon already owns port ${currentConfig.daemon.port}; exiting`);
663
+ process.exit(0);
664
+ }
665
+ log(`http server failed: ${error.message}`);
666
+ process.exit(1);
667
+ });
668
+ // Refuse a state directory written by a newer build before touching it: a load-time throw
669
+ // inside a request or a scan would surface late and repeatedly.
670
+ try {
671
+ assertStateReadable(process.env);
672
+ }
673
+ catch (error) {
674
+ log(error.message);
675
+ process.exit(EXIT_CONFIG);
676
+ }
677
+ const lock = acquireDaemonLock(process.env);
678
+ if (!lock.ok) {
679
+ log(`another daemon is already running (pid ${lock.pid}); exiting`);
680
+ process.exit(0);
681
+ }
682
+ const { port, host } = await service.listen();
683
+ log(`listening on http://${host}:${port}`);
684
+ // Register shutdown handlers BEFORE dialling IMAP: a SIGTERM during a slow
685
+ // DNS/TLS dial must still shut down cleanly.
686
+ let stopping = false;
687
+ const shutdown = async (signal) => {
688
+ if (stopping)
689
+ return;
690
+ stopping = true;
691
+ log(`${signal}: shutting down`);
692
+ lock.release();
693
+ if (pollHandle !== undefined)
694
+ clearTimeout(pollHandle);
695
+ await currentWatcher?.stop();
696
+ await service.close();
697
+ process.exit(0);
698
+ };
699
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
700
+ process.on("SIGINT", () => void shutdown("SIGINT"));
701
+ await startWatcher(currentConfig);
702
+ armPoll();
703
+ // Safety net: an IDLE that never fires (a provider that does not push, or a connection that
704
+ // silently stopped idling) would strand every reply until something else triggered a scan.
705
+ // A catch-up sweep costs one SEARCH that usually returns nothing. Observed live: a Hotmail
706
+ // reply sat unseen while the daemon reported "imap: connected".
707
+ }
708
+ /**
709
+ * Send, then read the delivered Message-ID back.
710
+ *
711
+ * The order is the point: the provider appends the Sent copy asynchronously (QQ took 20-30 s
712
+ * in one measurement), and an earlier version that captured first recorded the *previous*
713
+ * notification's id against this session. Extracted so a test can hold that order.
714
+ */
715
+ export async function sendWithCapture(message, send, deps) {
716
+ const sent = await send(message);
717
+ if (message.sessionId && message.messageId && deps.sentFolder) {
718
+ // Fire-and-forget: the notification never fails because the bookkeeping could not be read.
719
+ void deps
720
+ .capture(message.messageId, message.subject, message.sessionId)
721
+ .then((id) => {
722
+ if (!id)
723
+ deps.onMissed?.();
724
+ })
725
+ .catch(() => deps.onMissed?.());
726
+ }
727
+ return sent;
728
+ }
729
+ export async function main() {
730
+ const argv = process.argv.slice(2);
731
+ // Stamp before reading: a write landing between the read and a later Date.now() would
732
+ // otherwise be invisible to the freshness check.
733
+ const startupStamp = configStamp();
734
+ let config = loadConfig();
735
+ // A daemon spawned by a session is told the port that session probed, so it cannot bind a
736
+ // different one from its own env and leave the session reporting "unreachable".
737
+ const portArg = argValue(argv, "--port");
738
+ if (portArg !== undefined) {
739
+ const port = Number(portArg);
740
+ if (Number.isInteger(port) && port >= 1 && port <= 65_535) {
741
+ config = { ...config, daemon: { ...config.daemon, port } };
742
+ }
743
+ }
744
+ if (argv.includes("--print-token")) {
745
+ process.stdout.write(`${ensureSecret().toString("base64url")}\n`);
746
+ return;
747
+ }
748
+ if (argv.includes("--check")) {
749
+ process.exitCode = await check(config);
750
+ return;
751
+ }
752
+ if (argv.includes("--setup")) {
753
+ process.exitCode = await setup(argv);
754
+ return;
755
+ }
756
+ if (argv.includes("--auth")) {
757
+ process.exitCode = await login(config);
758
+ return;
759
+ }
760
+ if (argv.includes("--prime")) {
761
+ process.exitCode = await prime(config);
762
+ return;
763
+ }
764
+ if (argv.includes("--smoke")) {
765
+ for (const warning of config.warnings)
766
+ log(`warning: ${warning}`);
767
+ process.exitCode = await smoke(config);
768
+ return;
769
+ }
770
+ if (!hasUsableMailConfig(config)) {
771
+ for (const warning of config.warnings)
772
+ log(` ! ${warning}`);
773
+ log("mail config incomplete — run with --check for details");
774
+ process.exit(EXIT_CONFIG);
775
+ }
776
+ for (const warning of config.warnings)
777
+ log(`warning: ${warning}`);
778
+ await run(config, startupStamp);
779
+ }
780
+ // `import.meta.url` is a URL and `process.argv[1]` is a path: comparing them as strings fails
781
+ // for any install directory with a space or non-ASCII character (and for a symlinked package),
782
+ // where this file would then run nothing and exit 0 - the unit looks started and is dead.
783
+ function isEntryPoint() {
784
+ const invoked = process.argv[1];
785
+ if (!invoked)
786
+ return false;
787
+ try {
788
+ return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url));
789
+ }
790
+ catch (error) {
791
+ // A silent `false` here is a process that does nothing and exits 0 - under the unit's
792
+ // ExecStartPre that reads as success. Say why instead.
793
+ log(`cannot resolve the entry point (${error.message})`);
794
+ return false;
795
+ }
796
+ }
797
+ if (isEntryPoint()) {
798
+ main().catch((error) => {
799
+ log(`fatal: ${error instanceof Error ? error.message : String(error)}`);
800
+ process.exit(1);
801
+ });
802
+ }