@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +836 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/bin/pi-mailbridge.mjs +31 -0
- package/config.example.json +54 -0
- package/dist/core/attachments.js +87 -0
- package/dist/core/authz.js +65 -0
- package/dist/core/commands.js +72 -0
- package/dist/core/config-freshness.js +24 -0
- package/dist/core/config.js +286 -0
- package/dist/core/deliveries.js +180 -0
- package/dist/core/discover.js +282 -0
- package/dist/core/header-encoding.js +70 -0
- package/dist/core/html.js +48 -0
- package/dist/core/inline-images.js +355 -0
- package/dist/core/mail-command.js +62 -0
- package/dist/core/mailer.js +126 -0
- package/dist/core/markdown.js +96 -0
- package/dist/core/oauth-device.js +119 -0
- package/dist/core/oauth-openid.js +48 -0
- package/dist/core/oauth.js +137 -0
- package/dist/core/prime.js +51 -0
- package/dist/core/reply-parse.js +134 -0
- package/dist/core/sent-index.js +135 -0
- package/dist/core/settle.js +122 -0
- package/dist/core/setup.js +100 -0
- package/dist/core/smoke.js +164 -0
- package/dist/core/status.js +56 -0
- package/dist/core/token-source.js +27 -0
- package/dist/core/token.js +196 -0
- package/dist/core/version.js +44 -0
- package/dist/daemon/api.js +46 -0
- package/dist/daemon/imapflow.js +75 -0
- package/dist/daemon/main.js +802 -0
- package/dist/daemon/process.js +270 -0
- package/dist/daemon/scan-queue.js +38 -0
- package/dist/daemon/script.js +44 -0
- package/dist/daemon/send-budget.js +30 -0
- package/dist/daemon/service.js +455 -0
- package/dist/daemon/source-proxy.js +64 -0
- package/dist/daemon/spawn.js +227 -0
- package/dist/daemon/watcher.js +308 -0
- package/dist/extension.js +1233 -0
- package/dist/store/journal.js +92 -0
- package/dist/store/mode-store.js +47 -0
- package/dist/store/oauth-store.js +50 -0
- package/dist/store/paths.js +33 -0
- package/dist/store/pending-store.js +212 -0
- package/dist/store/registry.js +61 -0
- package/dist/store/routes.js +223 -0
- package/dist/store/schema.js +93 -0
- package/dist/store/secret.js +70 -0
- package/dist/store/threads.js +145 -0
- package/dist/store/uid-cursor.js +112 -0
- package/docs/ARCHITECTURE.md +356 -0
- package/docs/MAPPING.md +201 -0
- package/docs/OAUTH.md +448 -0
- package/docs/SECURITY.md +273 -0
- package/docs/images/flow.svg +124 -0
- package/docs/images/notification.png +0 -0
- package/package.json +85 -4
- package/scripts/render-teaser.ts +56 -0
- package/src/core/attachments.ts +129 -0
- package/src/core/authz.ts +87 -0
- package/src/core/commands.ts +99 -0
- package/src/core/config-freshness.ts +33 -0
- package/src/core/config.ts +373 -0
- package/src/core/deliveries.ts +233 -0
- package/src/core/discover.ts +333 -0
- package/src/core/header-encoding.ts +75 -0
- package/src/core/html.ts +62 -0
- package/src/core/inline-images.ts +421 -0
- package/src/core/mail-command.ts +76 -0
- package/src/core/mailer.ts +193 -0
- package/src/core/markdown.ts +114 -0
- package/src/core/oauth-device.ts +195 -0
- package/src/core/oauth-openid.ts +61 -0
- package/src/core/oauth.ts +182 -0
- package/src/core/prime.ts +74 -0
- package/src/core/reply-parse.ts +153 -0
- package/src/core/sent-index.ts +164 -0
- package/src/core/settle.ts +179 -0
- package/src/core/setup.ts +139 -0
- package/src/core/smoke.ts +207 -0
- package/src/core/status.ts +75 -0
- package/src/core/token-source.ts +34 -0
- package/src/core/token.ts +240 -0
- package/src/core/version.ts +45 -0
- package/src/daemon/api.ts +52 -0
- package/src/daemon/imapflow.ts +101 -0
- package/src/daemon/main.ts +832 -0
- package/src/daemon/process.ts +348 -0
- package/src/daemon/scan-queue.ts +45 -0
- package/src/daemon/script.ts +45 -0
- package/src/daemon/send-budget.ts +36 -0
- package/src/daemon/service.ts +550 -0
- package/src/daemon/source-proxy.ts +88 -0
- package/src/daemon/spawn.ts +332 -0
- package/src/daemon/watcher.ts +427 -0
- package/src/extension.ts +1381 -0
- package/src/store/journal.ts +104 -0
- package/src/store/mode-store.ts +60 -0
- package/src/store/oauth-store.ts +55 -0
- package/src/store/paths.ts +38 -0
- package/src/store/pending-store.ts +282 -0
- package/src/store/registry.ts +91 -0
- package/src/store/routes.ts +267 -0
- package/src/store/schema.ts +104 -0
- package/src/store/secret.ts +69 -0
- package/src/store/threads.ts +177 -0
- package/src/store/uid-cursor.ts +135 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Daemon supervision from the extension side.
|
|
2
|
+
//
|
|
3
|
+
// The extension must never assume a daemon is running: a session can start on a
|
|
4
|
+
// freshly booted machine. `ensureDaemon` probes /health and, if nothing answers,
|
|
5
|
+
// spawns the daemon detached so it outlives this pi session.
|
|
6
|
+
//
|
|
7
|
+
// Detached, with output appended to daemon.log (not stdio:ignore: a silent daemon made the
|
|
8
|
+
// catch-up failures invisible). The daemon owns the mailbox and must outlive the
|
|
9
|
+
// process that started it so another session (or the phone) can still be served. It is not
|
|
10
|
+
// immortal: it exits once every session has stopped pinging.
|
|
11
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
12
|
+
import { chmodSync, closeSync, mkdirSync, openSync, renameSync, statSync } from "node:fs";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import { API_VERSION, apiCompatible } from "./api.js";
|
|
15
|
+
import { statePath } from "../store/paths.js";
|
|
16
|
+
/** True when a daemon answers /health. Never throws. */
|
|
17
|
+
export async function probeHealth(options) {
|
|
18
|
+
const health = await probeHealthDetail(options);
|
|
19
|
+
return health.ok;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* `/health`, with the protocol it speaks.
|
|
23
|
+
*
|
|
24
|
+
* `ok` means "something answered"; `apiVersion` says whether *this* build can use it. A daemon
|
|
25
|
+
* from another protocol is alive but unusable, and reusing it would make a replacement look
|
|
26
|
+
* successful while the mismatch stayed.
|
|
27
|
+
*/
|
|
28
|
+
export async function probeHealthDetail(options) {
|
|
29
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 750);
|
|
32
|
+
try {
|
|
33
|
+
const response = await doFetch(`${options.baseUrl}/health`, { signal: controller.signal });
|
|
34
|
+
if (!response.ok)
|
|
35
|
+
return { ok: false };
|
|
36
|
+
const body = (await response.json().catch(() => undefined));
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
...(typeof body?.apiVersion === "number" ? { apiVersion: body.apiVersion } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { ok: false };
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
+
/**
|
|
51
|
+
* Make sure a daemon is running.
|
|
52
|
+
*
|
|
53
|
+
* Reuses an existing daemon rather than racing it: `daemon.lock` in the state directory is the
|
|
54
|
+
* lock (the port is a second guard), and a
|
|
55
|
+
* second daemon exits 0 on EADDRINUSE, so the worst case is a wasted spawn rather
|
|
56
|
+
* than two watchers on one mailbox.
|
|
57
|
+
*/
|
|
58
|
+
export async function ensureDaemon(options) {
|
|
59
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
60
|
+
const found = await probeHealthDetail({ baseUrl: options.baseUrl, fetchImpl: doFetch });
|
|
61
|
+
if (found.ok) {
|
|
62
|
+
// Reuse only a daemon this build can talk to. An incompatible one is reported so the
|
|
63
|
+
// caller can replace it, instead of being adopted and failing on the first request.
|
|
64
|
+
if (apiCompatible(API_VERSION, found.apiVersion))
|
|
65
|
+
return { started: false, healthy: true };
|
|
66
|
+
return { started: false, healthy: false, incompatible: true, apiVersion: found.apiVersion };
|
|
67
|
+
}
|
|
68
|
+
const spawn = options.spawnImpl ?? nodeSpawn;
|
|
69
|
+
const execPath = options.execPath ?? process.execPath;
|
|
70
|
+
// The port this process probed, so the child cannot pick a different one from its own env -
|
|
71
|
+
// otherwise two daemons can end up on one state directory, which has no cross-process locks.
|
|
72
|
+
const probedPort = (() => {
|
|
73
|
+
try {
|
|
74
|
+
return new URL(options.baseUrl).port;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
// `--experimental-strip-types` only for the TypeScript entry: the published build runs
|
|
81
|
+
// `dist/daemon/main.js`, and Node refuses type stripping under `node_modules` anyway.
|
|
82
|
+
const args = [
|
|
83
|
+
...(options.scriptPath.endsWith(".ts") ? ["--experimental-strip-types"] : []),
|
|
84
|
+
options.scriptPath,
|
|
85
|
+
...(probedPort ? ["--port", probedPort] : []),
|
|
86
|
+
...(options.args ?? []),
|
|
87
|
+
];
|
|
88
|
+
// Detached with output ignored would make failures invisible: a catch-up scan could
|
|
89
|
+
// fail on every boot with nothing to show for it. Append to a log file instead.
|
|
90
|
+
const env = options.env ?? process.env;
|
|
91
|
+
const logPath = statePath(env, "daemon.log");
|
|
92
|
+
let logHandle;
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(dirname(logPath), { recursive: true, mode: 0o700 });
|
|
95
|
+
// One rotation at 5 MB: the log grows only when mail is seen, but it grows forever.
|
|
96
|
+
try {
|
|
97
|
+
if (statSync(logPath).size > 5 * 1024 * 1024)
|
|
98
|
+
renameSync(logPath, `${logPath}.1`);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* no log yet, or cannot rotate - appending is still better than nothing */
|
|
102
|
+
}
|
|
103
|
+
logHandle = openSync(logPath, "a", 0o600);
|
|
104
|
+
// An older build created it 0644; the log carries addresses and session names.
|
|
105
|
+
try {
|
|
106
|
+
chmodSync(logPath, 0o600);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* best effort */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// A read-only state dir must not stop the daemon from starting.
|
|
114
|
+
}
|
|
115
|
+
const child = spawn(execPath, args, {
|
|
116
|
+
detached: true,
|
|
117
|
+
stdio: logHandle === undefined ? "ignore" : ["ignore", logHandle, logHandle],
|
|
118
|
+
env,
|
|
119
|
+
});
|
|
120
|
+
// A fork failure (EAGAIN under fd pressure, a sandbox denying exec) arrives as an `error`
|
|
121
|
+
// event. Without a listener Node treats it as an uncaught exception in *pi's* process: the
|
|
122
|
+
// session dies while the daemon it was trying to start never existed.
|
|
123
|
+
let spawnError;
|
|
124
|
+
child.on?.("error", (error) => {
|
|
125
|
+
spawnError = error;
|
|
126
|
+
});
|
|
127
|
+
child.unref?.();
|
|
128
|
+
if (logHandle !== undefined) {
|
|
129
|
+
// The child holds its own copy of the descriptor.
|
|
130
|
+
try {
|
|
131
|
+
closeSync(logHandle);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* already closed */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
138
|
+
const deadline = Date.now() + (options.waitMs ?? 5_000);
|
|
139
|
+
while (Date.now() < deadline) {
|
|
140
|
+
if (spawnError)
|
|
141
|
+
return { started: true, healthy: false, error: spawnError.message };
|
|
142
|
+
await sleep(100);
|
|
143
|
+
if (await probeHealth({ baseUrl: options.baseUrl, fetchImpl: doFetch })) {
|
|
144
|
+
return { started: true, healthy: true };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { started: true, healthy: false };
|
|
148
|
+
}
|
|
149
|
+
/** Default loopback base URL for the configured port. */
|
|
150
|
+
export function daemonBaseUrl(port, host = "127.0.0.1") {
|
|
151
|
+
return `http://${host}:${port}`;
|
|
152
|
+
}
|
|
153
|
+
/** SMTP transactions with inlined images are slow; be generous rather than lose the mail. */
|
|
154
|
+
const SEND_TIMEOUT_MS = 120_000;
|
|
155
|
+
/** Reload tears down and re-dials IMAP, which can involve a token fetch. */
|
|
156
|
+
const RELOAD_TIMEOUT_MS = 120_000;
|
|
157
|
+
export function createDaemonClient(options) {
|
|
158
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
159
|
+
const deviceName = options.device ?? "";
|
|
160
|
+
const headers = { authorization: `Bearer ${options.token}`, "content-type": "application/json" };
|
|
161
|
+
async function call(method, path, body, timeoutMs = options.timeoutMs ?? 15_000) {
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
164
|
+
try {
|
|
165
|
+
const response = await doFetch(`${options.baseUrl}${path}`, {
|
|
166
|
+
method,
|
|
167
|
+
headers,
|
|
168
|
+
signal: controller.signal,
|
|
169
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
170
|
+
});
|
|
171
|
+
const text = await response.text();
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
let code;
|
|
174
|
+
try {
|
|
175
|
+
code = JSON.parse(text).error?.code;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* non-JSON error body */
|
|
179
|
+
}
|
|
180
|
+
return { ok: false, status: response.status, ...(code ? { code } : {}) };
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, data: (text ? JSON.parse(text) : {}) };
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
// A timeout is reported distinctly: the outcome is unknown, so callers must
|
|
186
|
+
// treat it like a network failure (retry later) rather than a definite "no".
|
|
187
|
+
if (error instanceof Error && error.name === "AbortError")
|
|
188
|
+
return { ok: false, status: 0, code: "timeout" };
|
|
189
|
+
return { ok: false, status: 0, code: "network" };
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
health: () => call("GET", "/health"),
|
|
197
|
+
pending: () => call("GET", `/pending?device=${encodeURIComponent(deviceName)}`),
|
|
198
|
+
mode: () => call("GET", "/mode"),
|
|
199
|
+
setMode: (mode) => call("POST", "/mode", { mode }),
|
|
200
|
+
claim: (uid, instanceId, uidValidity) => call("POST", "/claim", {
|
|
201
|
+
uid,
|
|
202
|
+
instanceId,
|
|
203
|
+
...(uidValidity === undefined ? {} : { uidValidity }),
|
|
204
|
+
}),
|
|
205
|
+
ack: (uid, instanceId, sessionId, uidValidity) => call("POST", "/ack", {
|
|
206
|
+
uid,
|
|
207
|
+
instanceId,
|
|
208
|
+
sessionId,
|
|
209
|
+
...(uidValidity === undefined ? {} : { uidValidity }),
|
|
210
|
+
}),
|
|
211
|
+
register: (sessionId) => call("POST", "/register", { sessionId }),
|
|
212
|
+
release: (sessionId) => call("DELETE", "/register", { sessionId }),
|
|
213
|
+
// A notification can carry a few hundred KB of inlined images and the daemon awaits
|
|
214
|
+
// the whole SMTP transaction, so a slow-but-healthy relay must not be cut off: the
|
|
215
|
+
// generic 15 s ceiling would drop the one mail this product exists to deliver.
|
|
216
|
+
send: (message) => call("POST", "/send", message, SEND_TIMEOUT_MS),
|
|
217
|
+
/** Re-read config and restart the watcher (after setup or auth). */
|
|
218
|
+
// Includes an IMAP logout, an XOAUTH2 token fetch and a fresh dial.
|
|
219
|
+
reload: () => call("POST", "/reload", undefined, RELOAD_TIMEOUT_MS),
|
|
220
|
+
/** Ask the daemon to exit, so a fresh process can load updated code. */
|
|
221
|
+
shutdown: () => call("POST", "/shutdown"),
|
|
222
|
+
/** Liveness heartbeat; the daemon exits when every client stops pinging. */
|
|
223
|
+
ping: (instanceId) => call("POST", "/ping", { instanceId }),
|
|
224
|
+
/** Tell the daemon this session is gone. */
|
|
225
|
+
bye: (instanceId) => call("POST", "/bye", { instanceId }),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// IMAP IDLE watcher — the read side. Port of opencode-afk `core/watcher.js`.
|
|
2
|
+
//
|
|
3
|
+
// One connection per machine, owned by the daemon, exposed to the scan pipeline
|
|
4
|
+
// as a `MailSource`. Two things make this testable without a socket:
|
|
5
|
+
// - the client comes from an injectable factory (`ImapClientFactory`)
|
|
6
|
+
// - body decoding comes from an injectable parser (`BodyParser`)
|
|
7
|
+
//
|
|
8
|
+
// Design notes carried over from the reference implementation:
|
|
9
|
+
// - IDLE renews well under the server's ~29 minute timeout
|
|
10
|
+
// - reconnect is exponential and bounded
|
|
11
|
+
// - credentials are never logged
|
|
12
|
+
// - errors are summarised from responseStatus/responseText only
|
|
13
|
+
import { simpleParser } from "mailparser";
|
|
14
|
+
import { decodeRfc2047 } from "../core/header-encoding.js";
|
|
15
|
+
export const DEFAULT_IDLE_RENEW_MS = 25 * 60 * 1000;
|
|
16
|
+
/**
|
|
17
|
+
* Hard ceiling on a message we will parse. The attachment caps (4 x 1 MB) live well inside
|
|
18
|
+
* it; anything larger is refused before the body is downloaded.
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
21
|
+
const DEFAULT_BACKOFF_INITIAL_MS = 1_000;
|
|
22
|
+
export const DEFAULT_BACKOFF_MAX_MS = 60_000;
|
|
23
|
+
/** Headers the authorization and routing layers need. */
|
|
24
|
+
export const WANTED_HEADERS = [
|
|
25
|
+
"authentication-results",
|
|
26
|
+
"auto-submitted",
|
|
27
|
+
"precedence",
|
|
28
|
+
"x-auto-response-suppress",
|
|
29
|
+
"list-id",
|
|
30
|
+
"from",
|
|
31
|
+
"subject",
|
|
32
|
+
// Our own Message-ID and marker, so a notification that comes back is recognised as an
|
|
33
|
+
// echo even if the id was never recorded (a send that raced the scan).
|
|
34
|
+
"message-id",
|
|
35
|
+
"x-pi-mailbridge",
|
|
36
|
+
// Reply-chain fallback: threading headers, so a reply reaches the session even
|
|
37
|
+
// when the subject token was rewritten by a client or a human.
|
|
38
|
+
"in-reply-to",
|
|
39
|
+
"references",
|
|
40
|
+
];
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Pure helpers (exported for tests)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/**
|
|
45
|
+
* IMAP UID range for "everything after `cursor`". UIDs are 32-bit and a server
|
|
46
|
+
* may assign 0, so `1:*` is the safe floor.
|
|
47
|
+
*/
|
|
48
|
+
export function searchRangeFor(cursor) {
|
|
49
|
+
return `${Math.max(1, cursor + 1)}:*`;
|
|
50
|
+
}
|
|
51
|
+
/** Bounded exponential backoff. */
|
|
52
|
+
export function nextBackoff(attempt, initial = DEFAULT_BACKOFF_INITIAL_MS, max = DEFAULT_BACKOFF_MAX_MS) {
|
|
53
|
+
const safeAttempt = Math.max(0, Math.min(attempt, 30));
|
|
54
|
+
return Math.min(max, initial * 2 ** safeAttempt);
|
|
55
|
+
}
|
|
56
|
+
export function extractHeaders(raw) {
|
|
57
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
|
58
|
+
// Headers end at the first blank line.
|
|
59
|
+
const end = text.search(/\r?\n\r?\n/);
|
|
60
|
+
const head = end === -1 ? text : text.slice(0, end);
|
|
61
|
+
const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
|
|
62
|
+
const headerValues = new Map();
|
|
63
|
+
for (const line of unfolded.split(/\r?\n/)) {
|
|
64
|
+
const colon = line.indexOf(":");
|
|
65
|
+
if (colon <= 0)
|
|
66
|
+
continue;
|
|
67
|
+
const name = line.slice(0, colon).trim().toLowerCase();
|
|
68
|
+
const value = line.slice(colon + 1).trim();
|
|
69
|
+
if (!name)
|
|
70
|
+
continue;
|
|
71
|
+
const existing = headerValues.get(name);
|
|
72
|
+
if (existing)
|
|
73
|
+
existing.push(value);
|
|
74
|
+
else
|
|
75
|
+
headerValues.set(name, [value]);
|
|
76
|
+
}
|
|
77
|
+
const pick = (name) => {
|
|
78
|
+
const values = headerValues.get(name);
|
|
79
|
+
if (!values)
|
|
80
|
+
return undefined;
|
|
81
|
+
// From/Subject are logically single-valued; servers may fold them, so join
|
|
82
|
+
// rather than silently using only the first instance. Then decode RFC 2047:
|
|
83
|
+
// a client that encodes a subject makes the routing token unreadable otherwise.
|
|
84
|
+
return decodeRfc2047(values.join(" "));
|
|
85
|
+
};
|
|
86
|
+
return { headerValues, from: pick("from"), subject: pick("subject") };
|
|
87
|
+
}
|
|
88
|
+
/** Map extracted headers onto the shape the authorization layer consumes. */
|
|
89
|
+
export function toHeaderLike(extracted) {
|
|
90
|
+
const one = (name) => extracted.headerValues.get(name)?.[0];
|
|
91
|
+
return {
|
|
92
|
+
from: extracted.from,
|
|
93
|
+
subject: extracted.subject,
|
|
94
|
+
authResults: extracted.headerValues.get("authentication-results"),
|
|
95
|
+
// message-id is what lets the scanner recognise our own notification coming back.
|
|
96
|
+
...(one("message-id") ? { messageId: one("message-id") } : {}),
|
|
97
|
+
...(one("x-pi-mailbridge") ? { piMailbridge: one("x-pi-mailbridge") } : {}),
|
|
98
|
+
...(one("in-reply-to") ? { inReplyTo: one("in-reply-to") } : {}),
|
|
99
|
+
...(extracted.headerValues.get("references") ? { references: extracted.headerValues.get("references") } : {}),
|
|
100
|
+
autoSubmitted: one("auto-submitted"),
|
|
101
|
+
precedence: one("precedence"),
|
|
102
|
+
autoResponseSuppress: one("x-auto-response-suppress"),
|
|
103
|
+
listId: one("list-id"),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export const defaultBodyParser = async (raw) => {
|
|
107
|
+
const parsed = await simpleParser(raw);
|
|
108
|
+
const attachments = (parsed.attachments ?? []).map((a) => ({
|
|
109
|
+
...(a.filename ? { filename: a.filename } : {}),
|
|
110
|
+
contentType: a.contentType ?? "application/octet-stream",
|
|
111
|
+
content: a.content,
|
|
112
|
+
}));
|
|
113
|
+
return {
|
|
114
|
+
text: parsed.text ?? "",
|
|
115
|
+
...(parsed.html ? { html: parsed.html } : {}),
|
|
116
|
+
attachments,
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
/** Coarse HTML → text: enough to keep the human's words, not a renderer. */
|
|
120
|
+
export function htmlToText(html) {
|
|
121
|
+
return html
|
|
122
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
|
|
123
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
124
|
+
.replace(/<\/(p|div|tr|li|h[1-6])>/gi, "\n")
|
|
125
|
+
.replace(/<[^>]+>/g, " ")
|
|
126
|
+
.replace(/ /gi, " ")
|
|
127
|
+
.replace(/&/gi, "&")
|
|
128
|
+
.replace(/</gi, "<")
|
|
129
|
+
.replace(/>/gi, ">")
|
|
130
|
+
.replace(/"/gi, '"')
|
|
131
|
+
.replace(/'/gi, "'")
|
|
132
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
133
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
134
|
+
.trim();
|
|
135
|
+
}
|
|
136
|
+
const defaultTimers = {
|
|
137
|
+
setTimeout: (handler, ms) => setTimeout(handler, ms),
|
|
138
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
139
|
+
};
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Watcher
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
/** imapflow reports these as bigint; normalise both to a safe number. */
|
|
144
|
+
function normaliseUid(raw) {
|
|
145
|
+
if (raw === undefined)
|
|
146
|
+
return undefined;
|
|
147
|
+
const value = typeof raw === "bigint" ? Number(raw) : raw;
|
|
148
|
+
return Number.isFinite(value) ? value : undefined;
|
|
149
|
+
}
|
|
150
|
+
export function createImapWatcher(config, options) {
|
|
151
|
+
const { factory, callbacks } = options;
|
|
152
|
+
const parser = options.parser ?? defaultBodyParser;
|
|
153
|
+
const timers = options.timers ?? defaultTimers;
|
|
154
|
+
const idleRenewMs = config.tuning.idleRenewMs ?? DEFAULT_IDLE_RENEW_MS;
|
|
155
|
+
let client;
|
|
156
|
+
let reconnectHandle;
|
|
157
|
+
let attempt = 0;
|
|
158
|
+
let stopped = false;
|
|
159
|
+
let connecting = false;
|
|
160
|
+
async function teardown(target) {
|
|
161
|
+
// Take the connection explicitly when the caller still holds it: stop() may already
|
|
162
|
+
// have nulled `client` (and logged out nothing, because the dial had not finished),
|
|
163
|
+
// and tearing down `client` again would silently do nothing.
|
|
164
|
+
const c = target ?? client;
|
|
165
|
+
// Clear it either way: after a stop during the dial, `client` was assigned the very
|
|
166
|
+
// connection we are about to close, and leaving it set lets search/fetch reach a
|
|
167
|
+
// logged-out socket instead of failing fast.
|
|
168
|
+
if (client === c)
|
|
169
|
+
client = undefined;
|
|
170
|
+
if (!c)
|
|
171
|
+
return;
|
|
172
|
+
try {
|
|
173
|
+
await c.logout();
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// A dead connection cannot log out; that is expected during reconnect.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function scheduleReconnect(error) {
|
|
180
|
+
// A dying connection emits both "close" and "error", and connectOnce's catch adds a
|
|
181
|
+
// third path. Without this guard each one arms its own timer, so the watcher opens
|
|
182
|
+
// several connections and leaks all but the last.
|
|
183
|
+
if (stopped || reconnectHandle !== undefined || connecting)
|
|
184
|
+
return;
|
|
185
|
+
const delay = nextBackoff(attempt, config.tuning.backoffInitialMs, config.tuning.backoffMaxMs);
|
|
186
|
+
attempt++;
|
|
187
|
+
callbacks.onError?.(error, delay);
|
|
188
|
+
reconnectHandle = timers.setTimeout(() => {
|
|
189
|
+
reconnectHandle = undefined;
|
|
190
|
+
void connectOnce();
|
|
191
|
+
}, delay);
|
|
192
|
+
}
|
|
193
|
+
async function connectOnce() {
|
|
194
|
+
if (stopped || connecting)
|
|
195
|
+
return;
|
|
196
|
+
connecting = true;
|
|
197
|
+
try {
|
|
198
|
+
const c = await factory(config, { idleRenewMs });
|
|
199
|
+
client = c;
|
|
200
|
+
await c.connect();
|
|
201
|
+
await c.mailboxOpen(config.mail.imap.folder || "INBOX");
|
|
202
|
+
// stop() may have run while those were awaited; the entry check cannot see that.
|
|
203
|
+
// Without this, the connection stays open with handlers attached and nothing
|
|
204
|
+
// will ever tear it down (isHealthy() would meanwhile report false).
|
|
205
|
+
if (stopped) {
|
|
206
|
+
await teardown(c);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
attempt = 0;
|
|
210
|
+
c.on("exists", () => callbacks.onMail());
|
|
211
|
+
c.on("close", () => {
|
|
212
|
+
if (stopped)
|
|
213
|
+
return;
|
|
214
|
+
scheduleReconnect(new Error("imap connection closed"));
|
|
215
|
+
});
|
|
216
|
+
c.on("error", (error) => scheduleReconnect(error instanceof Error ? error : new Error(String(error))));
|
|
217
|
+
callbacks.onReady();
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
await teardown();
|
|
221
|
+
// Clear the in-flight flag first: scheduleReconnect refuses while a connect is
|
|
222
|
+
// pending, and this catch is one of the legs that must be able to arm it.
|
|
223
|
+
connecting = false;
|
|
224
|
+
scheduleReconnect(error instanceof Error ? error : new Error(String(error)));
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
connecting = false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
async start() {
|
|
232
|
+
stopped = false;
|
|
233
|
+
await connectOnce();
|
|
234
|
+
},
|
|
235
|
+
async stop() {
|
|
236
|
+
stopped = true;
|
|
237
|
+
if (reconnectHandle !== undefined) {
|
|
238
|
+
timers.clearTimeout(reconnectHandle);
|
|
239
|
+
reconnectHandle = undefined;
|
|
240
|
+
}
|
|
241
|
+
await teardown();
|
|
242
|
+
},
|
|
243
|
+
/** The open mailbox's UIDVALIDITY, when the client exposes one. */
|
|
244
|
+
uidValidity() {
|
|
245
|
+
return normaliseUid(client?.mailbox?.uidValidity);
|
|
246
|
+
},
|
|
247
|
+
/** The open mailbox's next UID, when the client exposes one. */
|
|
248
|
+
uidNext() {
|
|
249
|
+
return normaliseUid(client?.mailbox?.uidNext);
|
|
250
|
+
},
|
|
251
|
+
isHealthy() {
|
|
252
|
+
return !stopped && client !== undefined && client.usable !== false;
|
|
253
|
+
},
|
|
254
|
+
async searchAfter(afterUid) {
|
|
255
|
+
const c = client;
|
|
256
|
+
if (!c)
|
|
257
|
+
throw new Error("imap watcher not connected");
|
|
258
|
+
const uids = await c.search({ uid: searchRangeFor(afterUid) }, { uid: true });
|
|
259
|
+
return uids.filter((n) => Number.isFinite(n) && n > afterUid).sort((a, b) => a - b);
|
|
260
|
+
},
|
|
261
|
+
async fetch(uid) {
|
|
262
|
+
const c = client;
|
|
263
|
+
if (!c)
|
|
264
|
+
throw new Error("imap watcher not connected");
|
|
265
|
+
// Ask for the size first: the full source is downloaded before authorization, so
|
|
266
|
+
// an oversized message from any sender - spam, a 200 MB newsletter - would cost the
|
|
267
|
+
// one shared daemon a full download and parse. `size` is cheap and needs no body.
|
|
268
|
+
const meta = (await c.fetchOne(uid, { size: true, envelope: true }, { uid: true }));
|
|
269
|
+
if (meta && typeof meta.size === "number" && meta.size > MAX_MESSAGE_BYTES) {
|
|
270
|
+
return {
|
|
271
|
+
uid,
|
|
272
|
+
from: meta.envelope?.from?.[0]?.address,
|
|
273
|
+
subject: meta.envelope?.subject,
|
|
274
|
+
text: "",
|
|
275
|
+
headers: { subject: meta.envelope?.subject },
|
|
276
|
+
receivedAt: Date.now(),
|
|
277
|
+
tooLarge: meta.size,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const fetched = (await c.fetchOne(uid, { source: true, envelope: true, headers: [...WANTED_HEADERS] }, { uid: true }));
|
|
281
|
+
if (!fetched || !fetched.source)
|
|
282
|
+
return undefined;
|
|
283
|
+
const extracted = extractHeaders(fetched.source);
|
|
284
|
+
const parsed = await parser(fetched.source);
|
|
285
|
+
// A body with no text content falls back to the HTML part, tag-stripped. Testing for
|
|
286
|
+
// content, not for undefined: mailparser yields "" for a text/html-only multipart.
|
|
287
|
+
const bodyText = parsed.text?.trim() ? parsed.text : parsed.html ? htmlToText(parsed.html) : "";
|
|
288
|
+
const from = extracted.from ?? fetched.envelope?.from?.[0]?.address;
|
|
289
|
+
const subject = extracted.subject ?? fetched.envelope?.subject;
|
|
290
|
+
return {
|
|
291
|
+
uid,
|
|
292
|
+
from,
|
|
293
|
+
subject,
|
|
294
|
+
text: bodyText,
|
|
295
|
+
...(parsed.attachments?.length ? { attachments: parsed.attachments } : {}),
|
|
296
|
+
headers: toHeaderLike(extracted),
|
|
297
|
+
receivedAt: Date.now(),
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
/** Mailbox hygiene, invoked by the daemon's /ack handler only. */
|
|
301
|
+
async markSeen(uid) {
|
|
302
|
+
const c = client;
|
|
303
|
+
if (!c?.messageFlagsAdd)
|
|
304
|
+
return;
|
|
305
|
+
await c.messageFlagsAdd(uid, ["\\Seen"], { uid: true });
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|