@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,92 @@
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
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { dirname } from "node:path";
11
+ import { statePath } from "./paths.js";
12
+ import { StateVersionError, checkStateVersion, stamp } from "./schema.js";
13
+ /** Keep the last N acked UIDs. */
14
+ export const JOURNAL_CAP = 5_000;
15
+ function path(env) {
16
+ return statePath(env, "journal.json");
17
+ }
18
+ function loadFile(env) {
19
+ const p = path(env);
20
+ try {
21
+ if (!existsSync(p))
22
+ return { uids: [] };
23
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
24
+ checkStateVersion("journal.json", parsed);
25
+ return {
26
+ uids: Array.isArray(parsed?.uids) ? parsed.uids.filter((n) => typeof n === "number") : [],
27
+ ...(typeof parsed?.uidValidity === "number" ? { uidValidity: parsed.uidValidity } : {}),
28
+ };
29
+ }
30
+ catch (error) {
31
+ if (error instanceof StateVersionError)
32
+ throw error;
33
+ return { uids: [] };
34
+ }
35
+ }
36
+ function save(env, file) {
37
+ const p = path(env);
38
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
39
+ const tmp = `${p}.${process.pid}.tmp`;
40
+ const trimmed = file.uids.length > JOURNAL_CAP ? file.uids.slice(file.uids.length - JOURNAL_CAP) : file.uids;
41
+ writeFileSync(tmp, JSON.stringify({ ...stamp(), ...file, uids: trimmed }), { mode: 0o600 });
42
+ renameSync(tmp, p);
43
+ }
44
+ /**
45
+ * Has this UID been acked?
46
+ *
47
+ * When the caller knows the mailbox's UIDVALIDITY and it differs from the journal's, the
48
+ * answer is "no": those UIDs belonged to a different numbering and say nothing about this one.
49
+ */
50
+ export function isJournaled(env, uid, uidValidity) {
51
+ const file = loadFile(env);
52
+ if (uidValidity !== undefined) {
53
+ // The caller knows the live numbering. A journal tagged with a different space cannot
54
+ // answer for it - and neither can an untagged one, written before the field existed:
55
+ // after a renumbering its old UIDs are reused, and "already seen" would let the cursor
56
+ // advance past genuinely new mail.
57
+ if (file.uidValidity === undefined)
58
+ return false;
59
+ if (file.uidValidity !== uidValidity)
60
+ return false;
61
+ }
62
+ return file.uids.includes(uid);
63
+ }
64
+ /** Append `uid` (idempotent). Returns false when it was already journaled. */
65
+ export function journal(env, uid, uidValidity) {
66
+ const file = loadFile(env);
67
+ // A new numbering starts a new journal: keeping old entries would suppress new mail.
68
+ const sameSpace = uidValidity === undefined || file.uidValidity === undefined || file.uidValidity === uidValidity;
69
+ const uids = sameSpace ? file.uids : [];
70
+ // Never erase a validity we already know: an ack from a caller that could not report one
71
+ // must not blind the next comparison.
72
+ // The caller's space wins, because claim/ack refuse a record from another space: an ack
73
+ // that reaches here is for the live numbering, and the file must adopt it. (Preferring the
74
+ // file's space made it write-once - after the first renumbering every later ack was
75
+ // journaled under the dead numbering and dedupe was permanently inert.)
76
+ const known = uidValidity ?? file.uidValidity;
77
+ const next = { uids, ...(known === undefined ? {} : { uidValidity: known }) };
78
+ if (uids.includes(uid)) {
79
+ save(env, next);
80
+ return false;
81
+ }
82
+ uids.push(uid);
83
+ save(env, next);
84
+ return true;
85
+ }
86
+ export function journalSize(env) {
87
+ return loadFile(env).uids.length;
88
+ }
89
+ /** Test helper: forget every ack. */
90
+ export function resetJournal(env) {
91
+ save(env, { uids: [] });
92
+ }
@@ -0,0 +1,47 @@
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
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { dirname } from "node:path";
11
+ import { statePath } from "./paths.js";
12
+ export const DEFAULT_MODE = "off";
13
+ function path(env) {
14
+ return statePath(env, "mode.json");
15
+ }
16
+ export function isMode(value) {
17
+ return value === "on" || value === "off";
18
+ }
19
+ /** Current mode; a missing or corrupt file means off. */
20
+ export function readMode(env = process.env) {
21
+ const p = path(env);
22
+ try {
23
+ if (!existsSync(p))
24
+ return DEFAULT_MODE;
25
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
26
+ return isMode(parsed?.mode) ? parsed.mode : DEFAULT_MODE;
27
+ }
28
+ catch {
29
+ return DEFAULT_MODE;
30
+ }
31
+ }
32
+ /** Persist the mode. Returns the value actually stored. */
33
+ export function writeMode(env, mode, now = Date.now()) {
34
+ const p = path(env);
35
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
36
+ const tmp = `${p}.${process.pid}.tmp`;
37
+ writeFileSync(tmp, JSON.stringify({ mode, updatedAt: now }), { mode: 0o600 });
38
+ renameSync(tmp, p);
39
+ return mode;
40
+ }
41
+ /**
42
+ * The single rule for whether the daemon may touch the mailbox. `/mailbridge off` is documented as
43
+ * closing the control channel, so this is the gate that makes that true.
44
+ */
45
+ export function scanningEnabled(mode) {
46
+ return mode === "on";
47
+ }
@@ -0,0 +1,50 @@
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
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+ import { statePath } from "./paths.js";
8
+ function path(env) {
9
+ return statePath(env, "oauth.json");
10
+ }
11
+ /** File-backed token store. Atomic replace, mode 0600. */
12
+ export function fileTokenStore(env = process.env) {
13
+ const file = path(env);
14
+ return {
15
+ read() {
16
+ try {
17
+ if (!existsSync(file))
18
+ return undefined;
19
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
20
+ if (typeof parsed?.refreshToken !== "string" && typeof parsed?.accessToken !== "string")
21
+ return undefined;
22
+ return parsed;
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ },
28
+ write(tokens) {
29
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
30
+ const tmp = `${file}.${process.pid}.tmp`;
31
+ writeFileSync(tmp, JSON.stringify(tokens), { mode: 0o600 });
32
+ renameSync(tmp, file);
33
+ },
34
+ };
35
+ }
36
+ /** In-memory store for tests and for `--auth` dry runs. */
37
+ export function memoryTokenStore(initial) {
38
+ let tokens = initial;
39
+ return {
40
+ read: () => tokens,
41
+ write(next) {
42
+ tokens = next;
43
+ },
44
+ value: () => tokens,
45
+ };
46
+ }
47
+ /** True when a refresh token is on disk, i.e. login already happened. */
48
+ export function hasStoredRefreshToken(env = process.env) {
49
+ return Boolean(fileTokenStore(env).read()?.refreshToken);
50
+ }
@@ -0,0 +1,33 @@
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
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ export function stateDir(env = process.env) {
21
+ const base = env.XDG_STATE_HOME || join(homedir(), ".local", "state");
22
+ return join(base, "pi-mailbridge");
23
+ }
24
+ export function configDir(env = process.env) {
25
+ const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
26
+ return join(base, "pi-mailbridge");
27
+ }
28
+ export function statePath(env, ...parts) {
29
+ return join(stateDir(env), ...parts);
30
+ }
31
+ export function configPath(env = process.env) {
32
+ return join(configDir(env), "config.json");
33
+ }
@@ -0,0 +1,212 @@
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
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
11
+ import { dirname } from "node:path";
12
+ import { statePath } from "./paths.js";
13
+ import { StateVersionError, checkStateVersion, stamp } from "./schema.js";
14
+ /** Bound the store so a stuck mailbox cannot grow it without limit. */
15
+ export const PENDING_CAP = 1_000;
16
+ /** A claim older than this is considered abandoned and may be re-taken. */
17
+ export const CLAIM_TTL_MS = 120_000;
18
+ function path(env) {
19
+ return statePath(env, "pending.json");
20
+ }
21
+ /**
22
+ * Parsed-file cache, keyed by the file's mtime+size.
23
+ *
24
+ * /health is unauthenticated and reads the queue length on every request; every poll tick
25
+ * and every claim/ack re-reads it too. Without a cache, one large queued reply is re-parsed
26
+ * (and re-serialised to JSON) many times a minute.
27
+ */
28
+ let cached;
29
+ function load(env) {
30
+ const p = path(env);
31
+ try {
32
+ if (!existsSync(p))
33
+ return [];
34
+ const stat = statSync(p);
35
+ const stamp = `${stat.mtimeMs}:${stat.size}`;
36
+ if (cached && cached.path === p && cached.stamp === stamp)
37
+ return cached.items;
38
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
39
+ // Refuses a file from a newer schema rather than filtering records it cannot read.
40
+ checkStateVersion("pending.json", parsed);
41
+ if (!Array.isArray(parsed?.items))
42
+ return [];
43
+ const items = parsed.items.filter((r) => typeof r?.uid === "number" && typeof r?.sessionId === "string" && typeof r?.body === "string");
44
+ cached = { path: p, stamp, items };
45
+ return items;
46
+ }
47
+ catch (error) {
48
+ if (error instanceof StateVersionError)
49
+ throw error;
50
+ // Unparsable is not empty. Returning [] lets the next addPending rewrite the file with a
51
+ // single record, discarding a queue whose replies the cursor has already passed. Keep the
52
+ // bytes where a human can recover them and start clean.
53
+ try {
54
+ renameSync(p, `${p}.corrupt-${Date.now()}`);
55
+ }
56
+ catch {
57
+ /* if it cannot be moved, the caller still gets an empty queue rather than a crash */
58
+ }
59
+ return [];
60
+ }
61
+ }
62
+ function save(env, items) {
63
+ const p = path(env);
64
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
65
+ const tmp = `${p}.${process.pid}.tmp`;
66
+ // Never trim here. These records were accepted and the cursor has already moved past
67
+ // them, so dropping one is silent, unrecoverable mail loss. The cap is enforced at
68
+ // insertion instead, where the caller can refuse and leave the cursor alone.
69
+ writeFileSync(tmp, JSON.stringify({ ...stamp(), items }), { mode: 0o600 });
70
+ renameSync(tmp, p);
71
+ // The process that wrote knows the truth; an mtime+size stamp can collide when two writes
72
+ // land in the same millisecond with the same length (measured on ext4).
73
+ try {
74
+ const stat = statSync(p);
75
+ cached = { path: p, stamp: `${stat.mtimeMs}:${stat.size}`, items };
76
+ }
77
+ catch {
78
+ cached = undefined;
79
+ }
80
+ }
81
+ export function listPending(env = process.env) {
82
+ return load(env);
83
+ }
84
+ /** Only the records this device scanned; absent device means "from before the field". */
85
+ export function listPendingForDevice(env, device) {
86
+ return load(env).filter((r) => r.device === undefined || r.device === device);
87
+ }
88
+ export function listPendingForSession(env, sessionId) {
89
+ return load(env).filter((r) => r.sessionId === sessionId);
90
+ }
91
+ export function getPending(env, uid) {
92
+ return load(env).find((r) => r.uid === uid);
93
+ }
94
+ /**
95
+ * Add or replace the record for `uid`. Idempotent on replay.
96
+ *
97
+ * Refuses a NEW record once the store is full rather than making room by discarding an
98
+ * older one: every record here was already accepted, and the caller's cursor is about to
99
+ * move past it. Replacing an existing uid (replay) is always allowed.
100
+ */
101
+ export function addPending(env, record) {
102
+ const existing = load(env);
103
+ const without = existing.filter((r) => r.uid !== record.uid);
104
+ if (without.length >= PENDING_CAP)
105
+ return { ok: false, reason: "full" };
106
+ without.push(record);
107
+ save(env, without);
108
+ return { ok: true, record };
109
+ }
110
+ /**
111
+ * Claim a delivery for `instanceId`.
112
+ *
113
+ * Succeeds when the record is unclaimed, already claimed by this same instance,
114
+ * or its claim has expired (abandoned instance).
115
+ */
116
+ export function claimPending(env, uid, instanceId, now = Date.now(), uidValidity, liveValidity, device) {
117
+ const items = load(env);
118
+ const record = items.find((r) => r.uid === uid);
119
+ if (!record)
120
+ return { ok: false, reason: "not-found" };
121
+ // Defence in depth for a shared state directory: /pending already filters by device, but a
122
+ // caller that names a uid it should not have (an older client, or a direct POST) is refused.
123
+ if (record.device !== undefined && device !== undefined && record.device !== device) {
124
+ return { ok: false, reason: "foreign-device" };
125
+ }
126
+ // A caller holding a record from a previous UID numbering must not act on whatever now
127
+ // occupies that UID: after a renumbering it is a different message.
128
+ if (record.uidValidity !== undefined && uidValidity !== undefined && record.uidValidity !== uidValidity) {
129
+ return { ok: false, reason: "stale-space" };
130
+ }
131
+ // Also compare against the mailbox itself: a caller and a record can agree with each other
132
+ // while both belong to a numbering the mailbox has already left.
133
+ if (record.uidValidity !== undefined && liveValidity !== undefined && record.uidValidity !== liveValidity) {
134
+ return { ok: false, reason: "stale-space" };
135
+ }
136
+ if (record.claimedBy && record.claimedBy !== instanceId) {
137
+ const age = now - (record.claimedAt ?? 0);
138
+ if (age < CLAIM_TTL_MS)
139
+ return { ok: false, reason: "claimed-by-other" };
140
+ }
141
+ record.claimedBy = instanceId;
142
+ record.claimedAt = now;
143
+ save(env, items);
144
+ return { ok: true };
145
+ }
146
+ export function ackPending(env, uid, instanceId, uidValidity, liveValidity) {
147
+ const items = load(env);
148
+ const record = items.find((r) => r.uid === uid);
149
+ if (!record)
150
+ return { ok: false, reason: "not-found" };
151
+ // Same rule as claim: an ack from another UID space would journal a UID that now means
152
+ // something else.
153
+ if (record.uidValidity !== undefined && uidValidity !== undefined && record.uidValidity !== uidValidity) {
154
+ return { ok: false, reason: "stale-space" };
155
+ }
156
+ if (record.uidValidity !== undefined && liveValidity !== undefined && record.uidValidity !== liveValidity) {
157
+ return { ok: false, reason: "stale-space" };
158
+ }
159
+ if (record.claimedBy !== instanceId)
160
+ return { ok: false, reason: "not-claimed" };
161
+ save(env, items.filter((r) => r.uid !== uid));
162
+ return { ok: true, sessionId: record.sessionId, ...(record.uidValidity === undefined ? {} : { uidValidity: record.uidValidity }) };
163
+ }
164
+ /**
165
+ * Drop queued records that belong to a different UID numbering.
166
+ *
167
+ * A re-indexed mailbox renumbers from 1, so an old UID can collide with a new one and
168
+ * `addPending` would silently replace a queued reply. The dropped mail is *not* lost: it was
169
+ * never acked, so it is still unread in the mailbox, and the cursor reset that accompanies
170
+ * this re-reads it.
171
+ *
172
+ * @returns how many records were dropped.
173
+ */
174
+ export function dropPendingFromOtherSpaces(env, uidValidity, options = {}) {
175
+ const items = load(env);
176
+ // On a detected renumbering, a record that carries no space cannot be shown to belong to the
177
+ // new numbering - and a live review caught one being claimed there and injecting a different
178
+ // message's body. Its mail is unread and the reset cursor re-reads it.
179
+ const keep = (r) => r.uidValidity === uidValidity || (r.uidValidity === undefined && !options.includeUnknown);
180
+ const kept = items.filter(keep);
181
+ const dropped = items.filter((r) => !keep(r));
182
+ if (dropped.length > 0)
183
+ save(env, kept);
184
+ // Returned, not just counted: the caller releases exactly these sessions' decision slots,
185
+ // and a second pass over the file could disagree with what was actually removed.
186
+ return dropped;
187
+ }
188
+ /** Records older than this, for sessions that are gone, are expired under queue pressure only
189
+ * (see `process.ts`): never on connect, where a `/mailbridge off` plus a holiday would delete replies. */
190
+ export const PENDING_MAX_AGE_MS = 7 * 24 * 3600_000;
191
+ /**
192
+ * Drop records nothing can deliver any more.
193
+ *
194
+ * A record whose session never comes back is refused by every runner (`foreign-session`) and
195
+ * removed by nothing else, so a handful of them can fill `PENDING_CAP` - and at the cap the
196
+ * scanner stops advancing the cursor, which stalls *all* inbound mail. Expiring them is a
197
+ * deliberate, bounded loss: their mail stays unread in the mailbox, but the cursor has moved
198
+ * past it, so it will not be re-read.
199
+ *
200
+ * @returns the dropped records, so the caller can release their decision slots.
201
+ */
202
+ export function expirePending(env, maxAgeMs = PENDING_MAX_AGE_MS, now = Date.now()) {
203
+ const items = load(env);
204
+ const expired = items.filter((r) => now - r.receivedAt > maxAgeMs);
205
+ if (expired.length === 0)
206
+ return [];
207
+ save(env, items.filter((r) => now - r.receivedAt <= maxAgeMs));
208
+ return expired;
209
+ }
210
+ export function pendingSize(env) {
211
+ return load(env).length;
212
+ }
@@ -0,0 +1,61 @@
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
+ export const REGISTRY_TTL_MS = 24 * 60 * 60 * 1000;
16
+ export const DEFAULT_MAX_ENTRIES = 10_000;
17
+ export function createRegistry(options = {}) {
18
+ const ttlMs = options.ttlMs ?? REGISTRY_TTL_MS;
19
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
20
+ const now = options.now ?? (() => Date.now());
21
+ /** sessionId -> last refresh time */
22
+ const entries = new Map();
23
+ /** Lazy prune: a dead entry can never be served after its TTL. */
24
+ function prune() {
25
+ const cutoff = now() - ttlMs;
26
+ for (const [id, at] of entries) {
27
+ if (at < cutoff)
28
+ entries.delete(id);
29
+ }
30
+ }
31
+ return {
32
+ register(sessionId) {
33
+ prune();
34
+ const existing = entries.get(sessionId);
35
+ if (existing !== undefined) {
36
+ entries.set(sessionId, now()); // refresh TTL
37
+ return { alreadyPending: true, full: false };
38
+ }
39
+ if (entries.size >= maxEntries) {
40
+ return { alreadyPending: false, full: true };
41
+ }
42
+ entries.set(sessionId, now());
43
+ return { alreadyPending: false, full: false };
44
+ },
45
+ release(sessionId) {
46
+ entries.delete(sessionId);
47
+ },
48
+ has(sessionId) {
49
+ prune();
50
+ return entries.has(sessionId);
51
+ },
52
+ size() {
53
+ prune();
54
+ return entries.size;
55
+ },
56
+ list() {
57
+ prune();
58
+ return [...entries.keys()];
59
+ },
60
+ };
61
+ }