@dumbmice/pi-mailbridge 0.1.3 → 0.1.4

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 CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 — 2026-11-13
4
+
5
+ - **`/mailbridge` no longer freezes the agent process on a lock.** `ensureRouteForSession` ran under
6
+ `routes.lock` for every notification, and the wait sleeps the calling thread - measured at 3012 ms
7
+ with the lock held, on the pi main thread. A session with a live route now returns it without
8
+ taking the lock, and the route is only refreshed (or re-pointed at a renamed device) when needed.
9
+ - **The pending queue is bounded in bytes, not only in records.** 1 000 records at the 100k-char
10
+ body cap was a ~100 MB file rewritten on every claim and ack; the ceiling is now 8 MB of stored
11
+ records as well. Refusal is a stall, not a drop: the cursor holds and the reply is retried, and a
12
+ queue inherited from an earlier version that is already over 8 MB simply defers *new* mail until
13
+ it drains - nothing is discarded, and the seven-day pressure expiry still frees an abandoned
14
+ backlog.
15
+ - **The stored subject is capped at 300 characters.** It is display only (routing read the raw
16
+ header), and it was the one field the scanner did not bound: a multi-megabyte `Subject:` header
17
+ produced a record bigger than the queue's ceiling, which would then be refused on every scan with
18
+ nothing to expire.
19
+
20
+ - **The persisted subject and attachment filenames are capped** (300 and 200 characters). Both are
21
+ mail-controlled and were unbounded; a multi-megabyte header or MIME filename produced a record
22
+ larger than the queue's new ceiling, which would then be refused on every scan with nothing to
23
+ expire - a permanent stall. Bodies were already capped; the display fields were not.
24
+ - **The attachment-only path can now dig itself out of a full queue.** It skipped the age-expiry
25
+ that the record path runs, so it stalled where a reply with a body would have recovered.
26
+
27
+ Also: a non-loopback `daemon.host` is now reported by `/mailbridge check` and fails
28
+ `pi-mailbridge --check` (it was silent), and the panel names both queue bounds instead of a count
29
+ that could not explain a byte-full queue; an expiry is listed as its own line rather than under
30
+ "rejections" beside `rejected 0`.
31
+
3
32
  ## 0.1.3 — 2026-11-13
4
33
 
5
34
  - **The package page showed alt text instead of the images.** The README's markup was fixed to
package/README.md CHANGED
@@ -342,7 +342,7 @@ Every scan records why each message was refused, so this is answerable:
342
342
  ```
343
343
 
344
344
  ```
345
- daemon: healthy (http://127.0.0.1:4177) — daemon version 0.1.3
345
+ daemon: healthy (http://127.0.0.1:4177) — daemon version 0.1.4, protocol 1
346
346
  device: dev-box-7
347
347
  mode: on
348
348
  imap: connected
@@ -383,9 +383,9 @@ proof the channel is open.
383
383
  | `attachment-only-dropped` | the reply's only content was an attachment that was dropped (over the 1 MB total, a refused type); a note naming the drop is delivered | send the text, or a smaller file |
384
384
  | `empty-body` | no prose, no attachment at all | — |
385
385
  | `attachment-*` | an attachment was dropped (type, size, count) | see the suffix |
386
- | `pending-full` | 1 000 undelivered replies are queued | start a session so they drain |
386
+ | `pending-full` | the queue is at its limit: 1 000 undelivered replies, or 8 MB of queued bodies and attachments - a few attachment-heavy replies can fill it | start a session so they drain; nothing is discarded, the cursor waits |
387
387
  | `send_budget` | more than ten notifications in a minute were requested | a loop is mailing; the budget refuses rather than spending the account's quota |
388
- | `too-large` | over 8 MiB, refused before the body was downloaded | send a smaller one; inbound attachments are capped at 4 files and 1 MB *in total* |
388
+ | `too-large` | over 8 MB, refused before the body was downloaded | send a smaller one; inbound attachments are capped at 4 files and 1 MB *in total* |
389
389
 
390
390
  Where to look:
391
391
 
@@ -449,7 +449,7 @@ environment variables. A working example: [`config.example.json`](https://github
449
449
  | `security.trustedAuthservId` | unset | pin the trusted `Authentication-Results` host; leave unset for Microsoft consumer accounts |
450
450
  | `security.allowHeaderThreading` | `true` | accept a reply whose subject id was lost, by matching `In-Reply-To`/`References` |
451
451
  | `security.allowSelfReplies` | `false` | one address for both roles: accept a self-addressed message, but only a real reply |
452
- | `security.tokenTtlHours` | `720` | lifetime of a route id, signed token and reply-chain entry |
452
+ | `security.tokenTtlHours` | `720` | lifetime of a signed token and a reply-chain entry; a route id is refreshed once half its TTL is left, so it is guaranteed for at least `tokenTtlHours / 2` after the last notification, and the chain covers the rest |
453
453
  | `daemon.host` / `.port` | `127.0.0.1` / `4177` | loopback HTTP; a lock file in the state directory is the instance lock, and the port is a second guard |
454
454
  | `daemon.idleTimeoutSecs` | `120` | grace period after the last ping |
455
455
  | `daemon.scanIntervalSecs` | `60` | catch-up scan while mode is on, so a silent IDLE cannot strand a reply; `0` disables |
@@ -583,7 +583,7 @@ table and the module map: [docs/ARCHITECTURE.md](https://github.com/DumbMice/pi-
583
583
 
584
584
  ## Status
585
585
 
586
- `0.1.3` — feature-complete for the single-user, one-mailbox case, and exercised end to end
586
+ `0.1.4` — feature-complete for the single-user, one-mailbox case, and exercised end to end
587
587
  against a live QQ mailbox in both layouts: two addresses (DMARC verified on the reply) and one
588
588
  address for both roles (self-delivery, the self-echo guard, and a reply routed by the chain).
589
589
 
@@ -14,6 +14,15 @@ export const ALLOWED_IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "ima
14
14
  export const ALLOWED_TEXT_MIMES = ["text/plain", "text/markdown", "application/json"];
15
15
  export const MAX_ATTACHMENTS = 4;
16
16
  export const MAX_TOTAL_BYTES = 1_000_000;
17
+ /**
18
+ * Longest filename we keep.
19
+ *
20
+ * The name is persisted with the record and there is no upstream bound on the MIME `filename`
21
+ * parameter - a message at the 8 MB message ceiling can carry a multi-megabyte one. Unbounded, it
22
+ * was the last field that could push a record past `PENDING_MAX_BYTES` and be refused on every
23
+ * scan with nothing to expire. Outbound already caps names (`safeFilename`, inline-images.ts).
24
+ */
25
+ export const MAX_NAME_CHARS = 200;
17
26
  function isImage(mime) {
18
27
  return ALLOWED_IMAGE_MIMES.includes(mime);
19
28
  }
@@ -36,7 +45,8 @@ export function selectAttachments(raw, limits = {}) {
36
45
  let total = 0;
37
46
  for (const item of raw) {
38
47
  const mimeType = normalizeMime(item.contentType);
39
- const name = (item.filename ?? "").trim() || `attachment-${accepted.length + dropped.length + 1}`;
48
+ const name = (item.filename ?? "").trim().slice(0, MAX_NAME_CHARS) ||
49
+ `attachment-${accepted.length + dropped.length + 1}`;
40
50
  if (!isImage(mimeType) && !isText(mimeType)) {
41
51
  dropped.push({ name, reason: "mime-not-allowed" });
42
52
  continue;
@@ -3,6 +3,7 @@
3
3
  // Pure on purpose: this is the answer to "why did my reply not arrive", and it lived inline
4
4
  // in the extension where no test could reach it - which is how an unconditional "queue full"
5
5
  // line shipped in its first version.
6
+ import { PENDING_CAP, PENDING_MAX_BYTES } from "../store/pending-store.js";
6
7
  /**
7
8
  * Counter keys that are not refusals.
8
9
  *
@@ -22,6 +23,24 @@ const HANDLED = new Set([
22
23
  ]);
23
24
  /** Deferred, not dropped: the queue was full and the same UID is retried. */
24
25
  const DEFERRED = new Set(["pending-full"]);
26
+ /**
27
+ * Aged out under queue pressure. Not a rejection of anything in *this* scan - the record was
28
+ * already queued and undeliverable - so listing it under "rejections" beside `rejected 0` read
29
+ * as the same contradiction the counter fix was meant to remove.
30
+ */
31
+ const EXPIRED = new Set(["expired"]);
32
+ /**
33
+ * The queue's two bounds, named from the constants.
34
+ *
35
+ * Hardcoding the numbers here is how the panel would start lying the day one of them changed -
36
+ * with a green suite, because the only assertion matched the words "queue full".
37
+ */
38
+ export function queueLimits() {
39
+ // Grouped like the docs ("1 000"), not `1000`: the panel and the troubleshooting table are read
40
+ // side by side, and two spellings of one number read as two different limits.
41
+ const grouped = String(PENDING_CAP).replace(/\B(?=(\d{3})+(?!\d))/g, " ");
42
+ return `${grouped} replies or ${Math.round(PENDING_MAX_BYTES / 1024 / 1024)} MB queued`;
43
+ }
25
44
  export function formatScan(summary) {
26
45
  if (!summary)
27
46
  return ["last scan: (never — is the daemon running?)"];
@@ -36,7 +55,8 @@ export function formatScan(summary) {
36
55
  .filter(([reason]) => pick(reason))
37
56
  .map(([reason, n]) => `${reason} ${n}`)
38
57
  .join(", ");
39
- const refused = list((r) => !HANDLED.has(r) && !DEFERRED.has(r) && !r.startsWith("attachment-"));
58
+ const refused = list((r) => !HANDLED.has(r) && !DEFERRED.has(r) && !EXPIRED.has(r) && !r.startsWith("attachment-"));
59
+ const expired = list((r) => EXPIRED.has(r));
40
60
  const handled = list((r) => HANDLED.has(r));
41
61
  const dropped = list((r) => r.startsWith("attachment-"));
42
62
  const deferred = entries.some(([reason]) => DEFERRED.has(reason));
@@ -50,7 +70,12 @@ export function formatScan(summary) {
50
70
  `rejections: ${refused || "none"}`,
51
71
  ...(handled ? [`handled: ${handled}`] : []),
52
72
  ...(dropped ? [`attachments dropped: ${dropped}`] : []),
53
- // Conditional, and it says what the reader should do about it.
54
- ...(deferred ? ["queue full: replies are deferred, not dropped — start a session so they drain"] : []),
73
+ ...(expired ? [`aged out under queue pressure: ${expired}`] : []),
74
+ // Conditional, and it says what the reader should do about it. Both bounds are named: a
75
+ // byte-full queue can hold six replies, so the count alone read as a contradiction with
76
+ // the "pending replies: 6" line right above it.
77
+ ...(deferred
78
+ ? [`queue full: ${queueLimits()} — deferred, not dropped; start a session so they drain`]
79
+ : []),
55
80
  ];
56
81
  }
@@ -27,6 +27,19 @@ import { isJournaled } from "../store/journal.js";
27
27
  import { advanceCursor, readCursor } from "../store/uid-cursor.js";
28
28
  /** Longest body we keep. Well above any real instruction; far below "a whole newsletter". */
29
29
  export const MAX_BODY_CHARS = 100_000;
30
+ /**
31
+ * Longest subject we persist. Routing already happened on the raw header, so this is a display
32
+ * field - and it is the one field the scanner does not otherwise bound (`MAX_MESSAGE_BYTES` is the
33
+ * only upstream limit, and it is the same size as the whole queue's ceiling). Without this cap a
34
+ * single message carrying a multi-megabyte `Subject:` header produced a record larger than
35
+ * `PENDING_MAX_BYTES`, which `addPending` then refused on every scan with nothing to expire: a
36
+ * permanent stall of all inbound mail. `test/pending-store.test.ts` pins the relationship.
37
+ */
38
+ export const MAX_SUBJECT_CHARS = 300;
39
+ export function persistedSubject(subject) {
40
+ const value = subject ?? "";
41
+ return value.length > MAX_SUBJECT_CHARS ? `${value.slice(0, MAX_SUBJECT_CHARS)} [truncated]` : value;
42
+ }
30
43
  function bump(reasons, key) {
31
44
  reasons[key] = (reasons[key] ?? 0) + 1;
32
45
  }
@@ -164,6 +177,32 @@ export async function scanAndProcess(config, deps) {
164
177
  result.cursor = advanceCursor(env, uid);
165
178
  continue;
166
179
  }
180
+ /**
181
+ * Persist, and if the queue is full make room by expiring records nothing can deliver any more.
182
+ *
183
+ * Shared by both insertion sites. The age-expiry recovery is the only relief a full queue has,
184
+ * and the attachment-only stub used to skip it: a byte-full queue stalled there where a real
185
+ * record would have recovered, which contradicted ARCHITECTURE's promise that refusal is a
186
+ * stall the queue digs itself out of.
187
+ */
188
+ function persistWithPressure(record) {
189
+ if (addPending(env, record).ok)
190
+ return true;
191
+ const expired = expirePending(env);
192
+ if (expired.length === 0)
193
+ return false;
194
+ // Not a rejection of *this* message: the reason says what was dropped, and the counts stay
195
+ // usable ("examined 1, rejected 0"), which they did not when the expiry was counted as one.
196
+ bump(result.reasons, "expired");
197
+ // Free a slot only when nothing else is queued for that session, or a live record would
198
+ // lose its reservation.
199
+ const stillQueued = new Set(listPending(env).map((r) => r.sessionId));
200
+ for (const gone of expired) {
201
+ if (!stillQueued.has(gone.sessionId))
202
+ deps.onDropped?.(gone.sessionId);
203
+ }
204
+ return addPending(env, record).ok;
205
+ }
167
206
  // A body cap as well as a message cap: the pending store is re-read on every poll and
168
207
  // on every /health, so one enormous reply would be paid for repeatedly.
169
208
  const stripped = stripQuoted(message.text);
@@ -196,14 +235,13 @@ export async function scanAndProcess(config, deps) {
196
235
  uid,
197
236
  sessionId,
198
237
  from: authz.from,
199
- subject: message.subject ?? "",
238
+ subject: persistedSubject(message.subject),
200
239
  body: note,
201
240
  receivedAt: message.receivedAt ?? now(),
202
241
  ...(uidValidity === undefined ? {} : { uidValidity }),
203
242
  device: config.device.name,
204
243
  };
205
- const persisted = addPending(env, stub);
206
- if (!persisted.ok) {
244
+ if (!persistWithPressure(stub)) {
207
245
  // Same rule as a real record: do not advance past it, retry when there is room.
208
246
  bump(result.reasons, "pending-full");
209
247
  break;
@@ -221,7 +259,7 @@ export async function scanAndProcess(config, deps) {
221
259
  uid,
222
260
  sessionId,
223
261
  from: authz.from,
224
- subject: message.subject ?? "",
262
+ subject: persistedSubject(message.subject),
225
263
  body,
226
264
  ...(accepted.length ? { attachments: accepted } : {}),
227
265
  receivedAt: message.receivedAt,
@@ -232,29 +270,7 @@ export async function scanAndProcess(config, deps) {
232
270
  : { uidValidity: deps.source.uidValidity?.() ?? uidValidity }),
233
271
  };
234
272
  // Persist BEFORE the cursor moves. A crash here leaves the mail unseen.
235
- const added = addPending(env, { ...record, device: config.device.name });
236
- if (!added.ok) {
237
- // The queue is full. Before stalling every future reply, drop records nothing can
238
- // deliver any more - a session that has not come back in a week. This is the only
239
- // place age-based expiry runs, because it is the only place the loss buys progress.
240
- const expired = expirePending(env);
241
- if (expired.length > 0) {
242
- result.rejected++;
243
- bump(result.reasons, "expired");
244
- // The same rule as the UID-space drop: free a slot only when nothing else is
245
- // queued for that session, or a live record would lose its reservation.
246
- const stillQueued = new Set(listPending(env).map((r) => r.sessionId));
247
- for (const gone of expired) {
248
- if (!stillQueued.has(gone.sessionId))
249
- deps.onDropped?.(gone.sessionId);
250
- }
251
- const retry = addPending(env, { ...record, device: config.device.name });
252
- if (retry.ok) {
253
- result.persisted++;
254
- result.cursor = advanceCursor(env, uid);
255
- continue;
256
- }
257
- }
273
+ if (!persistWithPressure({ ...record, device: config.device.name })) {
258
274
  // The queue is full. STOP the batch: advancing past this UID would lose it, and
259
275
  // so would processing a later UID (the cursor is monotonic, so it would jump
260
276
  // over this one). The mail stays unread and the whole batch is retried once
@@ -13,6 +13,20 @@ import { statePath } from "./paths.js";
13
13
  import { StateVersionError, checkStateVersion, stamp } from "./schema.js";
14
14
  /** Bound the store so a stuck mailbox cannot grow it without limit. */
15
15
  export const PENDING_CAP = 1_000;
16
+ /**
17
+ * Byte ceiling for the whole store, because the record cap alone was not a bound.
18
+ *
19
+ * `save()` rewrites the entire array on every claim and every ack, so 1000 records at the
20
+ * `MAX_BODY_CHARS` body cap is a ~100 MB file rewritten several times a minute - and the queue is
21
+ * already misbehaving when it is that full.
22
+ *
23
+ * It must stay comfortably above the largest single record the scanner can accept, or the queue
24
+ * would refuse that record forever with nothing to expire (the stall described in process.ts).
25
+ * That record is `MAX_BODY_CHARS` (100 000) plus a 1 MB attachment total base64'd (~1.33 MB) plus a
26
+ * subject capped at `MAX_SUBJECT_CHARS` (300), so ~1.5 MB; 8 MB leaves room for several.
27
+ * `test/pending-store.test.ts` asserts the relationship rather than trusting this comment.
28
+ */
29
+ export const PENDING_MAX_BYTES = 8 * 1024 * 1024;
16
30
  /** A claim older than this is considered abandoned and may be re-taken. */
17
31
  export const CLAIM_TTL_MS = 120_000;
18
32
  function path(env) {
@@ -91,18 +105,43 @@ export function listPendingForSession(env, sessionId) {
91
105
  export function getPending(env, uid) {
92
106
  return load(env).find((r) => r.uid === uid);
93
107
  }
108
+ /**
109
+ * What a record costs on disk: its serialised UTF-8 bytes.
110
+ *
111
+ * Serialised, because that is what `save()` writes and what a count of body + attachment data
112
+ * misses: the subject, the sender, attachment names and mime types, and JSON's escaping (one
113
+ * control character becomes six ASCII characters). Bytes rather than characters, because a CJK
114
+ * body costs up to three bytes per character in the file and one per character in a string index.
115
+ */
116
+ export function recordBytes(record) {
117
+ return Buffer.byteLength(JSON.stringify(record), "utf8");
118
+ }
94
119
  /**
95
120
  * Add or replace the record for `uid`. Idempotent on replay.
96
121
  *
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.
122
+ * Refuses rather than making room by discarding an older record: every record here was already
123
+ * accepted, and the caller's cursor is about to move past it. Two bounds, both checked here so
124
+ * the refusal happens before the cursor moves:
125
+ *
126
+ * - `PENDING_CAP` records. Replacing an existing uid is allowed regardless (a replay cannot
127
+ * grow the count).
128
+ * - `PENDING_MAX_BYTES` of serialised records. A replacement is re-measured rather than trusted:
129
+ * an over-size replay would otherwise be the one way past the ceiling.
130
+ *
131
+ * `claimPending` is the one writer that does not measure: it adds `claimedBy` (an instance id from
132
+ * a request body, itself capped at 1 MB) and `claimedAt` to one record. The scanner never sets
133
+ * those, so a stored record can only grow by a bounded field at claim time.
100
134
  */
101
- export function addPending(env, record) {
135
+ export function addPending(env, record, options = {}) {
102
136
  const existing = load(env);
103
137
  const without = existing.filter((r) => r.uid !== record.uid);
104
138
  if (without.length >= PENDING_CAP)
105
139
  return { ok: false, reason: "full" };
140
+ // The same rule as the count cap, for the same reason: refuse at insertion, so the caller's
141
+ // cursor stays put and the reply is retried instead of dropped.
142
+ const candidateBytes = without.reduce((total, r) => total + recordBytes(r), 0) + recordBytes(record);
143
+ if (candidateBytes > (options.maxBytes ?? PENDING_MAX_BYTES))
144
+ return { ok: false, reason: "full" };
106
145
  without.push(record);
107
146
  save(env, without);
108
147
  return { ok: true, record };
@@ -173,11 +173,32 @@ function registerLocked(options, env, now, ttlHours) {
173
173
  * id stable per session means the subject is byte-identical across notifications, so
174
174
  * Outlook's conversation topic and Gmail's subject grouping both keep one thread, and
175
175
  * the RFC 5322 headers we also set have something stable to point at.
176
+ *
177
+ * Lifetime guarantee: the id is refreshed once less than half its TTL remains, so a session that
178
+ * notifies at least once per TTL/2 keeps the same id indefinitely (15 days either side at the
179
+ * default 720 h). That is half of what the always-refresh version promised, which the reply-chain
180
+ * fallback covers: `recordOutbound` stamps the chain at send time with the full
181
+ * `security.tokenTtlHours`, so a reply to a mail older than TTL/2 still routes.
176
182
  */
177
183
  export function ensureRouteForSession(options) {
178
184
  const env = options.env ?? process.env;
179
185
  const now = options.now ?? Date.now();
180
186
  const ttlHours = options.ttlHours ?? 30 * 24;
187
+ // Fast path, and it is the common case: a session that already has a healthy route needs no
188
+ // write at all, so it needs no lock. This matters because every caller is the pi process on
189
+ // its main thread - settle, request_decision, notify_user - and `withLock` sleeps with
190
+ // `Atomics.wait` while it waits. A held lock used to freeze the session for up to ~3s on a
191
+ // path that runs on every notification. Reads are safe without the lock: `save()` publishes
192
+ // with an atomic rename, so a reader sees one complete file or the other.
193
+ const live = load(env).find((r) => r.sessionId === options.sessionId && r.expiresAt > now);
194
+ if (live) {
195
+ // Refresh only when it is worth a write: less than half the TTL left. Refreshing on every
196
+ // notification was pure write amplification, and the subject is diagnostic only.
197
+ const refreshBefore = now + (ttlHours * 3600_000) / 2;
198
+ const deviceUnchanged = (live.device ?? undefined) === (options.device ?? undefined);
199
+ if (live.expiresAt > refreshBefore && deviceUnchanged)
200
+ return live.id;
201
+ }
181
202
  return withLock(env, () => {
182
203
  const items = load(env);
183
204
  const existing = items.find((r) => r.sessionId === options.sessionId && r.expiresAt > now);
@@ -61,6 +61,12 @@ Invariants:
61
61
  journal (UID already acked), not by timing.
62
62
  - **The claim is short-lived.** It prevents two sessions with the same id (an
63
63
  old and new runtime during `/reload`) from injecting twice.
64
+ - **Persistence is bounded, and refusal is a stall, not a drop.** The pending store holds at most
65
+ 1 000 records or 8 MB of serialised records (`PENDING_CAP`, `PENDING_MAX_BYTES`). A refused
66
+ record leaves the cursor and the mail unread and stops the batch, which is retried once space
67
+ appears - that is why the byte ceiling must stay above the largest single record the scanner
68
+ accepts. The only deliberate loss is age: records older than seven days are expired *under that
69
+ pressure only*, never on connect.
64
70
 
65
71
  ## 3. Daemon HTTP surface
66
72
 
@@ -114,7 +120,9 @@ desired behaviour, not an error.
114
120
  - Replies keep the id because mail clients prefix — `Re: [pi:…] …`. Parsing
115
121
  tolerates `Re:`, `RE:`, `AW:`, `Re[2]:`.
116
122
  - Expiry bounds replay: an id older than `tokenTtlHours` resolves to nothing, and the
117
- reply chain ages out on the same clock.
123
+ reply chain ages out on the same clock. A short id is refreshed once less than half its TTL
124
+ remains, so after the last notification it is valid for between `tokenTtlHours / 2` and the
125
+ full TTL — and the chain, stamped with the full TTL at send time, covers the gap.
118
126
 
119
127
  ### What identifies a reply
120
128
 
package/docs/SECURITY.md CHANGED
@@ -118,7 +118,10 @@ A legitimate email can still contain text that reads like instructions
118
118
  therefore exfiltrate over SMTP. That is inside the trust boundary - the same process can read
119
119
  your mail - but worth knowing when deciding what runs as you.
120
120
  - Defaults to binding `127.0.0.1`; `daemon.host` can change that, but there is no TLS and
121
- no user model beyond the bearer token, so do not. Nothing else about the daemon is
121
+ no user model beyond the bearer token, so do not. It is loud if you do: `/mailbridge check`
122
+ reports it and `pi-mailbridge --check` **exits non-zero**, the same way an `allowSelfReplies` +
123
+ `requireDmarcPass` combination does - a supported setting that a supervisor should not gate on
124
+ silently. Nothing else about the daemon is
122
125
  reachable without the token, apart from the `/health` liveness probe. That returns mode,
123
126
  pending/clients counts, the decision-registry size, ping timing, the number of SSE subscribers,
124
127
  the last scan's reason *counts*, the build and protocol versions, and the config file's mtime and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dumbmice/pi-mailbridge",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Drive Pi coding-agent sessions from your mailbox: get an email when an agent settles, reply to resume that exact session.",
5
5
  "keywords": [
6
6
  "pi",
@@ -16,6 +16,15 @@ export const ALLOWED_TEXT_MIMES = ["text/plain", "text/markdown", "application/j
16
16
 
17
17
  export const MAX_ATTACHMENTS = 4
18
18
  export const MAX_TOTAL_BYTES = 1_000_000
19
+ /**
20
+ * Longest filename we keep.
21
+ *
22
+ * The name is persisted with the record and there is no upstream bound on the MIME `filename`
23
+ * parameter - a message at the 8 MB message ceiling can carry a multi-megabyte one. Unbounded, it
24
+ * was the last field that could push a record past `PENDING_MAX_BYTES` and be refused on every
25
+ * scan with nothing to expire. Outbound already caps names (`safeFilename`, inline-images.ts).
26
+ */
27
+ export const MAX_NAME_CHARS = 200
19
28
 
20
29
  export type UserContent =
21
30
  | { type: "text"; text: string }
@@ -76,7 +85,9 @@ export function selectAttachments(
76
85
 
77
86
  for (const item of raw) {
78
87
  const mimeType = normalizeMime(item.contentType)
79
- const name = (item.filename ?? "").trim() || `attachment-${accepted.length + dropped.length + 1}`
88
+ const name =
89
+ (item.filename ?? "").trim().slice(0, MAX_NAME_CHARS) ||
90
+ `attachment-${accepted.length + dropped.length + 1}`
80
91
 
81
92
  if (!isImage(mimeType) && !isText(mimeType)) {
82
93
  dropped.push({ name, reason: "mime-not-allowed" })
@@ -4,6 +4,8 @@
4
4
  // in the extension where no test could reach it - which is how an unconditional "queue full"
5
5
  // line shipped in its first version.
6
6
 
7
+ import { PENDING_CAP, PENDING_MAX_BYTES } from "../store/pending-store.ts"
8
+
7
9
  export interface ScanSummary {
8
10
  /** When the last scan that saw mail ran; absent until one has. */
9
11
  at?: number
@@ -36,6 +38,26 @@ const HANDLED = new Set([
36
38
  /** Deferred, not dropped: the queue was full and the same UID is retried. */
37
39
  const DEFERRED = new Set(["pending-full"])
38
40
 
41
+ /**
42
+ * Aged out under queue pressure. Not a rejection of anything in *this* scan - the record was
43
+ * already queued and undeliverable - so listing it under "rejections" beside `rejected 0` read
44
+ * as the same contradiction the counter fix was meant to remove.
45
+ */
46
+ const EXPIRED = new Set(["expired"])
47
+
48
+ /**
49
+ * The queue's two bounds, named from the constants.
50
+ *
51
+ * Hardcoding the numbers here is how the panel would start lying the day one of them changed -
52
+ * with a green suite, because the only assertion matched the words "queue full".
53
+ */
54
+ export function queueLimits(): string {
55
+ // Grouped like the docs ("1 000"), not `1000`: the panel and the troubleshooting table are read
56
+ // side by side, and two spellings of one number read as two different limits.
57
+ const grouped = String(PENDING_CAP).replace(/\B(?=(\d{3})+(?!\d))/g, " ")
58
+ return `${grouped} replies or ${Math.round(PENDING_MAX_BYTES / 1024 / 1024)} MB queued`
59
+ }
60
+
39
61
  export function formatScan(summary: ScanSummary | null | undefined): string[] {
40
62
  if (!summary) return ["last scan: (never — is the daemon running?)"]
41
63
  if (summary.at === undefined) {
@@ -52,7 +74,8 @@ export function formatScan(summary: ScanSummary | null | undefined): string[] {
52
74
  .map(([reason, n]) => `${reason} ${n}`)
53
75
  .join(", ")
54
76
 
55
- const refused = list((r) => !HANDLED.has(r) && !DEFERRED.has(r) && !r.startsWith("attachment-"))
77
+ const refused = list((r) => !HANDLED.has(r) && !DEFERRED.has(r) && !EXPIRED.has(r) && !r.startsWith("attachment-"))
78
+ const expired = list((r) => EXPIRED.has(r))
56
79
  const handled = list((r) => HANDLED.has(r))
57
80
  const dropped = list((r) => r.startsWith("attachment-"))
58
81
  const deferred = entries.some(([reason]) => DEFERRED.has(reason))
@@ -69,7 +92,12 @@ export function formatScan(summary: ScanSummary | null | undefined): string[] {
69
92
  `rejections: ${refused || "none"}`,
70
93
  ...(handled ? [`handled: ${handled}`] : []),
71
94
  ...(dropped ? [`attachments dropped: ${dropped}`] : []),
72
- // Conditional, and it says what the reader should do about it.
73
- ...(deferred ? ["queue full: replies are deferred, not dropped — start a session so they drain"] : []),
95
+ ...(expired ? [`aged out under queue pressure: ${expired}`] : []),
96
+ // Conditional, and it says what the reader should do about it. Both bounds are named: a
97
+ // byte-full queue can hold six replies, so the count alone read as a contradiction with
98
+ // the "pending replies: 6" line right above it.
99
+ ...(deferred
100
+ ? [`queue full: ${queueLimits()} — deferred, not dropped; start a session so they drain`]
101
+ : []),
74
102
  ]
75
103
  }
@@ -30,6 +30,20 @@ import { advanceCursor, readCursor } from "../store/uid-cursor.ts"
30
30
 
31
31
  /** Longest body we keep. Well above any real instruction; far below "a whole newsletter". */
32
32
  export const MAX_BODY_CHARS = 100_000
33
+ /**
34
+ * Longest subject we persist. Routing already happened on the raw header, so this is a display
35
+ * field - and it is the one field the scanner does not otherwise bound (`MAX_MESSAGE_BYTES` is the
36
+ * only upstream limit, and it is the same size as the whole queue's ceiling). Without this cap a
37
+ * single message carrying a multi-megabyte `Subject:` header produced a record larger than
38
+ * `PENDING_MAX_BYTES`, which `addPending` then refused on every scan with nothing to expire: a
39
+ * permanent stall of all inbound mail. `test/pending-store.test.ts` pins the relationship.
40
+ */
41
+ export const MAX_SUBJECT_CHARS = 300
42
+
43
+ export function persistedSubject(subject: string | undefined): string {
44
+ const value = subject ?? ""
45
+ return value.length > MAX_SUBJECT_CHARS ? `${value.slice(0, MAX_SUBJECT_CHARS)} [truncated]` : value
46
+ }
33
47
 
34
48
  export interface RawMessage {
35
49
  uid: number
@@ -237,7 +251,31 @@ export async function scanAndProcess(config: ResolvedConfig, deps: ScanDeps): Pr
237
251
  continue
238
252
  }
239
253
 
240
- // A body cap as well as a message cap: the pending store is re-read on every poll and
254
+ /**
255
+ * Persist, and if the queue is full make room by expiring records nothing can deliver any more.
256
+ *
257
+ * Shared by both insertion sites. The age-expiry recovery is the only relief a full queue has,
258
+ * and the attachment-only stub used to skip it: a byte-full queue stalled there where a real
259
+ * record would have recovered, which contradicted ARCHITECTURE's promise that refusal is a
260
+ * stall the queue digs itself out of.
261
+ */
262
+ function persistWithPressure(record: PendingRecord): boolean {
263
+ if (addPending(env, record).ok) return true
264
+ const expired = expirePending(env)
265
+ if (expired.length === 0) return false
266
+ // Not a rejection of *this* message: the reason says what was dropped, and the counts stay
267
+ // usable ("examined 1, rejected 0"), which they did not when the expiry was counted as one.
268
+ bump(result.reasons, "expired")
269
+ // Free a slot only when nothing else is queued for that session, or a live record would
270
+ // lose its reservation.
271
+ const stillQueued = new Set(listPending(env).map((r) => r.sessionId))
272
+ for (const gone of expired) {
273
+ if (!stillQueued.has(gone.sessionId)) deps.onDropped?.(gone.sessionId)
274
+ }
275
+ return addPending(env, record).ok
276
+ }
277
+
278
+ // A body cap as well as a message cap: the pending store is re-read on every poll and
241
279
  // on every /health, so one enormous reply would be paid for repeatedly.
242
280
  const stripped = stripQuoted(message.text)
243
281
  // Marked, not silently cut: the agent must be able to tell that its instruction is
@@ -271,14 +309,13 @@ export async function scanAndProcess(config: ResolvedConfig, deps: ScanDeps): Pr
271
309
  uid,
272
310
  sessionId,
273
311
  from: authz.from,
274
- subject: message.subject ?? "",
312
+ subject: persistedSubject(message.subject),
275
313
  body: note,
276
314
  receivedAt: message.receivedAt ?? now(),
277
315
  ...(uidValidity === undefined ? {} : { uidValidity }),
278
316
  device: config.device.name,
279
317
  }
280
- const persisted = addPending(env, stub)
281
- if (!persisted.ok) {
318
+ if (!persistWithPressure(stub)) {
282
319
  // Same rule as a real record: do not advance past it, retry when there is room.
283
320
  bump(result.reasons, "pending-full")
284
321
  break
@@ -297,7 +334,7 @@ export async function scanAndProcess(config: ResolvedConfig, deps: ScanDeps): Pr
297
334
  uid,
298
335
  sessionId,
299
336
  from: authz.from,
300
- subject: message.subject ?? "",
337
+ subject: persistedSubject(message.subject),
301
338
  body,
302
339
  ...(accepted.length ? { attachments: accepted } : {}),
303
340
  receivedAt: message.receivedAt,
@@ -309,28 +346,7 @@ export async function scanAndProcess(config: ResolvedConfig, deps: ScanDeps): Pr
309
346
  }
310
347
 
311
348
  // Persist BEFORE the cursor moves. A crash here leaves the mail unseen.
312
- const added = addPending(env, { ...record, device: config.device.name })
313
- if (!added.ok) {
314
- // The queue is full. Before stalling every future reply, drop records nothing can
315
- // deliver any more - a session that has not come back in a week. This is the only
316
- // place age-based expiry runs, because it is the only place the loss buys progress.
317
- const expired = expirePending(env)
318
- if (expired.length > 0) {
319
- result.rejected++
320
- bump(result.reasons, "expired")
321
- // The same rule as the UID-space drop: free a slot only when nothing else is
322
- // queued for that session, or a live record would lose its reservation.
323
- const stillQueued = new Set(listPending(env).map((r) => r.sessionId))
324
- for (const gone of expired) {
325
- if (!stillQueued.has(gone.sessionId)) deps.onDropped?.(gone.sessionId)
326
- }
327
- const retry = addPending(env, { ...record, device: config.device.name })
328
- if (retry.ok) {
329
- result.persisted++
330
- result.cursor = advanceCursor(env, uid)
331
- continue
332
- }
333
- }
349
+ if (!persistWithPressure({ ...record, device: config.device.name })) {
334
350
  // The queue is full. STOP the batch: advancing past this UID would lose it, and
335
351
  // so would processing a later UID (the cursor is monotonic, so it would jump
336
352
  // over this one). The mail stays unread and the whole batch is retried once
@@ -16,6 +16,20 @@ import { StateVersionError, checkStateVersion, stamp } from "./schema.ts"
16
16
 
17
17
  /** Bound the store so a stuck mailbox cannot grow it without limit. */
18
18
  export const PENDING_CAP = 1_000
19
+ /**
20
+ * Byte ceiling for the whole store, because the record cap alone was not a bound.
21
+ *
22
+ * `save()` rewrites the entire array on every claim and every ack, so 1000 records at the
23
+ * `MAX_BODY_CHARS` body cap is a ~100 MB file rewritten several times a minute - and the queue is
24
+ * already misbehaving when it is that full.
25
+ *
26
+ * It must stay comfortably above the largest single record the scanner can accept, or the queue
27
+ * would refuse that record forever with nothing to expire (the stall described in process.ts).
28
+ * That record is `MAX_BODY_CHARS` (100 000) plus a 1 MB attachment total base64'd (~1.33 MB) plus a
29
+ * subject capped at `MAX_SUBJECT_CHARS` (300), so ~1.5 MB; 8 MB leaves room for several.
30
+ * `test/pending-store.test.ts` asserts the relationship rather than trusting this comment.
31
+ */
32
+ export const PENDING_MAX_BYTES = 8 * 1024 * 1024
19
33
  /** A claim older than this is considered abandoned and may be re-taken. */
20
34
  export const CLAIM_TTL_MS = 120_000
21
35
 
@@ -130,17 +144,46 @@ export function getPending(env: NodeJS.ProcessEnv, uid: number): PendingRecord |
130
144
 
131
145
  export type AddResult = { ok: true; record: PendingRecord } | { ok: false; reason: "full" }
132
146
 
147
+ /**
148
+ * What a record costs on disk: its serialised UTF-8 bytes.
149
+ *
150
+ * Serialised, because that is what `save()` writes and what a count of body + attachment data
151
+ * misses: the subject, the sender, attachment names and mime types, and JSON's escaping (one
152
+ * control character becomes six ASCII characters). Bytes rather than characters, because a CJK
153
+ * body costs up to three bytes per character in the file and one per character in a string index.
154
+ */
155
+ export function recordBytes(record: PendingRecord): number {
156
+ return Buffer.byteLength(JSON.stringify(record), "utf8")
157
+ }
158
+
133
159
  /**
134
160
  * Add or replace the record for `uid`. Idempotent on replay.
135
161
  *
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.
162
+ * Refuses rather than making room by discarding an older record: every record here was already
163
+ * accepted, and the caller's cursor is about to move past it. Two bounds, both checked here so
164
+ * the refusal happens before the cursor moves:
165
+ *
166
+ * - `PENDING_CAP` records. Replacing an existing uid is allowed regardless (a replay cannot
167
+ * grow the count).
168
+ * - `PENDING_MAX_BYTES` of serialised records. A replacement is re-measured rather than trusted:
169
+ * an over-size replay would otherwise be the one way past the ceiling.
170
+ *
171
+ * `claimPending` is the one writer that does not measure: it adds `claimedBy` (an instance id from
172
+ * a request body, itself capped at 1 MB) and `claimedAt` to one record. The scanner never sets
173
+ * those, so a stored record can only grow by a bounded field at claim time.
139
174
  */
140
- export function addPending(env: NodeJS.ProcessEnv, record: PendingRecord): AddResult {
175
+ export function addPending(
176
+ env: NodeJS.ProcessEnv,
177
+ record: PendingRecord,
178
+ options: { maxBytes?: number } = {},
179
+ ): AddResult {
141
180
  const existing = load(env)
142
181
  const without = existing.filter((r) => r.uid !== record.uid)
143
182
  if (without.length >= PENDING_CAP) return { ok: false, reason: "full" }
183
+ // The same rule as the count cap, for the same reason: refuse at insertion, so the caller's
184
+ // cursor stays put and the reply is retried instead of dropped.
185
+ const candidateBytes = without.reduce((total, r) => total + recordBytes(r), 0) + recordBytes(record)
186
+ if (candidateBytes > (options.maxBytes ?? PENDING_MAX_BYTES)) return { ok: false, reason: "full" }
144
187
  without.push(record)
145
188
  save(env, without)
146
189
  return { ok: true, record }
@@ -212,28 +212,49 @@ function registerLocked(options: RegisterRouteOptions, env: NodeJS.ProcessEnv, n
212
212
  * id stable per session means the subject is byte-identical across notifications, so
213
213
  * Outlook's conversation topic and Gmail's subject grouping both keep one thread, and
214
214
  * the RFC 5322 headers we also set have something stable to point at.
215
+ *
216
+ * Lifetime guarantee: the id is refreshed once less than half its TTL remains, so a session that
217
+ * notifies at least once per TTL/2 keeps the same id indefinitely (15 days either side at the
218
+ * default 720 h). That is half of what the always-refresh version promised, which the reply-chain
219
+ * fallback covers: `recordOutbound` stamps the chain at send time with the full
220
+ * `security.tokenTtlHours`, so a reply to a mail older than TTL/2 still routes.
215
221
  */
216
222
  export function ensureRouteForSession(options: RegisterRouteOptions): string {
217
223
  const env = options.env ?? process.env
218
224
  const now = options.now ?? Date.now()
219
225
  const ttlHours = options.ttlHours ?? 30 * 24
220
226
 
227
+ // Fast path, and it is the common case: a session that already has a healthy route needs no
228
+ // write at all, so it needs no lock. This matters because every caller is the pi process on
229
+ // its main thread - settle, request_decision, notify_user - and `withLock` sleeps with
230
+ // `Atomics.wait` while it waits. A held lock used to freeze the session for up to ~3s on a
231
+ // path that runs on every notification. Reads are safe without the lock: `save()` publishes
232
+ // with an atomic rename, so a reader sees one complete file or the other.
233
+ const live = load(env).find((r) => r.sessionId === options.sessionId && r.expiresAt > now)
234
+ if (live) {
235
+ // Refresh only when it is worth a write: less than half the TTL left. Refreshing on every
236
+ // notification was pure write amplification, and the subject is diagnostic only.
237
+ const refreshBefore = now + (ttlHours * 3600_000) / 2
238
+ const deviceUnchanged = (live.device ?? undefined) === (options.device ?? undefined)
239
+ if (live.expiresAt > refreshBefore && deviceUnchanged) return live.id
240
+ }
241
+
221
242
  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
243
+ const items = load(env)
244
+ const existing = items.find((r) => r.sessionId === options.sessionId && r.expiresAt > now)
245
+ if (existing) {
246
+ // Refresh so a long-lived session keeps its id, and keep the subject current.
247
+ existing.expiresAt = now + ttlHours * 3600_000
248
+ if (existing.device !== options.device) {
249
+ if (options.device) existing.device = options.device
250
+ else delete existing.device
251
+ }
252
+ existing.subject = options.subject
253
+ save(env, items)
254
+ return existing.id
230
255
  }
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)
256
+ // registerLocked, not registerRoute: the lock is not re-entrant.
257
+ return registerLocked(options, env, now, ttlHours)
237
258
  })
238
259
  }
239
260