@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.
- package/CHANGELOG.md +836 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/bin/pi-mailbridge.mjs +31 -0
- package/config.example.json +54 -0
- package/dist/core/attachments.js +87 -0
- package/dist/core/authz.js +65 -0
- package/dist/core/commands.js +72 -0
- package/dist/core/config-freshness.js +24 -0
- package/dist/core/config.js +286 -0
- package/dist/core/deliveries.js +180 -0
- package/dist/core/discover.js +282 -0
- package/dist/core/header-encoding.js +70 -0
- package/dist/core/html.js +48 -0
- package/dist/core/inline-images.js +355 -0
- package/dist/core/mail-command.js +62 -0
- package/dist/core/mailer.js +126 -0
- package/dist/core/markdown.js +96 -0
- package/dist/core/oauth-device.js +119 -0
- package/dist/core/oauth-openid.js +48 -0
- package/dist/core/oauth.js +137 -0
- package/dist/core/prime.js +51 -0
- package/dist/core/reply-parse.js +134 -0
- package/dist/core/sent-index.js +135 -0
- package/dist/core/settle.js +122 -0
- package/dist/core/setup.js +100 -0
- package/dist/core/smoke.js +164 -0
- package/dist/core/status.js +56 -0
- package/dist/core/token-source.js +27 -0
- package/dist/core/token.js +196 -0
- package/dist/core/version.js +44 -0
- package/dist/daemon/api.js +46 -0
- package/dist/daemon/imapflow.js +75 -0
- package/dist/daemon/main.js +802 -0
- package/dist/daemon/process.js +270 -0
- package/dist/daemon/scan-queue.js +38 -0
- package/dist/daemon/script.js +44 -0
- package/dist/daemon/send-budget.js +30 -0
- package/dist/daemon/service.js +455 -0
- package/dist/daemon/source-proxy.js +64 -0
- package/dist/daemon/spawn.js +227 -0
- package/dist/daemon/watcher.js +308 -0
- package/dist/extension.js +1233 -0
- package/dist/store/journal.js +92 -0
- package/dist/store/mode-store.js +47 -0
- package/dist/store/oauth-store.js +50 -0
- package/dist/store/paths.js +33 -0
- package/dist/store/pending-store.js +212 -0
- package/dist/store/registry.js +61 -0
- package/dist/store/routes.js +223 -0
- package/dist/store/schema.js +93 -0
- package/dist/store/secret.js +70 -0
- package/dist/store/threads.js +145 -0
- package/dist/store/uid-cursor.js +112 -0
- package/docs/ARCHITECTURE.md +356 -0
- package/docs/MAPPING.md +201 -0
- package/docs/OAUTH.md +448 -0
- package/docs/SECURITY.md +273 -0
- package/docs/images/flow.svg +124 -0
- package/docs/images/notification.png +0 -0
- package/package.json +85 -4
- package/scripts/render-teaser.ts +56 -0
- package/src/core/attachments.ts +129 -0
- package/src/core/authz.ts +87 -0
- package/src/core/commands.ts +99 -0
- package/src/core/config-freshness.ts +33 -0
- package/src/core/config.ts +373 -0
- package/src/core/deliveries.ts +233 -0
- package/src/core/discover.ts +333 -0
- package/src/core/header-encoding.ts +75 -0
- package/src/core/html.ts +62 -0
- package/src/core/inline-images.ts +421 -0
- package/src/core/mail-command.ts +76 -0
- package/src/core/mailer.ts +193 -0
- package/src/core/markdown.ts +114 -0
- package/src/core/oauth-device.ts +195 -0
- package/src/core/oauth-openid.ts +61 -0
- package/src/core/oauth.ts +182 -0
- package/src/core/prime.ts +74 -0
- package/src/core/reply-parse.ts +153 -0
- package/src/core/sent-index.ts +164 -0
- package/src/core/settle.ts +179 -0
- package/src/core/setup.ts +139 -0
- package/src/core/smoke.ts +207 -0
- package/src/core/status.ts +75 -0
- package/src/core/token-source.ts +34 -0
- package/src/core/token.ts +240 -0
- package/src/core/version.ts +45 -0
- package/src/daemon/api.ts +52 -0
- package/src/daemon/imapflow.ts +101 -0
- package/src/daemon/main.ts +832 -0
- package/src/daemon/process.ts +348 -0
- package/src/daemon/scan-queue.ts +45 -0
- package/src/daemon/script.ts +45 -0
- package/src/daemon/send-budget.ts +36 -0
- package/src/daemon/service.ts +550 -0
- package/src/daemon/source-proxy.ts +88 -0
- package/src/daemon/spawn.ts +332 -0
- package/src/daemon/watcher.ts +427 -0
- package/src/extension.ts +1381 -0
- package/src/store/journal.ts +104 -0
- package/src/store/mode-store.ts +60 -0
- package/src/store/oauth-store.ts +55 -0
- package/src/store/paths.ts +38 -0
- package/src/store/pending-store.ts +282 -0
- package/src/store/registry.ts +91 -0
- package/src/store/routes.ts +267 -0
- package/src/store/schema.ts +104 -0
- package/src/store/secret.ts +69 -0
- package/src/store/threads.ts +177 -0
- 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
|
+
}
|
package/src/core/html.ts
ADDED
|
@@ -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
|
+
"&": "&",
|
|
19
|
+
"<": "<",
|
|
20
|
+
">": ">",
|
|
21
|
+
'"': """,
|
|
22
|
+
"'": "'",
|
|
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
|
+
}
|