@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,421 @@
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
+
30
+ import { createHash } from "node:crypto"
31
+ import { readFileSync, realpathSync, statSync } from "node:fs"
32
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path"
33
+ import { homedir } from "node:os"
34
+ import { configDir, stateDir } from "../store/paths.ts"
35
+
36
+ export interface InlineAttachment {
37
+ filename: string
38
+ content: Buffer
39
+ contentType: string
40
+ /** Present for inlined images; absent for an ordinary file attachment. */
41
+ cid?: string
42
+ /** nodemailer: "attachment" shows a paperclip rather than a broken inline reference. */
43
+ contentDisposition?: "inline" | "attachment"
44
+ }
45
+
46
+ export interface SkippedImage {
47
+ src: string
48
+ reason:
49
+ | "not-found"
50
+ | "not-an-image"
51
+ | "too-large"
52
+ | "too-many"
53
+ | "unreadable"
54
+ | "not-allowed"
55
+ | "outside-root"
56
+ | "root-refused"
57
+ }
58
+
59
+ export interface InlineResult {
60
+ html: string
61
+ attachments: InlineAttachment[]
62
+ skipped: SkippedImage[]
63
+ }
64
+
65
+ /**
66
+ * Provider ceilings, which is what these numbers are derived from:
67
+ *
68
+ * Gmail 25 MB send (50 MB receive)
69
+ * Outlook.com 25 MB per message
70
+ * M365 work 35 MB send / 36 MB receive (Exchange default 10 MB)
71
+ * QQ Mail 50 MB attachments, 55 MB with the body, 200 MB/day
72
+ *
73
+ * Base64 inflates by about a third, so 18 MB of files is roughly 24 MB on the wire - under the
74
+ * smallest ceiling. `notify.maxAttachmentMb` raises the budget for a provider that allows more.
75
+ */
76
+ export const MAX_INLINE_BYTES = 5 * 1024 * 1024
77
+ /** Per-file cap for an attached (non-inline) file. */
78
+ export const MAX_FILE_BYTES = 15 * 1024 * 1024
79
+ /** Default total payload cap; a caller may pass `maxTotalBytes` instead. */
80
+ export const MAX_INLINE_TOTAL_BYTES = 18 * 1024 * 1024
81
+ export const MAX_INLINE_COUNT = 10
82
+
83
+ /**
84
+ * Extensions we will attach from a link.
85
+ *
86
+ * An allowlist rather than "anything readable": the path comes from model output, and the
87
+ * one thing that must never happen is mailing a private key because a prompt said so. Add
88
+ * to this list deliberately.
89
+ */
90
+ const ALLOWED_FILE_EXTENSIONS: Record<string, string> = {
91
+ pdf: "application/pdf",
92
+ txt: "text/plain",
93
+ log: "text/plain",
94
+ md: "text/markdown",
95
+ csv: "text/csv",
96
+ tsv: "text/tab-separated-values",
97
+ json: "application/json",
98
+ yml: "application/yaml",
99
+ yaml: "application/yaml",
100
+ xml: "application/xml",
101
+ html: "text/html",
102
+ zip: "application/zip",
103
+ gz: "application/gzip",
104
+ tar: "application/x-tar",
105
+ png: "image/png",
106
+ jpg: "image/jpeg",
107
+ jpeg: "image/jpeg",
108
+ gif: "image/gif",
109
+ webp: "image/webp",
110
+ svg: "image/svg+xml",
111
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
112
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
113
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
114
+ }
115
+
116
+ /**
117
+ * Identify an image from its magic bytes rather than trusting the extension.
118
+ *
119
+ * SVG is deliberately absent. Not because email cannot carry it - a linked SVG file
120
+ * (`<img src="x.svg">` or a cid attachment) is supported by roughly 97% of clients, and
121
+ * Gmail rasterises it to PNG server-side - but because **Outlook on Windows (2007-2019)
122
+ * renders nothing at all** for it, with no fallback. Inline `<svg>` markup is the truly
123
+ * dead form at ~40%, since Gmail deletes the element while sanitising.
124
+ *
125
+ * PNG is therefore the one format that renders everywhere, which matters more for a
126
+ * notification than vector sharpness. SVG could be added behind a flag with an explicit
127
+ * "invisible in Outlook desktop" caveat.
128
+ */
129
+ export function sniffImage(buffer: Buffer): { contentType: string; ext: string } | undefined {
130
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
131
+ return { contentType: "image/png", ext: "png" }
132
+ }
133
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
134
+ return { contentType: "image/jpeg", ext: "jpg" }
135
+ }
136
+ if (buffer.length >= 6) {
137
+ const head = buffer.subarray(0, 6).toString("latin1")
138
+ if (head === "GIF87a" || head === "GIF89a") return { contentType: "image/gif", ext: "gif" }
139
+ }
140
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString("latin1") === "RIFF" && buffer.subarray(8, 12).toString("latin1") === "WEBP") {
141
+ return { contentType: "image/webp", ext: "webp" }
142
+ }
143
+ return undefined
144
+ }
145
+
146
+ const IMG_TAG = /<img\b[^>]*>/gi
147
+ const SRC_ATTR = /\bsrc\s*=\s*"([^"]*)"/i
148
+ const ALT_ATTR = /\balt\s*=\s*"([^"]*)"/i
149
+ /** Markdown links become anchors; the text between them is already rendered HTML. */
150
+ const LINK_TAG = /<a\s+href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi
151
+
152
+ /** realpath, or the input when it cannot be resolved (tests use paths that may not exist). */
153
+ function safeRealpath(path: string): string | undefined {
154
+ try {
155
+ return realpathSync(path)
156
+ } catch {
157
+ return undefined
158
+ }
159
+ }
160
+
161
+ /** stat that follows symlinks; undefined when the path does not resolve. */
162
+ function safeStat(path: string): ReturnType<typeof statSync> | undefined {
163
+ try {
164
+ return statSync(path)
165
+ } catch {
166
+ return undefined
167
+ }
168
+ }
169
+
170
+ /** Where an attachment was refused, so the note can say something true. */
171
+ type RootVerdict = "ok" | "outside-root" | "root-refused"
172
+
173
+ /**
174
+ * Is this resolved file safe to attach?
175
+ *
176
+ * Three rules, all fail-closed:
177
+ * - it must resolve, and it must land inside the project directory (a symlink out is refused);
178
+ * - the project directory itself must be a real project, not `/` or your home - with cwd = ~
179
+ * the config file is "inside the project" and the allowlist would happily mail the password;
180
+ * - the daemon's own config and state directories are never attached, whatever the cwd.
181
+ *
182
+ * The comparison goes through `relative()`, not a `/`-joined prefix: on Windows the separator is
183
+ * `\`, so a prefix test silently refused every attachment there.
184
+ */
185
+ function insideRoot(path: string, rootReal: string | undefined): RootVerdict {
186
+ if (!rootReal) return "root-refused"
187
+
188
+ // A path that does not resolve yet (a missing file, a dangling link) is compared by its
189
+ // parent's real location plus its own name, so a project reached through a symlinked path
190
+ // (macOS /var -> /private/var, a symlinked checkout) still reports "not found" rather than
191
+ // "outside the project"; a path that resolves elsewhere - `ln -s /proc/self/fd/0` - is
192
+ // compared at its real location and refused.
193
+ const target = safeRealpath(path) ?? joinResolved(dirname(path), basename(path))
194
+
195
+ const home = safeRealpath(homedir())
196
+ // Exact equality: a project *inside* home is the normal case and must still attach.
197
+ if (rootReal === "/" || (home !== undefined && rootReal === home)) return "root-refused"
198
+
199
+ for (const dir of [configDir(), stateDir()]) {
200
+ const real = safeRealpath(dir)
201
+ if (real && contains(real, target)) return "root-refused"
202
+ }
203
+
204
+ return contains(rootReal, target) ? "ok" : "outside-root"
205
+ }
206
+
207
+ /** Does `parent` contain `child`? Separator-agnostic and not fooled by a shared prefix. */
208
+ function contains(parent: string, child: string): boolean {
209
+ const rel = relative(parent, child)
210
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
211
+ }
212
+
213
+ /** realpath of a directory plus one unresolved name. */
214
+ function joinResolved(dir: string, name: string): string {
215
+ return `${safeRealpath(dir) ?? dir}/${name}`
216
+ }
217
+
218
+ /** A filename a mail client will accept: no separators, no control characters, bounded. */
219
+ function safeFilename(path: string, fallback: string): string {
220
+ const base = path.split(/[\\/]/).pop() ?? fallback
221
+ const cleaned = base
222
+ .normalize("NFKC")
223
+ .replace(/[\u0000-\u001f\u007f"<>|:*?]+/g, "-")
224
+ .replace(/^[.]+/, "")
225
+ .trim()
226
+ return (cleaned || fallback).slice(0, 120)
227
+ }
228
+
229
+ function escapeHtml(value: string): string {
230
+ return value
231
+ .replace(/&/g, "&amp;")
232
+ .replace(/</g, "&lt;")
233
+ .replace(/>/g, "&gt;")
234
+ .replace(/"/g, "&quot;")
235
+ }
236
+
237
+ function note(label: string, detail: string): string {
238
+ return `<span style="opacity:.7">[${escapeHtml(label)}${detail ? `: ${escapeHtml(detail)}` : ""}]</span>`
239
+ }
240
+
241
+ function resolvePath(src: string, root: string): string {
242
+ let value = src.trim()
243
+ if (value.startsWith("file://")) value = value.slice("file://".length)
244
+ if (value.startsWith("~/")) value = `${homedir()}${value.slice(1)}`
245
+ return isAbsolute(value) ? value : resolve(root, value)
246
+ }
247
+
248
+ export interface InlineOptions {
249
+ /** Base directory for relative paths - normally the agent's project directory. */
250
+ root?: string
251
+ maxBytes?: number
252
+ maxCount?: number
253
+ /** Total bytes of all attachments; defaults to MAX_INLINE_TOTAL_BYTES. */
254
+ maxTotalBytes?: number
255
+ /** Injected for tests. */
256
+ readFile?: (path: string) => Buffer
257
+ sizeOf?: (path: string) => number
258
+ }
259
+
260
+ export function inlineLocalImages(html: string, options: InlineOptions = {}): InlineResult {
261
+ const root = options.root ?? process.cwd()
262
+ const maxBytes = options.maxBytes ?? MAX_INLINE_BYTES
263
+ const maxCount = options.maxCount ?? MAX_INLINE_COUNT
264
+ const maxTotal = options.maxTotalBytes ?? MAX_INLINE_TOTAL_BYTES
265
+ const read = options.readFile ?? ((path: string) => readFileSync(path))
266
+ const size = options.sizeOf ?? ((path: string) => statSync(path).size)
267
+
268
+ // Resolve the project directory once. Attachments are limited to it: a symlink inside the
269
+ // project pointing at `~/.config/pi-mailbridge/config.json` would otherwise mail the
270
+ // mailbox password, and the allowlist says nothing about where a file came from.
271
+ const rootReal = safeRealpath(root)
272
+
273
+ const attachments: InlineAttachment[] = []
274
+ const skipped: SkippedImage[] = []
275
+ const byCid = new Map<string, InlineAttachment>()
276
+ let total = 0
277
+
278
+ const replaced = html.replace(IMG_TAG, (tag) => {
279
+ const src = SRC_ATTR.exec(tag)?.[1] ?? ""
280
+ const alt = ALT_ATTR.exec(tag)?.[1] ?? ""
281
+ // Remote, protocol-relative and already-inlined sources are left alone.
282
+ if (!src || /^(?:https?:)?\/\//i.test(src) || src.startsWith("cid:") || src.startsWith("data:")) return tag
283
+
284
+ if (attachments.length >= maxCount) {
285
+ skipped.push({ src, reason: "too-many" })
286
+ return note(alt || "image", "not attached, too many images")
287
+ }
288
+
289
+ const path = resolvePath(src, root)
290
+ const verdict = insideRoot(path, rootReal)
291
+ if (verdict !== "ok") {
292
+ skipped.push({ src, reason: verdict })
293
+ return note(
294
+ alt || "image",
295
+ verdict === "root-refused"
296
+ ? "not attached, start pi in a project directory"
297
+ : "not attached, outside the project",
298
+ )
299
+ }
300
+ let bytes: Buffer
301
+ try {
302
+ // Existence first, so the note says "not found" rather than "unreadable"; then the
303
+ // type, because a FIFO or a device reports size 0 and blocks the read forever, which
304
+ // would hang the send (and the poll tick that awaits it).
305
+ const st = safeStat(path)
306
+ if (!st) {
307
+ skipped.push({ src, reason: "not-found" })
308
+ return note(alt || "image", "not attached, file not found")
309
+ }
310
+ if (!st.isFile()) {
311
+ skipped.push({ src, reason: "unreadable" })
312
+ return note(alt || "image", "not attached, not a regular file")
313
+ }
314
+ if (size(path) > maxBytes) {
315
+ skipped.push({ src, reason: "too-large" })
316
+ return note(alt || "image", `${Math.round(size(path) / 1024)} KB, over the ${Math.round(maxBytes / 1024)} KB limit`)
317
+ }
318
+ bytes = read(path)
319
+ } catch {
320
+ skipped.push({ src, reason: "not-found" })
321
+ return note(alt || "image", "not attached, file not found")
322
+ }
323
+
324
+ const kind = sniffImage(bytes)
325
+ if (!kind) {
326
+ skipped.push({ src, reason: "not-an-image" })
327
+ return note(alt || "image", "not attached, not an image")
328
+ }
329
+ if (bytes.length > maxBytes || total + bytes.length > maxTotal) {
330
+ skipped.push({ src, reason: "too-large" })
331
+ return note(alt || "image", "not attached, too large")
332
+ }
333
+
334
+ const digest = createHash("sha256").update(bytes).digest("hex").slice(0, 16)
335
+ const cid = `pi-${digest}@pi.local`
336
+ const existing = byCid.get(cid)
337
+ if (!existing) {
338
+ const base = (alt || "image").replace(/[^A-Za-z0-9._-]+/g, "-").slice(0, 40) || "image"
339
+ const attachment: InlineAttachment = {
340
+ filename: `${base}.${kind.ext}`,
341
+ content: bytes,
342
+ contentType: kind.contentType,
343
+ cid,
344
+ }
345
+ byCid.set(cid, attachment)
346
+ attachments.push(attachment)
347
+ total += bytes.length
348
+ }
349
+
350
+ return `<img src="cid:${cid}" alt="${escapeHtml(alt)}" style="max-width:100%;height:auto">`
351
+ })
352
+
353
+ // Links to local files become ordinary attachments. Their anchors cannot work in a mail
354
+ // client - a relative path does not exist there and `cid:` in an <a href> is unreliable -
355
+ // so the text stays and the filename is named beside it.
356
+ const withLinks = replaced.replace(LINK_TAG, (_tag, href: string, inner: string) => {
357
+ if (!href || /^(?:https?:|mailto:|tel:|cid:|data:|#)/i.test(href)) return _tag
358
+ const path = resolvePath(href, root)
359
+
360
+ // The skipped reason is a stable code (tests and diagnostics match on it); the note is
361
+ // what the reader sees.
362
+ const refuse = (reason: SkippedImage["reason"], text: string): string => {
363
+ skipped.push({ src: href, reason })
364
+ return `${inner} ${note("not attached", text)}`
365
+ }
366
+ if (attachments.length >= maxCount) return refuse("too-many", "too many attachments")
367
+ const verdict = insideRoot(path, rootReal)
368
+ if (verdict !== "ok") {
369
+ return refuse(
370
+ verdict,
371
+ verdict === "root-refused" ? "start pi in a project directory" : "outside the project",
372
+ )
373
+ }
374
+ const filename = safeFilename(href, "file")
375
+ const ext = filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : ""
376
+ const contentType = ALLOWED_FILE_EXTENSIONS[ext]
377
+ if (!contentType) return refuse("not-allowed", "this file type is not attached")
378
+
379
+ let bytes: Buffer
380
+ try {
381
+ const st = safeStat(path)
382
+ if (!st) return refuse("not-found", "file not found")
383
+ if (!st.isFile()) return refuse("unreadable", "not a regular file")
384
+ // Per-file cap before reading; the *total* is charged after the dedupe below, so a
385
+ // file that is already attached does not fail as "over the limit" for zero new bytes.
386
+ if (size(path) > MAX_FILE_BYTES) {
387
+ return refuse("too-large", `${Math.round(size(path) / 1024)} KB, over the limit`)
388
+ }
389
+ bytes = read(path)
390
+ // The file can grow between stat and read.
391
+ if (bytes.length > MAX_FILE_BYTES) {
392
+ return refuse("too-large", `${Math.round(bytes.length / 1024)} KB, over the limit`)
393
+ }
394
+ } catch {
395
+ skipped.push({ src: href, reason: "not-found" })
396
+ return `${inner} ${note("not attached", "file not found")}`
397
+ }
398
+
399
+ // Same content, same part: a report linked from two places is attached once.
400
+ const digest = createHash("sha256").update(bytes).digest("hex").slice(0, 16)
401
+ const existing = byCid.get(`file-${digest}`)
402
+ const name = existing?.filename ?? filename
403
+ if (!existing) {
404
+ if (total + bytes.length > maxTotal) {
405
+ return refuse("too-large", `${Math.round(bytes.length / 1024)} KB, over the limit`)
406
+ }
407
+ const attachment: InlineAttachment = {
408
+ filename,
409
+ content: bytes,
410
+ contentType,
411
+ contentDisposition: "attachment",
412
+ }
413
+ byCid.set(`file-${digest}`, attachment)
414
+ attachments.push(attachment)
415
+ total += bytes.length
416
+ }
417
+ return `${inner} <span style="opacity:.7">[attached: ${escapeHtml(name)}]</span>`
418
+ })
419
+
420
+ return { html: withLinks, attachments, skipped }
421
+ }
@@ -0,0 +1,76 @@
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
+
14
+ export type SafeSubcommand = "on" | "off" | "status" | "check" | "smoke" | "help"
15
+
16
+ /** Subcommands a mail reply may run. Everything else stays text for the agent to read. */
17
+ export const MAIL_SAFE_SUBCOMMANDS: readonly SafeSubcommand[] = [
18
+ "on",
19
+ "off",
20
+ "status",
21
+ "check",
22
+ "smoke",
23
+ "help",
24
+ ]
25
+
26
+ /** Subcommands that change the machine, the credentials or the queue - never by mail. */
27
+ export const MAIL_REFUSED_SUBCOMMANDS = ["setup", "auth", "restart", "prime"] as const
28
+
29
+ export interface MailCommand {
30
+ /** The command name as registered in pi, without the slash. */
31
+ name: "mailbridge"
32
+ /** Text after the command name; for `mailbridge`, the subcommand and its arguments. */
33
+ args: string
34
+ /** Present for `mailbridge`: the subcommand, already checked against the safe list. */
35
+ sub?: SafeSubcommand
36
+ }
37
+
38
+ /**
39
+ * Parse a reply body as a command, or return undefined if it is ordinary text.
40
+ *
41
+ * `\r\n` is normalised because mail bodies carry it. Leading and trailing blank lines are
42
+ * ignored, but any second non-blank line makes this text: a command must be the whole message.
43
+ */
44
+ export function parseMailCommand(body: string): MailCommand | undefined {
45
+ const lines = body
46
+ .replace(/\r\n/g, "\n")
47
+ .split("\n")
48
+ .map((line) => line.trim())
49
+ .filter((line) => line.length > 0)
50
+ if (lines.length !== 1) return undefined
51
+
52
+ const line = lines[0]!
53
+ if (!line.startsWith("/")) return undefined
54
+ const space = line.indexOf(" ")
55
+ const name = (space === -1 ? line.slice(1) : line.slice(1, space)).toLowerCase()
56
+ const args = space === -1 ? "" : line.slice(space + 1).trim()
57
+
58
+ if (name !== "mailbridge") return undefined
59
+
60
+ // Bare `/mailbridge` prints status, exactly as it does at the prompt.
61
+ // Every safe subcommand takes no arguments, so any leftover word means this is a message that
62
+ // happens to start with a command: `/mailbridge off thanks, the deploy is still broken` must
63
+ // reach the agent, not flip the mode and drop the sentence. Splitting on /\s+/ also catches
64
+ // U+2028/U+2029, which a body can contain without a newline - though the whitelist below
65
+ // would reject a token carrying them anyway.
66
+ const words = args.split(/\s+/).filter(Boolean)
67
+ if (words.length > 1) return undefined
68
+ const sub = (words[0] ?? "").toLowerCase() || "status"
69
+ if (!(MAIL_SAFE_SUBCOMMANDS as readonly string[]).includes(sub)) {
70
+ // `setup`, `auth`, `restart`, `prime` and unknown words stay text, so the agent sees the
71
+ // request and nothing reconfigures itself because someone's address was spoofed.
72
+ return undefined
73
+ }
74
+ return { name, args, sub: sub as SafeSubcommand }
75
+ }
76
+
@@ -0,0 +1,193 @@
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
+
16
+ import nodemailer from "nodemailer"
17
+
18
+ import type { ResolvedConfig } from "./config.ts"
19
+ import { withFreshToken, type TokenProvider } from "./oauth.ts"
20
+ import { renderMessage } from "./html.ts"
21
+
22
+ export interface OutboundMessage {
23
+ to: string
24
+ subject: string
25
+ text: string
26
+ /** Base directory for relative image paths; normally the agent's project dir. */
27
+ cwd?: string
28
+ /** RFC 5322 threading: the message this one answers. */
29
+ inReplyTo?: string
30
+ /** RFC 5322 threading: the conversation chain, oldest first. */
31
+ references?: string[]
32
+ /** Defaults to the SMTP user. */
33
+ from?: string
34
+ /**
35
+ * Message-ID to stamp. The caller mints it when it needs to record the id before the
36
+ * send completes (self-echo guard, threading); otherwise sendMail mints one.
37
+ */
38
+ messageId?: string
39
+ }
40
+
41
+ export interface TransportLike {
42
+ sendMail(options: Record<string, unknown>): Promise<unknown>
43
+ }
44
+
45
+ export type TransportFactory = (options: Record<string, unknown>) => TransportLike
46
+
47
+ /**
48
+ * Compile-time guard: nodemailer's transport must satisfy what we call. Same reason
49
+ * as the IMAP guard — the factory casts, so drift would otherwise be invisible.
50
+ */
51
+ type AssertNodemailerShape = ReturnType<typeof nodemailer.createTransport> extends TransportLike ? true : never
52
+ const _nodemailerShapeOk: AssertNodemailerShape = true
53
+ void _nodemailerShapeOk
54
+
55
+ /** Default: nodemailer. Tests inject a fake so no socket is ever opened. */
56
+ export const defaultTransportFactory: TransportFactory = (options) =>
57
+ nodemailer.createTransport(options as Parameters<typeof nodemailer.createTransport>[0]) as TransportLike
58
+
59
+ export interface SendDeps {
60
+ transportFactory?: TransportFactory
61
+ /**
62
+ * Supplies a fresh access token for XOAUTH2. Required when the account uses
63
+ * XOAUTH2; see src/core/oauth.ts for the caching implementation.
64
+ */
65
+ getAccessToken?: () => Promise<string>
66
+ /**
67
+ * Caching token provider. When supplied, an auth failure re-mints once and retries
68
+ * instead of failing the send — see withFreshToken in core/oauth.ts.
69
+ */
70
+ provider?: TokenProvider
71
+ }
72
+
73
+ /**
74
+ * Transport options for this account.
75
+ * Exported so `--check` can validate the shape without sending anything.
76
+ */
77
+ export async function buildTransportOptions(config: ResolvedConfig, deps: SendDeps = {}): Promise<Record<string, unknown>> {
78
+ const smtp = config.mail.smtp
79
+ const base = { host: smtp.host, port: smtp.port, secure: smtp.secure }
80
+
81
+ if (smtp.authType === "XOAUTH2") {
82
+ // A provider is enough on its own: that is the path withFreshToken retries through.
83
+ const getToken = deps.getAccessToken ?? (deps.provider ? () => deps.provider!.getToken() : undefined)
84
+ if (!getToken) {
85
+ throw new Error(
86
+ "pi-mailbridge: smtp.authType is XOAUTH2 but no token provider is configured — set mail.smtp.tokenCommand (see docs/OAUTH.md)",
87
+ )
88
+ }
89
+ const accessToken = await getToken()
90
+ if (!accessToken) throw new Error("pi-mailbridge: OAuth2 token provider returned an empty token")
91
+ return { ...base, auth: { type: "OAuth2", user: smtp.user, accessToken } }
92
+ }
93
+
94
+ return { ...base, auth: { user: smtp.user, pass: smtp.password } }
95
+ }
96
+
97
+ /**
98
+ * Send one plain-text message.
99
+ * Throws on any transport failure — there is no partial-success path.
100
+ *
101
+ * Returns the Message-ID WE stamped (minted here if the caller did not pass one), so the
102
+ * caller can record the id it actually sent - threading and the self-echo guard both
103
+ * depend on that value matching the header.
104
+ */
105
+ export async function sendMail(
106
+ config: ResolvedConfig,
107
+ message: OutboundMessage,
108
+ deps: SendDeps = {},
109
+ ): Promise<{ messageId?: string }> {
110
+ if (!config.mail.smtp.host) throw new Error("pi-mailbridge: mail.smtp.host is not configured")
111
+ if (!message.to) throw new Error("pi-mailbridge: no recipient (mail.to)")
112
+
113
+ const factory = deps.transportFactory ?? defaultTransportFactory
114
+ const rendered = renderMessage(message.text, {
115
+ ...(message.cwd ? { root: message.cwd } : {}),
116
+ // The user's cap, so a QQ account can use 45 MB and a Gmail account is not left with a
117
+ // send that fails at the provider.
118
+ maxTotalBytes: Math.round(config.notify.maxAttachmentMb * 1024 * 1024),
119
+ })
120
+
121
+ // withFreshToken hands each attempt its token; use it directly rather than fetching a
122
+ // second one, or the provider is called twice per attempt and the counts drift.
123
+ // One id for the header and the return value. Minting twice meant callers that did not
124
+ // pass one got back an id that was not the one in the message.
125
+ const stampedId = message.messageId ?? generateMessageId(config.mail.smtp.user)
126
+
127
+ const dispatch = async (accessToken?: string) => {
128
+ const transportOptions = await buildTransportOptions(
129
+ config,
130
+ accessToken ? { ...deps, getAccessToken: async () => accessToken } : deps,
131
+ )
132
+ const transporter = factory(transportOptions)
133
+ return (await transporter.sendMail({
134
+ from: message.from ?? config.mail.smtp.user,
135
+ // Minted here at the latest, so the id is known to the caller before the transport
136
+ // has spoken. A notification must be recognisable as ours even if the send races an
137
+ // IMAP exists event.
138
+ messageId: stampedId,
139
+ // Belt and braces for single-mailbox mode: the scan refuses any message carrying it.
140
+ // The second header carries the id we minted: providers may replace Message-ID (QQ
141
+ // does), and this is how the just-sent copy is identified afterwards.
142
+ headers: { "X-Pi-Mailbridge": "notification", "X-Pi-Mailbridge-Id": stampedId },
143
+ to: message.to,
144
+ // The subject carries model-controlled text. A CR/LF would fold a new header into
145
+ // the message (the classic header-injection shape), and an unbounded subject is
146
+ // rejected by some MTAs, so both are flattened at the single choke point.
147
+ subject: sanitizeSubject(message.subject),
148
+ // multipart/alternative: clients fold quoted history only for HTML mail, so
149
+ // plain-text-only notifications produced reply threads of raw repeated text.
150
+ text: message.text,
151
+ html: rendered.html,
152
+ // Thread every notification for a session into one conversation. Subject alone is
153
+ // enough for Outlook's conversation topic; these headers are what Gmail uses.
154
+ ...(message.inReplyTo ? { inReplyTo: message.inReplyTo } : {}),
155
+ ...(message.references?.length ? { references: message.references.join(" ") } : {}),
156
+ // Local images become cid: attachments; a bare file path would arrive broken.
157
+ ...(rendered.attachments.length > 0 ? { attachments: rendered.attachments } : {}),
158
+ })) as { messageId?: string } | undefined
159
+ }
160
+
161
+ // A revoked or expired grant surfaces as an auth error on the first attempt. With a
162
+ // provider, drop the cached token, mint a new one and retry once (OAUTH.md promises
163
+ // this); without one, the failure propagates as before.
164
+ // The transport's own result is deliberately ignored: the id we care about is ours.
165
+ if (deps.provider && config.mail.smtp.authType === "XOAUTH2") {
166
+ await withFreshToken(deps.provider, dispatch)
167
+ } else {
168
+ await dispatch()
169
+ }
170
+
171
+ // Ours, not the transport's: it is what was recorded and what threading references.
172
+ return { messageId: stampedId }
173
+ }
174
+
175
+ /**
176
+ * A Message-ID we control, on the sender's domain where possible.
177
+ * Domain is cosmetic; uniqueness is randomUUID's job.
178
+ */
179
+ export function generateMessageId(from: string): string {
180
+ const domain = /@([^@>\s]+)/.exec(from)?.[1] ?? "pi-mailbridge.invalid"
181
+ return `<${randomUUID()}@${domain}>`
182
+ }
183
+
184
+ /** RFC 5322 caps a header line at 998 octets; keep well inside it. */
185
+ const MAX_SUBJECT = 200
186
+
187
+ export function sanitizeSubject(subject: string): string {
188
+ return subject
189
+ .replace(/[\r\n\u0000]+/g, " ")
190
+ .replace(/\s{2,}/g, " ")
191
+ .trim()
192
+ .slice(0, MAX_SUBJECT)
193
+ }