@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,333 @@
1
+ // Provider discovery + config synthesis.
2
+ //
3
+ // The one thing a human cannot avoid is creating the mailbox. Everything after
4
+ // that — server names, ports, TLS mode, which auth the provider requires — is
5
+ // public data, so we fetch it instead of asking.
6
+ //
7
+ // Source: Thunderbird's autoconfig service (ISPDB), the same database Mozilla
8
+ // ships in Thunderbird. It publishes, for a domain:
9
+ //
10
+ // <incomingServer type="imap"> <hostname> <port> <socketType> <authentication>
11
+ // <outgoingServer type="smtp"> <hostname> <port> <socketType> <authentication>
12
+ //
13
+ // Verified for gmail.com and outlook.com; 404 means "not in the database", not
14
+ // "no such provider", so we report it as unknown rather than guessing.
15
+
16
+ import type { ResolvedConfig } from "./config.ts"
17
+
18
+ export type AuthMode = "LOGIN" | "XOAUTH2"
19
+
20
+ export interface Endpoint {
21
+ host: string
22
+ port: number
23
+ /** TLS from the first packet (implicit). False means STARTTLS. */
24
+ secure: boolean
25
+ }
26
+
27
+ export interface Discovered {
28
+ imap: Endpoint
29
+ smtp: Endpoint
30
+ authType: AuthMode
31
+ /**
32
+ * Which OAuth preset fits, when we can tell.
33
+ * `microsoft-365` means a work/school tenant (authority: organizations),
34
+ * `microsoft` means consumer outlook.com/hotmail (authority: consumers).
35
+ */
36
+ provider: "microsoft" | "microsoft-365" | "google" | "other"
37
+ folder?: string
38
+ }
39
+
40
+ /** Parse a Thunderbird `clientConfig` document. Pure, so it is testable. */
41
+ export function parseAutoconfig(xml: string): Discovered | undefined {
42
+ const block = (tag: "incomingServer" | "outgoingServer"): string | undefined => {
43
+ // type="imap" / type="smtp" specifically; pop3 must not win.
44
+ const re = new RegExp(`<${tag}\\b[^>]*type="(?:imap|smtp)"[^>]*>([\\s\\S]*?)</${tag}>`, "i")
45
+ return re.exec(xml)?.[1]
46
+ }
47
+ const one = (chunk: string, tag: string): string | undefined =>
48
+ new RegExp(`<${tag}>\\s*([^<]+?)\\s*</${tag}>`, "i").exec(chunk)?.[1]
49
+ const all = (chunk: string, tag: string): string[] =>
50
+ [...chunk.matchAll(new RegExp(`<${tag}>\\s*([^<]+?)\\s*</${tag}>`, "gi"))].map((m) => m[1]!.trim())
51
+
52
+ const incoming = block("incomingServer")
53
+ const outgoing = block("outgoingServer")
54
+ if (!incoming || !outgoing) return undefined
55
+
56
+ const endpoint = (chunk: string): Endpoint | undefined => {
57
+ const host = one(chunk, "hostname")
58
+ const port = Number(one(chunk, "port"))
59
+ if (!host || !Number.isInteger(port) || port <= 0) return undefined
60
+ const socket = (one(chunk, "socketType") ?? "").toUpperCase()
61
+ return { host, port, secure: socket !== "STARTTLS" }
62
+ }
63
+
64
+ const imap = endpoint(incoming)
65
+ const smtp = endpoint(outgoing)
66
+ if (!imap || !smtp) return undefined
67
+
68
+ // A provider may advertise several; OAuth2 wins when present because password
69
+ // auth is what every major provider is retiring.
70
+ const auths = [...all(incoming, "authentication"), ...all(outgoing, "authentication")].map((a) => a.toLowerCase())
71
+ const authType: AuthMode = auths.some((a) => a.includes("oauth2")) ? "XOAUTH2" : "LOGIN"
72
+
73
+ const host = imap.host.toLowerCase()
74
+ const provider: Discovered["provider"] = /(?:office365|outlook|hotmail|live|msn)\./.test(host)
75
+ ? "microsoft"
76
+ : /gmail|googlemail/.test(host)
77
+ ? "google"
78
+ : "other"
79
+
80
+ return { imap, smtp, authType, provider }
81
+ }
82
+
83
+ /**
84
+ * Provider-specific auth defaults.
85
+ *
86
+ * Google advertises OAuth2 in its published settings, but for a personal mailbox
87
+ * an app password is strictly simpler: Google OAuth for mail scopes is *restricted*
88
+ * (app verification plus an annual CASA assessment to publish), and a consent
89
+ * screen left in "Testing" issues refresh tokens that expire after **7 days** —
90
+ * fatal for a daemon that must run unattended.
91
+ *
92
+ * Microsoft has no equivalent: app passwords were retired on 2024-09-16, so XOAUTH2
93
+ * is the only path there.
94
+ */
95
+ function applyProviderDefaults(discovered: Discovered): Discovered {
96
+ if (discovered.provider === "google") return { ...discovered, authType: "LOGIN" }
97
+ return discovered
98
+ }
99
+
100
+ export interface DiscoverOptions {
101
+ /** Injected for tests; defaults to global fetch. */
102
+ fetchImpl?: typeof fetch
103
+ /** ISPDB base; overridable so tests never touch the network. */
104
+ baseUrl?: string
105
+ timeoutMs?: number
106
+ }
107
+
108
+ const ISPDB = "https://autoconfig.thunderbird.net/v1.1"
109
+
110
+ /**
111
+ * A work/school Microsoft 365 tenant mailbox. These have no ISPDB entry and an
112
+ * arbitrary subdomain, so the suffix is the signal: `something.onmicrosoft.com`.
113
+ * The SMTP host differs from the consumer one (`smtp.office365.com`, not
114
+ * `smtp-mail.outlook.com`) and the authority is `organizations`.
115
+ */
116
+ export const M365_PROVIDER: Discovered = {
117
+ imap: { host: "outlook.office365.com", port: 993, secure: true },
118
+ smtp: { host: "smtp.office365.com", port: 587, secure: false },
119
+ authType: "XOAUTH2",
120
+ provider: "microsoft-365",
121
+ }
122
+
123
+ /** Built-in fallback lookup, including the Microsoft 365 suffix rule. */
124
+ function knownProvider(domain: string): Discovered | undefined {
125
+ const exact = KNOWN_PROVIDERS[domain.toLowerCase()]
126
+ if (exact) return exact
127
+ if (domain.toLowerCase().endsWith(".onmicrosoft.com")) return M365_PROVIDER
128
+ return undefined
129
+ }
130
+
131
+ /**
132
+ * Built-in fallback for the provider families this package is used with.
133
+ *
134
+ * ISPDB is authoritative and current, so it is always tried first; this map only
135
+ * matters when it is unreachable or missing an entry. It also means the common
136
+ * cases work with no network at all.
137
+ */
138
+ export const KNOWN_PROVIDERS: Record<string, Omit<Discovered, "provider"> & { provider: Discovered["provider"] }> = {
139
+ "hotmail.com": {
140
+ imap: { host: "outlook.office365.com", port: 993, secure: true },
141
+ smtp: { host: "smtp-mail.outlook.com", port: 587, secure: false },
142
+ authType: "XOAUTH2",
143
+ provider: "microsoft",
144
+ },
145
+ "outlook.com": {
146
+ imap: { host: "outlook.office365.com", port: 993, secure: true },
147
+ smtp: { host: "smtp-mail.outlook.com", port: 587, secure: false },
148
+ authType: "XOAUTH2",
149
+ provider: "microsoft",
150
+ },
151
+ "live.com": {
152
+ imap: { host: "outlook.office365.com", port: 993, secure: true },
153
+ smtp: { host: "smtp-mail.outlook.com", port: 587, secure: false },
154
+ authType: "XOAUTH2",
155
+ provider: "microsoft",
156
+ },
157
+ "msn.com": {
158
+ imap: { host: "outlook.office365.com", port: 993, secure: true },
159
+ smtp: { host: "smtp-mail.outlook.com", port: 587, secure: false },
160
+ authType: "XOAUTH2",
161
+ provider: "microsoft",
162
+ },
163
+ // Chinese providers: the practical choice where Google is unreachable. All four
164
+ // authenticate with an app-specific code (授权码) as a password, so no OAuth app
165
+ // is needed. NetEase (163/126) requires the IMAP ID command, which imapflow
166
+ // sends automatically when the server advertises ID.
167
+ "qq.com": {
168
+ imap: { host: "imap.qq.com", port: 993, secure: true },
169
+ smtp: { host: "smtp.qq.com", port: 465, secure: true },
170
+ authType: "LOGIN",
171
+ provider: "other",
172
+ },
173
+ "foxmail.com": {
174
+ imap: { host: "imap.qq.com", port: 993, secure: true },
175
+ smtp: { host: "smtp.qq.com", port: 465, secure: true },
176
+ authType: "LOGIN",
177
+ provider: "other",
178
+ },
179
+ "163.com": {
180
+ imap: { host: "imap.163.com", port: 993, secure: true },
181
+ smtp: { host: "smtp.163.com", port: 465, secure: true },
182
+ authType: "LOGIN",
183
+ provider: "other",
184
+ },
185
+ "126.com": {
186
+ imap: { host: "imap.126.com", port: 993, secure: true },
187
+ smtp: { host: "smtp.126.com", port: 465, secure: true },
188
+ authType: "LOGIN",
189
+ provider: "other",
190
+ },
191
+ // NetEase's other free domains: same service, no waiting period.
192
+ "yeah.net": {
193
+ imap: { host: "imap.yeah.net", port: 993, secure: true },
194
+ smtp: { host: "smtp.yeah.net", port: 465, secure: true },
195
+ authType: "LOGIN",
196
+ provider: "other",
197
+ },
198
+ "188.com": {
199
+ imap: { host: "imap.188.com", port: 993, secure: true },
200
+ smtp: { host: "smtp.188.com", port: 465, secure: true },
201
+ authType: "LOGIN",
202
+ provider: "other",
203
+ },
204
+ "gmail.com": {
205
+ imap: { host: "imap.gmail.com", port: 993, secure: true },
206
+ smtp: { host: "smtp.gmail.com", port: 465, secure: true },
207
+ authType: "XOAUTH2",
208
+ provider: "google",
209
+ },
210
+ "googlemail.com": {
211
+ imap: { host: "imap.gmail.com", port: 993, secure: true },
212
+ smtp: { host: "smtp.gmail.com", port: 465, secure: true },
213
+ authType: "XOAUTH2",
214
+ provider: "google",
215
+ },
216
+ }
217
+
218
+ /** Look a domain up in ISPDB. Returns undefined when the domain is unknown. */
219
+ export async function discoverDomain(domain: string, options: DiscoverOptions = {}): Promise<Discovered | undefined> {
220
+ const doFetch = options.fetchImpl ?? fetch
221
+ const base = options.baseUrl ?? ISPDB
222
+ const controller = new AbortController()
223
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 5_000)
224
+ try {
225
+ const response = await doFetch(`${base}/${encodeURIComponent(domain)}`, { signal: controller.signal })
226
+ const parsed = response.ok ? (parseAutoconfig(await response.text()) ?? knownProvider(domain)) : knownProvider(domain)
227
+ return parsed ? applyProviderDefaults(parsed) : undefined
228
+ } catch {
229
+ // Offline or blocked: fall back to the built-in table before giving up.
230
+ const known = knownProvider(domain)
231
+ return known ? applyProviderDefaults(known) : undefined
232
+ } finally {
233
+ clearTimeout(timer)
234
+ }
235
+ }
236
+
237
+ export function domainOf(address: string): string | undefined {
238
+ const at = address.lastIndexOf("@")
239
+ if (at <= 0 || at === address.length - 1) return undefined
240
+ // A trailing dot is a valid FQDN form but never what a person means to type.
241
+ return address.slice(at + 1).trim().toLowerCase().replace(/\.+$/, "") || undefined
242
+ }
243
+
244
+ /** Scope presets, kept here so setup and docs cannot drift apart. */
245
+ export const OAUTH_SCOPE = {
246
+ microsoft:
247
+ "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send",
248
+ /** Same delegated scopes; the tenant, not the resource, is what differs. */
249
+ "microsoft-365":
250
+ "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send",
251
+ google: "https://mail.google.com/ offline_access",
252
+ } as const
253
+
254
+ export const OAUTH_ISSUER = {
255
+ /** consumer outlook.com / hotmail */
256
+ microsoft: "https://login.microsoftonline.com/consumers/v2.0",
257
+ /** work or school tenant, incl. a Developer Program sandbox */
258
+ "microsoft-365": "https://login.microsoftonline.com/organizations/v2.0",
259
+ google: "https://accounts.google.com",
260
+ } as const
261
+
262
+ /**
263
+ * Turn discovery into a config the daemon can actually run.
264
+ *
265
+ * `address` is pi's mailbox; `to` is the human. When they differ, the loop guard
266
+ * stays strict; when they are the same, single-mailbox mode is enabled
267
+ * automatically so replies are not rejected as self-loops.
268
+ */
269
+ export function buildSetupConfig(
270
+ address: string,
271
+ to: string,
272
+ discovered: Discovered,
273
+ ): Pick<ResolvedConfig, "mail" | "security" | "oauth"> & { oauth?: { issuer: string; clientId: string; scope: string } } {
274
+ const sameAddress = address.trim().toLowerCase() === to.trim().toLowerCase()
275
+ // Only attach an OAuth preset when OAuth is actually the chosen mechanism,
276
+ // otherwise a Gmail app-password setup would ship a clientId nobody needs.
277
+ const oauthPreset =
278
+ discovered.authType === "XOAUTH2" &&
279
+ (discovered.provider === "microsoft" || discovered.provider === "microsoft-365" || discovered.provider === "google")
280
+ ? discovered.provider
281
+ : undefined
282
+
283
+ // QQ rewrites the Message-ID we stamp (measured live), which silently breaks the reply-chain
284
+ // fallback. Naming the Sent folder makes `setup` record the delivered id instead, so the
285
+ // documented fallback works out of the box on the provider we know does this.
286
+ const rewritesMessageId = /(^|\.)(qq|foxmail)\.com$/i.test(discovered.imap.host)
287
+
288
+ return {
289
+ mail: {
290
+ imap: {
291
+ host: discovered.imap.host,
292
+ port: discovered.imap.port,
293
+ secure: discovered.imap.secure,
294
+ user: address,
295
+ password: "",
296
+ folder: discovered.folder ?? "INBOX",
297
+ authType: discovered.authType,
298
+ ...(rewritesMessageId ? { sentFolder: "Sent Messages" } : {}),
299
+ },
300
+ smtp: {
301
+ host: discovered.smtp.host,
302
+ port: discovered.smtp.port,
303
+ secure: discovered.smtp.secure,
304
+ user: address,
305
+ password: "",
306
+ authType: discovered.authType,
307
+ },
308
+ to,
309
+ },
310
+ security: {
311
+ allowFrom: [to],
312
+ // Single-mailbox mode sends and receives from one address, so the human's reply
313
+ // is a self-addressed message the provider may deliver without an
314
+ // Authentication-Results header at all (QQ does not add one to QQ->QQ mail).
315
+ // Requiring DMARC there rejects every genuine reply, so it is off for that shape
316
+ // and on everywhere else. The allowlist, loop guard and two self-echo guards
317
+ // still apply; setup says so and `--check` warns if both are set.
318
+ requireDmarcPass: !sameAddress,
319
+ allowHeaderThreading: true,
320
+ allowSelfReplies: sameAddress,
321
+ tokenTtlHours: 30 * 24,
322
+ },
323
+ ...(oauthPreset
324
+ ? {
325
+ oauth: {
326
+ issuer: OAUTH_ISSUER[oauthPreset],
327
+ clientId: "<your app client id>",
328
+ scope: OAUTH_SCOPE[oauthPreset],
329
+ },
330
+ }
331
+ : {}),
332
+ }
333
+ }
@@ -0,0 +1,75 @@
1
+ // RFC 2047 header decoding.
2
+ //
3
+ // Headers are read raw on purpose: `Authentication-Results` must keep its original
4
+ // order and multiplicity, which most MIME parsers collapse. The cost is that any
5
+ // header a mail client decides to encode arrives as `=?charset?B?...?=` words.
6
+ //
7
+ // That is not an edge case. A subject containing one non-ASCII character — an em
8
+ // dash, a Chinese session name — is encoded *whole*, so the routing token inside it
9
+ // becomes unreadable and every reply looks like `no-token`. This module decodes it.
10
+ //
11
+ // Node ships full ICU, so `TextDecoder` handles legacy charsets like gb2312/gbk
12
+ // without adding a dependency (verified: `new TextDecoder("gb2312")` works).
13
+
14
+ /** One encoded word: =?charset?B|Q?text?= */
15
+ const ENCODED_WORD = /=\?([^?\s]+)\?([BbQq])\?([^?\s]*)\?=/g
16
+
17
+ function decodeQ(text: string): Buffer {
18
+ // RFC 2047 §4.2: `_` means space; `=XX` is a hex byte.
19
+ const spaced = text.replace(/_/g, " ")
20
+ const bytes: number[] = []
21
+ for (let i = 0; i < spaced.length; i++) {
22
+ const char = spaced[i]!
23
+ if (char === "=" && /^[0-9A-Fa-f]{2}$/.test(spaced.slice(i + 1, i + 3))) {
24
+ bytes.push(Number.parseInt(spaced.slice(i + 1, i + 3), 16))
25
+ i += 2
26
+ continue
27
+ }
28
+ bytes.push(char.charCodeAt(0))
29
+ }
30
+ return Buffer.from(bytes)
31
+ }
32
+
33
+ function decodeBytes(bytes: Buffer, charset: string): string {
34
+ try {
35
+ return new TextDecoder(charset.trim().toLowerCase()).decode(bytes)
36
+ } catch {
37
+ // Unknown or unsupported charset: UTF-8 is the best guess and never throws
38
+ // on valid bytes, so a reply is not lost because of a weird label.
39
+ return bytes.toString("utf8")
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Decode every RFC 2047 encoded word in `value`.
45
+ *
46
+ * Whitespace *between* adjacent encoded words is dropped, per RFC 2047 §6.2 —
47
+ * clients split long encoded subjects on spaces, and keeping that space would
48
+ * corrupt the decoded text (and the token inside it).
49
+ */
50
+ export function decodeRfc2047(value: string | undefined | null): string {
51
+ if (!value) return ""
52
+ if (!value.includes("=?")) return value.replace(/\r?\n\s*/g, " ").trim()
53
+
54
+ let out = ""
55
+ let lastIndex = 0
56
+ let matched = false
57
+
58
+ for (const match of value.matchAll(ENCODED_WORD)) {
59
+ const index = match.index ?? 0
60
+ const between = value.slice(lastIndex, index)
61
+ // Before the first word, keep the prefix verbatim. Between two encoded words,
62
+ // drop it only when it is pure whitespace (RFC 2047 §6.2).
63
+ if (!matched || !/^\s*$/.test(between)) out += between
64
+
65
+ const [, charset = "", encoding = "", text = ""] = match
66
+ const bytes = encoding.toUpperCase() === "B" ? Buffer.from(text, "base64") : decodeQ(text)
67
+ out += decodeBytes(bytes, charset)
68
+
69
+ lastIndex = index + match[0].length
70
+ matched = true
71
+ }
72
+
73
+ out += value.slice(lastIndex)
74
+ return out.replace(/\r?\n\s*/g, " ").trim()
75
+ }
@@ -0,0 +1,62 @@
1
+ // HTML alternative for outbound mail.
2
+ //
3
+ // Mail clients collapse quoted history only when the original is HTML: they quote it
4
+ // as a <blockquote> they can fold away. A text/plain-only mail gets quoted as raw
5
+ // lines, so a reply thread reads as walls of repeated text.
6
+ //
7
+ // So every message now goes out as multipart/alternative: `text` (the fallback, and
8
+ // what the scanner would see if pi ever read its own mail) plus `html`.
9
+ //
10
+ // Escaping is not optional. The body is model output — it can contain `<`, `&`, or
11
+ // something that looks like markup — and an unescaped interpolation would inject
12
+ // into the recipient's mail client.
13
+
14
+ import { renderBody } from "./markdown.ts"
15
+ import { inlineLocalImages, type InlineAttachment, type SkippedImage } from "./inline-images.ts"
16
+
17
+ const ENTITIES: Record<string, string> = {
18
+ "&": "&amp;",
19
+ "<": "&lt;",
20
+ ">": "&gt;",
21
+ '"': "&quot;",
22
+ "'": "&#39;",
23
+ }
24
+
25
+ /** Escape text for safe interpolation into HTML. */
26
+ export function escapeHtml(value: string): string {
27
+ return value.replace(/[&<>"']/g, (char) => ENTITIES[char] ?? char)
28
+ }
29
+
30
+ /**
31
+ * Render a mail body as HTML.
32
+ *
33
+ * The agent answers in Markdown, so it is rendered (core/markdown.ts) instead of being
34
+ * shown as literal `**bold**` and fence markers. Structure lives in the markup, not in
35
+ * CSS: clients that sanitise inline styles (Outlook web does) would collapse anything
36
+ * that relied on `white-space: pre-wrap`.
37
+ */
38
+ export function textToHtml(text: string): string {
39
+ return [
40
+ '<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.45">',
41
+ renderBody(text),
42
+ "</div>",
43
+ ].join("")
44
+ }
45
+
46
+ export interface RenderedMessage {
47
+ html: string
48
+ attachments: InlineAttachment[]
49
+ skipped: SkippedImage[]
50
+ }
51
+
52
+ /**
53
+ * Full HTML body plus any images that had to be inlined.
54
+ * `root` is the agent's working directory, used to resolve relative image paths.
55
+ */
56
+ export function renderMessage(
57
+ text: string,
58
+ options: { root?: string; maxTotalBytes?: number } = {},
59
+ ): RenderedMessage {
60
+ const rendered = inlineLocalImages(textToHtml(text), options)
61
+ return { html: rendered.html, attachments: rendered.attachments, skipped: rendered.skipped }
62
+ }