@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +824 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
@@ -0,0 +1,104 @@
1
+ // Acked UIDs — durable replay protection.
2
+ //
3
+ // The cursor says what we have *seen*; the journal says what a session has *acked*. A crash
4
+ // between the two is why this file exists.
5
+ //
6
+ // UIDs are only meaningful within one UIDVALIDITY, so the journal stores the one it belongs
7
+ // to. A re-indexed mailbox renumbers from 1, and an old journal then suppresses brand-new
8
+ // mail as "already acked" - so a validity mismatch means the journal is not consulted.
9
+
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
11
+ import { dirname } from "node:path"
12
+
13
+ import { statePath } from "./paths.ts"
14
+ import { StateVersionError, checkStateVersion, stamp } from "./schema.ts"
15
+
16
+ /** Keep the last N acked UIDs. */
17
+ export const JOURNAL_CAP = 5_000
18
+
19
+ interface JournalFile {
20
+ v?: number
21
+ uids: number[]
22
+ /** The UIDVALIDITY these UIDs were acked under. */
23
+ uidValidity?: number
24
+ }
25
+
26
+ function path(env: NodeJS.ProcessEnv): string {
27
+ return statePath(env, "journal.json")
28
+ }
29
+
30
+ function loadFile(env: NodeJS.ProcessEnv): JournalFile {
31
+ const p = path(env)
32
+ try {
33
+ if (!existsSync(p)) return { uids: [] }
34
+ const parsed = JSON.parse(readFileSync(p, "utf8")) as Partial<JournalFile>
35
+ checkStateVersion("journal.json", parsed)
36
+ return {
37
+ uids: Array.isArray(parsed?.uids) ? parsed.uids.filter((n) => typeof n === "number") : [],
38
+ ...(typeof parsed?.uidValidity === "number" ? { uidValidity: parsed.uidValidity } : {}),
39
+ }
40
+ } catch (error) {
41
+ if (error instanceof StateVersionError) throw error
42
+ return { uids: [] }
43
+ }
44
+ }
45
+
46
+ function save(env: NodeJS.ProcessEnv, file: JournalFile): void {
47
+ const p = path(env)
48
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 })
49
+ const tmp = `${p}.${process.pid}.tmp`
50
+ const trimmed = file.uids.length > JOURNAL_CAP ? file.uids.slice(file.uids.length - JOURNAL_CAP) : file.uids
51
+ writeFileSync(tmp, JSON.stringify({ ...stamp(), ...file, uids: trimmed } satisfies JournalFile), { mode: 0o600 })
52
+ renameSync(tmp, p)
53
+ }
54
+
55
+ /**
56
+ * Has this UID been acked?
57
+ *
58
+ * When the caller knows the mailbox's UIDVALIDITY and it differs from the journal's, the
59
+ * answer is "no": those UIDs belonged to a different numbering and say nothing about this one.
60
+ */
61
+ export function isJournaled(env: NodeJS.ProcessEnv, uid: number, uidValidity?: number): boolean {
62
+ const file = loadFile(env)
63
+ if (uidValidity !== undefined) {
64
+ // The caller knows the live numbering. A journal tagged with a different space cannot
65
+ // answer for it - and neither can an untagged one, written before the field existed:
66
+ // after a renumbering its old UIDs are reused, and "already seen" would let the cursor
67
+ // advance past genuinely new mail.
68
+ if (file.uidValidity === undefined) return false
69
+ if (file.uidValidity !== uidValidity) return false
70
+ }
71
+ return file.uids.includes(uid)
72
+ }
73
+
74
+ /** Append `uid` (idempotent). Returns false when it was already journaled. */
75
+ export function journal(env: NodeJS.ProcessEnv, uid: number, uidValidity?: number): boolean {
76
+ const file = loadFile(env)
77
+ // A new numbering starts a new journal: keeping old entries would suppress new mail.
78
+ const sameSpace = uidValidity === undefined || file.uidValidity === undefined || file.uidValidity === uidValidity
79
+ const uids = sameSpace ? file.uids : []
80
+ // Never erase a validity we already know: an ack from a caller that could not report one
81
+ // must not blind the next comparison.
82
+ // The caller's space wins, because claim/ack refuse a record from another space: an ack
83
+ // that reaches here is for the live numbering, and the file must adopt it. (Preferring the
84
+ // file's space made it write-once - after the first renumbering every later ack was
85
+ // journaled under the dead numbering and dedupe was permanently inert.)
86
+ const known = uidValidity ?? file.uidValidity
87
+ const next = { uids, ...(known === undefined ? {} : { uidValidity: known }) }
88
+ if (uids.includes(uid)) {
89
+ save(env, next)
90
+ return false
91
+ }
92
+ uids.push(uid)
93
+ save(env, next)
94
+ return true
95
+ }
96
+
97
+ export function journalSize(env: NodeJS.ProcessEnv): number {
98
+ return loadFile(env).uids.length
99
+ }
100
+
101
+ /** Test helper: forget every ack. */
102
+ export function resetJournal(env: NodeJS.ProcessEnv): void {
103
+ save(env, { uids: [] })
104
+ }
@@ -0,0 +1,60 @@
1
+ // Global email mode: "am I away from the keyboard?"
2
+ //
3
+ // One human, one mailbox, one switch. It is deliberately global rather than
4
+ // per-session: you are either at the desk or you are not, and pretending
5
+ // otherwise makes the agent guess.
6
+ //
7
+ // Persisted so a daemon restart cannot silently re-enable emailing, and default
8
+ // OFF so installing the package never starts mailing you unprompted.
9
+
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
11
+ import { dirname } from "node:path"
12
+
13
+ import { statePath } from "./paths.ts"
14
+
15
+ export type Mode = "on" | "off"
16
+
17
+ export const DEFAULT_MODE: Mode = "off"
18
+
19
+ interface ModeFile {
20
+ mode: Mode
21
+ updatedAt: number
22
+ }
23
+
24
+ function path(env: NodeJS.ProcessEnv): string {
25
+ return statePath(env, "mode.json")
26
+ }
27
+
28
+ export function isMode(value: unknown): value is Mode {
29
+ return value === "on" || value === "off"
30
+ }
31
+
32
+ /** Current mode; a missing or corrupt file means off. */
33
+ export function readMode(env: NodeJS.ProcessEnv = process.env): Mode {
34
+ const p = path(env)
35
+ try {
36
+ if (!existsSync(p)) return DEFAULT_MODE
37
+ const parsed = JSON.parse(readFileSync(p, "utf8")) as Partial<ModeFile>
38
+ return isMode(parsed?.mode) ? parsed.mode : DEFAULT_MODE
39
+ } catch {
40
+ return DEFAULT_MODE
41
+ }
42
+ }
43
+
44
+ /** Persist the mode. Returns the value actually stored. */
45
+ export function writeMode(env: NodeJS.ProcessEnv, mode: Mode, now: number = Date.now()): Mode {
46
+ const p = path(env)
47
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 })
48
+ const tmp = `${p}.${process.pid}.tmp`
49
+ writeFileSync(tmp, JSON.stringify({ mode, updatedAt: now } satisfies ModeFile), { mode: 0o600 })
50
+ renameSync(tmp, p)
51
+ return mode
52
+ }
53
+
54
+ /**
55
+ * The single rule for whether the daemon may touch the mailbox. `/mailbridge off` is documented as
56
+ * closing the control channel, so this is the gate that makes that true.
57
+ */
58
+ export function scanningEnabled(mode: Mode): boolean {
59
+ return mode === "on"
60
+ }
@@ -0,0 +1,55 @@
1
+ // Persistence for OAuth tokens.
2
+ //
3
+ // Only the refresh token is precious: access tokens are short-lived and cheap to
4
+ // re-mint. The file is 0600 inside the 0700 state dir, and it is never logged.
5
+
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
7
+ import { dirname } from "node:path"
8
+
9
+ import { statePath } from "./paths.ts"
10
+ import type { StoredTokens, TokenStore } from "../core/oauth-device.ts"
11
+
12
+ function path(env: NodeJS.ProcessEnv): string {
13
+ return statePath(env, "oauth.json")
14
+ }
15
+
16
+ /** File-backed token store. Atomic replace, mode 0600. */
17
+ export function fileTokenStore(env: NodeJS.ProcessEnv = process.env): TokenStore {
18
+ const file = path(env)
19
+ return {
20
+ read() {
21
+ try {
22
+ if (!existsSync(file)) return undefined
23
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as StoredTokens
24
+ if (typeof parsed?.refreshToken !== "string" && typeof parsed?.accessToken !== "string") return undefined
25
+ return parsed
26
+ } catch {
27
+ return undefined
28
+ }
29
+ },
30
+
31
+ write(tokens) {
32
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
33
+ const tmp = `${file}.${process.pid}.tmp`
34
+ writeFileSync(tmp, JSON.stringify(tokens), { mode: 0o600 })
35
+ renameSync(tmp, file)
36
+ },
37
+ }
38
+ }
39
+
40
+ /** In-memory store for tests and for `--auth` dry runs. */
41
+ export function memoryTokenStore(initial?: StoredTokens): TokenStore & { value(): StoredTokens | undefined } {
42
+ let tokens = initial
43
+ return {
44
+ read: () => tokens,
45
+ write(next) {
46
+ tokens = next
47
+ },
48
+ value: () => tokens,
49
+ }
50
+ }
51
+
52
+ /** True when a refresh token is on disk, i.e. login already happened. */
53
+ export function hasStoredRefreshToken(env: NodeJS.ProcessEnv = process.env): boolean {
54
+ return Boolean(fileTokenStore(env).read()?.refreshToken)
55
+ }
@@ -0,0 +1,38 @@
1
+ // State + config locations. Everything the package writes lives under one
2
+ // directory so a user can inspect or delete it in one step.
3
+ //
4
+ // $XDG_STATE_HOME/pi-mailbridge/ (default ~/.local/state/pi-mailbridge)
5
+ // secret.key shared HMAC + daemon bearer key, mode 0600
6
+ // uid-cursor.json IMAP UID cursor + the mailbox UIDVALIDITY it belongs to
7
+ // pending.json durable pending deliveries
8
+ // journal.json UIDs already acked (replay protection)
9
+ // mode.json global on/off
10
+ // routes.json short routing ids (TTL-bounded)
11
+ // threads.json outbound Message-IDs: threading + reply chain
12
+ // oauth.json refresh token, 0600
13
+ //
14
+ // Config is read from, in order, each deep-merged over the previous:
15
+ // 1. built-in defaults
16
+ // 2. $XDG_CONFIG_HOME/pi-mailbridge/config.json
17
+ // 3. PI_MAILBRIDGE_* environment variables
18
+
19
+ import { homedir } from "node:os"
20
+ import { join } from "node:path"
21
+
22
+ export function stateDir(env: NodeJS.ProcessEnv = process.env): string {
23
+ const base = env.XDG_STATE_HOME || join(homedir(), ".local", "state")
24
+ return join(base, "pi-mailbridge")
25
+ }
26
+
27
+ export function configDir(env: NodeJS.ProcessEnv = process.env): string {
28
+ const base = env.XDG_CONFIG_HOME || join(homedir(), ".config")
29
+ return join(base, "pi-mailbridge")
30
+ }
31
+
32
+ export function statePath(env: NodeJS.ProcessEnv, ...parts: string[]): string {
33
+ return join(stateDir(env), ...parts)
34
+ }
35
+
36
+ export function configPath(env: NodeJS.ProcessEnv = process.env): string {
37
+ return join(configDir(env), "config.json")
38
+ }
@@ -0,0 +1,282 @@
1
+ // Durable pending deliveries.
2
+ //
3
+ // Written by the daemon immediately after a message is parsed and authorized —
4
+ // *before* any session is told about it. That ordering is the whole point: a
5
+ // crash after this write still leaves the reply available for redelivery, while
6
+ // the mail stays unseen on the server.
7
+ //
8
+ // Single-writer model: only the daemon mutates this file, so no inter-process
9
+ // lock is required. Atomic rename protects against torn reads.
10
+
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
12
+ import { dirname } from "node:path"
13
+
14
+ import { statePath } from "./paths.ts"
15
+ import { StateVersionError, checkStateVersion, stamp } from "./schema.ts"
16
+
17
+ /** Bound the store so a stuck mailbox cannot grow it without limit. */
18
+ export const PENDING_CAP = 1_000
19
+ /** A claim older than this is considered abandoned and may be re-taken. */
20
+ export const CLAIM_TTL_MS = 120_000
21
+
22
+ export interface PendingRecord {
23
+ uid: number
24
+ sessionId: string
25
+ from: string
26
+ subject: string
27
+ body: string
28
+ /** Already allowlisted and capped by core/attachments before persisting. */
29
+ attachments?: { name: string; mimeType: string; size: number; data: string }[]
30
+ receivedAt: number
31
+ /** The UIDVALIDITY this UID belongs to, when known. */
32
+ uidValidity?: number
33
+ /**
34
+ * The device that scanned it. Two machines sharing one state directory (or one device.name)
35
+ * must not deliver each other's replies, so /pending filters and /claim refuses on this.
36
+ */
37
+ device?: string
38
+ claimedBy?: string
39
+ claimedAt?: number
40
+ }
41
+
42
+ interface PendingFile {
43
+ v?: number
44
+ items: PendingRecord[]
45
+ }
46
+
47
+ export type ClaimResult =
48
+ | { ok: true }
49
+ | { ok: false; reason: "not-found" | "claimed-by-other" | "stale-space" | "foreign-device" }
50
+
51
+ function path(env: NodeJS.ProcessEnv): string {
52
+ return statePath(env, "pending.json")
53
+ }
54
+
55
+ /**
56
+ * Parsed-file cache, keyed by the file's mtime+size.
57
+ *
58
+ * /health is unauthenticated and reads the queue length on every request; every poll tick
59
+ * and every claim/ack re-reads it too. Without a cache, one large queued reply is re-parsed
60
+ * (and re-serialised to JSON) many times a minute.
61
+ */
62
+ let cached: { path: string; stamp: string; items: PendingRecord[] } | undefined
63
+
64
+ function load(env: NodeJS.ProcessEnv): PendingRecord[] {
65
+ const p = path(env)
66
+ try {
67
+ if (!existsSync(p)) return []
68
+ const stat = statSync(p)
69
+ const stamp = `${stat.mtimeMs}:${stat.size}`
70
+ if (cached && cached.path === p && cached.stamp === stamp) return cached.items
71
+ const parsed = JSON.parse(readFileSync(p, "utf8")) as Partial<PendingFile>
72
+ // Refuses a file from a newer schema rather than filtering records it cannot read.
73
+ checkStateVersion("pending.json", parsed)
74
+ if (!Array.isArray(parsed?.items)) return []
75
+ const items = parsed.items.filter(
76
+ (r): r is PendingRecord =>
77
+ typeof r?.uid === "number" && typeof r?.sessionId === "string" && typeof r?.body === "string",
78
+ )
79
+ cached = { path: p, stamp, items }
80
+ return items
81
+ } catch (error) {
82
+ if (error instanceof StateVersionError) throw error
83
+ // Unparsable is not empty. Returning [] lets the next addPending rewrite the file with a
84
+ // single record, discarding a queue whose replies the cursor has already passed. Keep the
85
+ // bytes where a human can recover them and start clean.
86
+ try {
87
+ renameSync(p, `${p}.corrupt-${Date.now()}`)
88
+ } catch {
89
+ /* if it cannot be moved, the caller still gets an empty queue rather than a crash */
90
+ }
91
+ return []
92
+ }
93
+ }
94
+
95
+ function save(env: NodeJS.ProcessEnv, items: PendingRecord[]): void {
96
+ const p = path(env)
97
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 })
98
+ const tmp = `${p}.${process.pid}.tmp`
99
+ // Never trim here. These records were accepted and the cursor has already moved past
100
+ // them, so dropping one is silent, unrecoverable mail loss. The cap is enforced at
101
+ // insertion instead, where the caller can refuse and leave the cursor alone.
102
+ writeFileSync(tmp, JSON.stringify({ ...stamp(), items } satisfies PendingFile), { mode: 0o600 })
103
+ renameSync(tmp, p)
104
+ // The process that wrote knows the truth; an mtime+size stamp can collide when two writes
105
+ // land in the same millisecond with the same length (measured on ext4).
106
+ try {
107
+ const stat = statSync(p)
108
+ cached = { path: p, stamp: `${stat.mtimeMs}:${stat.size}`, items }
109
+ } catch {
110
+ cached = undefined
111
+ }
112
+ }
113
+
114
+ export function listPending(env: NodeJS.ProcessEnv = process.env): PendingRecord[] {
115
+ return load(env)
116
+ }
117
+
118
+ /** Only the records this device scanned; absent device means "from before the field". */
119
+ export function listPendingForDevice(env: NodeJS.ProcessEnv, device: string): PendingRecord[] {
120
+ return load(env).filter((r) => r.device === undefined || r.device === device)
121
+ }
122
+
123
+ export function listPendingForSession(env: NodeJS.ProcessEnv, sessionId: string): PendingRecord[] {
124
+ return load(env).filter((r) => r.sessionId === sessionId)
125
+ }
126
+
127
+ export function getPending(env: NodeJS.ProcessEnv, uid: number): PendingRecord | undefined {
128
+ return load(env).find((r) => r.uid === uid)
129
+ }
130
+
131
+ export type AddResult = { ok: true; record: PendingRecord } | { ok: false; reason: "full" }
132
+
133
+ /**
134
+ * Add or replace the record for `uid`. Idempotent on replay.
135
+ *
136
+ * Refuses a NEW record once the store is full rather than making room by discarding an
137
+ * older one: every record here was already accepted, and the caller's cursor is about to
138
+ * move past it. Replacing an existing uid (replay) is always allowed.
139
+ */
140
+ export function addPending(env: NodeJS.ProcessEnv, record: PendingRecord): AddResult {
141
+ const existing = load(env)
142
+ const without = existing.filter((r) => r.uid !== record.uid)
143
+ if (without.length >= PENDING_CAP) return { ok: false, reason: "full" }
144
+ without.push(record)
145
+ save(env, without)
146
+ return { ok: true, record }
147
+ }
148
+
149
+ /**
150
+ * Claim a delivery for `instanceId`.
151
+ *
152
+ * Succeeds when the record is unclaimed, already claimed by this same instance,
153
+ * or its claim has expired (abandoned instance).
154
+ */
155
+ export function claimPending(
156
+ env: NodeJS.ProcessEnv,
157
+ uid: number,
158
+ instanceId: string,
159
+ now: number = Date.now(),
160
+ uidValidity?: number,
161
+ liveValidity?: number,
162
+ device?: string,
163
+ ): ClaimResult {
164
+ const items = load(env)
165
+ const record = items.find((r) => r.uid === uid)
166
+ if (!record) return { ok: false, reason: "not-found" }
167
+ // Defence in depth for a shared state directory: /pending already filters by device, but a
168
+ // caller that names a uid it should not have (an older client, or a direct POST) is refused.
169
+ if (record.device !== undefined && device !== undefined && record.device !== device) {
170
+ return { ok: false, reason: "foreign-device" }
171
+ }
172
+ // A caller holding a record from a previous UID numbering must not act on whatever now
173
+ // occupies that UID: after a renumbering it is a different message.
174
+ if (record.uidValidity !== undefined && uidValidity !== undefined && record.uidValidity !== uidValidity) {
175
+ return { ok: false, reason: "stale-space" }
176
+ }
177
+ // Also compare against the mailbox itself: a caller and a record can agree with each other
178
+ // while both belong to a numbering the mailbox has already left.
179
+ if (record.uidValidity !== undefined && liveValidity !== undefined && record.uidValidity !== liveValidity) {
180
+ return { ok: false, reason: "stale-space" }
181
+ }
182
+ if (record.claimedBy && record.claimedBy !== instanceId) {
183
+ const age = now - (record.claimedAt ?? 0)
184
+ if (age < CLAIM_TTL_MS) return { ok: false, reason: "claimed-by-other" }
185
+ }
186
+ record.claimedBy = instanceId
187
+ record.claimedAt = now
188
+ save(env, items)
189
+ return { ok: true }
190
+ }
191
+
192
+ /**
193
+ * Remove a delivery. Only the claimant may ack.
194
+ *
195
+ * An unclaimed record must NOT be ackable: with two sessions on one machine, any
196
+ * authenticated client could otherwise delete, journal and mark `\Seen` another
197
+ * session's reply - and nothing would re-read it, because the cursor has already moved.
198
+ * Returns the record's session so the caller releases the right decision slot instead of
199
+ * trusting whatever session id the request body carried.
200
+ */
201
+ export type AckResult =
202
+ | { ok: true; sessionId: string; uidValidity?: number }
203
+ | { ok: false; reason: "not-found" | "not-claimed" | "stale-space" }
204
+
205
+ export function ackPending(
206
+ env: NodeJS.ProcessEnv,
207
+ uid: number,
208
+ instanceId: string,
209
+ uidValidity?: number,
210
+ liveValidity?: number,
211
+ ): AckResult {
212
+ const items = load(env)
213
+ const record = items.find((r) => r.uid === uid)
214
+ if (!record) return { ok: false, reason: "not-found" }
215
+ // Same rule as claim: an ack from another UID space would journal a UID that now means
216
+ // something else.
217
+ if (record.uidValidity !== undefined && uidValidity !== undefined && record.uidValidity !== uidValidity) {
218
+ return { ok: false, reason: "stale-space" }
219
+ }
220
+ if (record.uidValidity !== undefined && liveValidity !== undefined && record.uidValidity !== liveValidity) {
221
+ return { ok: false, reason: "stale-space" }
222
+ }
223
+ if (record.claimedBy !== instanceId) return { ok: false, reason: "not-claimed" }
224
+ save(env, items.filter((r) => r.uid !== uid))
225
+ return { ok: true, sessionId: record.sessionId, ...(record.uidValidity === undefined ? {} : { uidValidity: record.uidValidity }) }
226
+ }
227
+
228
+ /**
229
+ * Drop queued records that belong to a different UID numbering.
230
+ *
231
+ * A re-indexed mailbox renumbers from 1, so an old UID can collide with a new one and
232
+ * `addPending` would silently replace a queued reply. The dropped mail is *not* lost: it was
233
+ * never acked, so it is still unread in the mailbox, and the cursor reset that accompanies
234
+ * this re-reads it.
235
+ *
236
+ * @returns how many records were dropped.
237
+ */
238
+ export function dropPendingFromOtherSpaces(
239
+ env: NodeJS.ProcessEnv,
240
+ uidValidity: number,
241
+ options: { includeUnknown?: boolean } = {},
242
+ ): PendingRecord[] {
243
+ const items = load(env)
244
+ // On a detected renumbering, a record that carries no space cannot be shown to belong to the
245
+ // new numbering - and a live review caught one being claimed there and injecting a different
246
+ // message's body. Its mail is unread and the reset cursor re-reads it.
247
+ const keep = (r: PendingRecord): boolean =>
248
+ r.uidValidity === uidValidity || (r.uidValidity === undefined && !options.includeUnknown)
249
+ const kept = items.filter(keep)
250
+ const dropped = items.filter((r) => !keep(r))
251
+ if (dropped.length > 0) save(env, kept)
252
+ // Returned, not just counted: the caller releases exactly these sessions' decision slots,
253
+ // and a second pass over the file could disagree with what was actually removed.
254
+ return dropped
255
+ }
256
+
257
+ /** Records older than this, for sessions that are gone, are expired under queue pressure only
258
+ * (see `process.ts`): never on connect, where a `/mailbridge off` plus a holiday would delete replies. */
259
+ export const PENDING_MAX_AGE_MS = 7 * 24 * 3600_000
260
+
261
+ /**
262
+ * Drop records nothing can deliver any more.
263
+ *
264
+ * A record whose session never comes back is refused by every runner (`foreign-session`) and
265
+ * removed by nothing else, so a handful of them can fill `PENDING_CAP` - and at the cap the
266
+ * scanner stops advancing the cursor, which stalls *all* inbound mail. Expiring them is a
267
+ * deliberate, bounded loss: their mail stays unread in the mailbox, but the cursor has moved
268
+ * past it, so it will not be re-read.
269
+ *
270
+ * @returns the dropped records, so the caller can release their decision slots.
271
+ */
272
+ export function expirePending(env: NodeJS.ProcessEnv, maxAgeMs = PENDING_MAX_AGE_MS, now = Date.now()): PendingRecord[] {
273
+ const items = load(env)
274
+ const expired = items.filter((r) => now - r.receivedAt > maxAgeMs)
275
+ if (expired.length === 0) return []
276
+ save(env, items.filter((r) => now - r.receivedAt <= maxAgeMs))
277
+ return expired
278
+ }
279
+
280
+ export function pendingSize(env: NodeJS.ProcessEnv): number {
281
+ return load(env).length
282
+ }
@@ -0,0 +1,91 @@
1
+ // One-outstanding-decision registry.
2
+ //
3
+ // Why this exists: `request_decision` emails the human and ends the turn. If the
4
+ // agent calls it twice for the same session, the human gets two emails for one
5
+ // decision and can only answer one of them. The registry reserves a slot per
6
+ // session so the second call is refused before any mail is sent.
7
+ //
8
+ // Scope note: this is intentionally **in memory**, owned by the daemon. A daemon
9
+ // restart drops reservations — that is acceptable, because a reply is keyed by
10
+ // its own UID in the pending store, not by a reservation. A dropped reservation
11
+ // can therefore never lose a reply; it can only allow a duplicate *question*.
12
+ //
13
+ // Bounds: entries expire by TTL and the map has a hard cap, because the
14
+ // /register endpoint is reachable by any local session.
15
+
16
+ export const REGISTRY_TTL_MS = 24 * 60 * 60 * 1000
17
+ export const DEFAULT_MAX_ENTRIES = 10_000
18
+
19
+ export interface RegisterResult {
20
+ /** The session already had an outstanding decision. */
21
+ alreadyPending: boolean
22
+ /** The cap was hit; the new entry was NOT admitted. */
23
+ full: boolean
24
+ }
25
+
26
+ export interface Registry {
27
+ register(sessionId: string): RegisterResult
28
+ release(sessionId: string): void
29
+ has(sessionId: string): boolean
30
+ size(): number
31
+ /** Live session ids, for `/mailbridge` output. */
32
+ list(): string[]
33
+ }
34
+
35
+ export interface RegistryOptions {
36
+ ttlMs?: number
37
+ maxEntries?: number
38
+ now?: () => number
39
+ }
40
+
41
+ export function createRegistry(options: RegistryOptions = {}): Registry {
42
+ const ttlMs = options.ttlMs ?? REGISTRY_TTL_MS
43
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
44
+ const now = options.now ?? (() => Date.now())
45
+
46
+ /** sessionId -> last refresh time */
47
+ const entries = new Map<string, number>()
48
+
49
+ /** Lazy prune: a dead entry can never be served after its TTL. */
50
+ function prune(): void {
51
+ const cutoff = now() - ttlMs
52
+ for (const [id, at] of entries) {
53
+ if (at < cutoff) entries.delete(id)
54
+ }
55
+ }
56
+
57
+ return {
58
+ register(sessionId) {
59
+ prune()
60
+ const existing = entries.get(sessionId)
61
+ if (existing !== undefined) {
62
+ entries.set(sessionId, now()) // refresh TTL
63
+ return { alreadyPending: true, full: false }
64
+ }
65
+ if (entries.size >= maxEntries) {
66
+ return { alreadyPending: false, full: true }
67
+ }
68
+ entries.set(sessionId, now())
69
+ return { alreadyPending: false, full: false }
70
+ },
71
+
72
+ release(sessionId) {
73
+ entries.delete(sessionId)
74
+ },
75
+
76
+ has(sessionId) {
77
+ prune()
78
+ return entries.has(sessionId)
79
+ },
80
+
81
+ size() {
82
+ prune()
83
+ return entries.size
84
+ },
85
+
86
+ list() {
87
+ prune()
88
+ return [...entries.keys()]
89
+ },
90
+ }
91
+ }