@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +824 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
@@ -0,0 +1,119 @@
1
+ // Device-code OAuth2 — one auth path for every provider.
2
+ //
3
+ // Microsoft killed app passwords for outlook.com; Gmail keeps them but discourages
4
+ // them; self-hosted boxes may move to OAuth2 next. Rather than write a flow per
5
+ // provider, this module speaks plain OAuth2 **device authorization** (RFC 8628),
6
+ // which every provider we care about supports:
7
+ //
8
+ // Microsoft (consumers or any tenant) https://login.microsoftonline.com/consumers/v2.0
9
+ // Google https://accounts.google.com
10
+ // any OIDC your issuer
11
+ //
12
+ // The flow is deliberately the *pull* variant: no redirect URI, no local listener,
13
+ // no browser on the daemon box. You approve on whatever device you like — which is
14
+ // also why the rest of this package can be driven from any device with email.
15
+ //
16
+ // openid-client is reached through an adapter (see core/oauth-openid.ts) so the
17
+ // provider itself is testable and the library stays swappable.
18
+ const DEFAULT_SKEW_MS = 60_000;
19
+ /**
20
+ * A `TokenProvider` backed by the device-code grant.
21
+ *
22
+ * Resolution order: cached access token -> stored refresh token -> fail with an
23
+ * instruction to run the login command. It never silently degrades to a password:
24
+ * half-configured auth is worse than a clear error.
25
+ */
26
+ /** Consecutive refresh failures tolerated before the provider disables itself. */
27
+ const MAX_REFRESH_FAILURES = 3;
28
+ /**
29
+ * Errors that mean the grant itself is gone, so retrying cannot help.
30
+ * Everything else (DNS, TLS, timeout, 5xx) is treated as transient.
31
+ */
32
+ function isCredentialError(error) {
33
+ const text = error instanceof Error ? `${error.name} ${error.message}` : String(error);
34
+ return /invalid_grant|invalid_request|unauthorized_client|consent_required|interaction_required|refresh token (?:is )?(?:invalid|expired|revoked)/i.test(text);
35
+ }
36
+ export function createDeviceCodeProvider(options) {
37
+ const { client, store } = options;
38
+ const now = options.now ?? (() => Date.now());
39
+ const skew = options.skewMs ?? DEFAULT_SKEW_MS;
40
+ let cached;
41
+ let failures = 0;
42
+ let disabled = false;
43
+ let inFlight;
44
+ function fresh(tokens) {
45
+ return Boolean(tokens?.accessToken && tokens.expiresAt && tokens.expiresAt - skew > now());
46
+ }
47
+ async function acquire() {
48
+ const stored = store.read();
49
+ if (fresh(stored)) {
50
+ cached = { accessToken: stored.accessToken, expiresAt: stored.expiresAt };
51
+ return cached.accessToken;
52
+ }
53
+ if (!stored?.refreshToken) {
54
+ disabled = true;
55
+ throw new Error("pi-mailbridge: not authorised yet — run `pi-mailbridge --auth` once to approve device login, or set mail.*.tokenCommand");
56
+ }
57
+ try {
58
+ const refreshed = await client.refresh(stored.refreshToken);
59
+ cached = refreshed;
60
+ store.write({
61
+ ...(refreshed.refreshToken ? { refreshToken: refreshed.refreshToken } : { refreshToken: stored.refreshToken }),
62
+ accessToken: refreshed.accessToken,
63
+ expiresAt: refreshed.expiresAt,
64
+ });
65
+ failures = 0;
66
+ return refreshed.accessToken;
67
+ }
68
+ catch (error) {
69
+ failures++;
70
+ // A revoked grant is permanent: say so now. A network failure is not - disabling
71
+ // on the first DNS blip stopped every notification until a human restarted pi.
72
+ const fatal = isCredentialError(error) || failures >= MAX_REFRESH_FAILURES;
73
+ if (fatal)
74
+ disabled = true;
75
+ const reason = error instanceof Error ? error.message : String(error);
76
+ const suffix = fatal
77
+ ? " — re-run `pi-mailbridge --auth`"
78
+ : ` — retrying on next use (${failures}/${MAX_REFRESH_FAILURES})`;
79
+ throw new Error(`pi-mailbridge: OAuth2 token refresh failed: ${reason}${suffix}`);
80
+ }
81
+ }
82
+ return {
83
+ async getToken() {
84
+ if (cached && fresh(cached))
85
+ return cached.accessToken;
86
+ if (disabled) {
87
+ throw new Error(`pi-mailbridge: OAuth2 is disabled after ${failures} failure(s) — re-run \`pi-mailbridge --auth\``);
88
+ }
89
+ if (!inFlight) {
90
+ inFlight = acquire().finally(() => {
91
+ inFlight = undefined;
92
+ });
93
+ }
94
+ return inFlight;
95
+ },
96
+ invalidate() {
97
+ cached = undefined;
98
+ const stored = store.read();
99
+ if (stored)
100
+ store.write({ ...stored, accessToken: undefined, expiresAt: undefined });
101
+ },
102
+ isDisabled() {
103
+ return disabled;
104
+ },
105
+ state() {
106
+ return { cached: Boolean(cached && fresh(cached)), disabled, failures };
107
+ },
108
+ async login() {
109
+ const authorization = await client.deviceAuthorization();
110
+ const tokens = await client.pollDeviceAuthorization();
111
+ if (!tokens.refreshToken) {
112
+ throw new Error("pi-mailbridge: the provider returned no refresh_token — make sure `offline_access` is in oauth.scope");
113
+ }
114
+ cached = tokens;
115
+ store.write({ refreshToken: tokens.refreshToken, accessToken: tokens.accessToken, expiresAt: tokens.expiresAt });
116
+ return authorization;
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,48 @@
1
+ // openid-client adapter.
2
+ //
3
+ // The one place that knows the OAuth library's API. Types are derived from the
4
+ // library itself (`Awaited<ReturnType<...>>`) so a major bump fails the build here
5
+ // rather than producing silently wrong field names.
6
+ import * as oidc from "openid-client";
7
+ /** Map an OAuth token response onto our shape. */
8
+ export function toOAuthTokens(tokens, now = Date.now()) {
9
+ const lifetime = typeof tokens.expires_in === "number" ? tokens.expires_in : 3600;
10
+ return {
11
+ accessToken: tokens.access_token,
12
+ ...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
13
+ expiresAt: now + lifetime * 1000,
14
+ };
15
+ }
16
+ /** Human-facing part of a device authorization response. */
17
+ export function toDeviceAuthorization(response) {
18
+ return {
19
+ // Prefer the combined URL when the provider offers one: fewer typos.
20
+ verificationUri: response.verification_uri_complete ?? response.verification_uri,
21
+ userCode: response.user_code,
22
+ };
23
+ }
24
+ /**
25
+ * Build a real OAuth client for `config`.
26
+ * Async because discovery fetches the provider metadata.
27
+ */
28
+ export async function createOpenidClient(config, now = Date.now) {
29
+ const issuer = new URL(config.issuer);
30
+ const configuration = await oidc.discovery(issuer, config.clientId, config.clientSecret);
31
+ let pending;
32
+ return {
33
+ async deviceAuthorization() {
34
+ pending = await oidc.initiateDeviceAuthorization(configuration, { scope: config.scope });
35
+ return toDeviceAuthorization(pending);
36
+ },
37
+ async pollDeviceAuthorization() {
38
+ if (!pending)
39
+ throw new Error("pi-mailbridge: deviceAuthorization() must run before polling");
40
+ const tokens = await oidc.pollDeviceAuthorizationGrant(configuration, pending);
41
+ return toOAuthTokens(tokens, now());
42
+ },
43
+ async refresh(refreshToken) {
44
+ const tokens = await oidc.refreshTokenGrant(configuration, refreshToken);
45
+ return toOAuthTokens(tokens, now());
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,137 @@
1
+ // OAuth2 access-token supply for XOAUTH2 (Outlook).
2
+ //
3
+ // Microsoft retired SMTP basic auth for outlook.com/hotmail on 2024-09-16, so an
4
+ // app password is not an option there. Two flows are built in - device code
5
+ // (oauth-device.ts) and OpenID Connect (oauth-openid.ts) - and token acquisition can also
6
+ // be delegated to a configurable command whose stdout is the token. This module is the
7
+ // caching/retry layer shared by all three.
8
+ //
9
+ // Two design rules:
10
+ // 1. The command is executed with `execFile` and an argv array — never a shell
11
+ // — so a token or a path containing shell metacharacters cannot be
12
+ // reinterpreted as syntax.
13
+ // 2. A broken command disables the provider instead of being retried forever.
14
+ // The daemon would otherwise spawn a failing process on every scan.
15
+ import { execFile } from "node:child_process";
16
+ const DEFAULT_TIMEOUT_MS = 10_000;
17
+ /** Default executor: argv only, bounded output, hard timeout. */
18
+ export const defaultExec = (file, args) => new Promise((resolve, reject) => {
19
+ execFile(file, args, { timeout: DEFAULT_TIMEOUT_MS, maxBuffer: 1 << 20, shell: false }, (error, stdout) => {
20
+ if (error) {
21
+ reject(error);
22
+ return;
23
+ }
24
+ resolve({ stdout: stdout.toString() });
25
+ });
26
+ });
27
+ const DEFAULT_TTL_MS = 50 * 60 * 1000;
28
+ export function createCommandTokenProvider(options) {
29
+ const { command } = options;
30
+ const exec = options.exec ?? defaultExec;
31
+ const now = options.now ?? (() => Date.now());
32
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
33
+ // "A broken command should not be retried forever" is right; disabling after ONE failure
34
+ // is not - a DNS blip or a momentary exec failure then stops all mail until a restart.
35
+ // Three consecutive failures is still quick to notice and survives a transient one.
36
+ const maxFailures = options.maxConsecutiveFailures ?? 3;
37
+ let token;
38
+ let fetchedAt = 0;
39
+ let failures = 0;
40
+ let disabled = false;
41
+ let inFlight;
42
+ function describeCommand() {
43
+ // Only argv[0]; later args may contain secrets (client ids, paths).
44
+ return command[0] ? `${command[0]} …` : "(empty command)";
45
+ }
46
+ async function fetchToken() {
47
+ if (!command.length) {
48
+ disabled = true;
49
+ throw new Error("pi-mailbridge: mail.smtp.tokenCommand is empty but authType is XOAUTH2");
50
+ }
51
+ try {
52
+ const { stdout } = await exec(command[0], command.slice(1));
53
+ const value = stdout.trim().split(/\r?\n/).pop()?.trim() ?? "";
54
+ if (!value)
55
+ throw new Error("command produced no output");
56
+ failures = 0;
57
+ token = value;
58
+ fetchedAt = now();
59
+ return value;
60
+ }
61
+ catch (error) {
62
+ failures++;
63
+ if (failures >= maxFailures)
64
+ disabled = true;
65
+ // Node's execFile message is "Command failed: <file> <all args>\n<stderr>", and the
66
+ // argv tail is exactly what describeCommand() refuses to print because it may hold a
67
+ // client secret. Keep the exit shape, drop the rest.
68
+ const reason = (() => {
69
+ const raw = error instanceof Error ? error.message : String(error);
70
+ const code = error?.code;
71
+ const firstLine = raw.split("\n")[0] ?? "";
72
+ const withoutArgv = firstLine.replace(/^Command failed: .*$/i, "command failed");
73
+ const stderr = raw.split("\n").slice(1).join(" ").trim();
74
+ const detail = stderr ? `${withoutArgv}: ${stderr.slice(0, 200)}` : withoutArgv;
75
+ return code && !detail.includes(code) ? `${detail} (${code})` : detail;
76
+ })();
77
+ const suffix = disabled
78
+ ? " — OAuth2 is now disabled for this session; fix mail.smtp.tokenCommand and restart"
79
+ : ` — will retry on next use (${failures}/${maxFailures})`;
80
+ throw new Error(`pi-mailbridge: OAuth2 token command failed (${describeCommand()}): ${reason}${suffix}`);
81
+ }
82
+ }
83
+ return {
84
+ async getToken() {
85
+ if (disabled) {
86
+ throw new Error(`pi-mailbridge: OAuth2 token provider disabled after ${failures} failure(s) (${describeCommand()}); re-authenticate and restart`);
87
+ }
88
+ if (token && now() - fetchedAt < ttlMs)
89
+ return token;
90
+ // Collapse concurrent callers onto one exec.
91
+ if (!inFlight) {
92
+ inFlight = fetchToken().finally(() => {
93
+ inFlight = undefined;
94
+ });
95
+ }
96
+ return inFlight;
97
+ },
98
+ invalidate() {
99
+ token = undefined;
100
+ fetchedAt = 0;
101
+ },
102
+ isDisabled() {
103
+ return disabled;
104
+ },
105
+ state() {
106
+ return { cached: token !== undefined && now() - fetchedAt < ttlMs, disabled, failures };
107
+ },
108
+ };
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // Auth-failure detection + one-shot refresh
112
+ // ---------------------------------------------------------------------------
113
+ /** SMTP/IMAP auth failures as surfacing from the transport or a provider API. */
114
+ const AUTH_ERROR_RE = /(\b401\b|\b535\b|\b534\b|invalid.?credentials?|InvalidAuthenticationToken|AUTHENTICATIONFAILED|token.?(expired|invalid))/i;
115
+ export function isAuthError(error) {
116
+ const text = error instanceof Error ? `${error.name} ${error.message}` : String(error);
117
+ return AUTH_ERROR_RE.test(text);
118
+ }
119
+ /**
120
+ * Run `use(token)`; on an auth error invalidate and retry exactly once.
121
+ *
122
+ * One retry, not a loop: a second failure means the credential is genuinely bad,
123
+ * and retrying forever would hammer the provider and get the account locked.
124
+ */
125
+ export async function withFreshToken(provider, use, isAuth = isAuthError) {
126
+ const first = await provider.getToken();
127
+ try {
128
+ return await use(first);
129
+ }
130
+ catch (error) {
131
+ if (!isAuth(error))
132
+ throw error;
133
+ provider.invalidate();
134
+ const second = await provider.getToken();
135
+ return use(second);
136
+ }
137
+ }
@@ -0,0 +1,51 @@
1
+ // "Watch from now on": skip the mail that is already in the mailbox.
2
+ //
3
+ // This is a first-run decision, not an operation. The scanner polls `UID <cursor>+1:*`, so
4
+ // moving the cursor past unscanned mail makes it permanently invisible to the bridge: no
5
+ // ack, no `\Seen`, no error - just a reply nobody ever answers. That is only possible when a
6
+ // cursor already exists, which is why priming refuses to run a second time (a `/mailbridge
7
+ // on` that processes what arrived meanwhile depends on that cursor staying put).
8
+ //
9
+ // Both the CLI (`--prime`) and the in-pi command go through here, so the two cannot disagree
10
+ // about when it is allowed.
11
+ /**
12
+ * Decide whether to prime, given the stored cursor and the mailbox's UIDs.
13
+ *
14
+ * Pure: the callers do the IMAP work, so the rule is testable without a server.
15
+ */
16
+ export function planPrime(currentUid, uids, alreadyWatching) {
17
+ // Neither "the file exists" nor "uid > 0" answers this on its own. The daemon writes the file
18
+ // on its first connect, before reading anything (a fresh install wants prime); a renumbering
19
+ // resets the uid to 0 while the mailbox is already being watched (prime would skip exactly
20
+ // the mail the reset was meant to re-read). The caller passes a flag set by prime itself.
21
+ if (alreadyWatching)
22
+ return { ok: false, currentUid };
23
+ const highest = uids.length > 0 ? Math.max(...uids) : 0;
24
+ return { ok: true, highest, count: uids.length, currentUid };
25
+ }
26
+ /** The message shown when priming is refused, in both surfaces. */
27
+ export function alreadyPrimedMessage(currentUid) {
28
+ return (`already watching from uid ${currentUid} - prime is a first-run step, and moving the cursor ` +
29
+ `now would skip any reply that arrived while the bridge was not scanning. ` +
30
+ `To re-prime deliberately, remove uid-cursor.json from the state directory.`);
31
+ }
32
+ /**
33
+ * Run the check, and only touch the cursor when the plan allows it.
34
+ *
35
+ * The mailbox is still searched when priming is refused: the count is what makes the refusal
36
+ * concrete ("3 messages are in that range"), and a search is cheap.
37
+ */
38
+ export async function runPrime(config, deps) {
39
+ const mailbox = await deps.openMailbox(config);
40
+ try {
41
+ const plan = planPrime(deps.readCursor(), await mailbox.search(), deps.isPrimed());
42
+ if (plan.ok) {
43
+ deps.advanceCursor(plan.highest);
44
+ deps.markPrimed();
45
+ }
46
+ return plan;
47
+ }
48
+ finally {
49
+ await mailbox.close().catch(() => undefined);
50
+ }
51
+ }
@@ -0,0 +1,134 @@
1
+ // Reply parsing: turn a raw mail body into the single line of human intent.
2
+ //
3
+ // Quoted-text stripping is the fragile part of any email bridge — every client
4
+ // quotes differently. The approach here is deliberately blunt: cut at the first
5
+ // recognizable quote boundary and accept the remainder. Being clever (nested
6
+ // quoting, locale heuristics) loses more than it gains.
7
+ //
8
+ // Outbound mail instructs the human to reply *above* the quoted text, so the
9
+ // top of the body is the answer.
10
+ /** Markers that begin a quoted/original section. Matched case-insensitively. */
11
+ const QUOTE_BOUNDARIES = [
12
+ // "On Tue, 3 Jun 2025 at 09:12, pi <pi@example.com> wrote:"
13
+ /^\s*On\b.{0,200}\bwrote:\s*$/i,
14
+ // Outlook / web clients
15
+ /^\s*-{2,}\s*Original Message\s*-{2,}\s*$/i,
16
+ // QQ Mail web (Chinese UI) - the primary client for the primary target users:
17
+ // "------------------ 原始邮件 ------------------" and its 163/126 cousins.
18
+ /^\s*-{2,}\s*原始邮件\s*-{2,}\s*$/,
19
+ /^\s*-{2,}\s*原邮件\s*-{2,}\s*$/,
20
+ // "在 2025年6月3日 09:12,pi <pi@example.com> 写道:"
21
+ /^\s*在\s.{0,120}写道[::]\s*$/,
22
+ // "发件人: pi <pi@example.com>" (QQ/163 header block)
23
+ /^\s*发件人[::]\s*.+$/,
24
+ /^\s*_{10,}\s*$/,
25
+ /^\s*From:\s.+$/i,
26
+ // "> " quoted block start (BEFORE the boundary scan strips lines)
27
+ /^\s*>/,
28
+ // Forwarded-message banner
29
+ /^\s*-{2,}\s*Forwarded message\s*-{2,}\s*$/i,
30
+ // Mobile clients
31
+ /^\s*Sent from my\b/i,
32
+ // Outlook mobile app signature, in either the English or the Chinese build.
33
+ /^\s*(?:获取|Get)\s*Outlook\s*for\s+(?:Android|iOS|iPhone|iPad)\b/i,
34
+ ];
35
+ /** Signature delimiter per RFC 3676 §4.3 — "dash dash space". */
36
+ const SIGNATURE_DELIM = /^--\s*$/;
37
+ function normalizeNewlines(text) {
38
+ return text.replace(/\r\n?/g, "\n");
39
+ }
40
+ /**
41
+ * Strip quoted text and signature from a reply body.
42
+ *
43
+ * Returns the top-of-mail content, or `""` when the message carried nothing
44
+ * new (e.g. the body was entirely quoted). Callers must treat `""` as "no
45
+ * instruction" and reject — re-injecting the quoted original into the agent
46
+ * would just replay the agent's own output back at it.
47
+ */
48
+ export function stripQuoted(raw) {
49
+ if (!raw)
50
+ return "";
51
+ const text = normalizeNewlines(raw);
52
+ const lines = text.split("\n");
53
+ const kept = [];
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const line = lines[i];
56
+ if (SIGNATURE_DELIM.test(line))
57
+ break;
58
+ if (QUOTE_BOUNDARIES.some((re) => re.test(line)))
59
+ break;
60
+ kept.push(line);
61
+ }
62
+ // Drop trailing blank lines.
63
+ while (kept.length > 0 && kept[kept.length - 1].trim() === "")
64
+ kept.pop();
65
+ return kept.join("\n").trim();
66
+ }
67
+ /**
68
+ * Reduce `"Name" <User@Example.COM>` to `user@example.com`.
69
+ * Handles bare addresses, angle-addresses, and display names containing commas.
70
+ */
71
+ export function normalizeAddress(input) {
72
+ if (!input)
73
+ return undefined;
74
+ const angle = /<([^<>]+)>\s*$/.exec(input);
75
+ const candidate = (angle?.[1] ?? input).trim();
76
+ // Anything with whitespace left is a display name we could not parse.
77
+ if (!candidate || /\s/.test(candidate))
78
+ return undefined;
79
+ if (!/^[^@\s]+@[^@\s]+$/.test(candidate))
80
+ return undefined;
81
+ return candidate.toLowerCase();
82
+ }
83
+ /**
84
+ * Is this message machine-generated? Replying to one of these is how mail loops
85
+ * start (out-of-office → us → out-of-office → …).
86
+ */
87
+ export function isAutoGenerated(headers) {
88
+ const auto = headers.autoSubmitted?.trim().toLowerCase();
89
+ if (auto && auto !== "no")
90
+ return true;
91
+ const prec = headers.precedence?.trim().toLowerCase();
92
+ if (prec === "bulk" || prec === "junk" || prec === "list")
93
+ return true;
94
+ // Not "any non-empty value": Outlook and corporate gateways stamp this header on ordinary
95
+ // mail too, and treating presence as machine-generated rejected genuine replies.
96
+ const suppress = headers.autoResponseSuppress?.trim().toLowerCase() ?? "";
97
+ if (/\b(oof|autoreply|auto_reply|dr|rn|nrn)\b/.test(suppress))
98
+ return true;
99
+ if (headers.listId?.trim())
100
+ return true;
101
+ return false;
102
+ }
103
+ /**
104
+ * Find the `Authentication-Results` header that our own relay added.
105
+ *
106
+ * Headers are prepended as a message traverses MTAs, so the TOPMOST
107
+ * `Authentication-Results` is the one the final hop (your provider) wrote on
108
+ * delivery. A sender that includes a forged header ends up *below* it, which is
109
+ * why topmost-wins defeats header forgery — see docs/SECURITY.md §2.1.
110
+ *
111
+ * `authservId` pins it further when a provider writes one. It is optional
112
+ * because Microsoft's consumer relay emits bare headers with no authserv-id
113
+ * (`Authentication-Results: spf=pass ...; dmarc=pass ...`), and requiring a match
114
+ * there would reject every piece of genuine mail.
115
+ */
116
+ export function findTrustedAuthResults(headers, authservId) {
117
+ const values = headers.authResults ?? [];
118
+ if (values.length === 0)
119
+ return undefined;
120
+ const wanted = authservId?.trim().toLowerCase();
121
+ if (!wanted)
122
+ return values[0]; // topmost = the delivering relay's
123
+ for (const value of values) {
124
+ const id = /^\s*([^\s;]+)/.exec(value)?.[1]?.toLowerCase();
125
+ if (id === wanted)
126
+ return value;
127
+ }
128
+ // Pinned but absent: do not silently fall back to an unverified header.
129
+ return undefined;
130
+ }
131
+ /** `dmarc=pass` inside a specific AR header value. */
132
+ export function dmarcPasses(authResultsValue) {
133
+ return /\bdmarc\s*=\s*pass\b/i.test(authResultsValue);
134
+ }
@@ -0,0 +1,135 @@
1
+ // Record the Message-ID the provider actually delivered.
2
+ //
3
+ // We mint a Message-ID and stamp it, but some providers (QQ among them) replace it on
4
+ // submission: a live test showed our `<uuid@qq.com>` stored in the Sent folder as
5
+ // `<tencent_…@qq.com>`, and the human's reply carried the tencent id in In-Reply-To.
6
+ //
7
+ // The reply-chain fallback matches In-Reply-To against ids we recorded, so on such a provider
8
+ // it never matches - the subject tag is the only remaining route.
9
+ //
10
+ // The just-sent copy is found by the `X-Pi-Mailbridge-Id` header, which carries the id we
11
+ // minted. Two things learned the hard way (a live review caught both):
12
+ // - the copy is appended *after* the send completes, so this must run after it, and must
13
+ // retry: QQ took 20-30 s in one measurement;
14
+ // - matching on the marker header alone records a *different* notification's id when two
15
+ // sessions send close together, which routes a reply into the wrong session.
16
+ import { recordOutbound } from "../store/threads.js";
17
+ /** Look back far enough to see a send that is still being appended. */
18
+ const CAPTURE_WINDOW_MS = 30 * 60_000;
19
+ /** How many recent sent messages to inspect per attempt. */
20
+ const CAPTURE_SCAN = 20;
21
+ /** Backoff between attempts: providers append asynchronously. */
22
+ const DEFAULT_RETRY_DELAYS_MS = [2_000, 5_000, 15_000, 30_000, 30_000, 30_000];
23
+ /** Pull the first `Message-ID:` out of a header block. */
24
+ export function messageIdFromHeaders(headers) {
25
+ const unfolded = headers.replace(/\r?\n[ \t]+/g, " ");
26
+ for (const line of unfolded.split(/\r?\n/)) {
27
+ const match = /^message-id:\s*<([^>]+)>/i.exec(line.trim());
28
+ if (match)
29
+ return match[1].trim();
30
+ }
31
+ return undefined;
32
+ }
33
+ /** `X-Pi-Mailbridge-Id: <uuid@domain>` — normalised, without angle brackets. */
34
+ export function stampedIdFromHeaders(headers) {
35
+ const unfolded = headers.replace(/\r?\n[ \t]+/g, " ");
36
+ for (const line of unfolded.split(/\r?\n/)) {
37
+ const match = /^x-pi-mailbridge-id:\s*<?([^>\s]+)>?/i.exec(line.trim());
38
+ if (match)
39
+ return match[1].trim();
40
+ }
41
+ return undefined;
42
+ }
43
+ /**
44
+ * Read the just-sent message back from the Sent folder and record its Message-ID for the
45
+ * session. Best-effort: a failure never fails the send, and one capture runs at a time.
46
+ */
47
+ export async function captureDeliveredMessageId(options) {
48
+ const folder = options.config.mail.imap.sentFolder;
49
+ if (!folder)
50
+ return undefined;
51
+ return serialise(options.mintedId, () => attempt(options, folder));
52
+ }
53
+ /**
54
+ * One capture in flight at a time, and identical requests share it.
55
+ *
56
+ * A burst of notifications must not open one connection each, and a capture can hold the
57
+ * queue for a minute; coalescing means a repeated request for the same minted id costs
58
+ * nothing extra.
59
+ */
60
+ let queue = Promise.resolve();
61
+ const inFlightByMintedId = new Map();
62
+ function serialise(key, run) {
63
+ const existing = inFlightByMintedId.get(key);
64
+ if (existing)
65
+ return existing;
66
+ const next = queue.then(run, run);
67
+ queue = next.catch(() => undefined);
68
+ inFlightByMintedId.set(key, next);
69
+ next.catch(() => undefined).finally(() => inFlightByMintedId.delete(key));
70
+ return next;
71
+ }
72
+ async function attempt(options, folder) {
73
+ const env = options.env ?? process.env;
74
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
75
+ const delays = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
76
+ const connect = options.connect ?? (await defaultConnect(options.config, options.getAccessToken));
77
+ const wanted = options.mintedId.replace(/[<>]/g, "");
78
+ let client;
79
+ try {
80
+ client = await connect();
81
+ await client.mailboxOpen(folder);
82
+ for (const delay of delays) {
83
+ if (delay > 0)
84
+ await sleep(delay);
85
+ const since = new Date(Date.now() - CAPTURE_WINDOW_MS);
86
+ const uids = await client.search({ since }, { uid: true });
87
+ if (!uids.length)
88
+ continue;
89
+ const newestFirst = uids.slice().sort((a, b) => b - a).slice(0, CAPTURE_SCAN);
90
+ for (const uid of newestFirst) {
91
+ const fetched = (await client.fetchOne(uid, { source: true }, { uid: true }));
92
+ if (!fetched || !fetched.source)
93
+ continue;
94
+ const head = (fetched.source.toString("utf8").split(/\r?\n\r?\n/)[0] ?? "").replace(/\r?\n[ \t]+/g, " ");
95
+ // Only the copy of the message we just sent: the header carries our minted id.
96
+ if (stampedIdFromHeaders(head) !== wanted)
97
+ continue;
98
+ const messageId = messageIdFromHeaders(head);
99
+ if (!messageId)
100
+ continue;
101
+ (options.record ?? recordOutbound)(env, {
102
+ messageId,
103
+ sessionId: options.sessionId,
104
+ ...(options.config.device.name ? { device: options.config.device.name } : {}),
105
+ subject: options.subject,
106
+ });
107
+ return messageId;
108
+ }
109
+ }
110
+ return undefined;
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ finally {
116
+ await client?.logout().catch(() => undefined);
117
+ }
118
+ }
119
+ async function defaultConnect(config, getAccessToken) {
120
+ const { createImapflowFactory } = await import("../daemon/imapflow.js");
121
+ return async () => {
122
+ const client = await createImapflowFactory({ ...(getAccessToken ? { getAccessToken } : {}) })(config, {
123
+ idleRenewMs: config.tuning.idleRenewMs,
124
+ });
125
+ try {
126
+ await client.connect();
127
+ }
128
+ catch (error) {
129
+ // Do not leak the socket of a client whose connect failed.
130
+ await client.logout().catch(() => undefined);
131
+ throw error;
132
+ }
133
+ return client;
134
+ };
135
+ }