@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,832 @@
|
|
|
1
|
+
// pi-mailbridge daemon — process entry.
|
|
2
|
+
//
|
|
3
|
+
// Boot order matters:
|
|
4
|
+
// 1. load config, surface warnings
|
|
5
|
+
// 2. resolve the shared secret (also the HTTP bearer token)
|
|
6
|
+
// 3. start the HTTP service (so /health answers while IMAP is still dialling)
|
|
7
|
+
// 4. catch-up scan, then the IDLE watcher
|
|
8
|
+
//
|
|
9
|
+
// Single-instance: a lock file in the state directory, not the port. The port is still checked
|
|
10
|
+
// (EADDRINUSE exits 0), but two processes that disagree about `daemon.port` - a per-process
|
|
11
|
+
// PI_MAILBRIDGE_PORT, or the config edited while one is running - would otherwise start two
|
|
12
|
+
// daemons, two IMAP watchers, on one state directory whose stores have no cross-process lock.
|
|
13
|
+
//
|
|
14
|
+
// Modes:
|
|
15
|
+
// (none) run the daemon
|
|
16
|
+
// --check validate config + secrets + token command, touch no mail
|
|
17
|
+
// --setup write a config from an address (headless equivalent of /mailbridge setup)
|
|
18
|
+
// --auth run the OAuth device-code flow
|
|
19
|
+
// --prime move the UID cursor to the newest message
|
|
20
|
+
// --smoke send one real mail, then open the folder and count
|
|
21
|
+
// --print-token print the shared secret (for scripts and tests)
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
24
|
+
import { statePath } from "../store/paths.ts"
|
|
25
|
+
import { assertStateReadable } from "../store/schema.ts"
|
|
26
|
+
import { dirname } from "node:path"
|
|
27
|
+
import { fileURLToPath } from "node:url"
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
loadConfig,
|
|
31
|
+
hasUsableMailConfig,
|
|
32
|
+
shortHostname,
|
|
33
|
+
REPAIRED_PREFIX,
|
|
34
|
+
type ResolvedConfig,
|
|
35
|
+
} from "../core/config.ts"
|
|
36
|
+
import { configPath } from "../store/paths.ts"
|
|
37
|
+
import { buildSetupConfig, discoverDomain, domainOf } from "../core/discover.ts"
|
|
38
|
+
import { ensureSecret } from "../store/secret.ts"
|
|
39
|
+
import { readMode, scanningEnabled } from "../store/mode-store.ts"
|
|
40
|
+
import { createScanQueue } from "./scan-queue.ts"
|
|
41
|
+
import { sendMail } from "../core/mailer.ts"
|
|
42
|
+
import { captureDeliveredMessageId } from "../core/sent-index.ts"
|
|
43
|
+
import { createCommandTokenProvider } from "../core/oauth.ts"
|
|
44
|
+
import { createOpenidClient } from "../core/oauth-openid.ts"
|
|
45
|
+
import { buildTokenProvider } from "../core/token-source.ts"
|
|
46
|
+
import { fileTokenStore } from "../store/oauth-store.ts"
|
|
47
|
+
import { scanAndProcess } from "./process.ts"
|
|
48
|
+
import { applyUidValidity, createSourceProxy } from "./source-proxy.ts"
|
|
49
|
+
import { createRegistry } from "../store/registry.ts"
|
|
50
|
+
import { createImapWatcher, type ImapWatcher } from "./watcher.ts"
|
|
51
|
+
import { createImapflowFactory } from "./imapflow.ts"
|
|
52
|
+
import { createDaemonService, type DaemonService } from "./service.ts"
|
|
53
|
+
import type { MailSource } from "./process.ts"
|
|
54
|
+
import { runSmoke } from "../core/smoke.ts"
|
|
55
|
+
import { runSetup } from "../core/setup.ts"
|
|
56
|
+
import { listPending } from "../store/pending-store.ts"
|
|
57
|
+
import { advanceCursor, isPrimed, markPrimed, readCursor } from "../store/uid-cursor.ts"
|
|
58
|
+
import { alreadyPrimedMessage, runPrime } from "../core/prime.ts"
|
|
59
|
+
import { packageVersion } from "../core/version.ts"
|
|
60
|
+
import { API_VERSION } from "./api.ts"
|
|
61
|
+
import { recordOutbound } from "../store/threads.ts"
|
|
62
|
+
import { tokenFromSubject } from "../core/token.ts"
|
|
63
|
+
|
|
64
|
+
const EXIT_CONFIG = 2
|
|
65
|
+
const EXIT_AUTH = 3
|
|
66
|
+
const EXIT_NETWORK = 4
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Token supply
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pick the token source for this installation.
|
|
74
|
+
*
|
|
75
|
+
* Precedence: built-in device-code OAuth2 (self-refreshing) -> a configured
|
|
76
|
+
* command -> none (LOGIN auth). Returning `undefined` is not an error: it means
|
|
77
|
+
* the account does not use XOAUTH2.
|
|
78
|
+
*/
|
|
79
|
+
/**
|
|
80
|
+
* `--auth`: approve the device grant once and persist the refresh token.
|
|
81
|
+
* Prints the URL and code, waits for approval, then exits.
|
|
82
|
+
*/
|
|
83
|
+
async function login(config: ResolvedConfig): Promise<number> {
|
|
84
|
+
if (!config.oauth?.issuer || !config.oauth.clientId) {
|
|
85
|
+
log("oauth.issuer and oauth.clientId are required for device login — see docs/OAUTH.md")
|
|
86
|
+
return EXIT_CONFIG
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const client = await createOpenidClient(config.oauth)
|
|
90
|
+
|
|
91
|
+
// Start the grant and show the human what to do BEFORE waiting on them.
|
|
92
|
+
const authorization = await client.deviceAuthorization()
|
|
93
|
+
process.stdout.write(`\nApprove this login:\n ${authorization.verificationUri}\n`)
|
|
94
|
+
if (authorization.userCode) process.stdout.write(`Code: ${authorization.userCode}\n`)
|
|
95
|
+
process.stdout.write("\nWaiting for approval…\n")
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const tokens = await client.pollDeviceAuthorization()
|
|
99
|
+
if (!tokens.refreshToken) {
|
|
100
|
+
log("the provider returned no refresh_token — add offline_access to oauth.scope")
|
|
101
|
+
return EXIT_AUTH
|
|
102
|
+
}
|
|
103
|
+
fileTokenStore().write({
|
|
104
|
+
refreshToken: tokens.refreshToken,
|
|
105
|
+
accessToken: tokens.accessToken,
|
|
106
|
+
expiresAt: tokens.expiresAt,
|
|
107
|
+
})
|
|
108
|
+
} catch (error) {
|
|
109
|
+
log(`login failed: ${(error as Error).message}`)
|
|
110
|
+
return EXIT_AUTH
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
log("authorised; refresh token stored")
|
|
114
|
+
return 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `--smoke`: prove the real account works, both directions, without pi.
|
|
119
|
+
*
|
|
120
|
+
* 1. resolve a token (LOGIN or OAuth2)
|
|
121
|
+
* 2. send one mail over SMTP
|
|
122
|
+
* 3. open the IMAP folder and count messages
|
|
123
|
+
*
|
|
124
|
+
* Deliberately does NOT touch the cursor, the journal, or any pending record, so a
|
|
125
|
+
* smoke test can never disturb real deliveries.
|
|
126
|
+
*/
|
|
127
|
+
async function smoke(config: ResolvedConfig): Promise<number> {
|
|
128
|
+
const tokenProvider = await buildTokenProvider(config)
|
|
129
|
+
const result = await runSmoke(config, {
|
|
130
|
+
...(tokenProvider ? { getAccessToken: () => tokenProvider.getToken() } : {}),
|
|
131
|
+
})
|
|
132
|
+
for (const step of result.steps) {
|
|
133
|
+
log(`${step.step} ${step.ok ? "ok" : "FAILED"}: ${step.detail}`)
|
|
134
|
+
if (step.hint) log(` hint: ${step.hint}`)
|
|
135
|
+
}
|
|
136
|
+
if (!result.ok) return EXIT_AUTH
|
|
137
|
+
log("smoke ok: both directions work")
|
|
138
|
+
return 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `--prime`: move the UID cursor to the newest message without reading anything.
|
|
143
|
+
*
|
|
144
|
+
* Without this, the first scan walks the entire existing mailbox: it fetches and
|
|
145
|
+
* parses every message looking for a routing token. Nothing is stored and nothing
|
|
146
|
+
* is marked seen, but on a mailbox that also carries personal mail that is both
|
|
147
|
+
* slow and more exposure than a notification bridge needs. Priming says "watch
|
|
148
|
+
* from now on".
|
|
149
|
+
*
|
|
150
|
+
* First run only: after that the cursor is the thing that makes mail visible, and
|
|
151
|
+
* moving it forward would silently skip replies that arrived while nothing scanned.
|
|
152
|
+
*/
|
|
153
|
+
async function prime(config: ResolvedConfig): Promise<number> {
|
|
154
|
+
const tokenProvider = await buildTokenProvider(config)
|
|
155
|
+
const getAccessToken = tokenProvider ? () => tokenProvider.getToken() : undefined
|
|
156
|
+
const client = await createImapflowFactory({ ...(getAccessToken ? { getAccessToken } : {}) })(
|
|
157
|
+
config,
|
|
158
|
+
{ idleRenewMs: config.tuning.idleRenewMs },
|
|
159
|
+
)
|
|
160
|
+
try {
|
|
161
|
+
const plan = await runPrime(config, {
|
|
162
|
+
openMailbox: async (cfg: ResolvedConfig) => {
|
|
163
|
+
await client.connect()
|
|
164
|
+
await client.mailboxOpen(cfg.mail.imap.folder || "INBOX")
|
|
165
|
+
return {
|
|
166
|
+
search: () => client.search({ uid: "1:*" }, { uid: true }),
|
|
167
|
+
close: async () => client.logout(),
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
readCursor: () => readCursor(process.env),
|
|
171
|
+
isPrimed: () => isPrimed(process.env),
|
|
172
|
+
markPrimed: () => markPrimed(process.env),
|
|
173
|
+
advanceCursor: (uid: number) => {
|
|
174
|
+
advanceCursor(process.env, uid)
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
if (!plan.ok) {
|
|
178
|
+
log(alreadyPrimedMessage(plan.currentUid))
|
|
179
|
+
return EXIT_CONFIG
|
|
180
|
+
}
|
|
181
|
+
log(`primed: cursor set to uid ${plan.highest} (${plan.count} existing message(s) will not be read)`)
|
|
182
|
+
return 0
|
|
183
|
+
} catch (error) {
|
|
184
|
+
log(`prime failed: ${(error as Error).message}`)
|
|
185
|
+
return EXIT_AUTH
|
|
186
|
+
} finally {
|
|
187
|
+
try {
|
|
188
|
+
await client.logout()
|
|
189
|
+
} catch {
|
|
190
|
+
/* already gone */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* One daemon per state directory, whatever port it thinks it owns.
|
|
197
|
+
*
|
|
198
|
+
* `wx` makes the create atomic; the pid inside lets a later process tell a live holder from a
|
|
199
|
+
* crash. A holder that is gone is stolen, so a killed daemon does not lock the machine out.
|
|
200
|
+
*/
|
|
201
|
+
function acquireDaemonLock(env: NodeJS.ProcessEnv): { ok: true; release: () => void } | { ok: false; pid: number } {
|
|
202
|
+
const path = statePath(env, "daemon.lock")
|
|
203
|
+
const take = (): { ok: true; release: () => void } | undefined => {
|
|
204
|
+
try {
|
|
205
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
206
|
+
writeFileSync(path, String(process.pid), { flag: "wx", mode: 0o600 })
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
release: () => {
|
|
210
|
+
// Only remove our own: a stolen lock belongs to someone else now.
|
|
211
|
+
try {
|
|
212
|
+
if (readFileSync(path, "utf8").trim() === String(process.pid)) unlinkSync(path)
|
|
213
|
+
} catch {
|
|
214
|
+
/* already gone */
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const first = take()
|
|
224
|
+
if (first) return first
|
|
225
|
+
|
|
226
|
+
let holder = 0
|
|
227
|
+
try {
|
|
228
|
+
holder = Number(readFileSync(path, "utf8").trim())
|
|
229
|
+
} catch {
|
|
230
|
+
/* unreadable: treat as stale */
|
|
231
|
+
}
|
|
232
|
+
const alive = holder > 0 && holder !== process.pid && (() => {
|
|
233
|
+
try {
|
|
234
|
+
process.kill(holder, 0)
|
|
235
|
+
return true
|
|
236
|
+
} catch (error) {
|
|
237
|
+
return (error as NodeJS.ErrnoException).code === "EPERM"
|
|
238
|
+
}
|
|
239
|
+
})()
|
|
240
|
+
if (alive) return { ok: false, pid: holder }
|
|
241
|
+
|
|
242
|
+
// Stale: the holder is gone (a crash, or a reboot with a leftover file). Steal it by
|
|
243
|
+
// RENAMING it aside, not unlinking it: an unlink plus a retake lets two stealers both win -
|
|
244
|
+
// B unlinks the corpse, creates its own lock, then C unlinks *B's live lock* and creates
|
|
245
|
+
// its own. A rename is atomic, so exactly one stealer gets the file; the winner verifies it
|
|
246
|
+
// took the corpse it judged dead, and puts back anything that is not.
|
|
247
|
+
const aside = `${path}.${process.pid}.stale`
|
|
248
|
+
try {
|
|
249
|
+
renameSync(path, aside)
|
|
250
|
+
} catch {
|
|
251
|
+
// Someone else moved it first; try again from the top once.
|
|
252
|
+
const retry = take()
|
|
253
|
+
return retry ?? { ok: false, pid: holder }
|
|
254
|
+
}
|
|
255
|
+
let moved = Number.NaN
|
|
256
|
+
try {
|
|
257
|
+
moved = Number(readFileSync(aside, "utf8").trim())
|
|
258
|
+
} catch {
|
|
259
|
+
/* unreadable */
|
|
260
|
+
}
|
|
261
|
+
if (moved !== holder) {
|
|
262
|
+
// The file changed between the liveness check and our rename: restore it untouched and
|
|
263
|
+
// report the current holder rather than stealing a live lock.
|
|
264
|
+
try {
|
|
265
|
+
renameSync(aside, path)
|
|
266
|
+
} catch {
|
|
267
|
+
/* if we cannot put it back, leave it aside; the next start will judge it */
|
|
268
|
+
}
|
|
269
|
+
const retry = take()
|
|
270
|
+
return retry ?? { ok: false, pid: Number.isFinite(moved) ? moved : holder }
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
unlinkSync(aside)
|
|
274
|
+
} catch {
|
|
275
|
+
/* already gone */
|
|
276
|
+
}
|
|
277
|
+
const second = take()
|
|
278
|
+
return second ?? { ok: false, pid: holder }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function argValue(argv: string[], flag: string): string | undefined {
|
|
282
|
+
const index = argv.indexOf(flag)
|
|
283
|
+
if (index < 0) return undefined
|
|
284
|
+
const next = argv[index + 1]
|
|
285
|
+
return next && !next.startsWith("--") ? next : undefined
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* `--setup <pi-address> [--to <your-address>] [--force]` — the headless twin of
|
|
290
|
+
* `/mailbridge setup`. Shares core/setup.ts, so behaviour cannot drift.
|
|
291
|
+
*/
|
|
292
|
+
async function setup(argv: string[]): Promise<number> {
|
|
293
|
+
const address = argValue(argv, "--setup")
|
|
294
|
+
if (!address) {
|
|
295
|
+
log("usage: --setup <pi-address> [--to <your-address>] [--force]")
|
|
296
|
+
return EXIT_CONFIG
|
|
297
|
+
}
|
|
298
|
+
const to = argValue(argv, "--to")
|
|
299
|
+
const result = await runSetup({ address, ...(to ? { to } : {}), force: argv.includes("--force") })
|
|
300
|
+
for (const step of result.steps) log(step)
|
|
301
|
+
if (result.next) log(`next: ${result.next}`)
|
|
302
|
+
return result.ok ? 0 : EXIT_CONFIG
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function log(message: string): void {
|
|
306
|
+
// Timestamped: a live review had to read TCP counters to prove the poll was running, because
|
|
307
|
+
// the log carried no time and empty scans are silent.
|
|
308
|
+
console.error(`pi-mailbridge: ${new Date().toISOString().slice(0, 19)}Z ${message}`)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* `--check`: report every problem distinctly and exit with a code a supervisor
|
|
313
|
+
* can act on. Never touches mail.
|
|
314
|
+
*/
|
|
315
|
+
/**
|
|
316
|
+
* Problems `--check` reports, without the network or the log side-effects.
|
|
317
|
+
* Exported so the combination rules can be tested.
|
|
318
|
+
*/
|
|
319
|
+
export function checkProblems(config: ResolvedConfig): string[] {
|
|
320
|
+
const problems: string[] = []
|
|
321
|
+
const add = (problem: string) => {
|
|
322
|
+
if (!problems.includes(problem)) problems.push(problem)
|
|
323
|
+
}
|
|
324
|
+
// config.warnings already covers missing host/user/to and an empty allowlist. A repaired
|
|
325
|
+
// malformed section is reported but is not a reason to refuse to start: the daemon runs.
|
|
326
|
+
for (const warning of config.warnings) if (!warning.startsWith(REPAIRED_PREFIX)) add(warning)
|
|
327
|
+
|
|
328
|
+
if (!hasUsableMailConfig(config)) {
|
|
329
|
+
add("mail.imap.host / mail.imap.user / mail.smtp.host / mail.to must all be set")
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Same check as /mailbridge check: one address plus DMARC means every reply is rejected
|
|
333
|
+
// on providers that stamp no Authentication-Results on self-addressed mail. This is the
|
|
334
|
+
// headless path a supervisor runs, so it must not stay silent.
|
|
335
|
+
if (config.security.allowSelfReplies && config.security.requireDmarcPass) {
|
|
336
|
+
add(
|
|
337
|
+
"security.allowSelfReplies is on but security.requireDmarcPass is too — providers that do not " +
|
|
338
|
+
"stamp Authentication-Results on self-addressed mail will reject every reply (dmarc-missing)",
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return problems
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function check(config: ResolvedConfig): Promise<number> {
|
|
346
|
+
let code = 0
|
|
347
|
+
const problems = checkProblems(config)
|
|
348
|
+
const add = (problem: string) => {
|
|
349
|
+
if (!problems.includes(problem)) problems.push(problem)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (config.mail.imap.authType === "XOAUTH2" || config.mail.smtp.authType === "XOAUTH2") {
|
|
353
|
+
const hasOAuth = Boolean(config.oauth?.issuer && config.oauth?.clientId)
|
|
354
|
+
const command = config.mail.smtp.tokenCommand ?? config.mail.imap.tokenCommand ?? []
|
|
355
|
+
if (hasOAuth) {
|
|
356
|
+
const stored = fileTokenStore().read()
|
|
357
|
+
if (stored?.refreshToken) {
|
|
358
|
+
log("oauth: refresh token present")
|
|
359
|
+
} else {
|
|
360
|
+
add("oauth is configured but no refresh token yet — run `/mailbridge auth` in pi (or `pi-mailbridge --auth`)")
|
|
361
|
+
code = EXIT_AUTH
|
|
362
|
+
}
|
|
363
|
+
} else if (command.length === 0) {
|
|
364
|
+
add("XOAUTH2 selected but neither oauth.* nor tokenCommand is configured")
|
|
365
|
+
code = EXIT_AUTH
|
|
366
|
+
} else {
|
|
367
|
+
const provider = createCommandTokenProvider({ command })
|
|
368
|
+
try {
|
|
369
|
+
await provider.getToken()
|
|
370
|
+
log(`token command ok (${command[0]})`)
|
|
371
|
+
} catch (error) {
|
|
372
|
+
add((error as Error).message)
|
|
373
|
+
code = EXIT_AUTH
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (config.security.requireDmarcPass && !config.security.trustedAuthservId) {
|
|
379
|
+
log("note: security.trustedAuthservId unset — trusting the topmost Authentication-Results header")
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
ensureSecret()
|
|
384
|
+
log("shared secret ok")
|
|
385
|
+
} catch (error) {
|
|
386
|
+
add(`secret: ${(error as Error).message}`)
|
|
387
|
+
code = code || EXIT_CONFIG
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
for (const problem of problems) log(` ! ${problem}`)
|
|
391
|
+
if (problems.length === 0) log("config ok")
|
|
392
|
+
return code || (problems.length > 0 ? EXIT_CONFIG : 0)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The config file's mtime, or 0 when there is no file. */
|
|
396
|
+
function configStamp(env: NodeJS.ProcessEnv = process.env): number {
|
|
397
|
+
try {
|
|
398
|
+
return statSync(configPath(env), { throwIfNoEntry: false })?.mtimeMs ?? 0
|
|
399
|
+
} catch {
|
|
400
|
+
return 0
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function run(initialConfig: ResolvedConfig, initialStamp: number): Promise<void> {
|
|
405
|
+
const token = ensureSecret().toString("base64url")
|
|
406
|
+
|
|
407
|
+
// Mutable refs: /reload swaps both without recreating the HTTP service.
|
|
408
|
+
let currentConfig = initialConfig
|
|
409
|
+
let currentWatcher: ImapWatcher | undefined
|
|
410
|
+
let tokenProvider: Awaited<ReturnType<typeof buildTokenProvider>>
|
|
411
|
+
let service: DaemonService
|
|
412
|
+
|
|
413
|
+
const getAccessToken = () => (tokenProvider ? tokenProvider.getToken() : Promise.reject(new Error("no token provider")))
|
|
414
|
+
|
|
415
|
+
// A stable MailSource the service can hold across reloads.
|
|
416
|
+
// One registry for the process: the service uses it for decisions, and the UIDVALIDITY
|
|
417
|
+
// reset releases the slots of records it drops.
|
|
418
|
+
const registry = createRegistry()
|
|
419
|
+
// Built once; every call reads the live watcher, which /reload swaps.
|
|
420
|
+
const sourceProxy: MailSource = createSourceProxy({ watcher: () => currentWatcher, env: process.env })
|
|
421
|
+
|
|
422
|
+
let reportedModeOff = false
|
|
423
|
+
// Last scan outcome, surfaced on /health so `/mailbridge status` can answer "why did my
|
|
424
|
+
// reply not arrive?" - the reasons were already computed, just never shown.
|
|
425
|
+
// When the running config was read. The extension compares this with the config file's
|
|
426
|
+
// mtime: a session that attaches to an already-running daemon must not inherit a config
|
|
427
|
+
// the daemon read before the user changed it.
|
|
428
|
+
// The mtime of the file this config came from, taken BEFORE it was read, so a write in
|
|
429
|
+
// the gap is still detected as newer.
|
|
430
|
+
let configLoadedAt = initialStamp
|
|
431
|
+
/** Periodic catch-up timer (armed at the end of run()). */
|
|
432
|
+
let pollHandle: ReturnType<typeof setTimeout> | undefined
|
|
433
|
+
let lastCheckedAt: number | undefined
|
|
434
|
+
let lastScan: { at: number; examined: number; persisted: number; rejected: number; reasons: Record<string, number> } | undefined
|
|
435
|
+
const scanOnce = createScanQueue((why) => scanNow(why))
|
|
436
|
+
|
|
437
|
+
function formatReasons(reasons: Record<string, number>): string {
|
|
438
|
+
const parts = Object.entries(reasons)
|
|
439
|
+
.filter(([, n]) => n > 0)
|
|
440
|
+
.sort((a, b) => b[1] - a[1])
|
|
441
|
+
.map(([reason, n]) => `${reason}=${n}`)
|
|
442
|
+
return parts.length ? parts.join(" ") : "no reasons"
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function scanNow(why: string): Promise<void> {
|
|
446
|
+
// `off` must actually close the channel, not just stop the mail coming back.
|
|
447
|
+
// While mode is off nothing is fetched or persisted and the cursor stays put, so
|
|
448
|
+
// turning `/mailbridge on` back on processes what arrived meanwhile.
|
|
449
|
+
// Stamped before the gate: "last checked" answers "is the poll loop alive", and while
|
|
450
|
+
// mode is off it is the only thing it can honestly mean. Leaving it stale made a quiet
|
|
451
|
+
// off period look identical to a dead poll loop - the symptom this line exists to show.
|
|
452
|
+
lastCheckedAt = Date.now()
|
|
453
|
+
if (!scanningEnabled(readMode(process.env))) {
|
|
454
|
+
if (!reportedModeOff) {
|
|
455
|
+
reportedModeOff = true
|
|
456
|
+
log(`${why}: email mode is off - not scanning`)
|
|
457
|
+
}
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
reportedModeOff = false
|
|
461
|
+
const before = new Set(listPending().map((r) => r.uid))
|
|
462
|
+
try {
|
|
463
|
+
const result = await scanAndProcess(currentConfig, {
|
|
464
|
+
source: sourceProxy,
|
|
465
|
+
secret: ensureSecret(),
|
|
466
|
+
onDropped: (sessionId) => registry.release(sessionId),
|
|
467
|
+
})
|
|
468
|
+
// Keep the last scan that actually saw mail. Overwriting it with every empty
|
|
469
|
+
// catch-up scan made the "why did my reply not arrive" panel a few seconds old on a
|
|
470
|
+
// mailbox that receives anything else.
|
|
471
|
+
if (result.examined > 0) {
|
|
472
|
+
lastScan = {
|
|
473
|
+
at: Date.now(),
|
|
474
|
+
examined: result.examined,
|
|
475
|
+
persisted: result.persisted,
|
|
476
|
+
rejected: result.rejected,
|
|
477
|
+
reasons: result.reasons,
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (result.persisted > 0) {
|
|
481
|
+
// Name the reasons even on a partial success: "rejected 1" alone leaves the user
|
|
482
|
+
// unable to tell which reply was dropped and why.
|
|
483
|
+
log(
|
|
484
|
+
`${why}: persisted ${result.persisted} of ${result.examined} (rejected ${result.rejected}: ${formatReasons(result.reasons)})`,
|
|
485
|
+
)
|
|
486
|
+
for (const record of listPending()) {
|
|
487
|
+
if (!before.has(record.uid)) service.broadcast({ type: "delivery", pending: record })
|
|
488
|
+
}
|
|
489
|
+
} else if (result.examined > 0) {
|
|
490
|
+
// Name the reasons: "nothing for us" is what makes a missing reply undebuggable.
|
|
491
|
+
log(`${why}: examined ${result.examined}, nothing for us (${formatReasons(result.reasons)})`)
|
|
492
|
+
}
|
|
493
|
+
} catch (error) {
|
|
494
|
+
// A scan failure must not kill the daemon: the cursor did not advance, so
|
|
495
|
+
// the next IDLE event or catch-up retries the same UIDs.
|
|
496
|
+
log(`${why}: scan failed: ${(error as Error).message}`)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const armPoll = (): void => {
|
|
501
|
+
// Clearing first makes this callable from /reload: setting the interval to 0 leaves no
|
|
502
|
+
// tick to re-read it, so without an external re-arm a later `/reload` could never turn
|
|
503
|
+
// the safety net back on.
|
|
504
|
+
if (pollHandle !== undefined) clearTimeout(pollHandle)
|
|
505
|
+
pollHandle = undefined
|
|
506
|
+
// Read the interval on every tick so `/reload` can change it, and reuse the same mode
|
|
507
|
+
// rule the scanner uses rather than spelling it out again.
|
|
508
|
+
const scanEveryMs = Math.max(0, currentConfig.daemon.scanIntervalSecs ?? 60) * 1000
|
|
509
|
+
if (scanEveryMs === 0) return
|
|
510
|
+
pollHandle = setTimeout(() => {
|
|
511
|
+
pollHandle = undefined
|
|
512
|
+
if (scanningEnabled(readMode(process.env)) && currentWatcher?.isHealthy()) void scanOnce("poll")
|
|
513
|
+
armPoll()
|
|
514
|
+
}, scanEveryMs)
|
|
515
|
+
pollHandle.unref?.()
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
let reloadInFlight: Promise<{ ok: boolean; error?: string; warning?: string }> | undefined
|
|
519
|
+
|
|
520
|
+
async function doReload(pass = 0): Promise<{ ok: boolean; error?: string; warning?: string }> {
|
|
521
|
+
const previous = currentConfig
|
|
522
|
+
const previousProvider = tokenProvider
|
|
523
|
+
const previousStamp = configLoadedAt
|
|
524
|
+
// Stamped at entry: if the file changes while this reload runs, the caller(s) joined to
|
|
525
|
+
// it must not be told their newer config is in force.
|
|
526
|
+
const startedFrom = configStamp()
|
|
527
|
+
try {
|
|
528
|
+
await currentWatcher?.stop()
|
|
529
|
+
configLoadedAt = startedFrom
|
|
530
|
+
currentConfig = loadConfig()
|
|
531
|
+
tokenProvider = await buildTokenProvider(currentConfig)
|
|
532
|
+
const watcher = await startWatcher(currentConfig)
|
|
533
|
+
// start() does not throw on a refused connect - it arms a reconnect - so ask the
|
|
534
|
+
// watcher whether it is actually connected before calling the reload a success.
|
|
535
|
+
if (!watcher.isHealthy()) {
|
|
536
|
+
// The config was re-read; the connection is what failed, and the watcher is
|
|
537
|
+
// retrying. Report it rather than calling the reload clean.
|
|
538
|
+
log("reloaded config, but IMAP did not connect (check the password/host)")
|
|
539
|
+
armPoll()
|
|
540
|
+
return { ok: true, warning: "config reloaded, but IMAP did not connect" }
|
|
541
|
+
}
|
|
542
|
+
// Someone wrote the config while we were re-dialling (a `/mailbridge setup` that
|
|
543
|
+
// joined this reload, for instance). One extra pass so they get a reload that
|
|
544
|
+
// started after their write - otherwise mail keeps going to the address they
|
|
545
|
+
// just replaced until the next session notices.
|
|
546
|
+
if (pass === 0 && configStamp() > startedFrom) return doReload(1)
|
|
547
|
+
log("reloaded config and restarted the watcher")
|
|
548
|
+
armPoll()
|
|
549
|
+
return { ok: true }
|
|
550
|
+
} catch (error) {
|
|
551
|
+
// Do not leave the daemon holding a config whose watcher never started - nor a token
|
|
552
|
+
// provider built for credentials that were just rolled back.
|
|
553
|
+
currentConfig = previous
|
|
554
|
+
tokenProvider = previousProvider
|
|
555
|
+
configLoadedAt = previousStamp
|
|
556
|
+
try {
|
|
557
|
+
await startWatcher(previous)
|
|
558
|
+
log(`reload failed (${(error as Error).message}); kept the previous config`)
|
|
559
|
+
} catch {
|
|
560
|
+
log(`reload failed (${(error as Error).message}); previous watcher could not restart either`)
|
|
561
|
+
}
|
|
562
|
+
return { ok: false, error: (error as Error).message }
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function startWatcher(config: ResolvedConfig): Promise<ImapWatcher> {
|
|
567
|
+
const watcher = createImapWatcher(config, {
|
|
568
|
+
factory: createImapflowFactory({ getAccessToken: tokenProvider ? getAccessToken : undefined }),
|
|
569
|
+
callbacks: {
|
|
570
|
+
onReady: () => {
|
|
571
|
+
// UIDs are only meaningful within one UIDVALIDITY. A recreated or migrated
|
|
572
|
+
// folder renumbers from 1; without this the high-water cursor would skip
|
|
573
|
+
// every future message, with "examined 0" as the only symptom.
|
|
574
|
+
// `currentWatcher` is assigned before start(), so it is this watcher here.
|
|
575
|
+
const outcome = applyUidValidity(process.env, currentWatcher, (sessionId) => registry.release(sessionId))
|
|
576
|
+
if (outcome.reset) {
|
|
577
|
+
log(
|
|
578
|
+
`mailbox UIDVALIDITY changed to ${outcome.validity}; cursor reset and ${outcome.dropped} queued record(s) dropped - re-reading from the start`,
|
|
579
|
+
)
|
|
580
|
+
} else if (outcome.dropped > 0) {
|
|
581
|
+
// No reset needed, but records from a previous numbering were still stuck in
|
|
582
|
+
// the queue (a crash between the cursor write and the drop). Say so.
|
|
583
|
+
log(`${outcome.dropped} queued record(s) from an earlier UID space dropped`)
|
|
584
|
+
}
|
|
585
|
+
log("imap ready; running catch-up scan")
|
|
586
|
+
void scanOnce("catch-up")
|
|
587
|
+
},
|
|
588
|
+
onMail: () => {
|
|
589
|
+
void scanOnce("idle")
|
|
590
|
+
},
|
|
591
|
+
onError: (error, delay) => log(`imap error (${error.message}); reconnecting in ${delay}ms`),
|
|
592
|
+
},
|
|
593
|
+
})
|
|
594
|
+
// Assign BEFORE starting: `start()` fires onReady, and the catch-up scan it
|
|
595
|
+
// triggers reads through sourceProxy. With the assignment after `await`, every
|
|
596
|
+
// catch-up scan failed with "imap watcher not started" and mail that arrived
|
|
597
|
+
// while the daemon was down was only picked up on the next IDLE event.
|
|
598
|
+
currentWatcher = watcher
|
|
599
|
+
await watcher.start()
|
|
600
|
+
return watcher
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
tokenProvider = await buildTokenProvider(initialConfig)
|
|
604
|
+
|
|
605
|
+
service = createDaemonService({
|
|
606
|
+
// The service must see the CURRENT config: /reload swaps it, and a stale recipient
|
|
607
|
+
// would keep mailing the previous address after a documented re-setup.
|
|
608
|
+
getConfig: () => currentConfig,
|
|
609
|
+
registry,
|
|
610
|
+
config: initialConfig,
|
|
611
|
+
token,
|
|
612
|
+
source: sourceProxy,
|
|
613
|
+
send: async (message) => {
|
|
614
|
+
const provider = tokenProvider
|
|
615
|
+
return sendWithCapture(
|
|
616
|
+
message,
|
|
617
|
+
(current) =>
|
|
618
|
+
sendMail(currentConfig, current, {
|
|
619
|
+
...(provider ? { getAccessToken: () => provider.getToken() } : {}),
|
|
620
|
+
// Lets a revoked grant be refreshed once instead of failing the notification.
|
|
621
|
+
...(provider ? { provider } : {}),
|
|
622
|
+
}),
|
|
623
|
+
{
|
|
624
|
+
sentFolder: currentConfig.mail.imap.sentFolder,
|
|
625
|
+
capture: (mintedId, subject, sessionId) =>
|
|
626
|
+
captureDeliveredMessageId({
|
|
627
|
+
config: currentConfig,
|
|
628
|
+
mintedId,
|
|
629
|
+
subject,
|
|
630
|
+
sessionId,
|
|
631
|
+
...(provider ? { getAccessToken: () => provider.getToken() } : {}),
|
|
632
|
+
}),
|
|
633
|
+
onMissed: () => log("could not read the sent copy back; the reply chain may not match this mail"),
|
|
634
|
+
},
|
|
635
|
+
)
|
|
636
|
+
},
|
|
637
|
+
onSent: ({ messageId, subject, sessionId }) => {
|
|
638
|
+
if (!messageId) return
|
|
639
|
+
// The sender knows the session; parsing it back out of the subject only works
|
|
640
|
+
// for legacy signed tokens, because production subjects carry a short route id.
|
|
641
|
+
// That bug silently disabled both threading and the reply-chain fallback.
|
|
642
|
+
const parsed = sessionId ? { sessionId, device: currentConfig.device.name } : tokenFromSubject(subject, ensureSecret())
|
|
643
|
+
if (!parsed) return
|
|
644
|
+
recordOutbound(process.env, {
|
|
645
|
+
messageId,
|
|
646
|
+
sessionId: parsed.sessionId,
|
|
647
|
+
...(parsed.device ? { device: parsed.device } : {}),
|
|
648
|
+
subject,
|
|
649
|
+
})
|
|
650
|
+
},
|
|
651
|
+
// Lets `/mailbridge restart` replace the process, not just its config, so an
|
|
652
|
+
// updated package actually takes effect.
|
|
653
|
+
shutdown: (reason?: string) => {
|
|
654
|
+
void shutdown(reason ?? "shutdown requested")
|
|
655
|
+
},
|
|
656
|
+
// Always carry the freshness stamp: on a quiet mailbox `lastScan` is absent, and the
|
|
657
|
+
// panel then said "(none yet)" while the daemon was happily polling.
|
|
658
|
+
scanSummary: () => ({ ...(lastScan ?? {}), ...(lastCheckedAt ? { checkedAt: lastCheckedAt } : {}) }),
|
|
659
|
+
version: packageVersion(import.meta.url),
|
|
660
|
+
apiVersion: API_VERSION,
|
|
661
|
+
log,
|
|
662
|
+
// The file's mtime, not a clock reading: comparing mtime to mtime has no clock coupling.
|
|
663
|
+
configLoadedAt: () => configLoadedAt,
|
|
664
|
+
imapHealthy: () => currentWatcher?.isHealthy() ?? false,
|
|
665
|
+
// Re-open the channel: process what arrived while mode was off, and retry anything a
|
|
666
|
+
// full queue refused. Safe to call when nothing changed (an empty scan is cheap).
|
|
667
|
+
onModeChange: (mode) => {
|
|
668
|
+
// Only "on" needs work; "off" simply stops the next scan from doing anything.
|
|
669
|
+
if (mode === "on" && currentWatcher) void scanOnce("mode-on")
|
|
670
|
+
},
|
|
671
|
+
idleTimeoutMs: (currentConfig.daemon.idleTimeoutSecs ?? 120) * 1000,
|
|
672
|
+
// `/mailbridge setup` / `auth` write new config or tokens; pick them up here.
|
|
673
|
+
// One reload at a time: two sessions noticing the same stale config must not each
|
|
674
|
+
// stop and redial the watcher.
|
|
675
|
+
reload: () => (reloadInFlight ??= doReload().finally(() => (reloadInFlight = undefined))),
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
service.server.on("error", (error: NodeJS.ErrnoException) => {
|
|
679
|
+
if (error.code === "EADDRINUSE") {
|
|
680
|
+
log(`another daemon already owns port ${currentConfig.daemon.port}; exiting`)
|
|
681
|
+
process.exit(0)
|
|
682
|
+
}
|
|
683
|
+
log(`http server failed: ${error.message}`)
|
|
684
|
+
process.exit(1)
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
// Refuse a state directory written by a newer build before touching it: a load-time throw
|
|
688
|
+
// inside a request or a scan would surface late and repeatedly.
|
|
689
|
+
try {
|
|
690
|
+
assertStateReadable(process.env)
|
|
691
|
+
} catch (error) {
|
|
692
|
+
log((error as Error).message)
|
|
693
|
+
process.exit(EXIT_CONFIG)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const lock = acquireDaemonLock(process.env)
|
|
697
|
+
if (!lock.ok) {
|
|
698
|
+
log(`another daemon is already running (pid ${lock.pid}); exiting`)
|
|
699
|
+
process.exit(0)
|
|
700
|
+
}
|
|
701
|
+
const { port, host } = await service.listen()
|
|
702
|
+
log(`listening on http://${host}:${port}`)
|
|
703
|
+
|
|
704
|
+
// Register shutdown handlers BEFORE dialling IMAP: a SIGTERM during a slow
|
|
705
|
+
// DNS/TLS dial must still shut down cleanly.
|
|
706
|
+
let stopping = false
|
|
707
|
+
const shutdown = async (signal: string) => {
|
|
708
|
+
if (stopping) return
|
|
709
|
+
stopping = true
|
|
710
|
+
log(`${signal}: shutting down`)
|
|
711
|
+
lock.release()
|
|
712
|
+
if (pollHandle !== undefined) clearTimeout(pollHandle)
|
|
713
|
+
await currentWatcher?.stop()
|
|
714
|
+
await service.close()
|
|
715
|
+
process.exit(0)
|
|
716
|
+
}
|
|
717
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
|
718
|
+
process.on("SIGINT", () => void shutdown("SIGINT"))
|
|
719
|
+
|
|
720
|
+
await startWatcher(currentConfig)
|
|
721
|
+
armPoll()
|
|
722
|
+
|
|
723
|
+
// Safety net: an IDLE that never fires (a provider that does not push, or a connection that
|
|
724
|
+
// silently stopped idling) would strand every reply until something else triggered a scan.
|
|
725
|
+
// A catch-up sweep costs one SEARCH that usually returns nothing. Observed live: a Hotmail
|
|
726
|
+
// reply sat unseen while the daemon reported "imap: connected".
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Send, then read the delivered Message-ID back.
|
|
731
|
+
*
|
|
732
|
+
* The order is the point: the provider appends the Sent copy asynchronously (QQ took 20-30 s
|
|
733
|
+
* in one measurement), and an earlier version that captured first recorded the *previous*
|
|
734
|
+
* notification's id against this session. Extracted so a test can hold that order.
|
|
735
|
+
*/
|
|
736
|
+
export async function sendWithCapture<M extends { messageId?: string; sessionId?: string; subject: string }, R>(
|
|
737
|
+
message: M,
|
|
738
|
+
send: (message: M) => Promise<R>,
|
|
739
|
+
deps: {
|
|
740
|
+
sentFolder?: string
|
|
741
|
+
capture: (mintedId: string, subject: string, sessionId: string) => Promise<string | undefined>
|
|
742
|
+
onMissed?: () => void
|
|
743
|
+
},
|
|
744
|
+
): Promise<R> {
|
|
745
|
+
const sent = await send(message)
|
|
746
|
+
if (message.sessionId && message.messageId && deps.sentFolder) {
|
|
747
|
+
// Fire-and-forget: the notification never fails because the bookkeeping could not be read.
|
|
748
|
+
void deps
|
|
749
|
+
.capture(message.messageId, message.subject, message.sessionId)
|
|
750
|
+
.then((id) => {
|
|
751
|
+
if (!id) deps.onMissed?.()
|
|
752
|
+
})
|
|
753
|
+
.catch(() => deps.onMissed?.())
|
|
754
|
+
}
|
|
755
|
+
return sent
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
export async function main(): Promise<void> {
|
|
759
|
+
const argv = process.argv.slice(2)
|
|
760
|
+
// Stamp before reading: a write landing between the read and a later Date.now() would
|
|
761
|
+
// otherwise be invisible to the freshness check.
|
|
762
|
+
const startupStamp = configStamp()
|
|
763
|
+
let config = loadConfig()
|
|
764
|
+
|
|
765
|
+
// A daemon spawned by a session is told the port that session probed, so it cannot bind a
|
|
766
|
+
// different one from its own env and leave the session reporting "unreachable".
|
|
767
|
+
const portArg = argValue(argv, "--port")
|
|
768
|
+
if (portArg !== undefined) {
|
|
769
|
+
const port = Number(portArg)
|
|
770
|
+
if (Number.isInteger(port) && port >= 1 && port <= 65_535) {
|
|
771
|
+
config = { ...config, daemon: { ...config.daemon, port } }
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (argv.includes("--print-token")) {
|
|
776
|
+
process.stdout.write(`${ensureSecret().toString("base64url")}\n`)
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
if (argv.includes("--check")) {
|
|
780
|
+
process.exitCode = await check(config)
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
if (argv.includes("--setup")) {
|
|
784
|
+
process.exitCode = await setup(argv)
|
|
785
|
+
return
|
|
786
|
+
}
|
|
787
|
+
if (argv.includes("--auth")) {
|
|
788
|
+
process.exitCode = await login(config)
|
|
789
|
+
return
|
|
790
|
+
}
|
|
791
|
+
if (argv.includes("--prime")) {
|
|
792
|
+
process.exitCode = await prime(config)
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
if (argv.includes("--smoke")) {
|
|
796
|
+
for (const warning of config.warnings) log(`warning: ${warning}`)
|
|
797
|
+
process.exitCode = await smoke(config)
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (!hasUsableMailConfig(config)) {
|
|
802
|
+
for (const warning of config.warnings) log(` ! ${warning}`)
|
|
803
|
+
log("mail config incomplete — run with --check for details")
|
|
804
|
+
process.exit(EXIT_CONFIG)
|
|
805
|
+
}
|
|
806
|
+
for (const warning of config.warnings) log(`warning: ${warning}`)
|
|
807
|
+
|
|
808
|
+
await run(config, startupStamp)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// `import.meta.url` is a URL and `process.argv[1]` is a path: comparing them as strings fails
|
|
812
|
+
// for any install directory with a space or non-ASCII character (and for a symlinked package),
|
|
813
|
+
// where this file would then run nothing and exit 0 - the unit looks started and is dead.
|
|
814
|
+
function isEntryPoint(): boolean {
|
|
815
|
+
const invoked = process.argv[1]
|
|
816
|
+
if (!invoked) return false
|
|
817
|
+
try {
|
|
818
|
+
return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url))
|
|
819
|
+
} catch (error) {
|
|
820
|
+
// A silent `false` here is a process that does nothing and exits 0 - under the unit's
|
|
821
|
+
// ExecStartPre that reads as success. Say why instead.
|
|
822
|
+
log(`cannot resolve the entry point (${(error as Error).message})`)
|
|
823
|
+
return false
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (isEntryPoint()) {
|
|
828
|
+
main().catch((error: unknown) => {
|
|
829
|
+
log(`fatal: ${error instanceof Error ? error.message : String(error)}`)
|
|
830
|
+
process.exit(1)
|
|
831
|
+
})
|
|
832
|
+
}
|