@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,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mail": {
|
|
3
|
+
"imap": {
|
|
4
|
+
"host": "imap.example.com",
|
|
5
|
+
"port": 993,
|
|
6
|
+
"secure": true,
|
|
7
|
+
"user": "pi@example.com",
|
|
8
|
+
"password": "<PASSWORD>",
|
|
9
|
+
"folder": "INBOX",
|
|
10
|
+
"authType": "LOGIN",
|
|
11
|
+
"tokenCommand": [],
|
|
12
|
+
"sentFolder": ""
|
|
13
|
+
},
|
|
14
|
+
"smtp": {
|
|
15
|
+
"host": "smtp.example.com",
|
|
16
|
+
"port": 465,
|
|
17
|
+
"secure": true,
|
|
18
|
+
"user": "pi@example.com",
|
|
19
|
+
"password": "<PASSWORD>",
|
|
20
|
+
"authType": "LOGIN",
|
|
21
|
+
"tokenCommand": []
|
|
22
|
+
},
|
|
23
|
+
"to": "you@example.com"
|
|
24
|
+
},
|
|
25
|
+
"device": {
|
|
26
|
+
"name": ""
|
|
27
|
+
},
|
|
28
|
+
"security": {
|
|
29
|
+
"allowFrom": [
|
|
30
|
+
"you@example.com"
|
|
31
|
+
],
|
|
32
|
+
"requireDmarcPass": true,
|
|
33
|
+
"allowHeaderThreading": true,
|
|
34
|
+
"allowSelfReplies": false,
|
|
35
|
+
"tokenTtlHours": 720
|
|
36
|
+
},
|
|
37
|
+
"daemon": {
|
|
38
|
+
"host": "127.0.0.1",
|
|
39
|
+
"port": 4177,
|
|
40
|
+
"idleTimeoutSecs": 120,
|
|
41
|
+
"scanIntervalSecs": 60
|
|
42
|
+
},
|
|
43
|
+
"notify": {
|
|
44
|
+
"onSettled": true,
|
|
45
|
+
"debounceMs": 10000,
|
|
46
|
+
"notifyOnAborted": false,
|
|
47
|
+
"maxAttachmentMb": 18
|
|
48
|
+
},
|
|
49
|
+
"tuning": {
|
|
50
|
+
"idleRenewMs": 1500000,
|
|
51
|
+
"backoffInitialMs": 1000,
|
|
52
|
+
"backoffMaxMs": 60000
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Inbound attachments.
|
|
2
|
+
//
|
|
3
|
+
// The point: reply to a pi notification from your phone with an image or a log
|
|
4
|
+
// and have the agent actually see it.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately restrictive. An attachment that reaches the model is untrusted
|
|
7
|
+
// input from a mail server, so:
|
|
8
|
+
// - MIME allowlist, not a denylist
|
|
9
|
+
// - hard caps on count and total bytes, enforced before anything is stored
|
|
10
|
+
// - anything dropped is reported with a reason rather than silently ignored
|
|
11
|
+
/** Images the model can actually consume. */
|
|
12
|
+
export const ALLOWED_IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
13
|
+
/** Text-like attachments we inline as text. */
|
|
14
|
+
export const ALLOWED_TEXT_MIMES = ["text/plain", "text/markdown", "application/json"];
|
|
15
|
+
export const MAX_ATTACHMENTS = 4;
|
|
16
|
+
export const MAX_TOTAL_BYTES = 1_000_000;
|
|
17
|
+
function isImage(mime) {
|
|
18
|
+
return ALLOWED_IMAGE_MIMES.includes(mime);
|
|
19
|
+
}
|
|
20
|
+
function isText(mime) {
|
|
21
|
+
return ALLOWED_TEXT_MIMES.includes(mime);
|
|
22
|
+
}
|
|
23
|
+
/** Lowercase, strip parameters (`text/plain; charset=utf-8` → `text/plain`). */
|
|
24
|
+
export function normalizeMime(contentType) {
|
|
25
|
+
return (contentType ?? "").split(";")[0].trim().toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Apply the allowlist and the caps.
|
|
29
|
+
* Order matters: mime first (cheap), then count, then size.
|
|
30
|
+
*/
|
|
31
|
+
export function selectAttachments(raw, limits = {}) {
|
|
32
|
+
const maxCount = limits.maxCount ?? MAX_ATTACHMENTS;
|
|
33
|
+
const maxTotalBytes = limits.maxTotalBytes ?? MAX_TOTAL_BYTES;
|
|
34
|
+
const accepted = [];
|
|
35
|
+
const dropped = [];
|
|
36
|
+
let total = 0;
|
|
37
|
+
for (const item of raw) {
|
|
38
|
+
const mimeType = normalizeMime(item.contentType);
|
|
39
|
+
const name = (item.filename ?? "").trim() || `attachment-${accepted.length + dropped.length + 1}`;
|
|
40
|
+
if (!isImage(mimeType) && !isText(mimeType)) {
|
|
41
|
+
dropped.push({ name, reason: "mime-not-allowed" });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (accepted.length >= maxCount) {
|
|
45
|
+
dropped.push({ name, reason: "too-many" });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (item.content.length === 0) {
|
|
49
|
+
dropped.push({ name, reason: "empty" });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (total + item.content.length > maxTotalBytes) {
|
|
53
|
+
dropped.push({ name, reason: "too-large" });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
total += item.content.length;
|
|
57
|
+
accepted.push({
|
|
58
|
+
name,
|
|
59
|
+
mimeType,
|
|
60
|
+
size: item.content.length,
|
|
61
|
+
data: item.content.toString("base64"),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { accepted, dropped };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Build the payload for `pi.sendUserMessage`.
|
|
68
|
+
*
|
|
69
|
+
* Returns a plain string when there is nothing to attach: that keeps the common
|
|
70
|
+
* case identical to a typed message, so no client special-cases it.
|
|
71
|
+
*/
|
|
72
|
+
export function toUserContent(text, attachments = []) {
|
|
73
|
+
if (attachments.length === 0)
|
|
74
|
+
return text;
|
|
75
|
+
// An attachment-only reply has no prose; an empty text block ahead of the image is noise
|
|
76
|
+
// the model has to read past.
|
|
77
|
+
const content = text ? [{ type: "text", text }] : [];
|
|
78
|
+
for (const attachment of attachments) {
|
|
79
|
+
if (isImage(attachment.mimeType)) {
|
|
80
|
+
content.push({ type: "image", data: attachment.data, mimeType: attachment.mimeType });
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
content.push({ type: "text", text: Buffer.from(attachment.data, "base64").toString("utf8") });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return content;
|
|
87
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Authorization for inbound mail.
|
|
2
|
+
//
|
|
3
|
+
// Order matters and is fail-closed at every step:
|
|
4
|
+
// 1. loop guard — never act on our own outbound address
|
|
5
|
+
// 2. auto-generated — no replies to robots (that is how loops start)
|
|
6
|
+
// 3. allowlist — exact addr-spec after RFC 5322-ish normalization
|
|
7
|
+
// 4. DMARC — dmarc=pass; authserv-id pinned only when configured
|
|
8
|
+
//
|
|
9
|
+
// The routing token is NOT consulted here. It routes; it does not authorize.
|
|
10
|
+
// See docs/SECURITY.md §2.2.
|
|
11
|
+
import { dmarcPasses, findTrustedAuthResults, isAutoGenerated, normalizeAddress } from "./reply-parse.js";
|
|
12
|
+
export function authorize(input, config) {
|
|
13
|
+
const from = normalizeAddress(input.from);
|
|
14
|
+
if (!from)
|
|
15
|
+
return { ok: false, reason: "no-from" };
|
|
16
|
+
// 1. Loop guard: the pi account must never be treated as the human.
|
|
17
|
+
// `mail.to` is the HUMAN's address, so it must not be used here — doing so
|
|
18
|
+
// rejected every genuine reply (the human is the one writing to us).
|
|
19
|
+
//
|
|
20
|
+
// Single-mailbox mode (`security.allowSelfReplies`): one address is both the
|
|
21
|
+
// mailbox and the human, which is the normal case for a personal Hotmail or
|
|
22
|
+
// Gmail. Then `from === self` is expected — but we still must not treat our
|
|
23
|
+
// OWN outbound notification as a reply. The discriminator is `In-Reply-To`:
|
|
24
|
+
// a reply carries it - but so does every threaded notification we send, so this
|
|
25
|
+
// header alone is not enough. process.ts rejects our own Message-IDs first, using
|
|
26
|
+
// the outbound registry; this check remains as the cheap early gate.
|
|
27
|
+
const self = normalizeAddress(config.mail.smtp.user) ?? normalizeAddress(config.mail.imap.user);
|
|
28
|
+
if (self && from === self) {
|
|
29
|
+
if (!config.security.allowSelfReplies)
|
|
30
|
+
return { ok: false, reason: "self" };
|
|
31
|
+
// Reply evidence is either header the router accepts. Requiring only In-Reply-To
|
|
32
|
+
// killed genuine replies from clients that set References alone (or a
|
|
33
|
+
// forward-then-reply), because the router would have routed them by chain.
|
|
34
|
+
const threaded = Boolean(input.headers.inReplyTo?.trim()) || Boolean(input.headers.references?.some((r) => r.trim()));
|
|
35
|
+
// Distinct from the self-echo guards (marker header, outbound Message-ID): this is a
|
|
36
|
+
// message from your own address with no reply evidence at all. The two are different
|
|
37
|
+
// problems for the user, so they get different names.
|
|
38
|
+
if (!threaded)
|
|
39
|
+
return { ok: false, reason: "no-reply-evidence" };
|
|
40
|
+
}
|
|
41
|
+
// 2. Never answer robots, and never act on a bounce: a null sender or a postmaster
|
|
42
|
+
// address is a delivery failure report, not a human.
|
|
43
|
+
if (!from.includes("@") || /^(mailer-daemon|postmaster|mail-daemon)$/i.test(from.split("@")[0] ?? "")) {
|
|
44
|
+
return { ok: false, reason: "auto-generated" };
|
|
45
|
+
}
|
|
46
|
+
if (isAutoGenerated(input.headers))
|
|
47
|
+
return { ok: false, reason: "auto-generated" };
|
|
48
|
+
// 3. Allowlist. An empty list denies everything.
|
|
49
|
+
const allow = config.security.allowFrom.map((a) => normalizeAddress(a)).filter((a) => Boolean(a));
|
|
50
|
+
if (allow.length === 0)
|
|
51
|
+
return { ok: false, reason: "allowlist-empty" };
|
|
52
|
+
if (!allow.includes(from))
|
|
53
|
+
return { ok: false, reason: "not-allowlisted" };
|
|
54
|
+
// 4. DMARC pinned to our relay.
|
|
55
|
+
if (config.security.requireDmarcPass) {
|
|
56
|
+
// Unset means "trust the topmost header, i.e. the delivering relay's".
|
|
57
|
+
// Pinned means "only that authserv-id".
|
|
58
|
+
const ar = findTrustedAuthResults(input.headers, config.security.trustedAuthservId);
|
|
59
|
+
if (!ar)
|
|
60
|
+
return { ok: false, reason: "dmarc-missing" };
|
|
61
|
+
if (!dmarcPasses(ar))
|
|
62
|
+
return { ok: false, reason: "dmarc-failed" };
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, from };
|
|
65
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `/mailbridge` argument routing.
|
|
2
|
+
//
|
|
3
|
+
// Pure, so the command surface is testable without pi. Keeping the parse separate
|
|
4
|
+
// from the handlers is also what lets the same subcommand set back both the in-pi
|
|
5
|
+
// command and (where it makes sense) the headless entry point.
|
|
6
|
+
export const USAGE = [
|
|
7
|
+
"/mailbridge status",
|
|
8
|
+
"/mailbridge on email mode on: decisions mail you",
|
|
9
|
+
"/mailbridge off email mode off: decisions ask in this terminal",
|
|
10
|
+
"/mailbridge setup <pi-address> [to <your-address>] [force]",
|
|
11
|
+
"/mailbridge check validate config, and that credentials are present",
|
|
12
|
+
"/mailbridge auth approve OAuth2 device login",
|
|
13
|
+
"/mailbridge smoke send one real mail, then read the folder back",
|
|
14
|
+
"/mailbridge prime start watching from now, ignoring existing mail",
|
|
15
|
+
"/mailbridge restart replace the daemon process (picks up new code, not just config)",
|
|
16
|
+
"/mailbridge help this list",
|
|
17
|
+
].join("\n");
|
|
18
|
+
const SUBCOMMANDS = [
|
|
19
|
+
"on",
|
|
20
|
+
"off",
|
|
21
|
+
"status",
|
|
22
|
+
"setup",
|
|
23
|
+
"check",
|
|
24
|
+
"auth",
|
|
25
|
+
"smoke",
|
|
26
|
+
"prime",
|
|
27
|
+
"restart",
|
|
28
|
+
"help",
|
|
29
|
+
];
|
|
30
|
+
export function isSubcommand(value) {
|
|
31
|
+
return SUBCOMMANDS.includes(value);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse the text after `/mailbridge`.
|
|
35
|
+
*
|
|
36
|
+
* Accepts both `--to` and bare `to` because a TUI prompt is not a shell and people
|
|
37
|
+
* type either. Unknown words that are not addresses are ignored rather than
|
|
38
|
+
* treated as errors, so a stray flag cannot silently change the target address.
|
|
39
|
+
*/
|
|
40
|
+
export function parseMailbridgeCommand(args) {
|
|
41
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
42
|
+
const first = tokens[0]?.toLowerCase();
|
|
43
|
+
if (!first || !isSubcommand(first)) {
|
|
44
|
+
return { sub: first ? "help" : "status", force: false };
|
|
45
|
+
}
|
|
46
|
+
const parsed = { sub: first, force: false };
|
|
47
|
+
if (first !== "setup")
|
|
48
|
+
return parsed;
|
|
49
|
+
const rest = tokens.slice(1);
|
|
50
|
+
const addresses = [];
|
|
51
|
+
for (let i = 0; i < rest.length; i++) {
|
|
52
|
+
const token = rest[i];
|
|
53
|
+
const lower = token.toLowerCase();
|
|
54
|
+
if (lower === "force" || lower === "--force") {
|
|
55
|
+
parsed.force = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (lower === "to" || lower === "--to") {
|
|
59
|
+
const next = rest[i + 1];
|
|
60
|
+
if (next && !next.startsWith("-") && next.toLowerCase() !== "force") {
|
|
61
|
+
parsed.to = next;
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (token.includes("@"))
|
|
67
|
+
addresses.push(token);
|
|
68
|
+
}
|
|
69
|
+
if (addresses[0])
|
|
70
|
+
parsed.address = addresses[0];
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Should a session tell the running daemon to re-read its config?
|
|
2
|
+
//
|
|
3
|
+
// A session that attaches to an already-running daemon inherits whatever config that daemon
|
|
4
|
+
// read - which may predate a hand-edited config.json or a headless `--setup`. Sending mail
|
|
5
|
+
// with a replaced address is the failure this exists to prevent.
|
|
6
|
+
//
|
|
7
|
+
// The daemon reports the *mtime of the file it is running on*, and the extension compares it
|
|
8
|
+
// with the file's current mtime. mtime-to-mtime deliberately: a clock reading would make a
|
|
9
|
+
// preserved/backdated mtime reload forever, and would hide a write that landed between the
|
|
10
|
+
// read and the timestamp.
|
|
11
|
+
export function shouldReloadConfig({ configLoadedAt, configMtimeMs }) {
|
|
12
|
+
// No file: nothing changed, leave the daemon alone.
|
|
13
|
+
if (!configMtimeMs)
|
|
14
|
+
return false;
|
|
15
|
+
// The daemon reports 0 when it loaded while there was no config file. That is not
|
|
16
|
+
// "nothing to compare" - it is a daemon running on defaults, and a file that has since
|
|
17
|
+
// appeared must be picked up. Treating 0 as absent made this state permanent.
|
|
18
|
+
if (!configLoadedAt)
|
|
19
|
+
return true;
|
|
20
|
+
// Strictly newer. No slack: a re-trigger is impossible when both sides are the same
|
|
21
|
+
// file's mtime, and a tolerance would permanently miss a save that lands within it
|
|
22
|
+
// (an editor that writes twice), which is the failure this check exists to prevent.
|
|
23
|
+
return configMtimeMs > configLoadedAt;
|
|
24
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { configPath } from "../store/paths.js";
|
|
6
|
+
/** Marks a warning produced by repairing a malformed section: the config still works. */
|
|
7
|
+
export const REPAIRED_PREFIX = "repaired: ";
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
mail: {
|
|
10
|
+
imap: { host: "", port: 993, secure: true, user: "", password: "", folder: "INBOX" },
|
|
11
|
+
smtp: { host: "", port: 465, secure: true, user: "", password: "", authType: "LOGIN" },
|
|
12
|
+
to: "",
|
|
13
|
+
},
|
|
14
|
+
device: { name: "" }, // resolved below: hostname() unless configured
|
|
15
|
+
security: {
|
|
16
|
+
allowFrom: [],
|
|
17
|
+
requireDmarcPass: true,
|
|
18
|
+
allowHeaderThreading: true,
|
|
19
|
+
allowSelfReplies: false,
|
|
20
|
+
tokenTtlHours: 30 * 24,
|
|
21
|
+
},
|
|
22
|
+
daemon: { host: "127.0.0.1", port: 4177, idleTimeoutSecs: 120, scanIntervalSecs: 60 },
|
|
23
|
+
notify: {
|
|
24
|
+
onSettled: true,
|
|
25
|
+
debounceMs: 10_000,
|
|
26
|
+
notifyOnAborted: false,
|
|
27
|
+
// 18 MB raw is about 24 MB once base64-encoded, under the 25 MB ceiling that Gmail and
|
|
28
|
+
// Outlook.com enforce. A provider that allows more (QQ: 50 MB) can raise it.
|
|
29
|
+
maxAttachmentMb: 18,
|
|
30
|
+
},
|
|
31
|
+
tuning: {
|
|
32
|
+
idleRenewMs: 25 * 60 * 1000,
|
|
33
|
+
backoffInitialMs: 1000,
|
|
34
|
+
backoffMaxMs: 60_000,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
function isRecord(v) {
|
|
38
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
39
|
+
}
|
|
40
|
+
/** First label of the hostname, lowercased: `dev-box-7.local` -> `dev-box-7`. */
|
|
41
|
+
export function shortHostname() {
|
|
42
|
+
try {
|
|
43
|
+
const full = hostname().toLowerCase();
|
|
44
|
+
return full.split(".")[0] || "unknown-device";
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return "unknown-device";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Parse a token command from env: JSON array, or comma-separated argv. */
|
|
51
|
+
function parseArgv(raw) {
|
|
52
|
+
const trimmed = raw.trim();
|
|
53
|
+
if (trimmed.startsWith("[")) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(trimmed);
|
|
56
|
+
if (Array.isArray(parsed))
|
|
57
|
+
return parsed.map(String);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* fall through to comma splitting */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return trimmed
|
|
64
|
+
.split(",")
|
|
65
|
+
.map((s) => s.trim())
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
/** Shallow-by-section merge: enough for this config shape, and predictable. */
|
|
69
|
+
function merge(base, over) {
|
|
70
|
+
if (!isRecord(base) || !isRecord(over))
|
|
71
|
+
return over ?? base;
|
|
72
|
+
const out = { ...base };
|
|
73
|
+
for (const [k, v] of Object.entries(over)) {
|
|
74
|
+
out[k] = isRecord(v) && isRecord(base[k]) ? merge(base[k], v) : v;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
function readJson(path, warnings) {
|
|
79
|
+
if (!existsSync(path))
|
|
80
|
+
return {};
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
83
|
+
return isRecord(parsed) ? parsed : {};
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
warnings.push(`${path}: ${error.message}`);
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function envOverrides(env) {
|
|
91
|
+
const o = {};
|
|
92
|
+
const set = (path, value) => {
|
|
93
|
+
if (value === undefined)
|
|
94
|
+
return;
|
|
95
|
+
let cur = o;
|
|
96
|
+
for (const key of path.slice(0, -1)) {
|
|
97
|
+
cur[key] ??= {};
|
|
98
|
+
cur = cur[key];
|
|
99
|
+
}
|
|
100
|
+
cur[path[path.length - 1]] = value;
|
|
101
|
+
};
|
|
102
|
+
set(["mail", "imap", "host"], env.PI_MAILBRIDGE_IMAP_HOST);
|
|
103
|
+
set(["mail", "imap", "user"], env.PI_MAILBRIDGE_IMAP_USER);
|
|
104
|
+
set(["mail", "imap", "password"], env.PI_MAILBRIDGE_IMAP_PASS);
|
|
105
|
+
set(["mail", "smtp", "host"], env.PI_MAILBRIDGE_SMTP_HOST);
|
|
106
|
+
set(["mail", "smtp", "user"], env.PI_MAILBRIDGE_SMTP_USER);
|
|
107
|
+
set(["mail", "smtp", "password"], env.PI_MAILBRIDGE_SMTP_PASS);
|
|
108
|
+
set(["mail", "to"], env.PI_MAILBRIDGE_TO);
|
|
109
|
+
set(["daemon", "port"], env.PI_MAILBRIDGE_PORT);
|
|
110
|
+
set(["device", "name"], env.PI_MAILBRIDGE_DEVICE);
|
|
111
|
+
// Token commands are argv lists; accept JSON or a comma-separated argv.
|
|
112
|
+
if (env.PI_MAILBRIDGE_IMAP_TOKEN_CMD)
|
|
113
|
+
set(["mail", "imap", "tokenCommand"], parseArgv(env.PI_MAILBRIDGE_IMAP_TOKEN_CMD));
|
|
114
|
+
if (env.PI_MAILBRIDGE_SMTP_TOKEN_CMD)
|
|
115
|
+
set(["mail", "smtp", "tokenCommand"], parseArgv(env.PI_MAILBRIDGE_SMTP_TOKEN_CMD));
|
|
116
|
+
return o;
|
|
117
|
+
}
|
|
118
|
+
export function loadConfig(env = process.env) {
|
|
119
|
+
const warnings = [];
|
|
120
|
+
let cfg = DEFAULTS;
|
|
121
|
+
cfg = merge(cfg, readJson(configPath(env), warnings));
|
|
122
|
+
cfg = merge(cfg, envOverrides(env));
|
|
123
|
+
const resolved = { ...cfg, warnings };
|
|
124
|
+
// Every check below dereferences these sections, and loadConfig's contract is that it never
|
|
125
|
+
// throws. A JSON `null` is not a record, and the env merge runs after the file merge - so a
|
|
126
|
+
// null section can come back as a *partial* record (e.g. `{"daemon": null}` plus
|
|
127
|
+
// A daemon env var over a null section leaves a record with no host and no port, which would bind an
|
|
128
|
+
// ephemeral port and drop the single-instance guard). Repair by filling the defaults
|
|
129
|
+
// underneath whatever survived, rather than replacing it.
|
|
130
|
+
const sections = resolved;
|
|
131
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
132
|
+
const section = sections[key];
|
|
133
|
+
const defaults = DEFAULTS[key];
|
|
134
|
+
if (!isRecord(section)) {
|
|
135
|
+
warnings.push(`${REPAIRED_PREFIX}${key} must be an object — using the defaults`);
|
|
136
|
+
sections[key] = structuredClone(defaults);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const repaired = { ...defaults, ...section };
|
|
140
|
+
// The schema nests exactly one level (mail.imap, mail.smtp).
|
|
141
|
+
for (const [sub, subDefaults] of Object.entries(defaults)) {
|
|
142
|
+
if (!isRecord(subDefaults))
|
|
143
|
+
continue;
|
|
144
|
+
const value = section[sub];
|
|
145
|
+
if (!isRecord(value)) {
|
|
146
|
+
warnings.push(`${REPAIRED_PREFIX}${key}.${sub} must be an object — using the defaults`);
|
|
147
|
+
repaired[sub] = structuredClone(subDefaults);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
repaired[sub] = { ...subDefaults, ...value };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
sections[key] = repaired;
|
|
154
|
+
}
|
|
155
|
+
// Device identity: a short, stable name so a notification says which machine
|
|
156
|
+
// the session is on. Hostname is the sensible default; it can be overridden
|
|
157
|
+
// when hostnames are opaque (e.g. `dev-box-7`).
|
|
158
|
+
const configuredDevice = String(resolved.device?.name ?? "").trim();
|
|
159
|
+
resolved.device = { name: configuredDevice || shortHostname() };
|
|
160
|
+
// Validation that must not throw in-process.
|
|
161
|
+
// allowFrom is checked with .length below, which a string also satisfies - and then
|
|
162
|
+
// every message throws in the allowlist check, killing the scan silently forever.
|
|
163
|
+
const rawAllow = resolved.security?.allowFrom;
|
|
164
|
+
if (!Array.isArray(rawAllow)) {
|
|
165
|
+
warnings.push("security.allowFrom must be an array of addresses — ignoring it (fail closed)");
|
|
166
|
+
resolved.security = { ...resolved.security, allowFrom: [] };
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
resolved.security = { ...resolved.security, allowFrom: rawAllow.map((v) => String(v).trim()).filter(Boolean) };
|
|
170
|
+
}
|
|
171
|
+
// A quoted or non-numeric delay silently disables debouncing (setTimeout coerces it).
|
|
172
|
+
const rawDebounce = resolved.notify?.debounceMs;
|
|
173
|
+
const debounce = typeof rawDebounce === "number" ? rawDebounce : Number.NaN;
|
|
174
|
+
if (!Number.isFinite(debounce) || debounce < 0) {
|
|
175
|
+
if (rawDebounce !== undefined)
|
|
176
|
+
warnings.push("notify.debounceMs must be a non-negative number — using 10000");
|
|
177
|
+
resolved.notify = { ...resolved.notify, debounceMs: 10_000 };
|
|
178
|
+
}
|
|
179
|
+
// `listen("abc")` is a UNIX socket path in Node, and a float is not a port. The env var is
|
|
180
|
+
// documented, and injected as a string, so coerce it rather than trusting it.
|
|
181
|
+
const rawPort = resolved.daemon.port;
|
|
182
|
+
const port = typeof rawPort === "number" ? rawPort : Number(rawPort);
|
|
183
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
184
|
+
if (rawPort !== undefined)
|
|
185
|
+
warnings.push("daemon.port must be an integer 1-65535 — using 4177");
|
|
186
|
+
resolved.daemon = { ...resolved.daemon, port: 4177 };
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
resolved.daemon = { ...resolved.daemon, port };
|
|
190
|
+
}
|
|
191
|
+
// Attachments are encoded ~4/3, and an over-limit send fails at the provider with the whole
|
|
192
|
+
// notification lost. Keep the default under the 25 MB Gmail/Outlook ceiling; cap the ceiling
|
|
193
|
+
// at what the largest common provider (QQ: 50 MB) accepts.
|
|
194
|
+
const rawMaxMb = resolved.notify.maxAttachmentMb;
|
|
195
|
+
if (rawMaxMb !== undefined) {
|
|
196
|
+
const mb = typeof rawMaxMb === "number" ? rawMaxMb : Number.NaN;
|
|
197
|
+
if (!Number.isFinite(mb) || mb < 1 || mb > 45) {
|
|
198
|
+
warnings.push("notify.maxAttachmentMb must be a number between 1 and 45 — using 18");
|
|
199
|
+
resolved.notify = { ...resolved.notify, maxAttachmentMb: 18 };
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
resolved.notify = { ...resolved.notify, maxAttachmentMb: mb };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// A zero or NaN idle timeout makes the daemon exit ~1 s after the first ping, which looks
|
|
206
|
+
// like a crash-loop; a NaN token TTL makes a route never expire. Both fail closed. The floor
|
|
207
|
+
// is 3x the session poll interval: a timeout shorter than the ping period reaps the daemon
|
|
208
|
+
// between ticks, so it would flap instead of serving.
|
|
209
|
+
const rawIdle = resolved.daemon.idleTimeoutSecs;
|
|
210
|
+
const idle = typeof rawIdle === "number" ? rawIdle : Number.NaN;
|
|
211
|
+
if (!Number.isFinite(idle) || idle < 30) {
|
|
212
|
+
if (rawIdle !== undefined) {
|
|
213
|
+
warnings.push("daemon.idleTimeoutSecs must be at least 30 (three poll intervals) — using 120");
|
|
214
|
+
}
|
|
215
|
+
resolved.daemon = { ...resolved.daemon, idleTimeoutSecs: 120 };
|
|
216
|
+
}
|
|
217
|
+
const rawTtl = resolved.security.tokenTtlHours;
|
|
218
|
+
const ttl = typeof rawTtl === "number" ? rawTtl : Number.NaN;
|
|
219
|
+
if (!Number.isFinite(ttl) || ttl < 1) {
|
|
220
|
+
if (rawTtl !== undefined)
|
|
221
|
+
warnings.push("security.tokenTtlHours must be a positive number — using 720");
|
|
222
|
+
resolved.security = { ...resolved.security, tokenTtlHours: 30 * 24 };
|
|
223
|
+
}
|
|
224
|
+
// A negative, null or non-numeric interval would silently disable the safety net.
|
|
225
|
+
// `Number(null)` is 0, which would read as "the user disabled the sweep" — only a real
|
|
226
|
+
// number counts.
|
|
227
|
+
const rawInterval = resolved.daemon.scanIntervalSecs;
|
|
228
|
+
if (rawInterval !== undefined) {
|
|
229
|
+
const n = typeof rawInterval === "number" ? rawInterval : Number.NaN;
|
|
230
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
231
|
+
warnings.push("daemon.scanIntervalSecs must be a non-negative number — using 60");
|
|
232
|
+
resolved.daemon = { ...resolved.daemon, scanIntervalSecs: 60 };
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
resolved.daemon = { ...resolved.daemon, scanIntervalSecs: n };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const imap = resolved.mail.imap;
|
|
239
|
+
if (!imap.host || !imap.user)
|
|
240
|
+
warnings.push("mail.imap.host and mail.imap.user are required");
|
|
241
|
+
if (!resolved.mail.to)
|
|
242
|
+
warnings.push("mail.to is required");
|
|
243
|
+
// Setup validates this now; a hand-edited config still can be a typo, and every notification
|
|
244
|
+
// would go nowhere while the daemon reported itself healthy.
|
|
245
|
+
else if (!String(resolved.mail.to).includes("@")) {
|
|
246
|
+
warnings.push("mail.to does not look like an email address — notifications cannot be delivered");
|
|
247
|
+
}
|
|
248
|
+
if (resolved.security.allowFrom.length === 0) {
|
|
249
|
+
warnings.push("security.allowFrom is empty — every sender will be rejected (fail closed)");
|
|
250
|
+
}
|
|
251
|
+
// An unset authserv-id is a legitimate configuration now: authorization falls
|
|
252
|
+
// back to the topmost Authentication-Results header. Reported as a note by
|
|
253
|
+
// `--check`, not as a warning, because it is not a problem. Never a hard failure.
|
|
254
|
+
// XOAUTH2 needs exactly one token source: a built-in device-code flow or a command.
|
|
255
|
+
for (const side of ["imap", "smtp"]) {
|
|
256
|
+
const account = resolved.mail[side];
|
|
257
|
+
if (account.authType !== "XOAUTH2")
|
|
258
|
+
continue;
|
|
259
|
+
const hasCommand = Boolean(account.tokenCommand?.length);
|
|
260
|
+
const hasOAuth = Boolean(resolved.oauth?.issuer && resolved.oauth?.clientId);
|
|
261
|
+
if (!hasCommand && !hasOAuth) {
|
|
262
|
+
warnings.push(`mail.${side}.authType is XOAUTH2 but neither mail.${side}.tokenCommand nor oauth.issuer/oauth.clientId is set`);
|
|
263
|
+
}
|
|
264
|
+
if (hasOAuth && /(partner\.microsoftonline\.cn|chinacloudapi\.cn)/i.test(resolved.oauth?.issuer ?? "")) {
|
|
265
|
+
warnings.push("oauth.issuer points at the Azure China (21Vianet) sovereign cloud — apps registered there cannot authenticate against global Outlook.com; use login.microsoftonline.com");
|
|
266
|
+
}
|
|
267
|
+
if (hasOAuth && !/\boffline_access\b/.test(resolved.oauth?.scope ?? "")) {
|
|
268
|
+
warnings.push("oauth.scope should include offline_access, otherwise no refresh token is issued");
|
|
269
|
+
}
|
|
270
|
+
if (account.password) {
|
|
271
|
+
warnings.push(`mail.${side}.password is set but authType is XOAUTH2 — the password will be ignored`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// A LOGIN account with no password cannot authenticate, and saying "config ok"
|
|
275
|
+
// in that state sends the user straight into a confusing smoke failure.
|
|
276
|
+
for (const side of ["imap", "smtp"]) {
|
|
277
|
+
const account = resolved.mail[side];
|
|
278
|
+
if (account.authType !== "XOAUTH2" && !account.password) {
|
|
279
|
+
warnings.push(`mail.${side}.password is empty — paste the provider's app password there (QQ/163 call it 授权码)`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return resolved;
|
|
283
|
+
}
|
|
284
|
+
export function hasUsableMailConfig(c) {
|
|
285
|
+
return Boolean(c.mail.imap.host && c.mail.imap.user && c.mail.smtp.host && c.mail.to);
|
|
286
|
+
}
|