@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,373 @@
1
+ // Layered configuration. Later layers win; invalid values are reported rather
2
+ // than thrown, so a typo never takes a session down mid-run.
3
+
4
+ import { existsSync, readFileSync } from "node:fs"
5
+ import { hostname } from "node:os"
6
+
7
+ import { configPath } from "../store/paths.ts"
8
+
9
+ export interface ImapConfig {
10
+ host: string
11
+ port: number
12
+ secure: boolean
13
+ user: string
14
+ password: string
15
+ folder: string
16
+ /** "XOAUTH2" needs a token command (Outlook). "LOGIN" uses an app password. */
17
+ authType?: "LOGIN" | "XOAUTH2"
18
+ /** argv whose stdout is an access token. Required when authType is XOAUTH2. */
19
+ tokenCommand?: string[]
20
+ /**
21
+ * Folder holding mail we sent. When set, the daemon reads the just-sent copy back and
22
+ * records the Message-ID the provider actually delivered - some providers (QQ) replace the
23
+ * one we stamp, which silently breaks the reply-chain fallback.
24
+ */
25
+ sentFolder?: string
26
+ }
27
+
28
+ export interface SmtpConfig {
29
+ host: string
30
+ port: number
31
+ secure: boolean
32
+ user: string
33
+ password: string
34
+ /** "XOAUTH2" needs an OAuth broker; "LOGIN" is a provider app password. */
35
+ authType?: "LOGIN" | "XOAUTH2"
36
+ /** argv whose stdout is an access token. Required when authType is XOAUTH2. */
37
+ tokenCommand?: string[]
38
+ }
39
+
40
+ export interface ResolvedConfig {
41
+ mail: { imap: ImapConfig; smtp: SmtpConfig; to: string }
42
+ /** Who this daemon is: named in settle mails, stored on the routing record. */
43
+ device: { name: string }
44
+ /**
45
+ * Device-code OAuth2, provider-agnostic. When present it is preferred over
46
+ * `tokenCommand` for XOAUTH2, because it refreshes itself and needs no external
47
+ * broker. See core/oauth-device.ts.
48
+ */
49
+ oauth?: { issuer: string; clientId: string; clientSecret?: string; scope: string }
50
+ security: {
51
+ allowFrom: string[]
52
+ requireDmarcPass: boolean
53
+ /** Only AR headers whose authserv-id equals this are trusted. See SECURITY.md §2.1. */
54
+ trustedAuthservId?: string
55
+ /**
56
+ * Accept a reply whose subject token was lost, by matching `In-Reply-To` /
57
+ * `References` against Message-IDs we minted. Default true; see SECURITY.md.
58
+ */
59
+ allowHeaderThreading?: boolean
60
+ /**
61
+ * Allow the pi mailbox and the human to be the same address (one personal
62
+ * Hotmail/Gmail account). A self-addressed message still has to be an actual
63
+ * reply, i.e. carry In-Reply-To. Default false.
64
+ */
65
+ allowSelfReplies?: boolean
66
+ tokenTtlHours: number
67
+ }
68
+ daemon: {
69
+ host: string
70
+ port: number
71
+ /** Grace period after the last ping before exiting. Default 120. */
72
+ idleTimeoutSecs?: number
73
+ /** Safety-net catch-up scan cadence, so a silent IDLE cannot strand a reply. Default 60; 0 disables. */
74
+ scanIntervalSecs?: number
75
+ }
76
+ notify: {
77
+ onSettled: boolean
78
+ debounceMs: number
79
+ notifyOnAborted: boolean
80
+ /** Total bytes of files attached to one notification, before base64 encoding. */
81
+ maxAttachmentMb: number
82
+ }
83
+ tuning: { idleRenewMs: number; backoffInitialMs: number; backoffMaxMs: number }
84
+ /** Human-readable problems found while loading; surfaced by `/mailbridge`. */
85
+ warnings: string[]
86
+ }
87
+
88
+ /** Marks a warning produced by repairing a malformed section: the config still works. */
89
+ export const REPAIRED_PREFIX = "repaired: "
90
+
91
+ const DEFAULTS: Omit<ResolvedConfig, "warnings"> = {
92
+ mail: {
93
+ imap: { host: "", port: 993, secure: true, user: "", password: "", folder: "INBOX" },
94
+ smtp: { host: "", port: 465, secure: true, user: "", password: "", authType: "LOGIN" },
95
+ to: "",
96
+ },
97
+ device: { name: "" }, // resolved below: hostname() unless configured
98
+ security: {
99
+ allowFrom: [],
100
+ requireDmarcPass: true,
101
+ allowHeaderThreading: true,
102
+ allowSelfReplies: false,
103
+ tokenTtlHours: 30 * 24,
104
+ },
105
+ daemon: { host: "127.0.0.1", port: 4177, idleTimeoutSecs: 120, scanIntervalSecs: 60 },
106
+ notify: {
107
+ onSettled: true,
108
+ debounceMs: 10_000,
109
+ notifyOnAborted: false,
110
+ // 18 MB raw is about 24 MB once base64-encoded, under the 25 MB ceiling that Gmail and
111
+ // Outlook.com enforce. A provider that allows more (QQ: 50 MB) can raise it.
112
+ maxAttachmentMb: 18,
113
+ },
114
+ tuning: {
115
+ idleRenewMs: 25 * 60 * 1000,
116
+ backoffInitialMs: 1000,
117
+ backoffMaxMs: 60_000,
118
+ },
119
+ }
120
+
121
+ function isRecord(v: unknown): v is Record<string, unknown> {
122
+ return typeof v === "object" && v !== null && !Array.isArray(v)
123
+ }
124
+
125
+ /** First label of the hostname, lowercased: `dev-box-7.local` -> `dev-box-7`. */
126
+ export function shortHostname(): string {
127
+ try {
128
+ const full = hostname().toLowerCase()
129
+ return full.split(".")[0] || "unknown-device"
130
+ } catch {
131
+ return "unknown-device"
132
+ }
133
+ }
134
+
135
+ /** Parse a token command from env: JSON array, or comma-separated argv. */
136
+ function parseArgv(raw: string): string[] {
137
+ const trimmed = raw.trim()
138
+ if (trimmed.startsWith("[")) {
139
+ try {
140
+ const parsed = JSON.parse(trimmed)
141
+ if (Array.isArray(parsed)) return parsed.map(String)
142
+ } catch {
143
+ /* fall through to comma splitting */
144
+ }
145
+ }
146
+ return trimmed
147
+ .split(",")
148
+ .map((s) => s.trim())
149
+ .filter(Boolean)
150
+ }
151
+
152
+ /** Shallow-by-section merge: enough for this config shape, and predictable. */
153
+ function merge(base: any, over: any): any {
154
+ if (!isRecord(base) || !isRecord(over)) return over ?? base
155
+ const out: Record<string, unknown> = { ...base }
156
+ for (const [k, v] of Object.entries(over)) {
157
+ out[k] = isRecord(v) && isRecord(base[k]) ? merge(base[k], v) : v
158
+ }
159
+ return out
160
+ }
161
+
162
+ function readJson(path: string, warnings: string[]): Record<string, unknown> {
163
+ if (!existsSync(path)) return {}
164
+ try {
165
+ const parsed = JSON.parse(readFileSync(path, "utf8"))
166
+ return isRecord(parsed) ? parsed : {}
167
+ } catch (error) {
168
+ warnings.push(`${path}: ${(error as Error).message}`)
169
+ return {}
170
+ }
171
+ }
172
+
173
+ function envOverrides(env: NodeJS.ProcessEnv): Record<string, unknown> {
174
+ const o: Record<string, unknown> = {}
175
+ const set = (path: string[], value: unknown) => {
176
+ if (value === undefined) return
177
+ let cur = o
178
+ for (const key of path.slice(0, -1)) {
179
+ cur[key] ??= {}
180
+ cur = cur[key] as Record<string, unknown>
181
+ }
182
+ cur[path[path.length - 1]!] = value
183
+ }
184
+ set(["mail", "imap", "host"], env.PI_MAILBRIDGE_IMAP_HOST)
185
+ set(["mail", "imap", "user"], env.PI_MAILBRIDGE_IMAP_USER)
186
+ set(["mail", "imap", "password"], env.PI_MAILBRIDGE_IMAP_PASS)
187
+ set(["mail", "smtp", "host"], env.PI_MAILBRIDGE_SMTP_HOST)
188
+ set(["mail", "smtp", "user"], env.PI_MAILBRIDGE_SMTP_USER)
189
+ set(["mail", "smtp", "password"], env.PI_MAILBRIDGE_SMTP_PASS)
190
+ set(["mail", "to"], env.PI_MAILBRIDGE_TO)
191
+ set(["daemon", "port"], env.PI_MAILBRIDGE_PORT)
192
+ set(["device", "name"], env.PI_MAILBRIDGE_DEVICE)
193
+ // Token commands are argv lists; accept JSON or a comma-separated argv.
194
+ if (env.PI_MAILBRIDGE_IMAP_TOKEN_CMD) set(["mail", "imap", "tokenCommand"], parseArgv(env.PI_MAILBRIDGE_IMAP_TOKEN_CMD))
195
+ if (env.PI_MAILBRIDGE_SMTP_TOKEN_CMD) set(["mail", "smtp", "tokenCommand"], parseArgv(env.PI_MAILBRIDGE_SMTP_TOKEN_CMD))
196
+ return o
197
+ }
198
+
199
+ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ResolvedConfig {
200
+ const warnings: string[] = []
201
+ let cfg: any = DEFAULTS
202
+ cfg = merge(cfg, readJson(configPath(env), warnings))
203
+ cfg = merge(cfg, envOverrides(env))
204
+ const resolved: ResolvedConfig = { ...cfg, warnings }
205
+
206
+ // Every check below dereferences these sections, and loadConfig's contract is that it never
207
+ // throws. A JSON `null` is not a record, and the env merge runs after the file merge - so a
208
+ // null section can come back as a *partial* record (e.g. `{"daemon": null}` plus
209
+ // A daemon env var over a null section leaves a record with no host and no port, which would bind an
210
+ // ephemeral port and drop the single-instance guard). Repair by filling the defaults
211
+ // underneath whatever survived, rather than replacing it.
212
+ const sections = resolved as unknown as Record<string, unknown>
213
+ for (const key of Object.keys(DEFAULTS) as (keyof typeof DEFAULTS)[]) {
214
+ const section = sections[key]
215
+ const defaults = DEFAULTS[key] as Record<string, unknown>
216
+ if (!isRecord(section)) {
217
+ warnings.push(`${REPAIRED_PREFIX}${key} must be an object — using the defaults`)
218
+ sections[key] = structuredClone(defaults)
219
+ continue
220
+ }
221
+ const repaired: Record<string, unknown> = { ...defaults, ...section }
222
+ // The schema nests exactly one level (mail.imap, mail.smtp).
223
+ for (const [sub, subDefaults] of Object.entries(defaults)) {
224
+ if (!isRecord(subDefaults)) continue
225
+ const value = section[sub]
226
+ if (!isRecord(value)) {
227
+ warnings.push(`${REPAIRED_PREFIX}${key}.${sub} must be an object — using the defaults`)
228
+ repaired[sub] = structuredClone(subDefaults)
229
+ } else {
230
+ repaired[sub] = { ...subDefaults, ...value }
231
+ }
232
+ }
233
+ sections[key] = repaired
234
+ }
235
+
236
+ // Device identity: a short, stable name so a notification says which machine
237
+ // the session is on. Hostname is the sensible default; it can be overridden
238
+ // when hostnames are opaque (e.g. `dev-box-7`).
239
+ const configuredDevice = String(resolved.device?.name ?? "").trim()
240
+ resolved.device = { name: configuredDevice || shortHostname() }
241
+
242
+ // Validation that must not throw in-process.
243
+ // allowFrom is checked with .length below, which a string also satisfies - and then
244
+ // every message throws in the allowlist check, killing the scan silently forever.
245
+ const rawAllow: unknown = resolved.security?.allowFrom
246
+ if (!Array.isArray(rawAllow)) {
247
+ warnings.push("security.allowFrom must be an array of addresses — ignoring it (fail closed)")
248
+ resolved.security = { ...resolved.security, allowFrom: [] }
249
+ } else {
250
+ resolved.security = { ...resolved.security, allowFrom: rawAllow.map((v) => String(v).trim()).filter(Boolean) }
251
+ }
252
+
253
+ // A quoted or non-numeric delay silently disables debouncing (setTimeout coerces it).
254
+ const rawDebounce: unknown = resolved.notify?.debounceMs
255
+ const debounce = typeof rawDebounce === "number" ? rawDebounce : Number.NaN
256
+ if (!Number.isFinite(debounce) || debounce < 0) {
257
+ if (rawDebounce !== undefined) warnings.push("notify.debounceMs must be a non-negative number — using 10000")
258
+ resolved.notify = { ...resolved.notify, debounceMs: 10_000 }
259
+ }
260
+
261
+ // `listen("abc")` is a UNIX socket path in Node, and a float is not a port. The env var is
262
+ // documented, and injected as a string, so coerce it rather than trusting it.
263
+ const rawPort: unknown = resolved.daemon.port
264
+ const port = typeof rawPort === "number" ? rawPort : Number(rawPort)
265
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
266
+ if (rawPort !== undefined) warnings.push("daemon.port must be an integer 1-65535 — using 4177")
267
+ resolved.daemon = { ...resolved.daemon, port: 4177 }
268
+ } else {
269
+ resolved.daemon = { ...resolved.daemon, port }
270
+ }
271
+
272
+ // Attachments are encoded ~4/3, and an over-limit send fails at the provider with the whole
273
+ // notification lost. Keep the default under the 25 MB Gmail/Outlook ceiling; cap the ceiling
274
+ // at what the largest common provider (QQ: 50 MB) accepts.
275
+ const rawMaxMb: unknown = resolved.notify.maxAttachmentMb
276
+ if (rawMaxMb !== undefined) {
277
+ const mb = typeof rawMaxMb === "number" ? rawMaxMb : Number.NaN
278
+ if (!Number.isFinite(mb) || mb < 1 || mb > 45) {
279
+ warnings.push("notify.maxAttachmentMb must be a number between 1 and 45 — using 18")
280
+ resolved.notify = { ...resolved.notify, maxAttachmentMb: 18 }
281
+ } else {
282
+ resolved.notify = { ...resolved.notify, maxAttachmentMb: mb }
283
+ }
284
+ }
285
+
286
+ // A zero or NaN idle timeout makes the daemon exit ~1 s after the first ping, which looks
287
+ // like a crash-loop; a NaN token TTL makes a route never expire. Both fail closed. The floor
288
+ // is 3x the session poll interval: a timeout shorter than the ping period reaps the daemon
289
+ // between ticks, so it would flap instead of serving.
290
+ const rawIdle: unknown = resolved.daemon.idleTimeoutSecs
291
+ const idle = typeof rawIdle === "number" ? rawIdle : Number.NaN
292
+ if (!Number.isFinite(idle) || idle < 30) {
293
+ if (rawIdle !== undefined) {
294
+ warnings.push("daemon.idleTimeoutSecs must be at least 30 (three poll intervals) — using 120")
295
+ }
296
+ resolved.daemon = { ...resolved.daemon, idleTimeoutSecs: 120 }
297
+ }
298
+
299
+ const rawTtl: unknown = resolved.security.tokenTtlHours
300
+ const ttl = typeof rawTtl === "number" ? rawTtl : Number.NaN
301
+ if (!Number.isFinite(ttl) || ttl < 1) {
302
+ if (rawTtl !== undefined) warnings.push("security.tokenTtlHours must be a positive number — using 720")
303
+ resolved.security = { ...resolved.security, tokenTtlHours: 30 * 24 }
304
+ }
305
+
306
+ // A negative, null or non-numeric interval would silently disable the safety net.
307
+ // `Number(null)` is 0, which would read as "the user disabled the sweep" — only a real
308
+ // number counts.
309
+ const rawInterval: unknown = resolved.daemon.scanIntervalSecs
310
+ if (rawInterval !== undefined) {
311
+ const n = typeof rawInterval === "number" ? rawInterval : Number.NaN
312
+ if (!Number.isFinite(n) || n < 0) {
313
+ warnings.push("daemon.scanIntervalSecs must be a non-negative number — using 60")
314
+ resolved.daemon = { ...resolved.daemon, scanIntervalSecs: 60 }
315
+ } else {
316
+ resolved.daemon = { ...resolved.daemon, scanIntervalSecs: n }
317
+ }
318
+ }
319
+
320
+ const imap = resolved.mail.imap
321
+ if (!imap.host || !imap.user) warnings.push("mail.imap.host and mail.imap.user are required")
322
+ if (!resolved.mail.to) warnings.push("mail.to is required")
323
+ // Setup validates this now; a hand-edited config still can be a typo, and every notification
324
+ // would go nowhere while the daemon reported itself healthy.
325
+ else if (!String(resolved.mail.to).includes("@")) {
326
+ warnings.push("mail.to does not look like an email address — notifications cannot be delivered")
327
+ }
328
+ if (resolved.security.allowFrom.length === 0) {
329
+ warnings.push("security.allowFrom is empty — every sender will be rejected (fail closed)")
330
+ }
331
+ // An unset authserv-id is a legitimate configuration now: authorization falls
332
+ // back to the topmost Authentication-Results header. Reported as a note by
333
+ // `--check`, not as a warning, because it is not a problem. Never a hard failure.
334
+ // XOAUTH2 needs exactly one token source: a built-in device-code flow or a command.
335
+ for (const side of ["imap", "smtp"] as const) {
336
+ const account = resolved.mail[side]
337
+ if (account.authType !== "XOAUTH2") continue
338
+ const hasCommand = Boolean(account.tokenCommand?.length)
339
+ const hasOAuth = Boolean(resolved.oauth?.issuer && resolved.oauth?.clientId)
340
+ if (!hasCommand && !hasOAuth) {
341
+ warnings.push(
342
+ `mail.${side}.authType is XOAUTH2 but neither mail.${side}.tokenCommand nor oauth.issuer/oauth.clientId is set`,
343
+ )
344
+ }
345
+ if (hasOAuth && /(partner\.microsoftonline\.cn|chinacloudapi\.cn)/i.test(resolved.oauth?.issuer ?? "")) {
346
+ warnings.push(
347
+ "oauth.issuer points at the Azure China (21Vianet) sovereign cloud — apps registered there cannot authenticate against global Outlook.com; use login.microsoftonline.com",
348
+ )
349
+ }
350
+ if (hasOAuth && !/\boffline_access\b/.test(resolved.oauth?.scope ?? "")) {
351
+ warnings.push("oauth.scope should include offline_access, otherwise no refresh token is issued")
352
+ }
353
+ if (account.password) {
354
+ warnings.push(`mail.${side}.password is set but authType is XOAUTH2 — the password will be ignored`)
355
+ }
356
+ }
357
+
358
+ // A LOGIN account with no password cannot authenticate, and saying "config ok"
359
+ // in that state sends the user straight into a confusing smoke failure.
360
+ for (const side of ["imap", "smtp"] as const) {
361
+ const account = resolved.mail[side]
362
+ if (account.authType !== "XOAUTH2" && !account.password) {
363
+ warnings.push(
364
+ `mail.${side}.password is empty — paste the provider's app password there (QQ/163 call it 授权码)`,
365
+ )
366
+ }
367
+ }
368
+ return resolved
369
+ }
370
+
371
+ export function hasUsableMailConfig(c: ResolvedConfig): boolean {
372
+ return Boolean(c.mail.imap.host && c.mail.imap.user && c.mail.smtp.host && c.mail.to)
373
+ }
@@ -0,0 +1,233 @@
1
+ // Delivery runner — the extension side of claim → inject → ack.
2
+ //
3
+ // Invariants, in order of importance:
4
+ //
5
+ // 1. Never inject a delivery for another session. The token already routed it;
6
+ // this is the second check, and it is cheap.
7
+ // 2. Never ack a delivery that was not injected. Acking early would mark the
8
+ // mail seen and drop the record, losing the human's reply.
9
+ // 3. Never inject the same UID twice *in this process*, even if the ack failed
10
+ // — a reconnect replays /pending, and a duplicate injection is worse than a
11
+ // stale seen-flag.
12
+ // 4. A failed claim (another instance owns it) is not an error: leave it alone.
13
+ //
14
+ // The runner is deliberately synchronous-per-delivery: mail replies are serial and
15
+ // ordering them keeps the transcript readable.
16
+
17
+ import type { PendingRecord } from "../store/pending-store.ts"
18
+
19
+ /** The daemon calls this runner needs. `createDaemonClient` satisfies it. */
20
+ export type ClientResult<T> = { ok: true; data: T } | { ok: false; status: number; code?: string }
21
+
22
+ export interface DeliveryClient {
23
+ claim(uid: number, instanceId: string, uidValidity?: number): Promise<ClientResult<{ ok: boolean }>>
24
+ ack(uid: number, instanceId: string, sessionId?: string, uidValidity?: number): Promise<ClientResult<{ ok: boolean }>>
25
+ pending(): Promise<ClientResult<{ pending: PendingRecord[] }>>
26
+ }
27
+
28
+ export type DeliveryOutcome =
29
+ | "injected"
30
+ | "already-injected"
31
+ | "foreign-session"
32
+ | "claimed-by-other"
33
+ | "inject-failed"
34
+ | "claim-unconfirmed"
35
+ | "stale-space"
36
+ | "foreign-device"
37
+ | "not-found"
38
+
39
+ export interface DeliveryRunnerDeps {
40
+ client: DeliveryClient
41
+ /** Stable id for this pi process; the daemon uses it for claim exclusivity. */
42
+ instanceId: string
43
+ /** Current session id, or undefined when there is no session. */
44
+ sessionId: () => string | undefined
45
+ /** Inject into the live session. Throw to signal failure (do not ack). */
46
+ inject: (body: string, record: PendingRecord) => Promise<void>
47
+ onError?: (error: Error, record: PendingRecord) => void
48
+ /**
49
+ * Is delivery still wanted? Checked between records, so `/mailbridge off` arriving as a
50
+ * reply stops the batch it came in: the records behind it stay pending (unclaimed, unseen)
51
+ * and are delivered when the mode goes back on, instead of landing in a session the human
52
+ * just asked to leave alone.
53
+ */
54
+ stillDelivering?: () => boolean
55
+ }
56
+
57
+ export interface DeliveryRunner {
58
+ handle(record: PendingRecord): Promise<DeliveryOutcome>
59
+ /** Replay everything the daemon still holds. Used on connect and reconnect. */
60
+ drain(): Promise<DeliveryOutcome[]>
61
+ /** Test/diagnostic: uids injected by this runner. */
62
+ injectedUids(): number[]
63
+ }
64
+
65
+ /** In-flight drains, shared across runner instances in this process. */
66
+ const inFlightByInstance = new Map<string, Promise<DeliveryOutcome[]>>()
67
+
68
+ /**
69
+ * UIDs already injected, per instance.
70
+ *
71
+ * Per instance, not per runner object: a rebuilt runtime (a respawn, or a new session in
72
+ * the same pi process after session_shutdown) constructs a new runner while the claim key -
73
+ * the pid - stays the same. A per-runner set let the rebuilt runner re-inject a reply whose
74
+ * ack had failed, which is the one failure this design cannot undo.
75
+ */
76
+ const injectedByInstance = new Map<string, Set<string>>()
77
+ /** A process has one instanceId; this only guards against a pathological caller. */
78
+ const MAX_INSTANCE_SETS = 8
79
+ /** Cap per instance, like the journal: one key per injected message, oldest dropped. */
80
+ const MAX_INJECTED_PER_INSTANCE = 5_000
81
+
82
+ /** UIDs are only unique within a UIDVALIDITY, so the key must carry it. */
83
+ function injectionKey(record: { uid: number; uidValidity?: number }): string {
84
+ return `${record.uidValidity ?? ""}:${record.uid}`
85
+ }
86
+
87
+ function injectedFor(instanceId: string): Set<string> {
88
+ let set = injectedByInstance.get(instanceId)
89
+ if (!set) {
90
+ set = new Set<string>()
91
+ injectedByInstance.set(instanceId, set)
92
+ if (injectedByInstance.size > MAX_INSTANCE_SETS) {
93
+ const oldest = injectedByInstance.keys().next().value
94
+ // Never evict the caller's own set: losing it would let this instance re-inject.
95
+ if (oldest !== undefined && oldest !== instanceId) injectedByInstance.delete(oldest)
96
+ }
97
+ }
98
+ return set
99
+ }
100
+
101
+ export function createDeliveryRunner(deps: DeliveryRunnerDeps): DeliveryRunner {
102
+ /** Stale-space refusals already reported, so a stuck record does not log every poll. */
103
+ const reported = new Set<string>()
104
+ const injected = (): Set<string> => injectedFor(deps.instanceId)
105
+
106
+ async function handle(record: PendingRecord): Promise<DeliveryOutcome> {
107
+ if (injected().has(injectionKey(record))) {
108
+ // Injected earlier but possibly not acked: a single failed /ack used to pin the
109
+ // record for the process lifetime, leaking a queue slot, leaving \Seen unset and
110
+ // the journal unwritten - and after a restart (new pid, new instance) the same
111
+ // instruction would be injected again. The ack is idempotent, so retry it.
112
+ try {
113
+ const retried = await deps.client.ack(record.uid, deps.instanceId, record.sessionId, record.uidValidity)
114
+ // A retry that keeps failing is the same silent leak as a first ack that
115
+ // failed: surface it rather than looping quietly.
116
+ if (!retried.ok && retried.status !== 404) {
117
+ deps.onError?.(new Error(`ack retry failed (${retried.code ?? retried.status})`), record)
118
+ }
119
+ } catch (error) {
120
+ deps.onError?.(error instanceof Error ? error : new Error(String(error)), record)
121
+ }
122
+ return "already-injected"
123
+ }
124
+
125
+ const mine = deps.sessionId()
126
+ if (!mine || record.sessionId !== mine) return "foreign-session"
127
+
128
+ const claim = await deps.client.claim(record.uid, deps.instanceId, record.uidValidity)
129
+ if (!claim.ok) {
130
+ // Expected when another instance won the race; the daemon answers 409.
131
+ if (claim.code === "claimed-by-other") return "claimed-by-other"
132
+ // The record belongs to a UID numbering the mailbox has left. It cannot be delivered
133
+ // and is dropped at the next connect; say so once rather than retrying in silence
134
+ // every poll tick.
135
+ if (claim.code === "stale-space") {
136
+ const key = injectionKey(record)
137
+ if (!reported.has(key)) {
138
+ reported.add(key)
139
+ deps.onError?.(new Error("reply belongs to an earlier UID space; it will be dropped"), record)
140
+ }
141
+ return "stale-space"
142
+ }
143
+ // A record this daemon does not own (another machine's, or one stamped under a
144
+ // device name that has since changed) cannot be delivered by retrying. Say it once:
145
+ // silence here is how a queued reply is lost without a trace.
146
+ if (claim.code === "foreign-device") {
147
+ const key = injectionKey(record)
148
+ if (!reported.has(key)) {
149
+ reported.add(key)
150
+ deps.onError?.(
151
+ new Error("reply was scanned under another device name; it will not be delivered here"),
152
+ record,
153
+ )
154
+ }
155
+ return "foreign-device"
156
+ }
157
+ // Only a definite 404 means the record is gone. Anything else - a timeout, a
158
+ // network error, a rotated secret (401) - leaves the outcome unknown, and calling
159
+ // that "not-found" is the same lie in a different costume. It self-heals (the
160
+ // claim TTL expires, the next drain re-claims), but the diagnostic must not mislead.
161
+ if (claim.status === 404) return "not-found"
162
+ return "claim-unconfirmed"
163
+ }
164
+
165
+ try {
166
+ await deps.inject(record.body, record)
167
+ } catch (error) {
168
+ // Do NOT ack: the delivery must stay pending so it is retried.
169
+ deps.onError?.(error instanceof Error ? error : new Error(String(error)), record)
170
+ return "inject-failed"
171
+ }
172
+
173
+ // Record before acking: if the ack fails we still refuse to inject twice.
174
+ const set = injected()
175
+ set.add(injectionKey(record))
176
+ // Bounded like the journal. Evicting an entry whose ack never landed would let it be
177
+ // injected again, so this is a last resort, not a free trade.
178
+ while (set.size > MAX_INJECTED_PER_INSTANCE) {
179
+ const oldest = set.values().next().value
180
+ if (oldest === undefined) break
181
+ set.delete(oldest)
182
+ }
183
+
184
+ try {
185
+ const ackResult = await deps.client.ack(record.uid, deps.instanceId, record.sessionId, record.uidValidity)
186
+ if (!ackResult.ok) {
187
+ // Injected but not acked: the record will be re-offered on reconnect and
188
+ // the `injected` set will absorb it. Surface it — a silent ack failure
189
+ // hides a growing pending queue.
190
+ deps.onError?.(new Error(`ack failed (${ackResult.code ?? ackResult.status})`), record)
191
+ }
192
+ } catch (error) {
193
+ // Injected but not acked: same handling as above.
194
+ deps.onError?.(error instanceof Error ? error : new Error(String(error)), record)
195
+ }
196
+
197
+ return "injected"
198
+ }
199
+
200
+ return {
201
+ handle,
202
+ injectedUids: () => [...injected()].map((key) => Number(key.split(":")[1])),
203
+
204
+ async drain() {
205
+ // One drain at a time. The claim registry is keyed per pid, so two overlapping
206
+ // drains in this process would both be granted the same uid and inject twice -
207
+ // and a duplicate injection cannot be undone.
208
+ // Keyed by instanceId, not by this runner object: a respawn builds a new runner
209
+ // while the claim key (the pid) stays the same, so a per-object flag would let an
210
+ // old runner's drain overlap a new one's.
211
+ const running = inFlightByInstance.get(deps.instanceId)
212
+ if (running) return running
213
+ const attempt = (async () => {
214
+ try {
215
+ const result = await deps.client.pending()
216
+ if (!result.ok) return []
217
+ const outcomes: DeliveryOutcome[] = []
218
+ for (const record of result.data.pending) {
219
+ // Re-checked every record: a reply that turns the mode off is in this very
220
+ // batch, and everything after it must wait.
221
+ if (deps.stillDelivering && !deps.stillDelivering()) break
222
+ outcomes.push(await handle(record))
223
+ }
224
+ return outcomes
225
+ } finally {
226
+ inFlightByInstance.delete(deps.instanceId)
227
+ }
228
+ })()
229
+ inFlightByInstance.set(deps.instanceId, attempt)
230
+ return attempt
231
+ },
232
+ }
233
+ }