@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,122 @@
|
|
|
1
|
+
// Settle notification: the email you get when the agent stops.
|
|
2
|
+
//
|
|
3
|
+
// Three rules earn this module its existence:
|
|
4
|
+
// 1. It fires on `agent_settled` only. `agent_end` precedes retries,
|
|
5
|
+
// compaction and queued follow-ups, so mailing there would send you a
|
|
6
|
+
// "finished" message for work that is still running.
|
|
7
|
+
// 2. It is debounced. Settle → you type again → settle would otherwise mail
|
|
8
|
+
// you a half-finished answer.
|
|
9
|
+
// 3. An aborted settle (Escape) is not "finished" and is skipped by default.
|
|
10
|
+
import { stampSubject } from "./token.js";
|
|
11
|
+
export const REPLY_HINT = "Reply above this line to continue the session.";
|
|
12
|
+
/**
|
|
13
|
+
* Subject: which session, on which device, with the routing id that makes the
|
|
14
|
+
* reply land back here. Both facts are in the subject because that is all a
|
|
15
|
+
* notification preview shows on a phone, tablet or another PC.
|
|
16
|
+
*/
|
|
17
|
+
export function buildSettleSubject(context, secret, tag, tokenTtlHours) {
|
|
18
|
+
const label = context.sessionName?.trim() || context.cwd.split("/").filter(Boolean).pop() || "session";
|
|
19
|
+
const device = context.device?.trim();
|
|
20
|
+
const where = device ? ` @ ${device}` : "";
|
|
21
|
+
const body = `pi: ${label}${where} is waiting for you`;
|
|
22
|
+
// A short local id keeps the subject readable; the signed token remains the
|
|
23
|
+
// fallback for callers that have no store to register against.
|
|
24
|
+
if (tag)
|
|
25
|
+
return `[pi:${tag}] ${body}`;
|
|
26
|
+
return stampSubject(context.sessionId, body, secret, {
|
|
27
|
+
...(device ? { device } : {}),
|
|
28
|
+
...(tokenTtlHours === undefined ? {} : { ttlHours: tokenTtlHours }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Cap on the quoted agent text, so a long answer cannot become a 200-line email. */
|
|
32
|
+
export const MAX_BODY_CHARS = 4_000;
|
|
33
|
+
function quoteAnswer(text) {
|
|
34
|
+
const trimmed = text?.trim();
|
|
35
|
+
if (!trimmed)
|
|
36
|
+
return "(the agent produced no text this turn)";
|
|
37
|
+
if (trimmed.length <= MAX_BODY_CHARS)
|
|
38
|
+
return trimmed;
|
|
39
|
+
return `${trimmed.slice(0, MAX_BODY_CHARS)}\n\n…truncated (${trimmed.length - MAX_BODY_CHARS} more characters)`;
|
|
40
|
+
}
|
|
41
|
+
/** Plain-text body. Deliberately boring: it must survive arbitrary mail clients. */
|
|
42
|
+
export function buildSettleBody(context) {
|
|
43
|
+
const lines = [];
|
|
44
|
+
lines.push(quoteAnswer(context.lastAssistantText));
|
|
45
|
+
lines.push("");
|
|
46
|
+
lines.push("--");
|
|
47
|
+
if (context.device?.trim())
|
|
48
|
+
lines.push(`device: ${context.device.trim()}`);
|
|
49
|
+
// Only name the session when it has a name; otherwise this line repeated the id.
|
|
50
|
+
if (context.sessionName?.trim())
|
|
51
|
+
lines.push(`session: ${context.sessionName.trim()}`);
|
|
52
|
+
lines.push(`session id: ${context.sessionId}`);
|
|
53
|
+
lines.push(`project: ${context.cwd}`);
|
|
54
|
+
if (typeof context.turns === "number")
|
|
55
|
+
lines.push(`turns: ${context.turns}`);
|
|
56
|
+
lines.push("");
|
|
57
|
+
lines.push(REPLY_HINT);
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
export function createSettleNotifier(deps) {
|
|
61
|
+
let handle;
|
|
62
|
+
let pendingContext;
|
|
63
|
+
async function deliver(context) {
|
|
64
|
+
const secret = deps.secret();
|
|
65
|
+
try {
|
|
66
|
+
await deps.send({
|
|
67
|
+
to: "", // the daemon fills in mail.to
|
|
68
|
+
subject: buildSettleSubject(context, secret, deps.tagFor?.(context), deps.config.tokenTtlHours),
|
|
69
|
+
text: buildSettleBody(context),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
deps.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function cancel() {
|
|
77
|
+
if (handle !== undefined) {
|
|
78
|
+
deps.timers.clearTimeout(handle);
|
|
79
|
+
handle = undefined;
|
|
80
|
+
}
|
|
81
|
+
pendingContext = undefined;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
settled(event) {
|
|
85
|
+
if (deps.mode() !== "on")
|
|
86
|
+
return "mode-off";
|
|
87
|
+
if (event?.aborted && !deps.config.notifyOnAborted)
|
|
88
|
+
return "aborted";
|
|
89
|
+
if (!deps.config.onSettled)
|
|
90
|
+
return "disabled";
|
|
91
|
+
const context = deps.context();
|
|
92
|
+
if (!context)
|
|
93
|
+
return "no-context";
|
|
94
|
+
// Debounce: a later settle replaces the earlier one entirely.
|
|
95
|
+
cancel();
|
|
96
|
+
pendingContext = context;
|
|
97
|
+
const delay = Math.max(0, deps.config.debounceMs);
|
|
98
|
+
handle = deps.timers.setTimeout(() => {
|
|
99
|
+
handle = undefined;
|
|
100
|
+
const pending = pendingContext;
|
|
101
|
+
pendingContext = undefined;
|
|
102
|
+
// The mode can change during the debounce (a `/mailbridge off` typed at t+4s, a
|
|
103
|
+
// timer firing at t+10s). Scheduling proved nothing about when it fires.
|
|
104
|
+
if (pending && deps.mode() === "on")
|
|
105
|
+
void deliver(pending);
|
|
106
|
+
}, delay);
|
|
107
|
+
return "scheduled";
|
|
108
|
+
},
|
|
109
|
+
async flush() {
|
|
110
|
+
const context = pendingContext;
|
|
111
|
+
cancel();
|
|
112
|
+
// The same gate the debounce callback applies, for the same reason: the mode can have
|
|
113
|
+
// been turned off during the window, and a flush must not bypass that.
|
|
114
|
+
if (context && deps.mode() === "on")
|
|
115
|
+
await deliver(context);
|
|
116
|
+
},
|
|
117
|
+
isPending() {
|
|
118
|
+
return pendingContext !== undefined;
|
|
119
|
+
},
|
|
120
|
+
cancel,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Setup: address in, runnable config out.
|
|
2
|
+
//
|
|
3
|
+
// Shared by `/mailbridge setup` (inside pi) and `--setup` (headless), so the two
|
|
4
|
+
// entry points cannot drift. It writes the config file and reports what it did;
|
|
5
|
+
// it never starts a process and never prompts.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { shortHostname } from "./config.js";
|
|
9
|
+
import { buildSetupConfig, discoverDomain, domainOf } from "./discover.js";
|
|
10
|
+
import { configPath } from "../store/paths.js";
|
|
11
|
+
export async function runSetup(options) {
|
|
12
|
+
const env = options.env ?? process.env;
|
|
13
|
+
const steps = [];
|
|
14
|
+
const address = options.address.trim();
|
|
15
|
+
const to = (options.to ?? address).trim();
|
|
16
|
+
const domain = domainOf(address);
|
|
17
|
+
if (!domain)
|
|
18
|
+
return { ok: false, steps: [`not an email address: ${address}`] };
|
|
19
|
+
if (!domain.includes(".")) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
steps: [`"${domain}" is not a domain — it has no dot`],
|
|
23
|
+
next: `addresses need a full domain — write something@${domain}.com`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
// The human's address is where every notification goes and the only sender the allowlist
|
|
27
|
+
// accepts. A typo here is a config that mails nowhere and rejects every reply, so check it
|
|
28
|
+
// rather than writing it.
|
|
29
|
+
if (!domainOf(to)) {
|
|
30
|
+
return { ok: false, steps: [`not an email address: ${to}`], next: "pass `to <your-address>`" };
|
|
31
|
+
}
|
|
32
|
+
const target = configPath(env);
|
|
33
|
+
// A device name is an identity, not a mail setting: `force` must not rename the machine,
|
|
34
|
+
// because records already scanned under the old name would never be offered again.
|
|
35
|
+
const previousDevice = (() => {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(readFileSync(target, "utf8"));
|
|
38
|
+
const name = parsed?.device?.name;
|
|
39
|
+
return typeof name === "string" && name.trim() ? name.trim() : undefined;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
if (existsSync(target) && !options.force) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
steps: [`${target} already exists`],
|
|
49
|
+
path: target,
|
|
50
|
+
next: "run `/mailbridge setup … force` to overwrite",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
steps.push(`discovering ${domain}…`);
|
|
54
|
+
const discovered = await discoverDomain(domain, options.fetchImpl ? { fetchImpl: options.fetchImpl } : {});
|
|
55
|
+
if (!discovered) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
steps: [...steps, `no published mail settings for ${domain}`],
|
|
59
|
+
next: "the domain is not in the autoconfig database; write config.json by hand (config.example.json)",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
steps.push(`imap ${discovered.imap.host}:${discovered.imap.port} (${discovered.imap.secure ? "TLS" : "STARTTLS"})`);
|
|
63
|
+
steps.push(`smtp ${discovered.smtp.host}:${discovered.smtp.port} (${discovered.smtp.secure ? "TLS" : "STARTTLS"})`);
|
|
64
|
+
steps.push(`auth ${discovered.authType}${discovered.provider !== "other" ? ` (${discovered.provider})` : ""}`);
|
|
65
|
+
const built = buildSetupConfig(address, to, discovered);
|
|
66
|
+
const config = {
|
|
67
|
+
...built,
|
|
68
|
+
device: { name: previousDevice ?? shortHostname() },
|
|
69
|
+
daemon: { host: "127.0.0.1", port: 4177 },
|
|
70
|
+
notify: { onSettled: true, debounceMs: 10_000, notifyOnAborted: false },
|
|
71
|
+
tuning: { idleRenewMs: 25 * 60 * 1000, backoffInitialMs: 1000, backoffMaxMs: 60_000 },
|
|
72
|
+
};
|
|
73
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
74
|
+
// Written atomically: an automatic reload can read this file at any moment, and a torn
|
|
75
|
+
// write parses as {} (readJson falls back to defaults), which would silently replace the
|
|
76
|
+
// user's config with an empty mailbox.
|
|
77
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
78
|
+
writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
79
|
+
renameSync(tmp, target);
|
|
80
|
+
steps.push(`wrote ${target}`);
|
|
81
|
+
if (built.security.allowSelfReplies) {
|
|
82
|
+
steps.push(`one mailbox for both roles — replies from ${to} are accepted only when they are real replies`);
|
|
83
|
+
steps.push("if this mailbox has other addresses that reach it (a QQ number, a foxmail alias), add each " +
|
|
84
|
+
"one to security.allowFrom - the allowlist matches the exact address you reply from");
|
|
85
|
+
steps.push("if the mailbox already has mail you do not want the agent to read, run /mailbridge prime " +
|
|
86
|
+
"before your first /mailbridge on");
|
|
87
|
+
steps.push("DMARC checks are off for this shape: a message from your address to the same address is " +
|
|
88
|
+
"delivered by your own provider and may carry no Authentication-Results header, so requiring " +
|
|
89
|
+
"one rejects every reply. The sender allowlist and the self-echo guards still apply.");
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
steps,
|
|
94
|
+
path: target,
|
|
95
|
+
discovered,
|
|
96
|
+
next: discovered.authType === "XOAUTH2"
|
|
97
|
+
? "put your app client id in the oauth block, then `/mailbridge auth`"
|
|
98
|
+
: "put the account app password (QQ/163 call it 授权码) in the mail block, then `/mailbridge smoke`",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Live connectivity probe, shared by `/mailbridge smoke` and the headless
|
|
2
|
+
// `--smoke` entry point so the two can never drift.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately does NOT touch the cursor, the journal or any pending record: a
|
|
5
|
+
// smoke test must never disturb real deliveries.
|
|
6
|
+
import { sendMail } from "./mailer.js";
|
|
7
|
+
import { createImapflowFactory } from "../daemon/imapflow.js";
|
|
8
|
+
import { messageIdFromHeaders, stampedIdFromHeaders } from "./sent-index.js";
|
|
9
|
+
const defaultConnect = async (config, getAccessToken) => {
|
|
10
|
+
const factory = createImapflowFactory({ getAccessToken });
|
|
11
|
+
const client = await factory(config, { idleRenewMs: config.tuning.idleRenewMs });
|
|
12
|
+
await client.connect();
|
|
13
|
+
await client.mailboxOpen(config.mail.imap.folder || "INBOX");
|
|
14
|
+
return {
|
|
15
|
+
count: async () => (await client.search({ uid: "1:*" }, { uid: true })).length,
|
|
16
|
+
recentSources: async (sinceMs) => {
|
|
17
|
+
const uids = await client.search({ since: new Date(sinceMs) }, { uid: true });
|
|
18
|
+
const newest = uids.slice().sort((a, b) => b - a).slice(0, 20);
|
|
19
|
+
const sources = [];
|
|
20
|
+
for (const uid of newest) {
|
|
21
|
+
const fetched = (await client.fetchOne(uid, { source: true }, { uid: true }));
|
|
22
|
+
if (fetched && fetched.source)
|
|
23
|
+
sources.push(fetched.source);
|
|
24
|
+
}
|
|
25
|
+
return sources;
|
|
26
|
+
},
|
|
27
|
+
close: async () => {
|
|
28
|
+
try {
|
|
29
|
+
await client.logout();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* already gone */
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
/** The personal-Outlook failure modes, which mean "use Graph", not "retry". */
|
|
38
|
+
export function looksLikeOutlookRefusal(message) {
|
|
39
|
+
return /authenticated but not connected|smtpclientauthentication is disabled|535 authentication unsuccessful/i.test(message);
|
|
40
|
+
}
|
|
41
|
+
export async function runSmoke(config, deps = {}) {
|
|
42
|
+
const steps = [];
|
|
43
|
+
const send = deps.send ?? sendMail;
|
|
44
|
+
/** The Message-ID we stamped, so the echo probe can look for it. */
|
|
45
|
+
let lastMessageId;
|
|
46
|
+
// --- SMTP ---
|
|
47
|
+
try {
|
|
48
|
+
const stamp = new Date().toISOString();
|
|
49
|
+
const sent = await send(config, {
|
|
50
|
+
to: config.mail.to,
|
|
51
|
+
subject: `pi-mailbridge smoke test ${stamp}`,
|
|
52
|
+
text: `This is a smoke test from ${config.device.name}.\n\nIf you can read this, SMTP works.\n`,
|
|
53
|
+
}, { getAccessToken: deps.getAccessToken });
|
|
54
|
+
lastMessageId = sent.messageId;
|
|
55
|
+
steps.push({
|
|
56
|
+
ok: true,
|
|
57
|
+
step: "smtp",
|
|
58
|
+
detail: `sent to ${config.mail.to}${sent.messageId ? ` (${sent.messageId})` : ""}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const detail = error.message;
|
|
63
|
+
steps.push({
|
|
64
|
+
ok: false,
|
|
65
|
+
step: "smtp",
|
|
66
|
+
detail,
|
|
67
|
+
...(looksLikeOutlookRefusal(detail) ? { hint: "the account refuses SMTP AUTH; the Graph transport is the fix — see docs/OAUTH.md" } : {}),
|
|
68
|
+
});
|
|
69
|
+
return { ok: false, steps };
|
|
70
|
+
}
|
|
71
|
+
// --- IMAP ---
|
|
72
|
+
const folder = config.mail.imap.folder || "INBOX";
|
|
73
|
+
let connection;
|
|
74
|
+
try {
|
|
75
|
+
connection = await (deps.connect ?? defaultConnect)(config, deps.getAccessToken);
|
|
76
|
+
const count = await connection.count();
|
|
77
|
+
steps.push({ ok: true, step: "imap", detail: `${count} message(s) visible in ${folder}` });
|
|
78
|
+
// --- echo probe ---
|
|
79
|
+
//
|
|
80
|
+
// Only meaningful in the single-mailbox layout: there the notification is delivered into
|
|
81
|
+
// the folder we watch, so we can see what the provider did to our headers. In layout A it
|
|
82
|
+
// goes to the human's mailbox and anything found here is unrelated.
|
|
83
|
+
//
|
|
84
|
+
// The copy arrives asynchronously (QQ: 20-30 s) and is identified by the
|
|
85
|
+
// `X-Pi-Mailbridge-Id` we stamped — reading "the newest message" once produced a false
|
|
86
|
+
// alarm on a live account.
|
|
87
|
+
const sameMailbox = Boolean(config.mail.to) &&
|
|
88
|
+
config.mail.to.trim().toLowerCase() === (config.mail.imap.user ?? "").trim().toLowerCase();
|
|
89
|
+
if (sameMailbox && lastMessageId && connection.recentSources) {
|
|
90
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
91
|
+
const delays = deps.echoRetryDelaysMs ?? [2_000, 5_000, 15_000, 30_000];
|
|
92
|
+
const wanted = lastMessageId.replace(/[<>]/g, "");
|
|
93
|
+
const since = Date.now() - 15 * 60_000;
|
|
94
|
+
let seen = "none";
|
|
95
|
+
for (const delay of delays) {
|
|
96
|
+
await sleep(delay);
|
|
97
|
+
for (const source of await connection.recentSources(since)) {
|
|
98
|
+
const head = (source.toString("utf8").split(/\r?\n\r?\n/)[0] ?? "").replace(/\r?\n[ \t]+/g, " ");
|
|
99
|
+
// The stamp is how we find the copy, but a provider can strip it while leaving
|
|
100
|
+
// our marker. Treat that copy as ours too, or the probe reports "did not come
|
|
101
|
+
// back" for a notification the scanner would recognise perfectly well.
|
|
102
|
+
const stamped = stampedIdFromHeaders(head);
|
|
103
|
+
const marked = /^x-pi-mailbridge:\s*notification/im.test(head);
|
|
104
|
+
if (stamped !== wanted && !(marked && stamped === undefined))
|
|
105
|
+
continue;
|
|
106
|
+
// Classify on what the scanner actually uses: the marker, then our
|
|
107
|
+
// Message-ID. Claiming the Message-ID survived without checking it was the
|
|
108
|
+
// bug the live account exposed.
|
|
109
|
+
if (/^x-pi-mailbridge:\s*notification/im.test(head))
|
|
110
|
+
seen = "marker";
|
|
111
|
+
else if (messageIdFromHeaders(head) === wanted)
|
|
112
|
+
seen = "id";
|
|
113
|
+
else
|
|
114
|
+
seen = "rewritten";
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
if (seen !== "none")
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
steps.push((() => {
|
|
121
|
+
if (seen === "none") {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
step: "echo",
|
|
125
|
+
detail: "our notification did not come back to this folder — single-mailbox mode relies on it returning with a header it can recognise",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (seen === "marker") {
|
|
129
|
+
return { ok: true, step: "echo", detail: "our marker header survives delivery" };
|
|
130
|
+
}
|
|
131
|
+
if (seen === "id") {
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
step: "echo",
|
|
135
|
+
detail: "marker header was stripped, but our Message-ID survived — the guard still holds",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// The provider rewrote both. Whether that is safe depends entirely on the
|
|
139
|
+
// sent-copy read-back, which is what then records the delivered id.
|
|
140
|
+
const covered = Boolean(config.mail.imap.sentFolder);
|
|
141
|
+
return {
|
|
142
|
+
ok: covered,
|
|
143
|
+
step: "echo",
|
|
144
|
+
detail: covered
|
|
145
|
+
? "the provider rewrote both our headers; the sentFolder read-back records the delivered id, so the registry still catches our own mail"
|
|
146
|
+
: "the provider rewrote both our headers and mail.imap.sentFolder is not set — our own mail is not recognisable; set it",
|
|
147
|
+
};
|
|
148
|
+
})());
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
const detail = error.message;
|
|
153
|
+
steps.push({
|
|
154
|
+
ok: false,
|
|
155
|
+
step: "imap",
|
|
156
|
+
detail,
|
|
157
|
+
...(looksLikeOutlookRefusal(detail) ? { hint: "the account refuses IMAP for this token; the Graph transport is the fix — see docs/OAUTH.md" } : {}),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
await connection?.close();
|
|
162
|
+
}
|
|
163
|
+
return { ok: steps.every((s) => s.ok), steps };
|
|
164
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Turning a scan summary into the lines `/mailbridge status` shows.
|
|
2
|
+
//
|
|
3
|
+
// Pure on purpose: this is the answer to "why did my reply not arrive", and it lived inline
|
|
4
|
+
// in the extension where no test could reach it - which is how an unconditional "queue full"
|
|
5
|
+
// line shipped in its first version.
|
|
6
|
+
/**
|
|
7
|
+
* Counter keys that are not refusals.
|
|
8
|
+
*
|
|
9
|
+
* `routed` / `threaded` / `journaled` / `vanished` are successful paths. `route-miss` is a
|
|
10
|
+
* miss on the subject id that still routed by token or chain. `attachment-*` drops one file
|
|
11
|
+
* but keeps the reply.
|
|
12
|
+
*/
|
|
13
|
+
const HANDLED = new Set([
|
|
14
|
+
"routed",
|
|
15
|
+
"threaded",
|
|
16
|
+
"journaled",
|
|
17
|
+
"vanished",
|
|
18
|
+
"route-miss",
|
|
19
|
+
// In single-mailbox mode our own notifications come back on every send. Rejecting them is
|
|
20
|
+
// the system working, so counting them as rejections made a healthy install look broken.
|
|
21
|
+
"self-echo",
|
|
22
|
+
]);
|
|
23
|
+
/** Deferred, not dropped: the queue was full and the same UID is retried. */
|
|
24
|
+
const DEFERRED = new Set(["pending-full"]);
|
|
25
|
+
export function formatScan(summary) {
|
|
26
|
+
if (!summary)
|
|
27
|
+
return ["last scan: (never — is the daemon running?)"];
|
|
28
|
+
if (summary.at === undefined) {
|
|
29
|
+
// No scan has seen mail yet. Say when we last looked: otherwise a healthy quiet
|
|
30
|
+
// mailbox is indistinguishable from a dead poll loop.
|
|
31
|
+
const checked = summary.checkedAt ? new Date(summary.checkedAt).toLocaleTimeString() : "unknown";
|
|
32
|
+
return [`no mail yet — last checked ${checked}`];
|
|
33
|
+
}
|
|
34
|
+
const entries = Object.entries(summary.reasons ?? {}).filter(([, n]) => Number(n) > 0);
|
|
35
|
+
const list = (pick) => entries
|
|
36
|
+
.filter(([reason]) => pick(reason))
|
|
37
|
+
.map(([reason, n]) => `${reason} ${n}`)
|
|
38
|
+
.join(", ");
|
|
39
|
+
const refused = list((r) => !HANDLED.has(r) && !DEFERRED.has(r) && !r.startsWith("attachment-"));
|
|
40
|
+
const handled = list((r) => HANDLED.has(r));
|
|
41
|
+
const dropped = list((r) => r.startsWith("attachment-"));
|
|
42
|
+
const deferred = entries.some(([reason]) => DEFERRED.has(reason));
|
|
43
|
+
// Freshness stays visible after the first message: "last scan with mail: 10:00" alone cannot
|
|
44
|
+
// tell a live poll loop from one that died at 10:05.
|
|
45
|
+
const checked = summary.checkedAt && summary.checkedAt - summary.at > 60_000
|
|
46
|
+
? ` (last checked ${new Date(summary.checkedAt).toLocaleTimeString()})`
|
|
47
|
+
: "";
|
|
48
|
+
return [
|
|
49
|
+
`last scan with mail: ${new Date(summary.at).toLocaleTimeString()}${checked} — examined ${summary.examined}, kept ${summary.persisted}, rejected ${summary.rejected}`,
|
|
50
|
+
`rejections: ${refused || "none"}`,
|
|
51
|
+
...(handled ? [`handled: ${handled}`] : []),
|
|
52
|
+
...(dropped ? [`attachments dropped: ${dropped}`] : []),
|
|
53
|
+
// Conditional, and it says what the reader should do about it.
|
|
54
|
+
...(deferred ? ["queue full: replies are deferred, not dropped — start a session so they drain"] : []),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Which token source this installation uses.
|
|
2
|
+
//
|
|
3
|
+
// Shared by the daemon and the in-pi commands so `/mailbridge smoke` and the
|
|
4
|
+
// background daemon can never authenticate differently.
|
|
5
|
+
import { createCommandTokenProvider } from "./oauth.js";
|
|
6
|
+
import { createDeviceCodeProvider } from "./oauth-device.js";
|
|
7
|
+
import { createOpenidClient } from "./oauth-openid.js";
|
|
8
|
+
import { fileTokenStore } from "../store/oauth-store.js";
|
|
9
|
+
/**
|
|
10
|
+
* Precedence: built-in device-code OAuth2 (self-refreshing) -> a configured
|
|
11
|
+
* command -> none (the account uses LOGIN).
|
|
12
|
+
* Returning undefined is not an error: it means XOAUTH2 is not in play.
|
|
13
|
+
*/
|
|
14
|
+
export async function buildTokenProvider(config, env = process.env) {
|
|
15
|
+
if (config.oauth?.issuer && config.oauth.clientId) {
|
|
16
|
+
const client = await createOpenidClient(config.oauth);
|
|
17
|
+
return createDeviceCodeProvider({ client, store: fileTokenStore(env) });
|
|
18
|
+
}
|
|
19
|
+
// An empty array is not nullish: the shipped example has `mail.smtp.tokenCommand: []`, so
|
|
20
|
+
// `??` picked the empty SMTP list and ignored a perfectly good IMAP command - XOAUTH2 then
|
|
21
|
+
// failed at connect time while `--check` said the config was fine.
|
|
22
|
+
const smtp = config.mail.smtp.tokenCommand ?? [];
|
|
23
|
+
const command = smtp.length > 0 ? smtp : (config.mail.imap.tokenCommand ?? []);
|
|
24
|
+
if (command.length > 0)
|
|
25
|
+
return createCommandTokenProvider({ command });
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// HMAC-signed routing tokens.
|
|
2
|
+
//
|
|
3
|
+
// A signed token is the fallback routing form: it is minted when no local route id is
|
|
4
|
+
// available (see store/routes.ts), and lifted back out of replies. It answers exactly one question: "which session did this mail come from?"
|
|
5
|
+
// It is ROUTING, not AUTHORIZATION — see docs/SECURITY.md §2.2.
|
|
6
|
+
//
|
|
7
|
+
// Two wire formats, because subjects are user-visible and the first one was ugly:
|
|
8
|
+
//
|
|
9
|
+
// v2 (current) [pi:<payload>.<mac>]
|
|
10
|
+
// payload = base64url( [0x02][16 session uuid][3 expiry minutes][2 device hash] )
|
|
11
|
+
// mac = base64url(HMAC-SHA256(secret, payload)) truncated to 6 bytes
|
|
12
|
+
// → 30 + 1 + 8 = 39 characters, so a whole subject stays under ~90
|
|
13
|
+
//
|
|
14
|
+
// v2-legacy (21-byte payload, minted before expiry went minute-granular)
|
|
15
|
+
// payload = base64url( [0x02][16 session uuid][2 expiry days][2 device hash] )
|
|
16
|
+
// → 28 + 1 + 8 = 37 characters. Still verified: a notification sent minutes before
|
|
17
|
+
// an upgrade keeps working.
|
|
18
|
+
//
|
|
19
|
+
// v1 (legacy) base64url(JSON {s,e,d}) + '.' + 16-byte mac
|
|
20
|
+
// → ~130 characters. Still verified, so tokens already in flight keep working.
|
|
21
|
+
//
|
|
22
|
+
// A non-UUID session id (tests, synthetic sessions) has no compact form, so those
|
|
23
|
+
// keep using v1.
|
|
24
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
25
|
+
/** MAC length for v2. 48 bits is ample for an online-verifiable routing token. */
|
|
26
|
+
const MAC_BYTES_V2 = 6;
|
|
27
|
+
/** MAC length for v1, kept for compatibility with tokens already sent. */
|
|
28
|
+
const MAC_BYTES_V1 = 16;
|
|
29
|
+
/** Tokens older than this are refused even when the MAC is valid. */
|
|
30
|
+
export const DEFAULT_TOKEN_TTL_HOURS = 30 * 24;
|
|
31
|
+
const V2 = 0x02;
|
|
32
|
+
/**
|
|
33
|
+
* [version][16 session][3 expiry][2 device]
|
|
34
|
+
*
|
|
35
|
+
* Expiry is minutes since the epoch: three bytes covers ~31 years, and a day of
|
|
36
|
+
* granularity (two bytes) let a token expire up to 24 h earlier than configured.
|
|
37
|
+
*/
|
|
38
|
+
const V2_PAYLOAD_BYTES = 1 + 16 + 3 + 2;
|
|
39
|
+
/**
|
|
40
|
+
* Expiry is minutes since 2020, not since 1970: three bytes reaches ~2052 from here, where
|
|
41
|
+
* minutes since 1970 topped out in 2001 and silently clamped every token to "expired".
|
|
42
|
+
*/
|
|
43
|
+
const MINUTE_EPOCH = Date.UTC(2020, 0, 1);
|
|
44
|
+
export const TOKEN_RE = /\[\s*pi:([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\s*\]/i;
|
|
45
|
+
function mac(secret, payload, bytes) {
|
|
46
|
+
return createHmac("sha256", secret).update(payload).digest().subarray(0, bytes).toString("base64url");
|
|
47
|
+
}
|
|
48
|
+
/** 32 hex characters (with or without dashes) → 16 bytes, else undefined. */
|
|
49
|
+
function uuidToBytes(sessionId) {
|
|
50
|
+
const hex = sessionId.replace(/-/g, "");
|
|
51
|
+
if (!/^[0-9a-fA-F]{32}$/.test(hex))
|
|
52
|
+
return undefined;
|
|
53
|
+
return Buffer.from(hex, "hex");
|
|
54
|
+
}
|
|
55
|
+
function bytesToUuid(bytes) {
|
|
56
|
+
const hex = bytes.toString("hex");
|
|
57
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Stable 2-byte fingerprint of a device name; enough to route, not to identify.
|
|
61
|
+
* All-zero means "no device claim", which must not read as a mismatch.
|
|
62
|
+
*/
|
|
63
|
+
function deviceHash(device) {
|
|
64
|
+
return createHash("sha256").update(device.toLowerCase()).digest().subarray(0, 2);
|
|
65
|
+
}
|
|
66
|
+
const NO_DEVICE = Buffer.alloc(2);
|
|
67
|
+
export function signToken(sessionId, secret, opts = {}) {
|
|
68
|
+
const now = opts.now ?? Date.now();
|
|
69
|
+
const expiresAt = now + (opts.ttlHours ?? DEFAULT_TOKEN_TTL_HOURS) * 3600_000;
|
|
70
|
+
const compactSession = uuidToBytes(sessionId);
|
|
71
|
+
if (compactSession) {
|
|
72
|
+
const payload = Buffer.alloc(V2_PAYLOAD_BYTES);
|
|
73
|
+
payload[0] = V2;
|
|
74
|
+
compactSession.copy(payload, 1);
|
|
75
|
+
const minutes = Math.floor((expiresAt - MINUTE_EPOCH) / 60_000);
|
|
76
|
+
payload.writeUIntBE(Math.max(0, Math.min(0xffffff, minutes)), 17, 3);
|
|
77
|
+
(opts.device ? deviceHash(opts.device) : NO_DEVICE).copy(payload, 20);
|
|
78
|
+
const encoded = payload.toString("base64url");
|
|
79
|
+
return `${encoded}.${mac(secret, encoded, MAC_BYTES_V2)}`;
|
|
80
|
+
}
|
|
81
|
+
// v1: no compact form for an arbitrary session id.
|
|
82
|
+
const body = { s: sessionId, e: expiresAt, ...(opts.device ? { d: opts.device } : {}) };
|
|
83
|
+
const payload = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
84
|
+
return `${payload}.${mac(secret, payload, MAC_BYTES_V1)}`;
|
|
85
|
+
}
|
|
86
|
+
function splitToken(token) {
|
|
87
|
+
const dot = token.lastIndexOf(".");
|
|
88
|
+
if (dot <= 0 || dot === token.length - 1)
|
|
89
|
+
return undefined;
|
|
90
|
+
return { payload: token.slice(0, dot), given: token.slice(dot + 1) };
|
|
91
|
+
}
|
|
92
|
+
function macMatches(given, expected) {
|
|
93
|
+
const a = Buffer.from(given);
|
|
94
|
+
const b = Buffer.from(expected);
|
|
95
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
96
|
+
}
|
|
97
|
+
export function verifyToken(token, secret, now = Date.now(), device) {
|
|
98
|
+
const parts = splitToken(token);
|
|
99
|
+
if (!parts)
|
|
100
|
+
return { ok: false, reason: "malformed" };
|
|
101
|
+
const { payload, given } = parts;
|
|
102
|
+
let bytes;
|
|
103
|
+
try {
|
|
104
|
+
bytes = Buffer.from(payload, "base64url");
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { ok: false, reason: "malformed" };
|
|
108
|
+
}
|
|
109
|
+
const fingerprintOf = (at) => bytes.subarray(at, at + 2);
|
|
110
|
+
// --- v2: compact binary payload ---
|
|
111
|
+
if (bytes.length === V2_PAYLOAD_BYTES && bytes[0] === V2) {
|
|
112
|
+
if (!macMatches(given, mac(secret, payload, MAC_BYTES_V2)))
|
|
113
|
+
return { ok: false, reason: "bad-mac" };
|
|
114
|
+
const expiresAt = MINUTE_EPOCH + bytes.readUIntBE(17, 3) * 60_000;
|
|
115
|
+
if (expiresAt <= now)
|
|
116
|
+
return { ok: false, reason: "expired" };
|
|
117
|
+
const fingerprint = fingerprintOf(20);
|
|
118
|
+
const claimsDevice = !fingerprint.equals(NO_DEVICE);
|
|
119
|
+
const deviceMatches = device && claimsDevice ? fingerprint.equals(deviceHash(device)) : undefined;
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
sessionId: bytesToUuid(bytes.subarray(1, 17)),
|
|
123
|
+
...(deviceMatches === undefined ? {} : { deviceMatches }),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// --- v2-legacy: same shape with two-byte day expiry, minted before the layout change ---
|
|
127
|
+
if (bytes.length === 21 && bytes[0] === V2) {
|
|
128
|
+
if (!macMatches(given, mac(secret, payload, MAC_BYTES_V2)))
|
|
129
|
+
return { ok: false, reason: "bad-mac" };
|
|
130
|
+
const expiresAt = bytes.readUInt16BE(17) * 86_400_000;
|
|
131
|
+
if (expiresAt <= now)
|
|
132
|
+
return { ok: false, reason: "expired" };
|
|
133
|
+
const fingerprint = fingerprintOf(19);
|
|
134
|
+
const claimsDevice = !fingerprint.equals(NO_DEVICE);
|
|
135
|
+
const deviceMatches = device && claimsDevice ? fingerprint.equals(deviceHash(device)) : undefined;
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
sessionId: bytesToUuid(bytes.subarray(1, 17)),
|
|
139
|
+
...(deviceMatches === undefined ? {} : { deviceMatches }),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// --- v1: JSON payload ---
|
|
143
|
+
if (!macMatches(given, mac(secret, payload, MAC_BYTES_V1)))
|
|
144
|
+
return { ok: false, reason: "bad-mac" };
|
|
145
|
+
let body;
|
|
146
|
+
try {
|
|
147
|
+
body = JSON.parse(bytes.toString("utf8"));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return { ok: false, reason: "malformed" };
|
|
151
|
+
}
|
|
152
|
+
if (typeof body?.s !== "string" || typeof body?.e !== "number")
|
|
153
|
+
return { ok: false, reason: "malformed" };
|
|
154
|
+
if (body.e <= now)
|
|
155
|
+
return { ok: false, reason: "expired" };
|
|
156
|
+
const claimedDevice = typeof body.d === "string" && body.d ? body.d : undefined;
|
|
157
|
+
const deviceMatches = claimedDevice && device ? claimedDevice === device : undefined;
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
sessionId: body.s,
|
|
161
|
+
...(claimedDevice ? { device: claimedDevice } : {}),
|
|
162
|
+
...(deviceMatches === undefined ? {} : { deviceMatches }),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Prefix a human subject with the signed token. */
|
|
166
|
+
export function stampSubject(sessionId, subject, secret, opts) {
|
|
167
|
+
return `[pi:${signToken(sessionId, secret, opts)}] ${subject}`;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Pull a verified routing token out of a subject line.
|
|
171
|
+
* Tolerates client prefixes (`Re:`, `RE:`, `AW:`, `Re[2]:`) because the token is
|
|
172
|
+
* matched anywhere in the subject, not anchored.
|
|
173
|
+
*/
|
|
174
|
+
export function tokenFromSubject(subject, secret, now, device) {
|
|
175
|
+
if (!subject)
|
|
176
|
+
return undefined;
|
|
177
|
+
const m = TOKEN_RE.exec(subject);
|
|
178
|
+
if (!m?.[1])
|
|
179
|
+
return undefined;
|
|
180
|
+
const result = verifyToken(m[1], secret, now, device);
|
|
181
|
+
if (!result.ok)
|
|
182
|
+
return undefined;
|
|
183
|
+
return {
|
|
184
|
+
sessionId: result.sessionId,
|
|
185
|
+
...(result.device ? { device: result.device } : {}),
|
|
186
|
+
...(result.deviceMatches === undefined ? {} : { deviceMatches: result.deviceMatches }),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Convenience wrapper for callers that only need the session id. */
|
|
190
|
+
export function sessionIdFromSubject(subject, secret, now) {
|
|
191
|
+
return tokenFromSubject(subject, secret, now)?.sessionId;
|
|
192
|
+
}
|
|
193
|
+
/** Shared secret material. 32 bytes. */
|
|
194
|
+
export function newSecret() {
|
|
195
|
+
return randomBytes(32);
|
|
196
|
+
}
|