@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.0
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 +824 -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,373 @@
|
|
|
1
|
+
// Layered configuration. Later layers win; invalid values are reported rather
|
|
2
|
+
// than thrown, so a typo never takes a session down mid-run.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
5
|
+
import { hostname } from "node:os"
|
|
6
|
+
|
|
7
|
+
import { configPath } from "../store/paths.ts"
|
|
8
|
+
|
|
9
|
+
export interface ImapConfig {
|
|
10
|
+
host: string
|
|
11
|
+
port: number
|
|
12
|
+
secure: boolean
|
|
13
|
+
user: string
|
|
14
|
+
password: string
|
|
15
|
+
folder: string
|
|
16
|
+
/** "XOAUTH2" needs a token command (Outlook). "LOGIN" uses an app password. */
|
|
17
|
+
authType?: "LOGIN" | "XOAUTH2"
|
|
18
|
+
/** argv whose stdout is an access token. Required when authType is XOAUTH2. */
|
|
19
|
+
tokenCommand?: string[]
|
|
20
|
+
/**
|
|
21
|
+
* Folder holding mail we sent. When set, the daemon reads the just-sent copy back and
|
|
22
|
+
* records the Message-ID the provider actually delivered - some providers (QQ) replace the
|
|
23
|
+
* one we stamp, which silently breaks the reply-chain fallback.
|
|
24
|
+
*/
|
|
25
|
+
sentFolder?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SmtpConfig {
|
|
29
|
+
host: string
|
|
30
|
+
port: number
|
|
31
|
+
secure: boolean
|
|
32
|
+
user: string
|
|
33
|
+
password: string
|
|
34
|
+
/** "XOAUTH2" needs an OAuth broker; "LOGIN" is a provider app password. */
|
|
35
|
+
authType?: "LOGIN" | "XOAUTH2"
|
|
36
|
+
/** argv whose stdout is an access token. Required when authType is XOAUTH2. */
|
|
37
|
+
tokenCommand?: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ResolvedConfig {
|
|
41
|
+
mail: { imap: ImapConfig; smtp: SmtpConfig; to: string }
|
|
42
|
+
/** Who this daemon is: named in settle mails, stored on the routing record. */
|
|
43
|
+
device: { name: string }
|
|
44
|
+
/**
|
|
45
|
+
* Device-code OAuth2, provider-agnostic. When present it is preferred over
|
|
46
|
+
* `tokenCommand` for XOAUTH2, because it refreshes itself and needs no external
|
|
47
|
+
* broker. See core/oauth-device.ts.
|
|
48
|
+
*/
|
|
49
|
+
oauth?: { issuer: string; clientId: string; clientSecret?: string; scope: string }
|
|
50
|
+
security: {
|
|
51
|
+
allowFrom: string[]
|
|
52
|
+
requireDmarcPass: boolean
|
|
53
|
+
/** Only AR headers whose authserv-id equals this are trusted. See SECURITY.md §2.1. */
|
|
54
|
+
trustedAuthservId?: string
|
|
55
|
+
/**
|
|
56
|
+
* Accept a reply whose subject token was lost, by matching `In-Reply-To` /
|
|
57
|
+
* `References` against Message-IDs we minted. Default true; see SECURITY.md.
|
|
58
|
+
*/
|
|
59
|
+
allowHeaderThreading?: boolean
|
|
60
|
+
/**
|
|
61
|
+
* Allow the pi mailbox and the human to be the same address (one personal
|
|
62
|
+
* Hotmail/Gmail account). A self-addressed message still has to be an actual
|
|
63
|
+
* reply, i.e. carry In-Reply-To. Default false.
|
|
64
|
+
*/
|
|
65
|
+
allowSelfReplies?: boolean
|
|
66
|
+
tokenTtlHours: number
|
|
67
|
+
}
|
|
68
|
+
daemon: {
|
|
69
|
+
host: string
|
|
70
|
+
port: number
|
|
71
|
+
/** Grace period after the last ping before exiting. Default 120. */
|
|
72
|
+
idleTimeoutSecs?: number
|
|
73
|
+
/** Safety-net catch-up scan cadence, so a silent IDLE cannot strand a reply. Default 60; 0 disables. */
|
|
74
|
+
scanIntervalSecs?: number
|
|
75
|
+
}
|
|
76
|
+
notify: {
|
|
77
|
+
onSettled: boolean
|
|
78
|
+
debounceMs: number
|
|
79
|
+
notifyOnAborted: boolean
|
|
80
|
+
/** Total bytes of files attached to one notification, before base64 encoding. */
|
|
81
|
+
maxAttachmentMb: number
|
|
82
|
+
}
|
|
83
|
+
tuning: { idleRenewMs: number; backoffInitialMs: number; backoffMaxMs: number }
|
|
84
|
+
/** Human-readable problems found while loading; surfaced by `/mailbridge`. */
|
|
85
|
+
warnings: string[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Marks a warning produced by repairing a malformed section: the config still works. */
|
|
89
|
+
export const REPAIRED_PREFIX = "repaired: "
|
|
90
|
+
|
|
91
|
+
const DEFAULTS: Omit<ResolvedConfig, "warnings"> = {
|
|
92
|
+
mail: {
|
|
93
|
+
imap: { host: "", port: 993, secure: true, user: "", password: "", folder: "INBOX" },
|
|
94
|
+
smtp: { host: "", port: 465, secure: true, user: "", password: "", authType: "LOGIN" },
|
|
95
|
+
to: "",
|
|
96
|
+
},
|
|
97
|
+
device: { name: "" }, // resolved below: hostname() unless configured
|
|
98
|
+
security: {
|
|
99
|
+
allowFrom: [],
|
|
100
|
+
requireDmarcPass: true,
|
|
101
|
+
allowHeaderThreading: true,
|
|
102
|
+
allowSelfReplies: false,
|
|
103
|
+
tokenTtlHours: 30 * 24,
|
|
104
|
+
},
|
|
105
|
+
daemon: { host: "127.0.0.1", port: 4177, idleTimeoutSecs: 120, scanIntervalSecs: 60 },
|
|
106
|
+
notify: {
|
|
107
|
+
onSettled: true,
|
|
108
|
+
debounceMs: 10_000,
|
|
109
|
+
notifyOnAborted: false,
|
|
110
|
+
// 18 MB raw is about 24 MB once base64-encoded, under the 25 MB ceiling that Gmail and
|
|
111
|
+
// Outlook.com enforce. A provider that allows more (QQ: 50 MB) can raise it.
|
|
112
|
+
maxAttachmentMb: 18,
|
|
113
|
+
},
|
|
114
|
+
tuning: {
|
|
115
|
+
idleRenewMs: 25 * 60 * 1000,
|
|
116
|
+
backoffInitialMs: 1000,
|
|
117
|
+
backoffMaxMs: 60_000,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
122
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** First label of the hostname, lowercased: `dev-box-7.local` -> `dev-box-7`. */
|
|
126
|
+
export function shortHostname(): string {
|
|
127
|
+
try {
|
|
128
|
+
const full = hostname().toLowerCase()
|
|
129
|
+
return full.split(".")[0] || "unknown-device"
|
|
130
|
+
} catch {
|
|
131
|
+
return "unknown-device"
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Parse a token command from env: JSON array, or comma-separated argv. */
|
|
136
|
+
function parseArgv(raw: string): string[] {
|
|
137
|
+
const trimmed = raw.trim()
|
|
138
|
+
if (trimmed.startsWith("[")) {
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(trimmed)
|
|
141
|
+
if (Array.isArray(parsed)) return parsed.map(String)
|
|
142
|
+
} catch {
|
|
143
|
+
/* fall through to comma splitting */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return trimmed
|
|
147
|
+
.split(",")
|
|
148
|
+
.map((s) => s.trim())
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Shallow-by-section merge: enough for this config shape, and predictable. */
|
|
153
|
+
function merge(base: any, over: any): any {
|
|
154
|
+
if (!isRecord(base) || !isRecord(over)) return over ?? base
|
|
155
|
+
const out: Record<string, unknown> = { ...base }
|
|
156
|
+
for (const [k, v] of Object.entries(over)) {
|
|
157
|
+
out[k] = isRecord(v) && isRecord(base[k]) ? merge(base[k], v) : v
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readJson(path: string, warnings: string[]): Record<string, unknown> {
|
|
163
|
+
if (!existsSync(path)) return {}
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"))
|
|
166
|
+
return isRecord(parsed) ? parsed : {}
|
|
167
|
+
} catch (error) {
|
|
168
|
+
warnings.push(`${path}: ${(error as Error).message}`)
|
|
169
|
+
return {}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function envOverrides(env: NodeJS.ProcessEnv): Record<string, unknown> {
|
|
174
|
+
const o: Record<string, unknown> = {}
|
|
175
|
+
const set = (path: string[], value: unknown) => {
|
|
176
|
+
if (value === undefined) return
|
|
177
|
+
let cur = o
|
|
178
|
+
for (const key of path.slice(0, -1)) {
|
|
179
|
+
cur[key] ??= {}
|
|
180
|
+
cur = cur[key] as Record<string, unknown>
|
|
181
|
+
}
|
|
182
|
+
cur[path[path.length - 1]!] = value
|
|
183
|
+
}
|
|
184
|
+
set(["mail", "imap", "host"], env.PI_MAILBRIDGE_IMAP_HOST)
|
|
185
|
+
set(["mail", "imap", "user"], env.PI_MAILBRIDGE_IMAP_USER)
|
|
186
|
+
set(["mail", "imap", "password"], env.PI_MAILBRIDGE_IMAP_PASS)
|
|
187
|
+
set(["mail", "smtp", "host"], env.PI_MAILBRIDGE_SMTP_HOST)
|
|
188
|
+
set(["mail", "smtp", "user"], env.PI_MAILBRIDGE_SMTP_USER)
|
|
189
|
+
set(["mail", "smtp", "password"], env.PI_MAILBRIDGE_SMTP_PASS)
|
|
190
|
+
set(["mail", "to"], env.PI_MAILBRIDGE_TO)
|
|
191
|
+
set(["daemon", "port"], env.PI_MAILBRIDGE_PORT)
|
|
192
|
+
set(["device", "name"], env.PI_MAILBRIDGE_DEVICE)
|
|
193
|
+
// Token commands are argv lists; accept JSON or a comma-separated argv.
|
|
194
|
+
if (env.PI_MAILBRIDGE_IMAP_TOKEN_CMD) set(["mail", "imap", "tokenCommand"], parseArgv(env.PI_MAILBRIDGE_IMAP_TOKEN_CMD))
|
|
195
|
+
if (env.PI_MAILBRIDGE_SMTP_TOKEN_CMD) set(["mail", "smtp", "tokenCommand"], parseArgv(env.PI_MAILBRIDGE_SMTP_TOKEN_CMD))
|
|
196
|
+
return o
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function loadConfig(env: NodeJS.ProcessEnv = process.env): ResolvedConfig {
|
|
200
|
+
const warnings: string[] = []
|
|
201
|
+
let cfg: any = DEFAULTS
|
|
202
|
+
cfg = merge(cfg, readJson(configPath(env), warnings))
|
|
203
|
+
cfg = merge(cfg, envOverrides(env))
|
|
204
|
+
const resolved: ResolvedConfig = { ...cfg, warnings }
|
|
205
|
+
|
|
206
|
+
// Every check below dereferences these sections, and loadConfig's contract is that it never
|
|
207
|
+
// throws. A JSON `null` is not a record, and the env merge runs after the file merge - so a
|
|
208
|
+
// null section can come back as a *partial* record (e.g. `{"daemon": null}` plus
|
|
209
|
+
// A daemon env var over a null section leaves a record with no host and no port, which would bind an
|
|
210
|
+
// ephemeral port and drop the single-instance guard). Repair by filling the defaults
|
|
211
|
+
// underneath whatever survived, rather than replacing it.
|
|
212
|
+
const sections = resolved as unknown as Record<string, unknown>
|
|
213
|
+
for (const key of Object.keys(DEFAULTS) as (keyof typeof DEFAULTS)[]) {
|
|
214
|
+
const section = sections[key]
|
|
215
|
+
const defaults = DEFAULTS[key] as Record<string, unknown>
|
|
216
|
+
if (!isRecord(section)) {
|
|
217
|
+
warnings.push(`${REPAIRED_PREFIX}${key} must be an object — using the defaults`)
|
|
218
|
+
sections[key] = structuredClone(defaults)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
const repaired: Record<string, unknown> = { ...defaults, ...section }
|
|
222
|
+
// The schema nests exactly one level (mail.imap, mail.smtp).
|
|
223
|
+
for (const [sub, subDefaults] of Object.entries(defaults)) {
|
|
224
|
+
if (!isRecord(subDefaults)) continue
|
|
225
|
+
const value = section[sub]
|
|
226
|
+
if (!isRecord(value)) {
|
|
227
|
+
warnings.push(`${REPAIRED_PREFIX}${key}.${sub} must be an object — using the defaults`)
|
|
228
|
+
repaired[sub] = structuredClone(subDefaults)
|
|
229
|
+
} else {
|
|
230
|
+
repaired[sub] = { ...subDefaults, ...value }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
sections[key] = repaired
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Device identity: a short, stable name so a notification says which machine
|
|
237
|
+
// the session is on. Hostname is the sensible default; it can be overridden
|
|
238
|
+
// when hostnames are opaque (e.g. `dev-box-7`).
|
|
239
|
+
const configuredDevice = String(resolved.device?.name ?? "").trim()
|
|
240
|
+
resolved.device = { name: configuredDevice || shortHostname() }
|
|
241
|
+
|
|
242
|
+
// Validation that must not throw in-process.
|
|
243
|
+
// allowFrom is checked with .length below, which a string also satisfies - and then
|
|
244
|
+
// every message throws in the allowlist check, killing the scan silently forever.
|
|
245
|
+
const rawAllow: unknown = resolved.security?.allowFrom
|
|
246
|
+
if (!Array.isArray(rawAllow)) {
|
|
247
|
+
warnings.push("security.allowFrom must be an array of addresses — ignoring it (fail closed)")
|
|
248
|
+
resolved.security = { ...resolved.security, allowFrom: [] }
|
|
249
|
+
} else {
|
|
250
|
+
resolved.security = { ...resolved.security, allowFrom: rawAllow.map((v) => String(v).trim()).filter(Boolean) }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// A quoted or non-numeric delay silently disables debouncing (setTimeout coerces it).
|
|
254
|
+
const rawDebounce: unknown = resolved.notify?.debounceMs
|
|
255
|
+
const debounce = typeof rawDebounce === "number" ? rawDebounce : Number.NaN
|
|
256
|
+
if (!Number.isFinite(debounce) || debounce < 0) {
|
|
257
|
+
if (rawDebounce !== undefined) warnings.push("notify.debounceMs must be a non-negative number — using 10000")
|
|
258
|
+
resolved.notify = { ...resolved.notify, debounceMs: 10_000 }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// `listen("abc")` is a UNIX socket path in Node, and a float is not a port. The env var is
|
|
262
|
+
// documented, and injected as a string, so coerce it rather than trusting it.
|
|
263
|
+
const rawPort: unknown = resolved.daemon.port
|
|
264
|
+
const port = typeof rawPort === "number" ? rawPort : Number(rawPort)
|
|
265
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
266
|
+
if (rawPort !== undefined) warnings.push("daemon.port must be an integer 1-65535 — using 4177")
|
|
267
|
+
resolved.daemon = { ...resolved.daemon, port: 4177 }
|
|
268
|
+
} else {
|
|
269
|
+
resolved.daemon = { ...resolved.daemon, port }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Attachments are encoded ~4/3, and an over-limit send fails at the provider with the whole
|
|
273
|
+
// notification lost. Keep the default under the 25 MB Gmail/Outlook ceiling; cap the ceiling
|
|
274
|
+
// at what the largest common provider (QQ: 50 MB) accepts.
|
|
275
|
+
const rawMaxMb: unknown = resolved.notify.maxAttachmentMb
|
|
276
|
+
if (rawMaxMb !== undefined) {
|
|
277
|
+
const mb = typeof rawMaxMb === "number" ? rawMaxMb : Number.NaN
|
|
278
|
+
if (!Number.isFinite(mb) || mb < 1 || mb > 45) {
|
|
279
|
+
warnings.push("notify.maxAttachmentMb must be a number between 1 and 45 — using 18")
|
|
280
|
+
resolved.notify = { ...resolved.notify, maxAttachmentMb: 18 }
|
|
281
|
+
} else {
|
|
282
|
+
resolved.notify = { ...resolved.notify, maxAttachmentMb: mb }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// A zero or NaN idle timeout makes the daemon exit ~1 s after the first ping, which looks
|
|
287
|
+
// like a crash-loop; a NaN token TTL makes a route never expire. Both fail closed. The floor
|
|
288
|
+
// is 3x the session poll interval: a timeout shorter than the ping period reaps the daemon
|
|
289
|
+
// between ticks, so it would flap instead of serving.
|
|
290
|
+
const rawIdle: unknown = resolved.daemon.idleTimeoutSecs
|
|
291
|
+
const idle = typeof rawIdle === "number" ? rawIdle : Number.NaN
|
|
292
|
+
if (!Number.isFinite(idle) || idle < 30) {
|
|
293
|
+
if (rawIdle !== undefined) {
|
|
294
|
+
warnings.push("daemon.idleTimeoutSecs must be at least 30 (three poll intervals) — using 120")
|
|
295
|
+
}
|
|
296
|
+
resolved.daemon = { ...resolved.daemon, idleTimeoutSecs: 120 }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const rawTtl: unknown = resolved.security.tokenTtlHours
|
|
300
|
+
const ttl = typeof rawTtl === "number" ? rawTtl : Number.NaN
|
|
301
|
+
if (!Number.isFinite(ttl) || ttl < 1) {
|
|
302
|
+
if (rawTtl !== undefined) warnings.push("security.tokenTtlHours must be a positive number — using 720")
|
|
303
|
+
resolved.security = { ...resolved.security, tokenTtlHours: 30 * 24 }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// A negative, null or non-numeric interval would silently disable the safety net.
|
|
307
|
+
// `Number(null)` is 0, which would read as "the user disabled the sweep" — only a real
|
|
308
|
+
// number counts.
|
|
309
|
+
const rawInterval: unknown = resolved.daemon.scanIntervalSecs
|
|
310
|
+
if (rawInterval !== undefined) {
|
|
311
|
+
const n = typeof rawInterval === "number" ? rawInterval : Number.NaN
|
|
312
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
313
|
+
warnings.push("daemon.scanIntervalSecs must be a non-negative number — using 60")
|
|
314
|
+
resolved.daemon = { ...resolved.daemon, scanIntervalSecs: 60 }
|
|
315
|
+
} else {
|
|
316
|
+
resolved.daemon = { ...resolved.daemon, scanIntervalSecs: n }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const imap = resolved.mail.imap
|
|
321
|
+
if (!imap.host || !imap.user) warnings.push("mail.imap.host and mail.imap.user are required")
|
|
322
|
+
if (!resolved.mail.to) warnings.push("mail.to is required")
|
|
323
|
+
// Setup validates this now; a hand-edited config still can be a typo, and every notification
|
|
324
|
+
// would go nowhere while the daemon reported itself healthy.
|
|
325
|
+
else if (!String(resolved.mail.to).includes("@")) {
|
|
326
|
+
warnings.push("mail.to does not look like an email address — notifications cannot be delivered")
|
|
327
|
+
}
|
|
328
|
+
if (resolved.security.allowFrom.length === 0) {
|
|
329
|
+
warnings.push("security.allowFrom is empty — every sender will be rejected (fail closed)")
|
|
330
|
+
}
|
|
331
|
+
// An unset authserv-id is a legitimate configuration now: authorization falls
|
|
332
|
+
// back to the topmost Authentication-Results header. Reported as a note by
|
|
333
|
+
// `--check`, not as a warning, because it is not a problem. Never a hard failure.
|
|
334
|
+
// XOAUTH2 needs exactly one token source: a built-in device-code flow or a command.
|
|
335
|
+
for (const side of ["imap", "smtp"] as const) {
|
|
336
|
+
const account = resolved.mail[side]
|
|
337
|
+
if (account.authType !== "XOAUTH2") continue
|
|
338
|
+
const hasCommand = Boolean(account.tokenCommand?.length)
|
|
339
|
+
const hasOAuth = Boolean(resolved.oauth?.issuer && resolved.oauth?.clientId)
|
|
340
|
+
if (!hasCommand && !hasOAuth) {
|
|
341
|
+
warnings.push(
|
|
342
|
+
`mail.${side}.authType is XOAUTH2 but neither mail.${side}.tokenCommand nor oauth.issuer/oauth.clientId is set`,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
if (hasOAuth && /(partner\.microsoftonline\.cn|chinacloudapi\.cn)/i.test(resolved.oauth?.issuer ?? "")) {
|
|
346
|
+
warnings.push(
|
|
347
|
+
"oauth.issuer points at the Azure China (21Vianet) sovereign cloud — apps registered there cannot authenticate against global Outlook.com; use login.microsoftonline.com",
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
if (hasOAuth && !/\boffline_access\b/.test(resolved.oauth?.scope ?? "")) {
|
|
351
|
+
warnings.push("oauth.scope should include offline_access, otherwise no refresh token is issued")
|
|
352
|
+
}
|
|
353
|
+
if (account.password) {
|
|
354
|
+
warnings.push(`mail.${side}.password is set but authType is XOAUTH2 — the password will be ignored`)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// A LOGIN account with no password cannot authenticate, and saying "config ok"
|
|
359
|
+
// in that state sends the user straight into a confusing smoke failure.
|
|
360
|
+
for (const side of ["imap", "smtp"] as const) {
|
|
361
|
+
const account = resolved.mail[side]
|
|
362
|
+
if (account.authType !== "XOAUTH2" && !account.password) {
|
|
363
|
+
warnings.push(
|
|
364
|
+
`mail.${side}.password is empty — paste the provider's app password there (QQ/163 call it 授权码)`,
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return resolved
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function hasUsableMailConfig(c: ResolvedConfig): boolean {
|
|
372
|
+
return Boolean(c.mail.imap.host && c.mail.imap.user && c.mail.smtp.host && c.mail.to)
|
|
373
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
|
|
17
|
+
import type { PendingRecord } from "../store/pending-store.ts"
|
|
18
|
+
|
|
19
|
+
/** The daemon calls this runner needs. `createDaemonClient` satisfies it. */
|
|
20
|
+
export type ClientResult<T> = { ok: true; data: T } | { ok: false; status: number; code?: string }
|
|
21
|
+
|
|
22
|
+
export interface DeliveryClient {
|
|
23
|
+
claim(uid: number, instanceId: string, uidValidity?: number): Promise<ClientResult<{ ok: boolean }>>
|
|
24
|
+
ack(uid: number, instanceId: string, sessionId?: string, uidValidity?: number): Promise<ClientResult<{ ok: boolean }>>
|
|
25
|
+
pending(): Promise<ClientResult<{ pending: PendingRecord[] }>>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type DeliveryOutcome =
|
|
29
|
+
| "injected"
|
|
30
|
+
| "already-injected"
|
|
31
|
+
| "foreign-session"
|
|
32
|
+
| "claimed-by-other"
|
|
33
|
+
| "inject-failed"
|
|
34
|
+
| "claim-unconfirmed"
|
|
35
|
+
| "stale-space"
|
|
36
|
+
| "foreign-device"
|
|
37
|
+
| "not-found"
|
|
38
|
+
|
|
39
|
+
export interface DeliveryRunnerDeps {
|
|
40
|
+
client: DeliveryClient
|
|
41
|
+
/** Stable id for this pi process; the daemon uses it for claim exclusivity. */
|
|
42
|
+
instanceId: string
|
|
43
|
+
/** Current session id, or undefined when there is no session. */
|
|
44
|
+
sessionId: () => string | undefined
|
|
45
|
+
/** Inject into the live session. Throw to signal failure (do not ack). */
|
|
46
|
+
inject: (body: string, record: PendingRecord) => Promise<void>
|
|
47
|
+
onError?: (error: Error, record: PendingRecord) => void
|
|
48
|
+
/**
|
|
49
|
+
* Is delivery still wanted? Checked between records, so `/mailbridge off` arriving as a
|
|
50
|
+
* reply stops the batch it came in: the records behind it stay pending (unclaimed, unseen)
|
|
51
|
+
* and are delivered when the mode goes back on, instead of landing in a session the human
|
|
52
|
+
* just asked to leave alone.
|
|
53
|
+
*/
|
|
54
|
+
stillDelivering?: () => boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DeliveryRunner {
|
|
58
|
+
handle(record: PendingRecord): Promise<DeliveryOutcome>
|
|
59
|
+
/** Replay everything the daemon still holds. Used on connect and reconnect. */
|
|
60
|
+
drain(): Promise<DeliveryOutcome[]>
|
|
61
|
+
/** Test/diagnostic: uids injected by this runner. */
|
|
62
|
+
injectedUids(): number[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** In-flight drains, shared across runner instances in this process. */
|
|
66
|
+
const inFlightByInstance = new Map<string, Promise<DeliveryOutcome[]>>()
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* UIDs already injected, per instance.
|
|
70
|
+
*
|
|
71
|
+
* Per instance, not per runner object: a rebuilt runtime (a respawn, or a new session in
|
|
72
|
+
* the same pi process after session_shutdown) constructs a new runner while the claim key -
|
|
73
|
+
* the pid - stays the same. A per-runner set let the rebuilt runner re-inject a reply whose
|
|
74
|
+
* ack had failed, which is the one failure this design cannot undo.
|
|
75
|
+
*/
|
|
76
|
+
const injectedByInstance = new Map<string, Set<string>>()
|
|
77
|
+
/** A process has one instanceId; this only guards against a pathological caller. */
|
|
78
|
+
const MAX_INSTANCE_SETS = 8
|
|
79
|
+
/** Cap per instance, like the journal: one key per injected message, oldest dropped. */
|
|
80
|
+
const MAX_INJECTED_PER_INSTANCE = 5_000
|
|
81
|
+
|
|
82
|
+
/** UIDs are only unique within a UIDVALIDITY, so the key must carry it. */
|
|
83
|
+
function injectionKey(record: { uid: number; uidValidity?: number }): string {
|
|
84
|
+
return `${record.uidValidity ?? ""}:${record.uid}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function injectedFor(instanceId: string): Set<string> {
|
|
88
|
+
let set = injectedByInstance.get(instanceId)
|
|
89
|
+
if (!set) {
|
|
90
|
+
set = new Set<string>()
|
|
91
|
+
injectedByInstance.set(instanceId, set)
|
|
92
|
+
if (injectedByInstance.size > MAX_INSTANCE_SETS) {
|
|
93
|
+
const oldest = injectedByInstance.keys().next().value
|
|
94
|
+
// Never evict the caller's own set: losing it would let this instance re-inject.
|
|
95
|
+
if (oldest !== undefined && oldest !== instanceId) injectedByInstance.delete(oldest)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return set
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createDeliveryRunner(deps: DeliveryRunnerDeps): DeliveryRunner {
|
|
102
|
+
/** Stale-space refusals already reported, so a stuck record does not log every poll. */
|
|
103
|
+
const reported = new Set<string>()
|
|
104
|
+
const injected = (): Set<string> => injectedFor(deps.instanceId)
|
|
105
|
+
|
|
106
|
+
async function handle(record: PendingRecord): Promise<DeliveryOutcome> {
|
|
107
|
+
if (injected().has(injectionKey(record))) {
|
|
108
|
+
// Injected earlier but possibly not acked: a single failed /ack used to pin the
|
|
109
|
+
// record for the process lifetime, leaking a queue slot, leaving \Seen unset and
|
|
110
|
+
// the journal unwritten - and after a restart (new pid, new instance) the same
|
|
111
|
+
// instruction would be injected again. The ack is idempotent, so retry it.
|
|
112
|
+
try {
|
|
113
|
+
const retried = await deps.client.ack(record.uid, deps.instanceId, record.sessionId, record.uidValidity)
|
|
114
|
+
// A retry that keeps failing is the same silent leak as a first ack that
|
|
115
|
+
// failed: surface it rather than looping quietly.
|
|
116
|
+
if (!retried.ok && retried.status !== 404) {
|
|
117
|
+
deps.onError?.(new Error(`ack retry failed (${retried.code ?? retried.status})`), record)
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
deps.onError?.(error instanceof Error ? error : new Error(String(error)), record)
|
|
121
|
+
}
|
|
122
|
+
return "already-injected"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const mine = deps.sessionId()
|
|
126
|
+
if (!mine || record.sessionId !== mine) return "foreign-session"
|
|
127
|
+
|
|
128
|
+
const claim = await deps.client.claim(record.uid, deps.instanceId, record.uidValidity)
|
|
129
|
+
if (!claim.ok) {
|
|
130
|
+
// Expected when another instance won the race; the daemon answers 409.
|
|
131
|
+
if (claim.code === "claimed-by-other") return "claimed-by-other"
|
|
132
|
+
// The record belongs to a UID numbering the mailbox has left. It cannot be delivered
|
|
133
|
+
// and is dropped at the next connect; say so once rather than retrying in silence
|
|
134
|
+
// every poll tick.
|
|
135
|
+
if (claim.code === "stale-space") {
|
|
136
|
+
const key = injectionKey(record)
|
|
137
|
+
if (!reported.has(key)) {
|
|
138
|
+
reported.add(key)
|
|
139
|
+
deps.onError?.(new Error("reply belongs to an earlier UID space; it will be dropped"), record)
|
|
140
|
+
}
|
|
141
|
+
return "stale-space"
|
|
142
|
+
}
|
|
143
|
+
// A record this daemon does not own (another machine's, or one stamped under a
|
|
144
|
+
// device name that has since changed) cannot be delivered by retrying. Say it once:
|
|
145
|
+
// silence here is how a queued reply is lost without a trace.
|
|
146
|
+
if (claim.code === "foreign-device") {
|
|
147
|
+
const key = injectionKey(record)
|
|
148
|
+
if (!reported.has(key)) {
|
|
149
|
+
reported.add(key)
|
|
150
|
+
deps.onError?.(
|
|
151
|
+
new Error("reply was scanned under another device name; it will not be delivered here"),
|
|
152
|
+
record,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
return "foreign-device"
|
|
156
|
+
}
|
|
157
|
+
// Only a definite 404 means the record is gone. Anything else - a timeout, a
|
|
158
|
+
// network error, a rotated secret (401) - leaves the outcome unknown, and calling
|
|
159
|
+
// that "not-found" is the same lie in a different costume. It self-heals (the
|
|
160
|
+
// claim TTL expires, the next drain re-claims), but the diagnostic must not mislead.
|
|
161
|
+
if (claim.status === 404) return "not-found"
|
|
162
|
+
return "claim-unconfirmed"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
await deps.inject(record.body, record)
|
|
167
|
+
} catch (error) {
|
|
168
|
+
// Do NOT ack: the delivery must stay pending so it is retried.
|
|
169
|
+
deps.onError?.(error instanceof Error ? error : new Error(String(error)), record)
|
|
170
|
+
return "inject-failed"
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Record before acking: if the ack fails we still refuse to inject twice.
|
|
174
|
+
const set = injected()
|
|
175
|
+
set.add(injectionKey(record))
|
|
176
|
+
// Bounded like the journal. Evicting an entry whose ack never landed would let it be
|
|
177
|
+
// injected again, so this is a last resort, not a free trade.
|
|
178
|
+
while (set.size > MAX_INJECTED_PER_INSTANCE) {
|
|
179
|
+
const oldest = set.values().next().value
|
|
180
|
+
if (oldest === undefined) break
|
|
181
|
+
set.delete(oldest)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const ackResult = await deps.client.ack(record.uid, deps.instanceId, record.sessionId, record.uidValidity)
|
|
186
|
+
if (!ackResult.ok) {
|
|
187
|
+
// Injected but not acked: the record will be re-offered on reconnect and
|
|
188
|
+
// the `injected` set will absorb it. Surface it — a silent ack failure
|
|
189
|
+
// hides a growing pending queue.
|
|
190
|
+
deps.onError?.(new Error(`ack failed (${ackResult.code ?? ackResult.status})`), record)
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// Injected but not acked: same handling as above.
|
|
194
|
+
deps.onError?.(error instanceof Error ? error : new Error(String(error)), record)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return "injected"
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
handle,
|
|
202
|
+
injectedUids: () => [...injected()].map((key) => Number(key.split(":")[1])),
|
|
203
|
+
|
|
204
|
+
async drain() {
|
|
205
|
+
// One drain at a time. The claim registry is keyed per pid, so two overlapping
|
|
206
|
+
// drains in this process would both be granted the same uid and inject twice -
|
|
207
|
+
// and a duplicate injection cannot be undone.
|
|
208
|
+
// Keyed by instanceId, not by this runner object: a respawn builds a new runner
|
|
209
|
+
// while the claim key (the pid) stays the same, so a per-object flag would let an
|
|
210
|
+
// old runner's drain overlap a new one's.
|
|
211
|
+
const running = inFlightByInstance.get(deps.instanceId)
|
|
212
|
+
if (running) return running
|
|
213
|
+
const attempt = (async () => {
|
|
214
|
+
try {
|
|
215
|
+
const result = await deps.client.pending()
|
|
216
|
+
if (!result.ok) return []
|
|
217
|
+
const outcomes: DeliveryOutcome[] = []
|
|
218
|
+
for (const record of result.data.pending) {
|
|
219
|
+
// Re-checked every record: a reply that turns the mode off is in this very
|
|
220
|
+
// batch, and everything after it must wait.
|
|
221
|
+
if (deps.stillDelivering && !deps.stillDelivering()) break
|
|
222
|
+
outcomes.push(await handle(record))
|
|
223
|
+
}
|
|
224
|
+
return outcomes
|
|
225
|
+
} finally {
|
|
226
|
+
inFlightByInstance.delete(deps.instanceId)
|
|
227
|
+
}
|
|
228
|
+
})()
|
|
229
|
+
inFlightByInstance.set(deps.instanceId, attempt)
|
|
230
|
+
return attempt
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
}
|