@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +836 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
@@ -0,0 +1,270 @@
1
+ // Scan → fetch → authorize → parse → persist. Port of opencode-afk `core/process.js`.
2
+ //
3
+ // THE ORDERING HERE IS THE CORRECTNESS PROPERTY (docs/ARCHITECTURE.md §2):
4
+ //
5
+ // 0. skip everything if mode is off `/mailbridge off` must close the channel, not just mute it
6
+ // 1. search `UID <cursor>+1:*` UID cursor, never \Seen, never SUBJECT
7
+ // 2. fetch envelope + body
8
+ // 3. self-echo, part 1: the marker header (before authorization, because in single-mailbox
9
+ // mode the sender IS the human and would otherwise pass every later gate)
10
+ // 4. authorize (loop → robot → allowlist → DMARC)
11
+ // 5. self-echo, part 2: our own recorded Message-ID
12
+ // 5. route: short id, then legacy signed token, then the reply chain (routes, does not authorize)
13
+ // 6. strip quoted text, then filter attachments
14
+ // 7. persist to the durable pending store <- nothing is lost after this
15
+ // 8. advance the cursor <- only after persist
16
+ //
17
+ // Marking \Seen and journaling happen later, in the daemon's /ack handler, once a
18
+ // session has injected the reply. Doing it here would lose replies on a crash.
19
+ import { authorize } from "../core/authz.js";
20
+ import { stripQuoted } from "../core/reply-parse.js";
21
+ import { tokenFromSubject } from "../core/token.js";
22
+ import { selectAttachments } from "../core/attachments.js";
23
+ import { findOutbound, parseMessageIds, lookupChain } from "../store/threads.js";
24
+ import { routeIdFromSubject, resolveRoute } from "../store/routes.js";
25
+ import { addPending, expirePending, listPending } from "../store/pending-store.js";
26
+ import { isJournaled } from "../store/journal.js";
27
+ import { advanceCursor, readCursor } from "../store/uid-cursor.js";
28
+ /** Longest body we keep. Well above any real instruction; far below "a whole newsletter". */
29
+ export const MAX_BODY_CHARS = 100_000;
30
+ function bump(reasons, key) {
31
+ reasons[key] = (reasons[key] ?? 0) + 1;
32
+ }
33
+ /**
34
+ * One incremental scan.
35
+ *
36
+ * A message is "handled" (and the cursor moves past it) when it is persisted,
37
+ * rejected, skipped, or has vanished. Only a thrown error from the source or the
38
+ * pending store stops the scan with the cursor left before the offending UID, so
39
+ * the next scan retries it.
40
+ */
41
+ export async function scanAndProcess(config, deps) {
42
+ const env = deps.env ?? process.env;
43
+ const now = deps.now ?? (() => Date.now());
44
+ const startCursor = readCursor(env);
45
+ const uidValidity = deps.source.uidValidity?.();
46
+ const uids = (await deps.source.searchAfter(startCursor)).slice().sort((a, b) => a - b);
47
+ const result = {
48
+ examined: uids.length,
49
+ persisted: 0,
50
+ rejected: 0,
51
+ skipped: 0,
52
+ reasons: {},
53
+ cursor: startCursor,
54
+ };
55
+ for (const uid of uids) {
56
+ // Dedupe: an acked UID must never be re-offered, whatever the server says.
57
+ if (isJournaled(env, uid, uidValidity)) {
58
+ result.skipped++;
59
+ bump(result.reasons, "journaled");
60
+ result.cursor = advanceCursor(env, uid);
61
+ continue;
62
+ }
63
+ const message = await deps.source.fetch(uid);
64
+ if (!message) {
65
+ // Deleted between search and fetch. Nothing to do; do not block the cursor.
66
+ result.skipped++;
67
+ bump(result.reasons, "vanished");
68
+ result.cursor = advanceCursor(env, uid);
69
+ continue;
70
+ }
71
+ // The marker is stamped on every notification we send. Seeing it inbound means the
72
+ // mail is ours coming back - check before authorization, because in single-mailbox
73
+ // mode it is From: the human and would otherwise pass every gate.
74
+ if (message.headers.piMailbridge) {
75
+ result.rejected++;
76
+ bump(result.reasons, "self-echo");
77
+ result.cursor = advanceCursor(env, uid);
78
+ continue;
79
+ }
80
+ // The watcher refuses to download anything over the size ceiling and marks it here.
81
+ // Reject before authorization: this is our resource guard, not the sender's fault.
82
+ if (message.tooLarge) {
83
+ result.rejected++;
84
+ bump(result.reasons, "too-large");
85
+ result.cursor = advanceCursor(env, uid);
86
+ continue;
87
+ }
88
+ const authz = authorize({ from: message.from, headers: message.headers }, config);
89
+ if (!authz.ok) {
90
+ result.rejected++;
91
+ bump(result.reasons, authz.reason);
92
+ result.cursor = advanceCursor(env, uid);
93
+ continue;
94
+ }
95
+ // Self-echo, before routing: never treat our own notification as a reply. It arrives
96
+ // From: the mailbox itself, and because notifications now carry In-Reply-To (threading)
97
+ // the single-mailbox guard in authorize() can no longer tell them from a genuine
98
+ // reply. The outbound registry can: if this message's own Message-ID is one we sent,
99
+ // it is ours, whatever its headers say.
100
+ const ownIds = parseMessageIds(message.headers.messageId);
101
+ if (ownIds.length > 0 && ownIds.some((id) => findOutbound(env, id))) {
102
+ result.rejected++;
103
+ bump(result.reasons, "self-echo");
104
+ result.cursor = advanceCursor(env, uid);
105
+ continue;
106
+ }
107
+ // Routing, in order of preference:
108
+ // 1. a short local route id [pi:a1b2c3] — what new notifications carry
109
+ // 2. a signed token [pi:<base64>.<mac>] — messages already in flight
110
+ // 3. the reply chain In-Reply-To / References → recorded Message-ID
111
+ // All three are routing only; authorization happened above.
112
+ let sessionId;
113
+ const routeId = routeIdFromSubject(message.subject);
114
+ if (routeId) {
115
+ // Short ids are local, so an id minted on another machine - or one whose 30-day
116
+ // TTL has passed - does not resolve here. Fall through to the older rules rather
117
+ // than dropping the reply: a human answering an old notification should still
118
+ // reach the session through the token or the reply chain.
119
+ const route = resolveRoute(routeId, { device: config.device.name, now: now(), env });
120
+ if (route) {
121
+ sessionId = route.sessionId;
122
+ bump(result.reasons, "routed");
123
+ }
124
+ else {
125
+ bump(result.reasons, "route-miss");
126
+ }
127
+ }
128
+ if (!sessionId) {
129
+ const token = tokenFromSubject(message.subject, deps.secret, now(), config.device.name);
130
+ if (token) {
131
+ // deviceMatches === false means the token was minted for another machine.
132
+ // undefined means the format carries no device, which is not a mismatch.
133
+ if (token.deviceMatches === false || (token.device && token.device !== config.device.name)) {
134
+ result.rejected++;
135
+ bump(result.reasons, "other-device");
136
+ result.cursor = advanceCursor(env, uid);
137
+ continue;
138
+ }
139
+ sessionId = token.sessionId;
140
+ }
141
+ else if (config.security.allowHeaderThreading !== false) {
142
+ // The reply chain: a client or human that rewrote the subject still leaves
143
+ // In-Reply-To/References intact, which is how "reply to this mail" works.
144
+ const ids = [
145
+ ...parseMessageIds(message.headers.inReplyTo),
146
+ ...parseMessageIds((message.headers.references ?? []).join(" ")),
147
+ ];
148
+ const thread = lookupChain(env, ids, { now: now(), ttlHours: config.security.tokenTtlHours });
149
+ if (thread?.device && thread.device !== config.device.name) {
150
+ result.rejected++;
151
+ bump(result.reasons, "other-device");
152
+ result.cursor = advanceCursor(env, uid);
153
+ continue;
154
+ }
155
+ if (thread) {
156
+ sessionId = thread.sessionId;
157
+ bump(result.reasons, "threaded");
158
+ }
159
+ }
160
+ }
161
+ if (!sessionId) {
162
+ result.rejected++;
163
+ bump(result.reasons, "no-token");
164
+ result.cursor = advanceCursor(env, uid);
165
+ continue;
166
+ }
167
+ // A body cap as well as a message cap: the pending store is re-read on every poll and
168
+ // on every /health, so one enormous reply would be paid for repeatedly.
169
+ const stripped = stripQuoted(message.text);
170
+ // Marked, not silently cut: the agent must be able to tell that its instruction is
171
+ // incomplete (the outbound truncation note does the same).
172
+ const body = stripped.length > MAX_BODY_CHARS
173
+ ? `${stripped.slice(0, MAX_BODY_CHARS)}\n\n[truncated: ${stripped.length - MAX_BODY_CHARS} more characters]`
174
+ : stripped;
175
+ // Attachments are filtered BEFORE persisting; a dropped one is recorded with
176
+ // a reason so `/mailbridge` can explain why the agent did not see it.
177
+ const { accepted, dropped } = selectAttachments(message.attachments ?? []);
178
+ for (const item of dropped)
179
+ bump(result.reasons, `attachment-${item.reason}`);
180
+ // A reply is empty only if it has neither prose nor a surviving attachment. Judging
181
+ // the body first discarded the image-from-a-phone case: nothing persisted, the
182
+ // attachment left unread, and the cursor moved past it.
183
+ //
184
+ // The same applies when the *only* content was an attachment that got dropped (a phone
185
+ // image over the 1 MB total, say). Rejecting it loses the human's whole reply with
186
+ // no trace, so it is persisted with a note naming the drop and delivered: the agent can
187
+ // ask for the text, and the mail is no longer sitting past the cursor unread.
188
+ if (!body && accepted.length === 0) {
189
+ const reasons = [...new Set(dropped.map((d) => d.reason))].join(", ");
190
+ if (reasons.length > 0) {
191
+ const note = `[pi-mailbridge: ${dropped.length} attachment(s) dropped (${reasons}); this reply had no text]`;
192
+ // Not counted as rejected: the reply *is* delivered, as a note. The reason is
193
+ // named so the panel can say the content was lost, without claiming the mail was.
194
+ bump(result.reasons, "attachment-only-dropped");
195
+ const stub = {
196
+ uid,
197
+ sessionId,
198
+ from: authz.from,
199
+ subject: message.subject ?? "",
200
+ body: note,
201
+ receivedAt: message.receivedAt ?? now(),
202
+ ...(uidValidity === undefined ? {} : { uidValidity }),
203
+ device: config.device.name,
204
+ };
205
+ const persisted = addPending(env, stub);
206
+ if (!persisted.ok) {
207
+ // Same rule as a real record: do not advance past it, retry when there is room.
208
+ bump(result.reasons, "pending-full");
209
+ break;
210
+ }
211
+ result.persisted++;
212
+ result.cursor = advanceCursor(env, uid);
213
+ continue;
214
+ }
215
+ result.rejected++;
216
+ bump(result.reasons, "empty-body");
217
+ result.cursor = advanceCursor(env, uid);
218
+ continue;
219
+ }
220
+ const record = {
221
+ uid,
222
+ sessionId,
223
+ from: authz.from,
224
+ subject: message.subject ?? "",
225
+ body,
226
+ ...(accepted.length ? { attachments: accepted } : {}),
227
+ receivedAt: message.receivedAt,
228
+ // Re-read per record: a renumber can land mid-scan, and stamping the scan's opening
229
+ // space would leave the tail of the batch unclaimable until the next connect.
230
+ ...((deps.source.uidValidity?.() ?? uidValidity) === undefined
231
+ ? {}
232
+ : { uidValidity: deps.source.uidValidity?.() ?? uidValidity }),
233
+ };
234
+ // 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
+ }
258
+ // The queue is full. STOP the batch: advancing past this UID would lose it, and
259
+ // so would processing a later UID (the cursor is monotonic, so it would jump
260
+ // over this one). The mail stays unread and the whole batch is retried once
261
+ // space appears.
262
+ result.rejected++;
263
+ bump(result.reasons, "pending-full");
264
+ break;
265
+ }
266
+ result.persisted++;
267
+ result.cursor = advanceCursor(env, uid);
268
+ }
269
+ return result;
270
+ }
@@ -0,0 +1,38 @@
1
+ // One scan at a time, with at most one queued behind it.
2
+ //
3
+ // Three things ask for a scan concurrently: an IMAP `exists`, a reconnect's catch-up, and
4
+ // mode turning on. Overlapping scans are not harmless - two that pass the journal check
5
+ // before either persists would deliver the same reply twice, which is the one outcome this
6
+ // design cannot undo. A burst of IDLE events also should not become a burst of full scans,
7
+ // so a request that arrives while one is running coalesces into a single follow-up.
8
+ export function createScanQueue(run) {
9
+ let current;
10
+ let queued;
11
+ const start = (why) => {
12
+ current = (async () => {
13
+ try {
14
+ await run(why);
15
+ }
16
+ catch {
17
+ // A scan failure is logged by the caller; the queue must survive it.
18
+ }
19
+ finally {
20
+ current = undefined;
21
+ if (queued !== undefined) {
22
+ const next = queued;
23
+ queued = undefined;
24
+ void start(next);
25
+ }
26
+ }
27
+ })();
28
+ return current;
29
+ };
30
+ const scan = (why) => {
31
+ if (!current)
32
+ return start(why);
33
+ // Coalesce: one follow-up covers any number of requests that arrive mid-scan.
34
+ queued ??= why;
35
+ return current;
36
+ };
37
+ return Object.assign(scan, { busy: () => current !== undefined });
38
+ }
@@ -0,0 +1,44 @@
1
+ // Which daemon entry should a session spawn?
2
+ //
3
+ // The pi manifest points at `src/extension.ts`, and pi loads it through jiti - so in a published
4
+ // install the loaded module is TypeScript *inside node_modules*, where Node refuses type
5
+ // stripping. The daemon beside it must therefore be the compiled `dist/` entry whenever one
6
+ // exists, whichever shape this module has.
7
+ //
8
+ // Pure and exported so both layouts can be tested without installing a package.
9
+ import { existsSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ /**
13
+ * Resolve the daemon script for the module at `moduleUrl`.
14
+ *
15
+ * - `dist/extension.js` -> `dist/daemon/main.js`
16
+ * - `src/extension.ts` -> `dist/daemon/main.js` when built (a published install)
17
+ * - `src/extension.ts` only -> `src/daemon/main.ts` (a checkout, run with the flag)
18
+ */
19
+ export function resolveDaemonScript(moduleUrl, exists = existsSync) {
20
+ const here = fileURLToPath(moduleUrl);
21
+ const dir = dirname(here);
22
+ const root = dirname(dir);
23
+ const compiled = here.endsWith(".js");
24
+ const candidates = compiled
25
+ ? [
26
+ // dist/extension.js -> dist/daemon/main.js
27
+ join(dir, "daemon", "main.js"),
28
+ join(root, "dist", "daemon", "main.js"),
29
+ join(dir, "daemon", "main.ts"),
30
+ ]
31
+ : [
32
+ // src/extension.ts -> the build, when the package was published
33
+ join(root, "dist", "daemon", "main.js"),
34
+ join(dir, "daemon", "main.js"),
35
+ // a checkout without a build
36
+ join(dir, "daemon", "main.ts"),
37
+ ];
38
+ for (const candidate of candidates) {
39
+ if (exists(candidate))
40
+ return candidate;
41
+ }
42
+ // Nothing exists: name the source path, so the failure mentions what the caller expected.
43
+ return join(dir, "daemon", "main.ts");
44
+ }
@@ -0,0 +1,30 @@
1
+ // How much mail this daemon will send per minute.
2
+ //
3
+ // The delivery path is model-driven: `notify_user` and settle notifications can be produced by an
4
+ // agent, and an injected agent can ask for a great many. Providers answer a burst by throttling
5
+ // the account or refusing for the day, which silences the notifications that actually matter.
6
+ // A small sliding window keeps a loop from spending the quota.
7
+ //
8
+ // Deliberately not a queue: a refused send is reported to the caller (`429 send_budget`), so the
9
+ // agent sees it and the human is not mailed a backlog of stale notifications later.
10
+ export function createSendBudget(options) {
11
+ const sent = [];
12
+ const prune = (now) => {
13
+ const floor = now - options.windowMs;
14
+ while (sent.length > 0 && sent[0] <= floor)
15
+ sent.shift();
16
+ };
17
+ return {
18
+ take(now) {
19
+ prune(now);
20
+ if (sent.length >= options.limit)
21
+ return false;
22
+ sent.push(now);
23
+ return true;
24
+ },
25
+ used(now) {
26
+ prune(now);
27
+ return sent.length;
28
+ },
29
+ };
30
+ }