@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,267 @@
1
+ // Short routing ids.
2
+ //
3
+ // The signed token proved out but cost ~42 characters of base64 in every subject
4
+ // line, which is most of what a mail client shows. Since the routing state is
5
+ // already local (per-daemon cursor, journal, pending store, thread ids), the subject
6
+ // only needs a local lookup key.
7
+ //
8
+ // So: mint a 6-hex-character id, remember which session it belongs to, and put
9
+ // `[pi:a1b2c3]` in the subject. Twelve characters instead of forty-two.
10
+ //
11
+ // What this gives up and why it is acceptable:
12
+ // - The tag is not self-describing, so another machine cannot resolve it. That is
13
+ // already true of thread ids, and correct: a device must not act on another
14
+ // device's mail.
15
+ // - The tag is not signed. It does not need to be: it is only a lookup key, and an
16
+ // unknown key resolves to nothing. Authorization is still the sender allowlist
17
+ // plus DMARC (docs/SECURITY.md §2.2).
18
+ //
19
+ // Signed tokens keep working: messages minted before this change still route.
20
+
21
+ import {
22
+ closeSync,
23
+ existsSync,
24
+ mkdirSync,
25
+ openSync,
26
+ readFileSync,
27
+ renameSync,
28
+ statSync,
29
+ unlinkSync,
30
+ writeFileSync,
31
+ writeSync,
32
+ } from "node:fs"
33
+ import { dirname } from "node:path"
34
+ import { randomBytes } from "node:crypto"
35
+
36
+ import { statePath } from "./paths.ts"
37
+ import { StateVersionError, checkStateVersion, stamp } from "./schema.ts"
38
+
39
+ /** Keep this many live routes. One per session (reused and refreshed), not per mail. */
40
+ export const ROUTES_CAP = 2_000
41
+ /** 6 hex characters: 16.7M values, ample for one mailbox's notifications. */
42
+ const ID_BYTES = 3
43
+ const ID_HEX = ID_BYTES * 2
44
+
45
+ /** 6 hex normally; 8 is the rare collision fallback further down, and must still match. */
46
+ export const ROUTE_RE = /\[\s*pi:([0-9a-f]{6,8})\s*\]/i
47
+
48
+ export interface RouteRecord {
49
+ id: string
50
+ sessionId: string
51
+ device?: string
52
+ /** Epoch ms; the route stops resolving after this. */
53
+ expiresAt: number
54
+ subject: string
55
+ }
56
+
57
+ interface RoutesFile {
58
+ v?: number
59
+ items: RouteRecord[]
60
+ }
61
+
62
+ function path(env: NodeJS.ProcessEnv): string {
63
+ return statePath(env, "routes.json")
64
+ }
65
+
66
+ function load(env: NodeJS.ProcessEnv): RouteRecord[] {
67
+ const p = path(env)
68
+ try {
69
+ if (!existsSync(p)) return []
70
+ const parsed = JSON.parse(readFileSync(p, "utf8")) as Partial<RoutesFile>
71
+ checkStateVersion("routes.json", parsed)
72
+ if (!Array.isArray(parsed?.items)) return []
73
+ return parsed.items.filter(
74
+ (r): r is RouteRecord =>
75
+ typeof r?.id === "string" && typeof r?.sessionId === "string" && typeof r?.expiresAt === "number",
76
+ )
77
+ } catch (error) {
78
+ if (error instanceof StateVersionError) throw error
79
+ return []
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Cross-process lock around the read-modify-write.
85
+ *
86
+ * routes.json is the one store written by every pi process, not just the daemon. Without a
87
+ * lock two sessions settling at once each write their whole in-memory copy and one route is
88
+ * lost - and a lost route means a reply falls back to the threading chain, or to `no-token`.
89
+ * The lock is an O_EXCL file, removed on release and stolen if a holder died.
90
+ */
91
+ /**
92
+ * A lock older than this is treated as abandoned by a process that died holding it.
93
+ * Legitimate holds are single-digit milliseconds; this is only reached by a dead holder or a
94
+ * process stalled for seconds, which cannot be told apart without an OS-level flock.
95
+ */
96
+ const LOCK_STALE_MS = 3_000
97
+ /** Retry budget must exceed the stale threshold, or a stale lock can never be stolen. */
98
+ const LOCK_ATTEMPTS = 900
99
+ const LOCK_SLEEP_MS = 5
100
+
101
+ function withLock<T>(env: NodeJS.ProcessEnv, run: () => T): T {
102
+ const lock = statePath(env, "routes.lock")
103
+ mkdirSync(dirname(lock), { recursive: true, mode: 0o700 })
104
+ // The owner is written into the file so a release only removes a lock it still holds.
105
+ const owner = `${process.pid}:${randomBytes(6).toString("hex")}`
106
+ const sleep = (): void => {
107
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_SLEEP_MS)
108
+ }
109
+ for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
110
+ try {
111
+ const fd = openSync(lock, "wx", 0o600)
112
+ try {
113
+ writeSync(fd, owner)
114
+ } catch {
115
+ /* the owner tag is a nicety; the lock itself is the guarantee */
116
+ }
117
+ try {
118
+ return run()
119
+ } finally {
120
+ closeSync(fd)
121
+ // Release by renaming the lock aside and checking the *renamed* file: rename is
122
+ // atomic, so a stealer that swapped the lock in between cannot have its own lock
123
+ // deleted by us (read-then-unlink had that window).
124
+ try {
125
+ const aside = `${lock}.mine.${process.pid}.${randomBytes(4).toString("hex")}`
126
+ renameSync(lock, aside)
127
+ if (readFileSync(aside, "utf8") === owner) unlinkSync(aside)
128
+ else renameSync(aside, lock) // we took somebody else's: put it back
129
+ } catch {
130
+ /* already gone, or the path was recreated: leave it alone */
131
+ }
132
+ }
133
+ } catch (error) {
134
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
135
+ // Steal only a lock old enough to belong to a dead holder, and steal it by
136
+ // *renaming* it aside: rename is atomic, so two stealers cannot both succeed (the
137
+ // loser gets ENOENT). The previous unlink-based steal let a second stealer delete
138
+ // the lock the first had just created, putting both writers in the critical section.
139
+ try {
140
+ if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
141
+ const aside = `${lock}.stale.${process.pid}.${randomBytes(4).toString("hex")}`
142
+ renameSync(lock, aside)
143
+ unlinkSync(aside)
144
+ }
145
+ } catch {
146
+ /* raced with the holder or another stealer: retry */
147
+ }
148
+ sleep()
149
+ }
150
+ }
151
+ throw new Error("pi-mailbridge: routes.json is locked by another process")
152
+ }
153
+
154
+ function save(env: NodeJS.ProcessEnv, items: RouteRecord[]): void {
155
+ const p = path(env)
156
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 })
157
+ const tmp = `${p}.${process.pid}.tmp`
158
+ const trimmed = items.length > ROUTES_CAP ? items.slice(items.length - ROUTES_CAP) : items
159
+ writeFileSync(tmp, JSON.stringify({ ...stamp(), items: trimmed } satisfies RoutesFile), { mode: 0o600 })
160
+ renameSync(tmp, p)
161
+ }
162
+
163
+ export interface RegisterRouteOptions {
164
+ sessionId: string
165
+ subject: string
166
+ device?: string
167
+ ttlHours?: number
168
+ now?: number
169
+ env?: NodeJS.ProcessEnv
170
+ }
171
+
172
+ /** Mint a route for an outbound notification and return its short id. */
173
+ export function registerRoute(options: RegisterRouteOptions): string {
174
+ const env = options.env ?? process.env
175
+ const now = options.now ?? Date.now()
176
+ const ttlHours = options.ttlHours ?? 30 * 24
177
+
178
+ return withLock(env, () => registerLocked(options, env, now, ttlHours))
179
+ }
180
+
181
+ function registerLocked(options: RegisterRouteOptions, env: NodeJS.ProcessEnv, now: number, ttlHours: number): string {
182
+ const items = load(env).filter((r) => r.expiresAt > now)
183
+ const taken = new Set(items.map((r) => r.id))
184
+
185
+ let id = ""
186
+ for (let attempt = 0; attempt < 32; attempt++) {
187
+ const candidate = randomBytes(ID_BYTES).toString("hex")
188
+ if (!taken.has(candidate)) {
189
+ id = candidate
190
+ break
191
+ }
192
+ }
193
+ // Astronomically unlikely; better to widen than to reuse someone's id.
194
+ if (!id) id = randomBytes(ID_BYTES + 1).toString("hex")
195
+
196
+ items.push({
197
+ id,
198
+ sessionId: options.sessionId,
199
+ ...(options.device ? { device: options.device } : {}),
200
+ expiresAt: now + ttlHours * 3600_000,
201
+ subject: options.subject,
202
+ })
203
+ save(env, items)
204
+ return id
205
+ }
206
+
207
+ /**
208
+ * One route per **session**, reused and refreshed on every notification.
209
+ *
210
+ * Per-message ids made every subject unique, which defeats the conversation grouping
211
+ * that mail clients do by subject - each notification became its own thread. Keeping the
212
+ * id stable per session means the subject is byte-identical across notifications, so
213
+ * Outlook's conversation topic and Gmail's subject grouping both keep one thread, and
214
+ * the RFC 5322 headers we also set have something stable to point at.
215
+ */
216
+ export function ensureRouteForSession(options: RegisterRouteOptions): string {
217
+ const env = options.env ?? process.env
218
+ const now = options.now ?? Date.now()
219
+ const ttlHours = options.ttlHours ?? 30 * 24
220
+
221
+ return withLock(env, () => {
222
+ const items = load(env)
223
+ const existing = items.find((r) => r.sessionId === options.sessionId && r.expiresAt > now)
224
+ if (existing) {
225
+ // Refresh so a long-lived session keeps its id, and keep the subject current.
226
+ existing.expiresAt = now + ttlHours * 3600_000
227
+ if (existing.device !== options.device) {
228
+ if (options.device) existing.device = options.device
229
+ else delete existing.device
230
+ }
231
+ existing.subject = options.subject
232
+ save(env, items)
233
+ return existing.id
234
+ }
235
+ // registerLocked, not registerRoute: the lock is not re-entrant.
236
+ return registerLocked(options, env, now, ttlHours)
237
+ })
238
+ }
239
+
240
+ export interface ResolveRouteOptions {
241
+ /** The scanning device; a route minted elsewhere does not resolve here. */
242
+ device?: string
243
+ now?: number
244
+ env?: NodeJS.ProcessEnv
245
+ }
246
+
247
+ /** Look a short id up. Undefined when unknown, expired, or minted elsewhere. */
248
+ export function resolveRoute(id: string, options: ResolveRouteOptions = {}): RouteRecord | undefined {
249
+ const env = options.env ?? process.env
250
+ const now = options.now ?? Date.now()
251
+ const normalized = id.trim().toLowerCase()
252
+ const record = load(env).find((r) => r.id === normalized)
253
+ if (!record) return undefined
254
+ if (record.expiresAt <= now) return undefined
255
+ if (record.device && options.device && record.device !== options.device) return undefined
256
+ return record
257
+ }
258
+
259
+ /** Extract the short id from a subject line, if present. */
260
+ export function routeIdFromSubject(subject: string | undefined | null): string | undefined {
261
+ if (!subject) return undefined
262
+ return ROUTE_RE.exec(subject)?.[1]?.toLowerCase()
263
+ }
264
+
265
+ export function routeCount(env: NodeJS.ProcessEnv = process.env): number {
266
+ return load(env).length
267
+ }
@@ -0,0 +1,104 @@
1
+ // On-disk schema versions.
2
+ //
3
+ // Separate from API_VERSION (daemon/api.ts) on purpose: that one asks "can these two processes
4
+ // talk", and is recovered by replacing a process. This one asks "can this build read the bytes",
5
+ // and the data outlives every process - a downgrade, a shared or synced state directory, or two
6
+ // builds pointed at one XDG_STATE_HOME.
7
+ //
8
+ // The policy every store follows (AGENTS.md "Compatibility"):
9
+ //
10
+ // absent -> schema 1: written before the field existed; read it, upgrade on the next save.
11
+ // older -> migrate on read, write the current version on the next save.
12
+ // current -> parse normally.
13
+ // newer -> refuse to read AND refuse to write, naming both versions. Filtering unknown
14
+ // records and saving the rest is how state is silently lost.
15
+
16
+ import { readFileSync } from "node:fs"
17
+
18
+ import { statePath } from "./paths.ts"
19
+
20
+ export const STATE_VERSION = 1
21
+
22
+ /**
23
+ * The version assumed for a file written before the field existed.
24
+ *
25
+ * Deliberately a constant, not `STATE_VERSION`: if this read `STATE_VERSION`, then bumping the
26
+ * current version would silently label a legacy file as current and skip its migration.
27
+ */
28
+ export const LEGACY_SCHEMA = 1
29
+
30
+ /** Every versioned file, so one probe can refuse the whole directory up front. */
31
+ export const VERSIONED_FILES = [
32
+ "pending.json",
33
+ "journal.json",
34
+ "routes.json",
35
+ "threads.json",
36
+ "uid-cursor.json",
37
+ ] as const
38
+
39
+ export class StateVersionError extends Error {
40
+ readonly file: string
41
+ readonly found: unknown
42
+ readonly current: number
43
+
44
+ constructor(file: string, found: unknown) {
45
+ super(
46
+ `${file} was written by a newer version of pi-mailbridge (schema ${String(found)}; ` +
47
+ `this build understands ${STATE_VERSION}). Refusing to read or rewrite it - update ` +
48
+ `pi-mailbridge, or move that file aside to start fresh.`,
49
+ )
50
+ this.name = "StateVersionError"
51
+ this.file = file
52
+ this.found = found
53
+ this.current = STATE_VERSION
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Validate the version of an already-parsed file.
59
+ *
60
+ * Returns the version to interpret the payload as. Throws `StateVersionError` for anything from
61
+ * the future, which is the only case where reading would corrupt: an older shape is migrated.
62
+ */
63
+ export function checkStateVersion(file: string, parsed: unknown): number {
64
+ const version = (parsed as { v?: unknown } | null | undefined)?.v
65
+ if (version === undefined) return LEGACY_SCHEMA
66
+ if (typeof version !== "number" || !Number.isFinite(version) || version < 0) {
67
+ throw new StateVersionError(file, version)
68
+ }
69
+ if (version > STATE_VERSION) throw new StateVersionError(file, version)
70
+ if (version < STATE_VERSION) {
71
+ // No migrations are registered yet. This guard makes a version bump fail loudly here
72
+ // instead of quietly reading pre-bump data with post-bump assumptions.
73
+ throw new Error(
74
+ `${file} is schema ${version}; this build is ${STATE_VERSION} and has no migration for it. ` +
75
+ `Register one in store/schema.ts before bumping STATE_VERSION.`,
76
+ )
77
+ }
78
+ return version
79
+ }
80
+
81
+ /** The version to write, stamped by every save. */
82
+ export function stamp(): { v: number } {
83
+ return { v: STATE_VERSION }
84
+ }
85
+
86
+ /**
87
+ * Read every versioned file's `v` and refuse the directory if any is newer.
88
+ *
89
+ * Called once at boot, before the daemon listens: a load-time throw inside a request would
90
+ * surface as a 500 and inside a scan as a repeating log line, while this is one clear message.
91
+ * Missing or unparsable files are left to their stores (they have their own recovery), so this
92
+ * only fails on a *version* the build does not understand.
93
+ */
94
+ export function assertStateReadable(env: NodeJS.ProcessEnv): void {
95
+ for (const name of VERSIONED_FILES) {
96
+ let parsed: unknown
97
+ try {
98
+ parsed = JSON.parse(readFileSync(statePath(env, name), "utf8"))
99
+ } catch {
100
+ continue
101
+ }
102
+ checkStateVersion(name, parsed)
103
+ }
104
+ }
@@ -0,0 +1,69 @@
1
+ // Shared secret material: one 32-byte key used both as the HMAC key for routing
2
+ // tokens and as the daemon's bearer token.
3
+ //
4
+ // Created first-writer-wins with mode 0600 so the plugin can mint a token before
5
+ // the daemon has ever started (the first mail must not fail because the daemon
6
+ // is down).
7
+
8
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeSync, chmodSync } from "node:fs"
9
+
10
+ import { stateDir, statePath } from "./paths.ts"
11
+ import { newSecret } from "../core/token.ts"
12
+
13
+ function ensureDir(env: NodeJS.ProcessEnv): string {
14
+ const dir = stateDir(env)
15
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
16
+ return dir
17
+ }
18
+
19
+ /** Read the key, or undefined when it does not exist yet. Never creates. */
20
+ export function readSecret(env: NodeJS.ProcessEnv = process.env): Buffer | undefined {
21
+ const path = statePath(env, "secret.key")
22
+ try {
23
+ if (!existsSync(path)) return undefined
24
+ const raw = readFileSync(path, "utf8").trim()
25
+ if (!raw) return undefined
26
+ const buf = Buffer.from(raw, "base64url")
27
+ return buf.length === 32 ? buf : undefined
28
+ } catch {
29
+ return undefined
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Read the key, creating it if absent. Atomic: `wx` create means exactly one
35
+ * process wins and the others read the winner's key.
36
+ */
37
+ /** Small synchronous pause; no timer is available on this path. */
38
+ function sleepMs(ms: number): void {
39
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
40
+ }
41
+
42
+ export function ensureSecret(env: NodeJS.ProcessEnv = process.env): Buffer {
43
+ const existing = readSecret(env)
44
+ if (existing) return existing
45
+
46
+ ensureDir(env)
47
+ const path = statePath(env, "secret.key")
48
+ try {
49
+ const fd = openSync(path, "wx", 0o600)
50
+ try {
51
+ writeSync(fd, newSecret().toString("base64url"))
52
+ } finally {
53
+ closeSync(fd)
54
+ }
55
+ chmodSync(path, 0o600)
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
58
+ // Lost the race: the winner's key is the truth.
59
+ }
60
+ // Another process may have won the create race a microsecond ago and not finished
61
+ // writing: `openSync(...,"wx")` makes the file visible before writeSync lands. Retry
62
+ // briefly before declaring the key corrupt.
63
+ for (let attempt = 0; attempt < 50; attempt++) {
64
+ const winner = readSecret(env)
65
+ if (winner) return winner
66
+ sleepMs(2)
67
+ }
68
+ throw new Error(`pi-mailbridge: ${path} exists but is not a valid 32-byte key`)
69
+ }
@@ -0,0 +1,177 @@
1
+ // Outbound Message-ID registry — the reply-chain fallback.
2
+ //
3
+ // The short route id in the subject is the primary way a reply is identified, but a human
4
+ // can edit a subject and some clients rewrite it. Mail clients, however, keep
5
+ // `In-Reply-To` / `References` intact: that is how threading works, and it is what
6
+ // "reply to this mail" actually does.
7
+ //
8
+ // So every outbound notification's Message-ID is recorded here, mapped to the
9
+ // session (and device) it came from. When a reply arrives with no usable token,
10
+ // the scan walks its References chain and accepts the first id we minted.
11
+ //
12
+ // Threat note: a Message-ID is unguessable in practice — it is only visible to the
13
+ // recipient of the mail — and this path still sits behind the sender allowlist and
14
+ // DMARC. It is a convenience fallback, not an authentication mechanism.
15
+ //
16
+ // Capped and atomic, like every other store here.
17
+
18
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
19
+ import { dirname } from "node:path"
20
+
21
+ import { statePath } from "./paths.ts"
22
+ import { StateVersionError, checkStateVersion, stamp } from "./schema.ts"
23
+
24
+ /** Keep this many recent outbound ids. Replies to older mail fall back to the token. */
25
+ export const THREAD_CAP = 2_000
26
+
27
+ /** Matches the token default, so all three routing rules age out together. */
28
+ export const DEFAULT_CHAIN_TTL_HOURS = 30 * 24
29
+
30
+ export interface ThreadRecord {
31
+ /** Normalized Message-ID without angle brackets. */
32
+ messageId: string
33
+ sessionId: string
34
+ device?: string
35
+ subject: string
36
+ createdAt: number
37
+ }
38
+
39
+ interface ThreadFile {
40
+ v?: number
41
+ items: ThreadRecord[]
42
+ }
43
+
44
+ /** Strip angle brackets and surrounding whitespace: `<a@b>` -> `a@b`. */
45
+ export function normalizeMessageId(raw: string | undefined | null): string {
46
+ if (!raw) return ""
47
+ return raw.trim().replace(/^<+/, "").replace(/>+$/, "").trim()
48
+ }
49
+
50
+ /** Every Message-ID inside a References/In-Reply-To header value. */
51
+ export function parseMessageIds(raw: string | undefined | null): string[] {
52
+ if (!raw) return []
53
+ const matches = raw.match(/<[^<>]+>/g)
54
+ if (matches) return matches.map((m) => normalizeMessageId(m)).filter(Boolean)
55
+ // Tolerate clients that drop the angle brackets.
56
+ return raw
57
+ .split(/\s+/)
58
+ .map((part) => normalizeMessageId(part))
59
+ .filter((part) => part.includes("@"))
60
+ }
61
+
62
+ function path(env: NodeJS.ProcessEnv): string {
63
+ return statePath(env, "threads.json")
64
+ }
65
+
66
+ function load(env: NodeJS.ProcessEnv): ThreadRecord[] {
67
+ const p = path(env)
68
+ try {
69
+ if (!existsSync(p)) return []
70
+ const parsed = JSON.parse(readFileSync(p, "utf8")) as Partial<ThreadFile>
71
+ checkStateVersion("threads.json", parsed)
72
+ if (!Array.isArray(parsed?.items)) return []
73
+ return parsed.items.filter(
74
+ (r): r is ThreadRecord =>
75
+ typeof r?.messageId === "string" && typeof r?.sessionId === "string" && Boolean(r.messageId),
76
+ )
77
+ } catch (error) {
78
+ if (error instanceof StateVersionError) throw error
79
+ return []
80
+ }
81
+ }
82
+
83
+ function save(env: NodeJS.ProcessEnv, items: ThreadRecord[]): void {
84
+ const p = path(env)
85
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 })
86
+ const tmp = `${p}.${process.pid}.tmp`
87
+ const trimmed = items.length > THREAD_CAP ? items.slice(items.length - THREAD_CAP) : items
88
+ writeFileSync(tmp, JSON.stringify({ ...stamp(), items: trimmed } satisfies ThreadFile), { mode: 0o600 })
89
+ renameSync(tmp, p)
90
+ }
91
+
92
+ /** Remember an outbound notification so its replies can be traced back. */
93
+ export function recordOutbound(
94
+ env: NodeJS.ProcessEnv,
95
+ record: { messageId: string; sessionId: string; device?: string; subject: string; createdAt?: number },
96
+ ): ThreadRecord | undefined {
97
+ const messageId = normalizeMessageId(record.messageId)
98
+ if (!messageId || !record.sessionId) return undefined
99
+
100
+ const entry: ThreadRecord = {
101
+ messageId,
102
+ sessionId: record.sessionId,
103
+ ...(record.device ? { device: record.device } : {}),
104
+ subject: record.subject,
105
+ createdAt: record.createdAt ?? Date.now(),
106
+ }
107
+
108
+ const items = load(env).filter((r) => r.messageId !== messageId)
109
+ items.push(entry)
110
+ save(env, items)
111
+ return entry
112
+ }
113
+
114
+ /**
115
+ * First recorded outbound message among `ids`, searched in order.
116
+ *
117
+ * References lists ancestors oldest-first, so we search from the newest backward:
118
+ * the closest ancestor is the notification the human actually replied to.
119
+ */
120
+ export function lookupChain(
121
+ env: NodeJS.ProcessEnv,
122
+ ids: readonly string[],
123
+ opts: { now?: number; ttlHours?: number } = {},
124
+ ): ThreadRecord | undefined {
125
+ if (ids.length === 0) return undefined
126
+ const now = opts.now ?? Date.now()
127
+ // The chain is the last routing rule, and it must not outlive the bound the other rules
128
+ // carry: without this, a re-sent copy of a month-old notification still routes into the
129
+ // session after route and token expiry have both passed, which made tokenTtlHours
130
+ // decorative for the ordinary reply case.
131
+ const oldest = now - (opts.ttlHours ?? DEFAULT_CHAIN_TTL_HOURS) * 3_600_000
132
+ const byId = new Map(load(env).filter((r) => r.createdAt >= oldest).map((r) => [r.messageId, r]))
133
+ for (let i = ids.length - 1; i >= 0; i--) {
134
+ const hit = byId.get(normalizeMessageId(ids[i]!))
135
+ if (hit) return hit
136
+ }
137
+ return undefined
138
+ }
139
+
140
+ /**
141
+ * Is this id one we sent? Age is irrelevant here: an old notification of ours is still ours,
142
+ * and recognising it is what stops it being re-ingested as a reply.
143
+ */
144
+ export function findOutbound(env: NodeJS.ProcessEnv, id: string): ThreadRecord | undefined {
145
+ const target = normalizeMessageId(id)
146
+ return load(env).find((r) => r.messageId === target)
147
+ }
148
+
149
+ /**
150
+ * Forget one outbound id. Used when a send fails after the id was recorded ahead of the
151
+ * transport: leaving it would make the next notification reply to a parent that never
152
+ * existed, and would make a re-sent copy look like our own mail.
153
+ */
154
+ export function removeOutbound(env: NodeJS.ProcessEnv, messageId: string): void {
155
+ const target = normalizeMessageId(messageId)
156
+ const items = load(env).filter((r) => r.messageId !== target)
157
+ save(env, items)
158
+ }
159
+
160
+ export function threadCount(env: NodeJS.ProcessEnv): number {
161
+ return load(env).length
162
+ }
163
+
164
+ /**
165
+ * The message ids for one session, oldest first use: `root` is what every later message
166
+ * references, `last` is what the next one replies to. Together they let a mail client
167
+ * keep all notifications for a session in one conversation.
168
+ */
169
+ export function sessionThread(
170
+ env: NodeJS.ProcessEnv,
171
+ sessionId: string,
172
+ ): { rootMessageId?: string; lastMessageId?: string } {
173
+ if (!sessionId) return {}
174
+ const mine = load(env).filter((r) => r.sessionId === sessionId)
175
+ if (mine.length === 0) return {}
176
+ return { rootMessageId: mine[0]!.messageId, lastMessageId: mine[mine.length - 1]!.messageId }
177
+ }