@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.
- package/CHANGELOG.md +836 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/bin/pi-mailbridge.mjs +31 -0
- package/config.example.json +54 -0
- package/dist/core/attachments.js +87 -0
- package/dist/core/authz.js +65 -0
- package/dist/core/commands.js +72 -0
- package/dist/core/config-freshness.js +24 -0
- package/dist/core/config.js +286 -0
- package/dist/core/deliveries.js +180 -0
- package/dist/core/discover.js +282 -0
- package/dist/core/header-encoding.js +70 -0
- package/dist/core/html.js +48 -0
- package/dist/core/inline-images.js +355 -0
- package/dist/core/mail-command.js +62 -0
- package/dist/core/mailer.js +126 -0
- package/dist/core/markdown.js +96 -0
- package/dist/core/oauth-device.js +119 -0
- package/dist/core/oauth-openid.js +48 -0
- package/dist/core/oauth.js +137 -0
- package/dist/core/prime.js +51 -0
- package/dist/core/reply-parse.js +134 -0
- package/dist/core/sent-index.js +135 -0
- package/dist/core/settle.js +122 -0
- package/dist/core/setup.js +100 -0
- package/dist/core/smoke.js +164 -0
- package/dist/core/status.js +56 -0
- package/dist/core/token-source.js +27 -0
- package/dist/core/token.js +196 -0
- package/dist/core/version.js +44 -0
- package/dist/daemon/api.js +46 -0
- package/dist/daemon/imapflow.js +75 -0
- package/dist/daemon/main.js +802 -0
- package/dist/daemon/process.js +270 -0
- package/dist/daemon/scan-queue.js +38 -0
- package/dist/daemon/script.js +44 -0
- package/dist/daemon/send-budget.js +30 -0
- package/dist/daemon/service.js +455 -0
- package/dist/daemon/source-proxy.js +64 -0
- package/dist/daemon/spawn.js +227 -0
- package/dist/daemon/watcher.js +308 -0
- package/dist/extension.js +1233 -0
- package/dist/store/journal.js +92 -0
- package/dist/store/mode-store.js +47 -0
- package/dist/store/oauth-store.js +50 -0
- package/dist/store/paths.js +33 -0
- package/dist/store/pending-store.js +212 -0
- package/dist/store/registry.js +61 -0
- package/dist/store/routes.js +223 -0
- package/dist/store/schema.js +93 -0
- package/dist/store/secret.js +70 -0
- package/dist/store/threads.js +145 -0
- package/dist/store/uid-cursor.js +112 -0
- package/docs/ARCHITECTURE.md +356 -0
- package/docs/MAPPING.md +201 -0
- package/docs/OAUTH.md +448 -0
- package/docs/SECURITY.md +273 -0
- package/docs/images/flow.svg +124 -0
- package/docs/images/notification.png +0 -0
- package/package.json +85 -4
- package/scripts/render-teaser.ts +56 -0
- package/src/core/attachments.ts +129 -0
- package/src/core/authz.ts +87 -0
- package/src/core/commands.ts +99 -0
- package/src/core/config-freshness.ts +33 -0
- package/src/core/config.ts +373 -0
- package/src/core/deliveries.ts +233 -0
- package/src/core/discover.ts +333 -0
- package/src/core/header-encoding.ts +75 -0
- package/src/core/html.ts +62 -0
- package/src/core/inline-images.ts +421 -0
- package/src/core/mail-command.ts +76 -0
- package/src/core/mailer.ts +193 -0
- package/src/core/markdown.ts +114 -0
- package/src/core/oauth-device.ts +195 -0
- package/src/core/oauth-openid.ts +61 -0
- package/src/core/oauth.ts +182 -0
- package/src/core/prime.ts +74 -0
- package/src/core/reply-parse.ts +153 -0
- package/src/core/sent-index.ts +164 -0
- package/src/core/settle.ts +179 -0
- package/src/core/setup.ts +139 -0
- package/src/core/smoke.ts +207 -0
- package/src/core/status.ts +75 -0
- package/src/core/token-source.ts +34 -0
- package/src/core/token.ts +240 -0
- package/src/core/version.ts +45 -0
- package/src/daemon/api.ts +52 -0
- package/src/daemon/imapflow.ts +101 -0
- package/src/daemon/main.ts +832 -0
- package/src/daemon/process.ts +348 -0
- package/src/daemon/scan-queue.ts +45 -0
- package/src/daemon/script.ts +45 -0
- package/src/daemon/send-budget.ts +36 -0
- package/src/daemon/service.ts +550 -0
- package/src/daemon/source-proxy.ts +88 -0
- package/src/daemon/spawn.ts +332 -0
- package/src/daemon/watcher.ts +427 -0
- package/src/extension.ts +1381 -0
- package/src/store/journal.ts +104 -0
- package/src/store/mode-store.ts +60 -0
- package/src/store/oauth-store.ts +55 -0
- package/src/store/paths.ts +38 -0
- package/src/store/pending-store.ts +282 -0
- package/src/store/registry.ts +91 -0
- package/src/store/routes.ts +267 -0
- package/src/store/schema.ts +104 -0
- package/src/store/secret.ts +69 -0
- package/src/store/threads.ts +177 -0
- 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
|
+
}
|