@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,355 @@
1
+ // Attach local files that a notification links to.
2
+ //
3
+ // Two shapes, both resolved from the agent's working directory:
4
+ //
5
+ // ![shot](./shot.png) an <img> - inlined as a CID part so it renders in place
6
+ // [report](./report.pdf) an <a> - attached with its real filename, and the link text
7
+ // says so, because a mail client cannot follow a
8
+ // relative path and a cid: anchor is unreliable
9
+ //
10
+ // The result is mailed, so the safety rules are the point: an image must sniff as an image
11
+ // (magic bytes, not an extension), a non-image must have an allowlisted extension, every
12
+ // file is size- and count-capped, and anything unreadable degrades to a sentence instead of
13
+ // a broken link. Content is hashed, so one file referenced twice rides once.
14
+ //
15
+ // A Markdown image pointing at a local path renders as `<img src="./shot.png">`, which a
16
+ // mail client cannot resolve - the reader sees a broken image. The standard fix is a
17
+ // `multipart/related` attachment referenced by `cid:`, which is what this does.
18
+ //
19
+ // Safety, because the src comes from model output and the result is emailed:
20
+ //
21
+ // - the file must actually **sniff** as an image (magic bytes), not merely have an
22
+ // image extension, so a renamed text file or key is never attached;
23
+ // - a size cap and a count cap, so a mail cannot become a file transfer;
24
+ // - a path that cannot be read or does not sniff degrades to a short note instead of
25
+ // a broken image.
26
+ //
27
+ // Images are also addressed by content hash, so the same image referenced twice is
28
+ // attached once.
29
+ import { createHash } from "node:crypto";
30
+ import { readFileSync, realpathSync, statSync } from "node:fs";
31
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
32
+ import { homedir } from "node:os";
33
+ import { configDir, stateDir } from "../store/paths.js";
34
+ /**
35
+ * Provider ceilings, which is what these numbers are derived from:
36
+ *
37
+ * Gmail 25 MB send (50 MB receive)
38
+ * Outlook.com 25 MB per message
39
+ * M365 work 35 MB send / 36 MB receive (Exchange default 10 MB)
40
+ * QQ Mail 50 MB attachments, 55 MB with the body, 200 MB/day
41
+ *
42
+ * Base64 inflates by about a third, so 18 MB of files is roughly 24 MB on the wire - under the
43
+ * smallest ceiling. `notify.maxAttachmentMb` raises the budget for a provider that allows more.
44
+ */
45
+ export const MAX_INLINE_BYTES = 5 * 1024 * 1024;
46
+ /** Per-file cap for an attached (non-inline) file. */
47
+ export const MAX_FILE_BYTES = 15 * 1024 * 1024;
48
+ /** Default total payload cap; a caller may pass `maxTotalBytes` instead. */
49
+ export const MAX_INLINE_TOTAL_BYTES = 18 * 1024 * 1024;
50
+ export const MAX_INLINE_COUNT = 10;
51
+ /**
52
+ * Extensions we will attach from a link.
53
+ *
54
+ * An allowlist rather than "anything readable": the path comes from model output, and the
55
+ * one thing that must never happen is mailing a private key because a prompt said so. Add
56
+ * to this list deliberately.
57
+ */
58
+ const ALLOWED_FILE_EXTENSIONS = {
59
+ pdf: "application/pdf",
60
+ txt: "text/plain",
61
+ log: "text/plain",
62
+ md: "text/markdown",
63
+ csv: "text/csv",
64
+ tsv: "text/tab-separated-values",
65
+ json: "application/json",
66
+ yml: "application/yaml",
67
+ yaml: "application/yaml",
68
+ xml: "application/xml",
69
+ html: "text/html",
70
+ zip: "application/zip",
71
+ gz: "application/gzip",
72
+ tar: "application/x-tar",
73
+ png: "image/png",
74
+ jpg: "image/jpeg",
75
+ jpeg: "image/jpeg",
76
+ gif: "image/gif",
77
+ webp: "image/webp",
78
+ svg: "image/svg+xml",
79
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
80
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
81
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
82
+ };
83
+ /**
84
+ * Identify an image from its magic bytes rather than trusting the extension.
85
+ *
86
+ * SVG is deliberately absent. Not because email cannot carry it - a linked SVG file
87
+ * (`<img src="x.svg">` or a cid attachment) is supported by roughly 97% of clients, and
88
+ * Gmail rasterises it to PNG server-side - but because **Outlook on Windows (2007-2019)
89
+ * renders nothing at all** for it, with no fallback. Inline `<svg>` markup is the truly
90
+ * dead form at ~40%, since Gmail deletes the element while sanitising.
91
+ *
92
+ * PNG is therefore the one format that renders everywhere, which matters more for a
93
+ * notification than vector sharpness. SVG could be added behind a flag with an explicit
94
+ * "invisible in Outlook desktop" caveat.
95
+ */
96
+ export function sniffImage(buffer) {
97
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
98
+ return { contentType: "image/png", ext: "png" };
99
+ }
100
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
101
+ return { contentType: "image/jpeg", ext: "jpg" };
102
+ }
103
+ if (buffer.length >= 6) {
104
+ const head = buffer.subarray(0, 6).toString("latin1");
105
+ if (head === "GIF87a" || head === "GIF89a")
106
+ return { contentType: "image/gif", ext: "gif" };
107
+ }
108
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString("latin1") === "RIFF" && buffer.subarray(8, 12).toString("latin1") === "WEBP") {
109
+ return { contentType: "image/webp", ext: "webp" };
110
+ }
111
+ return undefined;
112
+ }
113
+ const IMG_TAG = /<img\b[^>]*>/gi;
114
+ const SRC_ATTR = /\bsrc\s*=\s*"([^"]*)"/i;
115
+ const ALT_ATTR = /\balt\s*=\s*"([^"]*)"/i;
116
+ /** Markdown links become anchors; the text between them is already rendered HTML. */
117
+ const LINK_TAG = /<a\s+href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
118
+ /** realpath, or the input when it cannot be resolved (tests use paths that may not exist). */
119
+ function safeRealpath(path) {
120
+ try {
121
+ return realpathSync(path);
122
+ }
123
+ catch {
124
+ return undefined;
125
+ }
126
+ }
127
+ /** stat that follows symlinks; undefined when the path does not resolve. */
128
+ function safeStat(path) {
129
+ try {
130
+ return statSync(path);
131
+ }
132
+ catch {
133
+ return undefined;
134
+ }
135
+ }
136
+ /**
137
+ * Is this resolved file safe to attach?
138
+ *
139
+ * Three rules, all fail-closed:
140
+ * - it must resolve, and it must land inside the project directory (a symlink out is refused);
141
+ * - the project directory itself must be a real project, not `/` or your home - with cwd = ~
142
+ * the config file is "inside the project" and the allowlist would happily mail the password;
143
+ * - the daemon's own config and state directories are never attached, whatever the cwd.
144
+ *
145
+ * The comparison goes through `relative()`, not a `/`-joined prefix: on Windows the separator is
146
+ * `\`, so a prefix test silently refused every attachment there.
147
+ */
148
+ function insideRoot(path, rootReal) {
149
+ if (!rootReal)
150
+ return "root-refused";
151
+ // A path that does not resolve yet (a missing file, a dangling link) is compared by its
152
+ // parent's real location plus its own name, so a project reached through a symlinked path
153
+ // (macOS /var -> /private/var, a symlinked checkout) still reports "not found" rather than
154
+ // "outside the project"; a path that resolves elsewhere - `ln -s /proc/self/fd/0` - is
155
+ // compared at its real location and refused.
156
+ const target = safeRealpath(path) ?? joinResolved(dirname(path), basename(path));
157
+ const home = safeRealpath(homedir());
158
+ // Exact equality: a project *inside* home is the normal case and must still attach.
159
+ if (rootReal === "/" || (home !== undefined && rootReal === home))
160
+ return "root-refused";
161
+ for (const dir of [configDir(), stateDir()]) {
162
+ const real = safeRealpath(dir);
163
+ if (real && contains(real, target))
164
+ return "root-refused";
165
+ }
166
+ return contains(rootReal, target) ? "ok" : "outside-root";
167
+ }
168
+ /** Does `parent` contain `child`? Separator-agnostic and not fooled by a shared prefix. */
169
+ function contains(parent, child) {
170
+ const rel = relative(parent, child);
171
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
172
+ }
173
+ /** realpath of a directory plus one unresolved name. */
174
+ function joinResolved(dir, name) {
175
+ return `${safeRealpath(dir) ?? dir}/${name}`;
176
+ }
177
+ /** A filename a mail client will accept: no separators, no control characters, bounded. */
178
+ function safeFilename(path, fallback) {
179
+ const base = path.split(/[\\/]/).pop() ?? fallback;
180
+ const cleaned = base
181
+ .normalize("NFKC")
182
+ .replace(/[\u0000-\u001f\u007f"<>|:*?]+/g, "-")
183
+ .replace(/^[.]+/, "")
184
+ .trim();
185
+ return (cleaned || fallback).slice(0, 120);
186
+ }
187
+ function escapeHtml(value) {
188
+ return value
189
+ .replace(/&/g, "&amp;")
190
+ .replace(/</g, "&lt;")
191
+ .replace(/>/g, "&gt;")
192
+ .replace(/"/g, "&quot;");
193
+ }
194
+ function note(label, detail) {
195
+ return `<span style="opacity:.7">[${escapeHtml(label)}${detail ? `: ${escapeHtml(detail)}` : ""}]</span>`;
196
+ }
197
+ function resolvePath(src, root) {
198
+ let value = src.trim();
199
+ if (value.startsWith("file://"))
200
+ value = value.slice("file://".length);
201
+ if (value.startsWith("~/"))
202
+ value = `${homedir()}${value.slice(1)}`;
203
+ return isAbsolute(value) ? value : resolve(root, value);
204
+ }
205
+ export function inlineLocalImages(html, options = {}) {
206
+ const root = options.root ?? process.cwd();
207
+ const maxBytes = options.maxBytes ?? MAX_INLINE_BYTES;
208
+ const maxCount = options.maxCount ?? MAX_INLINE_COUNT;
209
+ const maxTotal = options.maxTotalBytes ?? MAX_INLINE_TOTAL_BYTES;
210
+ const read = options.readFile ?? ((path) => readFileSync(path));
211
+ const size = options.sizeOf ?? ((path) => statSync(path).size);
212
+ // Resolve the project directory once. Attachments are limited to it: a symlink inside the
213
+ // project pointing at `~/.config/pi-mailbridge/config.json` would otherwise mail the
214
+ // mailbox password, and the allowlist says nothing about where a file came from.
215
+ const rootReal = safeRealpath(root);
216
+ const attachments = [];
217
+ const skipped = [];
218
+ const byCid = new Map();
219
+ let total = 0;
220
+ const replaced = html.replace(IMG_TAG, (tag) => {
221
+ const src = SRC_ATTR.exec(tag)?.[1] ?? "";
222
+ const alt = ALT_ATTR.exec(tag)?.[1] ?? "";
223
+ // Remote, protocol-relative and already-inlined sources are left alone.
224
+ if (!src || /^(?:https?:)?\/\//i.test(src) || src.startsWith("cid:") || src.startsWith("data:"))
225
+ return tag;
226
+ if (attachments.length >= maxCount) {
227
+ skipped.push({ src, reason: "too-many" });
228
+ return note(alt || "image", "not attached, too many images");
229
+ }
230
+ const path = resolvePath(src, root);
231
+ const verdict = insideRoot(path, rootReal);
232
+ if (verdict !== "ok") {
233
+ skipped.push({ src, reason: verdict });
234
+ return note(alt || "image", verdict === "root-refused"
235
+ ? "not attached, start pi in a project directory"
236
+ : "not attached, outside the project");
237
+ }
238
+ let bytes;
239
+ try {
240
+ // Existence first, so the note says "not found" rather than "unreadable"; then the
241
+ // type, because a FIFO or a device reports size 0 and blocks the read forever, which
242
+ // would hang the send (and the poll tick that awaits it).
243
+ const st = safeStat(path);
244
+ if (!st) {
245
+ skipped.push({ src, reason: "not-found" });
246
+ return note(alt || "image", "not attached, file not found");
247
+ }
248
+ if (!st.isFile()) {
249
+ skipped.push({ src, reason: "unreadable" });
250
+ return note(alt || "image", "not attached, not a regular file");
251
+ }
252
+ if (size(path) > maxBytes) {
253
+ skipped.push({ src, reason: "too-large" });
254
+ return note(alt || "image", `${Math.round(size(path) / 1024)} KB, over the ${Math.round(maxBytes / 1024)} KB limit`);
255
+ }
256
+ bytes = read(path);
257
+ }
258
+ catch {
259
+ skipped.push({ src, reason: "not-found" });
260
+ return note(alt || "image", "not attached, file not found");
261
+ }
262
+ const kind = sniffImage(bytes);
263
+ if (!kind) {
264
+ skipped.push({ src, reason: "not-an-image" });
265
+ return note(alt || "image", "not attached, not an image");
266
+ }
267
+ if (bytes.length > maxBytes || total + bytes.length > maxTotal) {
268
+ skipped.push({ src, reason: "too-large" });
269
+ return note(alt || "image", "not attached, too large");
270
+ }
271
+ const digest = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
272
+ const cid = `pi-${digest}@pi.local`;
273
+ const existing = byCid.get(cid);
274
+ if (!existing) {
275
+ const base = (alt || "image").replace(/[^A-Za-z0-9._-]+/g, "-").slice(0, 40) || "image";
276
+ const attachment = {
277
+ filename: `${base}.${kind.ext}`,
278
+ content: bytes,
279
+ contentType: kind.contentType,
280
+ cid,
281
+ };
282
+ byCid.set(cid, attachment);
283
+ attachments.push(attachment);
284
+ total += bytes.length;
285
+ }
286
+ return `<img src="cid:${cid}" alt="${escapeHtml(alt)}" style="max-width:100%;height:auto">`;
287
+ });
288
+ // Links to local files become ordinary attachments. Their anchors cannot work in a mail
289
+ // client - a relative path does not exist there and `cid:` in an <a href> is unreliable -
290
+ // so the text stays and the filename is named beside it.
291
+ const withLinks = replaced.replace(LINK_TAG, (_tag, href, inner) => {
292
+ if (!href || /^(?:https?:|mailto:|tel:|cid:|data:|#)/i.test(href))
293
+ return _tag;
294
+ const path = resolvePath(href, root);
295
+ // The skipped reason is a stable code (tests and diagnostics match on it); the note is
296
+ // what the reader sees.
297
+ const refuse = (reason, text) => {
298
+ skipped.push({ src: href, reason });
299
+ return `${inner} ${note("not attached", text)}`;
300
+ };
301
+ if (attachments.length >= maxCount)
302
+ return refuse("too-many", "too many attachments");
303
+ const verdict = insideRoot(path, rootReal);
304
+ if (verdict !== "ok") {
305
+ return refuse(verdict, verdict === "root-refused" ? "start pi in a project directory" : "outside the project");
306
+ }
307
+ const filename = safeFilename(href, "file");
308
+ const ext = filename.includes(".") ? filename.split(".").pop().toLowerCase() : "";
309
+ const contentType = ALLOWED_FILE_EXTENSIONS[ext];
310
+ if (!contentType)
311
+ return refuse("not-allowed", "this file type is not attached");
312
+ let bytes;
313
+ try {
314
+ const st = safeStat(path);
315
+ if (!st)
316
+ return refuse("not-found", "file not found");
317
+ if (!st.isFile())
318
+ return refuse("unreadable", "not a regular file");
319
+ // Per-file cap before reading; the *total* is charged after the dedupe below, so a
320
+ // file that is already attached does not fail as "over the limit" for zero new bytes.
321
+ if (size(path) > MAX_FILE_BYTES) {
322
+ return refuse("too-large", `${Math.round(size(path) / 1024)} KB, over the limit`);
323
+ }
324
+ bytes = read(path);
325
+ // The file can grow between stat and read.
326
+ if (bytes.length > MAX_FILE_BYTES) {
327
+ return refuse("too-large", `${Math.round(bytes.length / 1024)} KB, over the limit`);
328
+ }
329
+ }
330
+ catch {
331
+ skipped.push({ src: href, reason: "not-found" });
332
+ return `${inner} ${note("not attached", "file not found")}`;
333
+ }
334
+ // Same content, same part: a report linked from two places is attached once.
335
+ const digest = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
336
+ const existing = byCid.get(`file-${digest}`);
337
+ const name = existing?.filename ?? filename;
338
+ if (!existing) {
339
+ if (total + bytes.length > maxTotal) {
340
+ return refuse("too-large", `${Math.round(bytes.length / 1024)} KB, over the limit`);
341
+ }
342
+ const attachment = {
343
+ filename,
344
+ content: bytes,
345
+ contentType,
346
+ contentDisposition: "attachment",
347
+ };
348
+ byCid.set(`file-${digest}`, attachment);
349
+ attachments.push(attachment);
350
+ total += bytes.length;
351
+ }
352
+ return `${inner} <span style="opacity:.7">[attached: ${escapeHtml(name)}]</span>`;
353
+ });
354
+ return { html: withLinks, attachments, skipped };
355
+ }
@@ -0,0 +1,62 @@
1
+ // Commands arriving as a reply.
2
+ //
3
+ // Why this exists instead of pi's own dispatcher: `sendUserMessage(..., { expandPromptTemplates:
4
+ // true })` hands any text starting with "/" to `_tryExecuteExtensionCommand`, which runs the
5
+ // command and returns *without a turn* - and returns `void` to the caller, so a caller cannot
6
+ // tell a command that ran from a prompt that pi rejected (no model, expired credentials,
7
+ // compaction). This package acks and marks `\Seen` after that call returns, so a silent
8
+ // rejection would consume the mail. Dispatching here means we know what happened, and we can
9
+ // refuse the commands that must never be triggered by mail.
10
+ //
11
+ // The parse is deliberately strict: the *entire* body must be one command line. A reply that
12
+ // says "please run this" and quotes `/mailbridge off` underneath is a message, not an instruction.
13
+ /** Subcommands a mail reply may run. Everything else stays text for the agent to read. */
14
+ export const MAIL_SAFE_SUBCOMMANDS = [
15
+ "on",
16
+ "off",
17
+ "status",
18
+ "check",
19
+ "smoke",
20
+ "help",
21
+ ];
22
+ /** Subcommands that change the machine, the credentials or the queue - never by mail. */
23
+ export const MAIL_REFUSED_SUBCOMMANDS = ["setup", "auth", "restart", "prime"];
24
+ /**
25
+ * Parse a reply body as a command, or return undefined if it is ordinary text.
26
+ *
27
+ * `\r\n` is normalised because mail bodies carry it. Leading and trailing blank lines are
28
+ * ignored, but any second non-blank line makes this text: a command must be the whole message.
29
+ */
30
+ export function parseMailCommand(body) {
31
+ const lines = body
32
+ .replace(/\r\n/g, "\n")
33
+ .split("\n")
34
+ .map((line) => line.trim())
35
+ .filter((line) => line.length > 0);
36
+ if (lines.length !== 1)
37
+ return undefined;
38
+ const line = lines[0];
39
+ if (!line.startsWith("/"))
40
+ return undefined;
41
+ const space = line.indexOf(" ");
42
+ const name = (space === -1 ? line.slice(1) : line.slice(1, space)).toLowerCase();
43
+ const args = space === -1 ? "" : line.slice(space + 1).trim();
44
+ if (name !== "mailbridge")
45
+ return undefined;
46
+ // Bare `/mailbridge` prints status, exactly as it does at the prompt.
47
+ // Every safe subcommand takes no arguments, so any leftover word means this is a message that
48
+ // happens to start with a command: `/mailbridge off thanks, the deploy is still broken` must
49
+ // reach the agent, not flip the mode and drop the sentence. Splitting on /\s+/ also catches
50
+ // U+2028/U+2029, which a body can contain without a newline - though the whitelist below
51
+ // would reject a token carrying them anyway.
52
+ const words = args.split(/\s+/).filter(Boolean);
53
+ if (words.length > 1)
54
+ return undefined;
55
+ const sub = (words[0] ?? "").toLowerCase() || "status";
56
+ if (!MAIL_SAFE_SUBCOMMANDS.includes(sub)) {
57
+ // `setup`, `auth`, `restart`, `prime` and unknown words stay text, so the agent sees the
58
+ // request and nothing reconfigures itself because someone's address was spoofed.
59
+ return undefined;
60
+ }
61
+ return { name, args, sub: sub };
62
+ }
@@ -0,0 +1,126 @@
1
+ import { randomUUID } from "node:crypto";
2
+ // SMTP send path.
3
+ //
4
+ // Deliberately tiny: build transport options, hand the message to the transport,
5
+ // let failures propagate. Callers decide how to degrade (release a reservation,
6
+ // surface an error to the tool) — this module never pretends a send succeeded.
7
+ //
8
+ // Two auth modes, because Outlook has no third option:
9
+ // LOGIN — provider app password. Works for Gmail, Fastmail, QQ, 163, …
10
+ // XOAUTH2 — access token. Use for outlook.com/hotmail: Microsoft retired SMTP basic
11
+ // auth on 2024-09-16, and IMAP rejects app passwords outright.
12
+ //
13
+ // Subject stamping is NOT done here. It belongs to core/token so there is exactly
14
+ // one place that mints routing tokens.
15
+ import nodemailer from "nodemailer";
16
+ import { withFreshToken } from "./oauth.js";
17
+ import { renderMessage } from "./html.js";
18
+ const _nodemailerShapeOk = true;
19
+ void _nodemailerShapeOk;
20
+ /** Default: nodemailer. Tests inject a fake so no socket is ever opened. */
21
+ export const defaultTransportFactory = (options) => nodemailer.createTransport(options);
22
+ /**
23
+ * Transport options for this account.
24
+ * Exported so `--check` can validate the shape without sending anything.
25
+ */
26
+ export async function buildTransportOptions(config, deps = {}) {
27
+ const smtp = config.mail.smtp;
28
+ const base = { host: smtp.host, port: smtp.port, secure: smtp.secure };
29
+ if (smtp.authType === "XOAUTH2") {
30
+ // A provider is enough on its own: that is the path withFreshToken retries through.
31
+ const getToken = deps.getAccessToken ?? (deps.provider ? () => deps.provider.getToken() : undefined);
32
+ if (!getToken) {
33
+ throw new Error("pi-mailbridge: smtp.authType is XOAUTH2 but no token provider is configured — set mail.smtp.tokenCommand (see docs/OAUTH.md)");
34
+ }
35
+ const accessToken = await getToken();
36
+ if (!accessToken)
37
+ throw new Error("pi-mailbridge: OAuth2 token provider returned an empty token");
38
+ return { ...base, auth: { type: "OAuth2", user: smtp.user, accessToken } };
39
+ }
40
+ return { ...base, auth: { user: smtp.user, pass: smtp.password } };
41
+ }
42
+ /**
43
+ * Send one plain-text message.
44
+ * Throws on any transport failure — there is no partial-success path.
45
+ *
46
+ * Returns the Message-ID WE stamped (minted here if the caller did not pass one), so the
47
+ * caller can record the id it actually sent - threading and the self-echo guard both
48
+ * depend on that value matching the header.
49
+ */
50
+ export async function sendMail(config, message, deps = {}) {
51
+ if (!config.mail.smtp.host)
52
+ throw new Error("pi-mailbridge: mail.smtp.host is not configured");
53
+ if (!message.to)
54
+ throw new Error("pi-mailbridge: no recipient (mail.to)");
55
+ const factory = deps.transportFactory ?? defaultTransportFactory;
56
+ const rendered = renderMessage(message.text, {
57
+ ...(message.cwd ? { root: message.cwd } : {}),
58
+ // The user's cap, so a QQ account can use 45 MB and a Gmail account is not left with a
59
+ // send that fails at the provider.
60
+ maxTotalBytes: Math.round(config.notify.maxAttachmentMb * 1024 * 1024),
61
+ });
62
+ // withFreshToken hands each attempt its token; use it directly rather than fetching a
63
+ // second one, or the provider is called twice per attempt and the counts drift.
64
+ // One id for the header and the return value. Minting twice meant callers that did not
65
+ // pass one got back an id that was not the one in the message.
66
+ const stampedId = message.messageId ?? generateMessageId(config.mail.smtp.user);
67
+ const dispatch = async (accessToken) => {
68
+ const transportOptions = await buildTransportOptions(config, accessToken ? { ...deps, getAccessToken: async () => accessToken } : deps);
69
+ const transporter = factory(transportOptions);
70
+ return (await transporter.sendMail({
71
+ from: message.from ?? config.mail.smtp.user,
72
+ // Minted here at the latest, so the id is known to the caller before the transport
73
+ // has spoken. A notification must be recognisable as ours even if the send races an
74
+ // IMAP exists event.
75
+ messageId: stampedId,
76
+ // Belt and braces for single-mailbox mode: the scan refuses any message carrying it.
77
+ // The second header carries the id we minted: providers may replace Message-ID (QQ
78
+ // does), and this is how the just-sent copy is identified afterwards.
79
+ headers: { "X-Pi-Mailbridge": "notification", "X-Pi-Mailbridge-Id": stampedId },
80
+ to: message.to,
81
+ // The subject carries model-controlled text. A CR/LF would fold a new header into
82
+ // the message (the classic header-injection shape), and an unbounded subject is
83
+ // rejected by some MTAs, so both are flattened at the single choke point.
84
+ subject: sanitizeSubject(message.subject),
85
+ // multipart/alternative: clients fold quoted history only for HTML mail, so
86
+ // plain-text-only notifications produced reply threads of raw repeated text.
87
+ text: message.text,
88
+ html: rendered.html,
89
+ // Thread every notification for a session into one conversation. Subject alone is
90
+ // enough for Outlook's conversation topic; these headers are what Gmail uses.
91
+ ...(message.inReplyTo ? { inReplyTo: message.inReplyTo } : {}),
92
+ ...(message.references?.length ? { references: message.references.join(" ") } : {}),
93
+ // Local images become cid: attachments; a bare file path would arrive broken.
94
+ ...(rendered.attachments.length > 0 ? { attachments: rendered.attachments } : {}),
95
+ }));
96
+ };
97
+ // A revoked or expired grant surfaces as an auth error on the first attempt. With a
98
+ // provider, drop the cached token, mint a new one and retry once (OAUTH.md promises
99
+ // this); without one, the failure propagates as before.
100
+ // The transport's own result is deliberately ignored: the id we care about is ours.
101
+ if (deps.provider && config.mail.smtp.authType === "XOAUTH2") {
102
+ await withFreshToken(deps.provider, dispatch);
103
+ }
104
+ else {
105
+ await dispatch();
106
+ }
107
+ // Ours, not the transport's: it is what was recorded and what threading references.
108
+ return { messageId: stampedId };
109
+ }
110
+ /**
111
+ * A Message-ID we control, on the sender's domain where possible.
112
+ * Domain is cosmetic; uniqueness is randomUUID's job.
113
+ */
114
+ export function generateMessageId(from) {
115
+ const domain = /@([^@>\s]+)/.exec(from)?.[1] ?? "pi-mailbridge.invalid";
116
+ return `<${randomUUID()}@${domain}>`;
117
+ }
118
+ /** RFC 5322 caps a header line at 998 octets; keep well inside it. */
119
+ const MAX_SUBJECT = 200;
120
+ export function sanitizeSubject(subject) {
121
+ return subject
122
+ .replace(/[\r\n\u0000]+/g, " ")
123
+ .replace(/\s{2,}/g, " ")
124
+ .trim()
125
+ .slice(0, MAX_SUBJECT);
126
+ }
@@ -0,0 +1,96 @@
1
+ // Markdown → HTML for the mail body.
2
+ //
3
+ // The agent answers in Markdown. Sending it escaped means the reader sees `**bold**`,
4
+ // `| a | b |` and ``` fences in a client that could have rendered them.
5
+ //
6
+ // Rendering is delegated to **markdown-it**, configured to be safe by default rather
7
+ // than trusted:
8
+ //
9
+ // - `html: false` — raw HTML in the model output is escaped, not passed through, so
10
+ // there is no sanitiser to get wrong and nothing to lose on a library upgrade.
11
+ // - `linkify: true` — bare URLs become links, which is most of the value of an email.
12
+ // - markdown-it's own `validateLink` refuses `javascript:`, `vbscript:` and `data:`
13
+ // hrefs, so a link in model output cannot become script in the reader's client.
14
+ // - `breaks: true` — single newlines are `<br>`, matching how the body is written
15
+ // (answer, then our fixed footer) instead of reflowing it into one paragraph.
16
+ //
17
+ // Dependency cost is markdown-it plus six small transitive packages (entities,
18
+ // linkify-it, mdurl, argparse, uc.micro, punycode.js), all from the same maintainers.
19
+ import MarkdownIt from "markdown-it";
20
+ const md = new MarkdownIt({
21
+ html: false,
22
+ linkify: true,
23
+ breaks: true,
24
+ typographer: false,
25
+ });
26
+ // --- email-safe styling ------------------------------------------------------
27
+ //
28
+ // markdown-it emits bare tags. Mail clients default to *no* table borders, so a table
29
+ // arrived as loose text; and a `<style>` block is stripped by several clients. Inline
30
+ // styles on each cell are what survive that, so the rendering rules are overridden
31
+ // rather than the output being post-processed.
32
+ const CELL_BORDER = "border:1px solid #c8ccd1";
33
+ /**
34
+ * Merge our style with any attributes markdown-it already set (alignment, mostly).
35
+ * `attrs` is typed loosely because @types/markdown-it uses its own TokenAttribute
36
+ * shape; it is always a list of name/value pairs at runtime.
37
+ */
38
+ function styledTag(name, rawAttrs, extra) {
39
+ const pairs = Array.isArray(rawAttrs) ? rawAttrs : [];
40
+ const attrs = new Map(pairs);
41
+ const existing = attrs.get("style");
42
+ attrs.set("style", existing ? `${existing};${extra}` : extra);
43
+ // cellspacing/cellpadding are attributes, not CSS, and survive sanitising better.
44
+ const rendered = [...attrs].map(([key, value]) => ` ${key}="${md.utils.escapeHtml(value)}"`).join("");
45
+ return `<${name}${rendered}>`;
46
+ }
47
+ md.renderer.rules.table_open = () => '<table cellspacing="0" cellpadding="0" style="border-collapse:collapse;margin:10px 0;font-size:13px">';
48
+ md.renderer.rules.th_open = (tokens, idx) => styledTag("th", tokens[idx]?.attrs, `${CELL_BORDER};padding:6px 10px;background:#f2f4f7;text-align:left`);
49
+ md.renderer.rules.td_open = (tokens, idx) => styledTag("td", tokens[idx]?.attrs, `${CELL_BORDER};padding:6px 10px`);
50
+ const MONO = "font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace";
51
+ md.renderer.rules.fence = (tokens, idx) => {
52
+ const token = tokens[idx];
53
+ const language = token.info.trim().split(/\s+/)[0] ?? "";
54
+ const cls = language ? ` class="language-${md.utils.escapeHtml(language)}"` : "";
55
+ return (`<pre style="background:#f6f8fa;${CELL_BORDER};border-radius:4px;padding:10px;overflow:auto;${MONO};font-size:13px">` +
56
+ `<code${cls}>${md.utils.escapeHtml(token.content)}</code></pre>`);
57
+ };
58
+ md.renderer.rules.code_inline = (tokens, idx) => `<code style="background:#f2f4f7;border-radius:3px;padding:1px 4px;${MONO};font-size:13px">${md.utils.escapeHtml(tokens[idx].content)}</code>`;
59
+ /** Inline Markdown (no block structure). */
60
+ export function renderInline(text) {
61
+ return md.renderInline(text);
62
+ }
63
+ /** Block Markdown as HTML, trimmed so callers can concatenate it. */
64
+ export function renderMarkdown(text) {
65
+ return md.render(text).trim();
66
+ }
67
+ function escapeHtml(value) {
68
+ return value
69
+ .replace(/&/g, "&amp;")
70
+ .replace(/</g, "&lt;")
71
+ .replace(/>/g, "&gt;")
72
+ .replace(/"/g, "&quot;")
73
+ .replace(/'/g, "&#39;");
74
+ }
75
+ /** The RFC 3676 signature delimiter, which our own body uses before its metadata. */
76
+ const FOOTER_SPLIT = /^--$/m;
77
+ /**
78
+ * Render an outbound body: Markdown above the `--` delimiter, literal text below it.
79
+ *
80
+ * The footer is deliberately not Markdown. A line of `--` after a text line is a setext
81
+ * heading underline, so rendering it would swallow our metadata into an `<h2>`.
82
+ */
83
+ export function renderBody(text) {
84
+ const index = text.search(FOOTER_SPLIT);
85
+ if (index === -1)
86
+ return renderMarkdown(text);
87
+ const head = text.slice(0, index);
88
+ const tailLines = text.slice(index).split("\n");
89
+ // The `--` line itself is dropped in HTML: the top border is the divider there. It
90
+ // stays in the text part, where RFC 3676 needs it and quote stripping relies on it.
91
+ const tail = (tailLines[0]?.trim() === "--" ? tailLines.slice(1) : tailLines).join("\n");
92
+ // A visible divider, but literal text: rendering `--` as Markdown would make it a
93
+ // setext heading underline and swallow the body above it.
94
+ const signatureStyle = "font:inherit;opacity:.75;margin:18px 0 0;padding-top:10px;border-top:1px solid #d8dee4";
95
+ return `${renderMarkdown(head)}\n<pre class="signature" style="${signatureStyle}">${escapeHtml(tail)}</pre>`;
96
+ }