@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,180 @@
1
+ // Delivery runner — the extension side of claim → inject → ack.
2
+ //
3
+ // Invariants, in order of importance:
4
+ //
5
+ // 1. Never inject a delivery for another session. The token already routed it;
6
+ // this is the second check, and it is cheap.
7
+ // 2. Never ack a delivery that was not injected. Acking early would mark the
8
+ // mail seen and drop the record, losing the human's reply.
9
+ // 3. Never inject the same UID twice *in this process*, even if the ack failed
10
+ // — a reconnect replays /pending, and a duplicate injection is worse than a
11
+ // stale seen-flag.
12
+ // 4. A failed claim (another instance owns it) is not an error: leave it alone.
13
+ //
14
+ // The runner is deliberately synchronous-per-delivery: mail replies are serial and
15
+ // ordering them keeps the transcript readable.
16
+ /** In-flight drains, shared across runner instances in this process. */
17
+ const inFlightByInstance = new Map();
18
+ /**
19
+ * UIDs already injected, per instance.
20
+ *
21
+ * Per instance, not per runner object: a rebuilt runtime (a respawn, or a new session in
22
+ * the same pi process after session_shutdown) constructs a new runner while the claim key -
23
+ * the pid - stays the same. A per-runner set let the rebuilt runner re-inject a reply whose
24
+ * ack had failed, which is the one failure this design cannot undo.
25
+ */
26
+ const injectedByInstance = new Map();
27
+ /** A process has one instanceId; this only guards against a pathological caller. */
28
+ const MAX_INSTANCE_SETS = 8;
29
+ /** Cap per instance, like the journal: one key per injected message, oldest dropped. */
30
+ const MAX_INJECTED_PER_INSTANCE = 5_000;
31
+ /** UIDs are only unique within a UIDVALIDITY, so the key must carry it. */
32
+ function injectionKey(record) {
33
+ return `${record.uidValidity ?? ""}:${record.uid}`;
34
+ }
35
+ function injectedFor(instanceId) {
36
+ let set = injectedByInstance.get(instanceId);
37
+ if (!set) {
38
+ set = new Set();
39
+ injectedByInstance.set(instanceId, set);
40
+ if (injectedByInstance.size > MAX_INSTANCE_SETS) {
41
+ const oldest = injectedByInstance.keys().next().value;
42
+ // Never evict the caller's own set: losing it would let this instance re-inject.
43
+ if (oldest !== undefined && oldest !== instanceId)
44
+ injectedByInstance.delete(oldest);
45
+ }
46
+ }
47
+ return set;
48
+ }
49
+ export function createDeliveryRunner(deps) {
50
+ /** Stale-space refusals already reported, so a stuck record does not log every poll. */
51
+ const reported = new Set();
52
+ const injected = () => injectedFor(deps.instanceId);
53
+ async function handle(record) {
54
+ if (injected().has(injectionKey(record))) {
55
+ // Injected earlier but possibly not acked: a single failed /ack used to pin the
56
+ // record for the process lifetime, leaking a queue slot, leaving \Seen unset and
57
+ // the journal unwritten - and after a restart (new pid, new instance) the same
58
+ // instruction would be injected again. The ack is idempotent, so retry it.
59
+ try {
60
+ const retried = await deps.client.ack(record.uid, deps.instanceId, record.sessionId, record.uidValidity);
61
+ // A retry that keeps failing is the same silent leak as a first ack that
62
+ // failed: surface it rather than looping quietly.
63
+ if (!retried.ok && retried.status !== 404) {
64
+ deps.onError?.(new Error(`ack retry failed (${retried.code ?? retried.status})`), record);
65
+ }
66
+ }
67
+ catch (error) {
68
+ deps.onError?.(error instanceof Error ? error : new Error(String(error)), record);
69
+ }
70
+ return "already-injected";
71
+ }
72
+ const mine = deps.sessionId();
73
+ if (!mine || record.sessionId !== mine)
74
+ return "foreign-session";
75
+ const claim = await deps.client.claim(record.uid, deps.instanceId, record.uidValidity);
76
+ if (!claim.ok) {
77
+ // Expected when another instance won the race; the daemon answers 409.
78
+ if (claim.code === "claimed-by-other")
79
+ return "claimed-by-other";
80
+ // The record belongs to a UID numbering the mailbox has left. It cannot be delivered
81
+ // and is dropped at the next connect; say so once rather than retrying in silence
82
+ // every poll tick.
83
+ if (claim.code === "stale-space") {
84
+ const key = injectionKey(record);
85
+ if (!reported.has(key)) {
86
+ reported.add(key);
87
+ deps.onError?.(new Error("reply belongs to an earlier UID space; it will be dropped"), record);
88
+ }
89
+ return "stale-space";
90
+ }
91
+ // A record this daemon does not own (another machine's, or one stamped under a
92
+ // device name that has since changed) cannot be delivered by retrying. Say it once:
93
+ // silence here is how a queued reply is lost without a trace.
94
+ if (claim.code === "foreign-device") {
95
+ const key = injectionKey(record);
96
+ if (!reported.has(key)) {
97
+ reported.add(key);
98
+ deps.onError?.(new Error("reply was scanned under another device name; it will not be delivered here"), record);
99
+ }
100
+ return "foreign-device";
101
+ }
102
+ // Only a definite 404 means the record is gone. Anything else - a timeout, a
103
+ // network error, a rotated secret (401) - leaves the outcome unknown, and calling
104
+ // that "not-found" is the same lie in a different costume. It self-heals (the
105
+ // claim TTL expires, the next drain re-claims), but the diagnostic must not mislead.
106
+ if (claim.status === 404)
107
+ return "not-found";
108
+ return "claim-unconfirmed";
109
+ }
110
+ try {
111
+ await deps.inject(record.body, record);
112
+ }
113
+ catch (error) {
114
+ // Do NOT ack: the delivery must stay pending so it is retried.
115
+ deps.onError?.(error instanceof Error ? error : new Error(String(error)), record);
116
+ return "inject-failed";
117
+ }
118
+ // Record before acking: if the ack fails we still refuse to inject twice.
119
+ const set = injected();
120
+ set.add(injectionKey(record));
121
+ // Bounded like the journal. Evicting an entry whose ack never landed would let it be
122
+ // injected again, so this is a last resort, not a free trade.
123
+ while (set.size > MAX_INJECTED_PER_INSTANCE) {
124
+ const oldest = set.values().next().value;
125
+ if (oldest === undefined)
126
+ break;
127
+ set.delete(oldest);
128
+ }
129
+ try {
130
+ const ackResult = await deps.client.ack(record.uid, deps.instanceId, record.sessionId, record.uidValidity);
131
+ if (!ackResult.ok) {
132
+ // Injected but not acked: the record will be re-offered on reconnect and
133
+ // the `injected` set will absorb it. Surface it — a silent ack failure
134
+ // hides a growing pending queue.
135
+ deps.onError?.(new Error(`ack failed (${ackResult.code ?? ackResult.status})`), record);
136
+ }
137
+ }
138
+ catch (error) {
139
+ // Injected but not acked: same handling as above.
140
+ deps.onError?.(error instanceof Error ? error : new Error(String(error)), record);
141
+ }
142
+ return "injected";
143
+ }
144
+ return {
145
+ handle,
146
+ injectedUids: () => [...injected()].map((key) => Number(key.split(":")[1])),
147
+ async drain() {
148
+ // One drain at a time. The claim registry is keyed per pid, so two overlapping
149
+ // drains in this process would both be granted the same uid and inject twice -
150
+ // and a duplicate injection cannot be undone.
151
+ // Keyed by instanceId, not by this runner object: a respawn builds a new runner
152
+ // while the claim key (the pid) stays the same, so a per-object flag would let an
153
+ // old runner's drain overlap a new one's.
154
+ const running = inFlightByInstance.get(deps.instanceId);
155
+ if (running)
156
+ return running;
157
+ const attempt = (async () => {
158
+ try {
159
+ const result = await deps.client.pending();
160
+ if (!result.ok)
161
+ return [];
162
+ const outcomes = [];
163
+ for (const record of result.data.pending) {
164
+ // Re-checked every record: a reply that turns the mode off is in this very
165
+ // batch, and everything after it must wait.
166
+ if (deps.stillDelivering && !deps.stillDelivering())
167
+ break;
168
+ outcomes.push(await handle(record));
169
+ }
170
+ return outcomes;
171
+ }
172
+ finally {
173
+ inFlightByInstance.delete(deps.instanceId);
174
+ }
175
+ })();
176
+ inFlightByInstance.set(deps.instanceId, attempt);
177
+ return attempt;
178
+ },
179
+ };
180
+ }
@@ -0,0 +1,282 @@
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
+ /** Parse a Thunderbird `clientConfig` document. Pure, so it is testable. */
16
+ export function parseAutoconfig(xml) {
17
+ const block = (tag) => {
18
+ // type="imap" / type="smtp" specifically; pop3 must not win.
19
+ const re = new RegExp(`<${tag}\\b[^>]*type="(?:imap|smtp)"[^>]*>([\\s\\S]*?)</${tag}>`, "i");
20
+ return re.exec(xml)?.[1];
21
+ };
22
+ const one = (chunk, tag) => new RegExp(`<${tag}>\\s*([^<]+?)\\s*</${tag}>`, "i").exec(chunk)?.[1];
23
+ const all = (chunk, tag) => [...chunk.matchAll(new RegExp(`<${tag}>\\s*([^<]+?)\\s*</${tag}>`, "gi"))].map((m) => m[1].trim());
24
+ const incoming = block("incomingServer");
25
+ const outgoing = block("outgoingServer");
26
+ if (!incoming || !outgoing)
27
+ return undefined;
28
+ const endpoint = (chunk) => {
29
+ const host = one(chunk, "hostname");
30
+ const port = Number(one(chunk, "port"));
31
+ if (!host || !Number.isInteger(port) || port <= 0)
32
+ return undefined;
33
+ const socket = (one(chunk, "socketType") ?? "").toUpperCase();
34
+ return { host, port, secure: socket !== "STARTTLS" };
35
+ };
36
+ const imap = endpoint(incoming);
37
+ const smtp = endpoint(outgoing);
38
+ if (!imap || !smtp)
39
+ return undefined;
40
+ // A provider may advertise several; OAuth2 wins when present because password
41
+ // auth is what every major provider is retiring.
42
+ const auths = [...all(incoming, "authentication"), ...all(outgoing, "authentication")].map((a) => a.toLowerCase());
43
+ const authType = auths.some((a) => a.includes("oauth2")) ? "XOAUTH2" : "LOGIN";
44
+ const host = imap.host.toLowerCase();
45
+ const provider = /(?:office365|outlook|hotmail|live|msn)\./.test(host)
46
+ ? "microsoft"
47
+ : /gmail|googlemail/.test(host)
48
+ ? "google"
49
+ : "other";
50
+ return { imap, smtp, authType, provider };
51
+ }
52
+ /**
53
+ * Provider-specific auth defaults.
54
+ *
55
+ * Google advertises OAuth2 in its published settings, but for a personal mailbox
56
+ * an app password is strictly simpler: Google OAuth for mail scopes is *restricted*
57
+ * (app verification plus an annual CASA assessment to publish), and a consent
58
+ * screen left in "Testing" issues refresh tokens that expire after **7 days** —
59
+ * fatal for a daemon that must run unattended.
60
+ *
61
+ * Microsoft has no equivalent: app passwords were retired on 2024-09-16, so XOAUTH2
62
+ * is the only path there.
63
+ */
64
+ function applyProviderDefaults(discovered) {
65
+ if (discovered.provider === "google")
66
+ return { ...discovered, authType: "LOGIN" };
67
+ return discovered;
68
+ }
69
+ const ISPDB = "https://autoconfig.thunderbird.net/v1.1";
70
+ /**
71
+ * A work/school Microsoft 365 tenant mailbox. These have no ISPDB entry and an
72
+ * arbitrary subdomain, so the suffix is the signal: `something.onmicrosoft.com`.
73
+ * The SMTP host differs from the consumer one (`smtp.office365.com`, not
74
+ * `smtp-mail.outlook.com`) and the authority is `organizations`.
75
+ */
76
+ export const M365_PROVIDER = {
77
+ imap: { host: "outlook.office365.com", port: 993, secure: true },
78
+ smtp: { host: "smtp.office365.com", port: 587, secure: false },
79
+ authType: "XOAUTH2",
80
+ provider: "microsoft-365",
81
+ };
82
+ /** Built-in fallback lookup, including the Microsoft 365 suffix rule. */
83
+ function knownProvider(domain) {
84
+ const exact = KNOWN_PROVIDERS[domain.toLowerCase()];
85
+ if (exact)
86
+ return exact;
87
+ if (domain.toLowerCase().endsWith(".onmicrosoft.com"))
88
+ return M365_PROVIDER;
89
+ return undefined;
90
+ }
91
+ /**
92
+ * Built-in fallback for the provider families this package is used with.
93
+ *
94
+ * ISPDB is authoritative and current, so it is always tried first; this map only
95
+ * matters when it is unreachable or missing an entry. It also means the common
96
+ * cases work with no network at all.
97
+ */
98
+ export const KNOWN_PROVIDERS = {
99
+ "hotmail.com": {
100
+ imap: { host: "outlook.office365.com", port: 993, secure: true },
101
+ smtp: { host: "smtp-mail.outlook.com", port: 587, secure: false },
102
+ authType: "XOAUTH2",
103
+ provider: "microsoft",
104
+ },
105
+ "outlook.com": {
106
+ imap: { host: "outlook.office365.com", port: 993, secure: true },
107
+ smtp: { host: "smtp-mail.outlook.com", port: 587, secure: false },
108
+ authType: "XOAUTH2",
109
+ provider: "microsoft",
110
+ },
111
+ "live.com": {
112
+ imap: { host: "outlook.office365.com", port: 993, secure: true },
113
+ smtp: { host: "smtp-mail.outlook.com", port: 587, secure: false },
114
+ authType: "XOAUTH2",
115
+ provider: "microsoft",
116
+ },
117
+ "msn.com": {
118
+ imap: { host: "outlook.office365.com", port: 993, secure: true },
119
+ smtp: { host: "smtp-mail.outlook.com", port: 587, secure: false },
120
+ authType: "XOAUTH2",
121
+ provider: "microsoft",
122
+ },
123
+ // Chinese providers: the practical choice where Google is unreachable. All four
124
+ // authenticate with an app-specific code (授权码) as a password, so no OAuth app
125
+ // is needed. NetEase (163/126) requires the IMAP ID command, which imapflow
126
+ // sends automatically when the server advertises ID.
127
+ "qq.com": {
128
+ imap: { host: "imap.qq.com", port: 993, secure: true },
129
+ smtp: { host: "smtp.qq.com", port: 465, secure: true },
130
+ authType: "LOGIN",
131
+ provider: "other",
132
+ },
133
+ "foxmail.com": {
134
+ imap: { host: "imap.qq.com", port: 993, secure: true },
135
+ smtp: { host: "smtp.qq.com", port: 465, secure: true },
136
+ authType: "LOGIN",
137
+ provider: "other",
138
+ },
139
+ "163.com": {
140
+ imap: { host: "imap.163.com", port: 993, secure: true },
141
+ smtp: { host: "smtp.163.com", port: 465, secure: true },
142
+ authType: "LOGIN",
143
+ provider: "other",
144
+ },
145
+ "126.com": {
146
+ imap: { host: "imap.126.com", port: 993, secure: true },
147
+ smtp: { host: "smtp.126.com", port: 465, secure: true },
148
+ authType: "LOGIN",
149
+ provider: "other",
150
+ },
151
+ // NetEase's other free domains: same service, no waiting period.
152
+ "yeah.net": {
153
+ imap: { host: "imap.yeah.net", port: 993, secure: true },
154
+ smtp: { host: "smtp.yeah.net", port: 465, secure: true },
155
+ authType: "LOGIN",
156
+ provider: "other",
157
+ },
158
+ "188.com": {
159
+ imap: { host: "imap.188.com", port: 993, secure: true },
160
+ smtp: { host: "smtp.188.com", port: 465, secure: true },
161
+ authType: "LOGIN",
162
+ provider: "other",
163
+ },
164
+ "gmail.com": {
165
+ imap: { host: "imap.gmail.com", port: 993, secure: true },
166
+ smtp: { host: "smtp.gmail.com", port: 465, secure: true },
167
+ authType: "XOAUTH2",
168
+ provider: "google",
169
+ },
170
+ "googlemail.com": {
171
+ imap: { host: "imap.gmail.com", port: 993, secure: true },
172
+ smtp: { host: "smtp.gmail.com", port: 465, secure: true },
173
+ authType: "XOAUTH2",
174
+ provider: "google",
175
+ },
176
+ };
177
+ /** Look a domain up in ISPDB. Returns undefined when the domain is unknown. */
178
+ export async function discoverDomain(domain, options = {}) {
179
+ const doFetch = options.fetchImpl ?? fetch;
180
+ const base = options.baseUrl ?? ISPDB;
181
+ const controller = new AbortController();
182
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 5_000);
183
+ try {
184
+ const response = await doFetch(`${base}/${encodeURIComponent(domain)}`, { signal: controller.signal });
185
+ const parsed = response.ok ? (parseAutoconfig(await response.text()) ?? knownProvider(domain)) : knownProvider(domain);
186
+ return parsed ? applyProviderDefaults(parsed) : undefined;
187
+ }
188
+ catch {
189
+ // Offline or blocked: fall back to the built-in table before giving up.
190
+ const known = knownProvider(domain);
191
+ return known ? applyProviderDefaults(known) : undefined;
192
+ }
193
+ finally {
194
+ clearTimeout(timer);
195
+ }
196
+ }
197
+ export function domainOf(address) {
198
+ const at = address.lastIndexOf("@");
199
+ if (at <= 0 || at === address.length - 1)
200
+ return undefined;
201
+ // A trailing dot is a valid FQDN form but never what a person means to type.
202
+ return address.slice(at + 1).trim().toLowerCase().replace(/\.+$/, "") || undefined;
203
+ }
204
+ /** Scope presets, kept here so setup and docs cannot drift apart. */
205
+ export const OAUTH_SCOPE = {
206
+ microsoft: "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send",
207
+ /** Same delegated scopes; the tenant, not the resource, is what differs. */
208
+ "microsoft-365": "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send",
209
+ google: "https://mail.google.com/ offline_access",
210
+ };
211
+ export const OAUTH_ISSUER = {
212
+ /** consumer outlook.com / hotmail */
213
+ microsoft: "https://login.microsoftonline.com/consumers/v2.0",
214
+ /** work or school tenant, incl. a Developer Program sandbox */
215
+ "microsoft-365": "https://login.microsoftonline.com/organizations/v2.0",
216
+ google: "https://accounts.google.com",
217
+ };
218
+ /**
219
+ * Turn discovery into a config the daemon can actually run.
220
+ *
221
+ * `address` is pi's mailbox; `to` is the human. When they differ, the loop guard
222
+ * stays strict; when they are the same, single-mailbox mode is enabled
223
+ * automatically so replies are not rejected as self-loops.
224
+ */
225
+ export function buildSetupConfig(address, to, discovered) {
226
+ const sameAddress = address.trim().toLowerCase() === to.trim().toLowerCase();
227
+ // Only attach an OAuth preset when OAuth is actually the chosen mechanism,
228
+ // otherwise a Gmail app-password setup would ship a clientId nobody needs.
229
+ const oauthPreset = discovered.authType === "XOAUTH2" &&
230
+ (discovered.provider === "microsoft" || discovered.provider === "microsoft-365" || discovered.provider === "google")
231
+ ? discovered.provider
232
+ : undefined;
233
+ // QQ rewrites the Message-ID we stamp (measured live), which silently breaks the reply-chain
234
+ // fallback. Naming the Sent folder makes `setup` record the delivered id instead, so the
235
+ // documented fallback works out of the box on the provider we know does this.
236
+ const rewritesMessageId = /(^|\.)(qq|foxmail)\.com$/i.test(discovered.imap.host);
237
+ return {
238
+ mail: {
239
+ imap: {
240
+ host: discovered.imap.host,
241
+ port: discovered.imap.port,
242
+ secure: discovered.imap.secure,
243
+ user: address,
244
+ password: "",
245
+ folder: discovered.folder ?? "INBOX",
246
+ authType: discovered.authType,
247
+ ...(rewritesMessageId ? { sentFolder: "Sent Messages" } : {}),
248
+ },
249
+ smtp: {
250
+ host: discovered.smtp.host,
251
+ port: discovered.smtp.port,
252
+ secure: discovered.smtp.secure,
253
+ user: address,
254
+ password: "",
255
+ authType: discovered.authType,
256
+ },
257
+ to,
258
+ },
259
+ security: {
260
+ allowFrom: [to],
261
+ // Single-mailbox mode sends and receives from one address, so the human's reply
262
+ // is a self-addressed message the provider may deliver without an
263
+ // Authentication-Results header at all (QQ does not add one to QQ->QQ mail).
264
+ // Requiring DMARC there rejects every genuine reply, so it is off for that shape
265
+ // and on everywhere else. The allowlist, loop guard and two self-echo guards
266
+ // still apply; setup says so and `--check` warns if both are set.
267
+ requireDmarcPass: !sameAddress,
268
+ allowHeaderThreading: true,
269
+ allowSelfReplies: sameAddress,
270
+ tokenTtlHours: 30 * 24,
271
+ },
272
+ ...(oauthPreset
273
+ ? {
274
+ oauth: {
275
+ issuer: OAUTH_ISSUER[oauthPreset],
276
+ clientId: "<your app client id>",
277
+ scope: OAUTH_SCOPE[oauthPreset],
278
+ },
279
+ }
280
+ : {}),
281
+ };
282
+ }
@@ -0,0 +1,70 @@
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
+ /** One encoded word: =?charset?B|Q?text?= */
14
+ const ENCODED_WORD = /=\?([^?\s]+)\?([BbQq])\?([^?\s]*)\?=/g;
15
+ function decodeQ(text) {
16
+ // RFC 2047 §4.2: `_` means space; `=XX` is a hex byte.
17
+ const spaced = text.replace(/_/g, " ");
18
+ const bytes = [];
19
+ for (let i = 0; i < spaced.length; i++) {
20
+ const char = spaced[i];
21
+ if (char === "=" && /^[0-9A-Fa-f]{2}$/.test(spaced.slice(i + 1, i + 3))) {
22
+ bytes.push(Number.parseInt(spaced.slice(i + 1, i + 3), 16));
23
+ i += 2;
24
+ continue;
25
+ }
26
+ bytes.push(char.charCodeAt(0));
27
+ }
28
+ return Buffer.from(bytes);
29
+ }
30
+ function decodeBytes(bytes, charset) {
31
+ try {
32
+ return new TextDecoder(charset.trim().toLowerCase()).decode(bytes);
33
+ }
34
+ catch {
35
+ // Unknown or unsupported charset: UTF-8 is the best guess and never throws
36
+ // on valid bytes, so a reply is not lost because of a weird label.
37
+ return bytes.toString("utf8");
38
+ }
39
+ }
40
+ /**
41
+ * Decode every RFC 2047 encoded word in `value`.
42
+ *
43
+ * Whitespace *between* adjacent encoded words is dropped, per RFC 2047 §6.2 —
44
+ * clients split long encoded subjects on spaces, and keeping that space would
45
+ * corrupt the decoded text (and the token inside it).
46
+ */
47
+ export function decodeRfc2047(value) {
48
+ if (!value)
49
+ return "";
50
+ if (!value.includes("=?"))
51
+ return value.replace(/\r?\n\s*/g, " ").trim();
52
+ let out = "";
53
+ let lastIndex = 0;
54
+ let matched = false;
55
+ for (const match of value.matchAll(ENCODED_WORD)) {
56
+ const index = match.index ?? 0;
57
+ const between = value.slice(lastIndex, index);
58
+ // Before the first word, keep the prefix verbatim. Between two encoded words,
59
+ // drop it only when it is pure whitespace (RFC 2047 §6.2).
60
+ if (!matched || !/^\s*$/.test(between))
61
+ out += between;
62
+ const [, charset = "", encoding = "", text = ""] = match;
63
+ const bytes = encoding.toUpperCase() === "B" ? Buffer.from(text, "base64") : decodeQ(text);
64
+ out += decodeBytes(bytes, charset);
65
+ lastIndex = index + match[0].length;
66
+ matched = true;
67
+ }
68
+ out += value.slice(lastIndex);
69
+ return out.replace(/\r?\n\s*/g, " ").trim();
70
+ }
@@ -0,0 +1,48 @@
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
+ import { renderBody } from "./markdown.js";
14
+ import { inlineLocalImages } from "./inline-images.js";
15
+ const ENTITIES = {
16
+ "&": "&amp;",
17
+ "<": "&lt;",
18
+ ">": "&gt;",
19
+ '"': "&quot;",
20
+ "'": "&#39;",
21
+ };
22
+ /** Escape text for safe interpolation into HTML. */
23
+ export function escapeHtml(value) {
24
+ return value.replace(/[&<>"']/g, (char) => ENTITIES[char] ?? char);
25
+ }
26
+ /**
27
+ * Render a mail body as HTML.
28
+ *
29
+ * The agent answers in Markdown, so it is rendered (core/markdown.ts) instead of being
30
+ * shown as literal `**bold**` and fence markers. Structure lives in the markup, not in
31
+ * CSS: clients that sanitise inline styles (Outlook web does) would collapse anything
32
+ * that relied on `white-space: pre-wrap`.
33
+ */
34
+ export function textToHtml(text) {
35
+ return [
36
+ '<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.45">',
37
+ renderBody(text),
38
+ "</div>",
39
+ ].join("");
40
+ }
41
+ /**
42
+ * Full HTML body plus any images that had to be inlined.
43
+ * `root` is the agent's working directory, used to resolve relative image paths.
44
+ */
45
+ export function renderMessage(text, options = {}) {
46
+ const rendered = inlineLocalImages(textToHtml(text), options);
47
+ return { html: rendered.html, attachments: rendered.attachments, skipped: rendered.skipped };
48
+ }