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