@dumbmice/pi-mailbridge 0.1.2 → 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 +39 -0
- package/README.md +7 -7
- package/dist/core/attachments.js +11 -1
- package/dist/core/status.js +28 -3
- package/dist/daemon/process.js +43 -27
- package/dist/store/pending-store.js +43 -4
- package/dist/store/routes.js +21 -0
- package/docs/ARCHITECTURE.md +9 -1
- package/docs/SECURITY.md +4 -1
- package/package.json +1 -1
- package/src/core/attachments.ts +12 -1
- package/src/core/status.ts +31 -3
- package/src/daemon/process.ts +43 -27
- package/src/store/pending-store.ts +47 -4
- package/src/store/routes.ts +35 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
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
|
+
|
|
32
|
+
## 0.1.3 — 2026-11-13
|
|
33
|
+
|
|
34
|
+
- **The package page showed alt text instead of the images.** The README's markup was fixed to
|
|
35
|
+
absolute URLs in 0.1.2, which npm requires - but `raw.githubusercontent.com` is unreachable on
|
|
36
|
+
many mainland networks (GitHub has no CDN there), so the reader saw red alt text while the
|
|
37
|
+
server answered 200 elsewhere. Both images now come from jsDelivr's npm mirror
|
|
38
|
+
(`cdn.jsdelivr.net/npm/@dumbmice/pi-mailbridge@latest/docs/images/…`): byte-identical files,
|
|
39
|
+
served out of the published package, on a CDN that is reachable where raw is not, and `@latest`
|
|
40
|
+
so the URL follows the release instead of rotting.
|
|
41
|
+
|
|
3
42
|
## 0.1.2 — 2026-11-13
|
|
4
43
|
|
|
5
44
|
The release that fixes the ways this package could quietly do the wrong thing.
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ continues**, with your answer as the next message. Notifications also carry rend
|
|
|
8
8
|
images embedded in the body where the agent put them, and any local file it linked attached with
|
|
9
9
|
its real name.
|
|
10
10
|
|
|
11
|
-

|
|
12
12
|
|
|
13
13
|
- **Your inbox is the client.** No app, no account, no relay, no push service. Any device with
|
|
14
14
|
email works: a phone, a tablet, another PC, a webmail tab.
|
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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 |
|
|
@@ -566,7 +566,7 @@ that is the credential that actually reaches your mailbox.
|
|
|
566
566
|
|
|
567
567
|
## How it works
|
|
568
568
|
|
|
569
|
-

|
|
570
570
|
|
|
571
571
|
The numbered path is the whole product. **1** The agent finishes its turn and goes idle — that is
|
|
572
572
|
the normal case, one mail per finished turn — or it stops to ask you a question; either way the
|
|
@@ -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.
|
|
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
|
|
package/dist/core/attachments.js
CHANGED
|
@@ -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()
|
|
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;
|
package/dist/core/status.js
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
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
|
}
|
package/dist/daemon/process.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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 };
|
package/dist/store/routes.js
CHANGED
|
@@ -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);
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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.
|
|
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
package/src/core/attachments.ts
CHANGED
|
@@ -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 =
|
|
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" })
|
package/src/core/status.ts
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
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
|
}
|
package/src/daemon/process.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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(
|
|
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 }
|
package/src/store/routes.ts
CHANGED
|
@@ -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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
|