@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +836 -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,114 @@
1
+ // Markdown → HTML for the mail body.
2
+ //
3
+ // The agent answers in Markdown. Sending it escaped means the reader sees `**bold**`,
4
+ // `| a | b |` and ``` fences in a client that could have rendered them.
5
+ //
6
+ // Rendering is delegated to **markdown-it**, configured to be safe by default rather
7
+ // than trusted:
8
+ //
9
+ // - `html: false` — raw HTML in the model output is escaped, not passed through, so
10
+ // there is no sanitiser to get wrong and nothing to lose on a library upgrade.
11
+ // - `linkify: true` — bare URLs become links, which is most of the value of an email.
12
+ // - markdown-it's own `validateLink` refuses `javascript:`, `vbscript:` and `data:`
13
+ // hrefs, so a link in model output cannot become script in the reader's client.
14
+ // - `breaks: true` — single newlines are `<br>`, matching how the body is written
15
+ // (answer, then our fixed footer) instead of reflowing it into one paragraph.
16
+ //
17
+ // Dependency cost is markdown-it plus six small transitive packages (entities,
18
+ // linkify-it, mdurl, argparse, uc.micro, punycode.js), all from the same maintainers.
19
+
20
+ import MarkdownIt from "markdown-it"
21
+
22
+ const md = new MarkdownIt({
23
+ html: false,
24
+ linkify: true,
25
+ breaks: true,
26
+ typographer: false,
27
+ })
28
+
29
+ // --- email-safe styling ------------------------------------------------------
30
+ //
31
+ // markdown-it emits bare tags. Mail clients default to *no* table borders, so a table
32
+ // arrived as loose text; and a `<style>` block is stripped by several clients. Inline
33
+ // styles on each cell are what survive that, so the rendering rules are overridden
34
+ // rather than the output being post-processed.
35
+
36
+ const CELL_BORDER = "border:1px solid #c8ccd1"
37
+
38
+ /**
39
+ * Merge our style with any attributes markdown-it already set (alignment, mostly).
40
+ * `attrs` is typed loosely because @types/markdown-it uses its own TokenAttribute
41
+ * shape; it is always a list of name/value pairs at runtime.
42
+ */
43
+ function styledTag(name: string, rawAttrs: unknown, extra: string): string {
44
+ const pairs = Array.isArray(rawAttrs) ? (rawAttrs as [string, string][]) : []
45
+ const attrs = new Map<string, string>(pairs)
46
+ const existing = attrs.get("style")
47
+ attrs.set("style", existing ? `${existing};${extra}` : extra)
48
+ // cellspacing/cellpadding are attributes, not CSS, and survive sanitising better.
49
+ const rendered = [...attrs].map(([key, value]) => ` ${key}="${md.utils.escapeHtml(value)}"`).join("")
50
+ return `<${name}${rendered}>`
51
+ }
52
+
53
+ md.renderer.rules.table_open = () =>
54
+ '<table cellspacing="0" cellpadding="0" style="border-collapse:collapse;margin:10px 0;font-size:13px">'
55
+ md.renderer.rules.th_open = (tokens, idx) =>
56
+ styledTag("th", tokens[idx]?.attrs, `${CELL_BORDER};padding:6px 10px;background:#f2f4f7;text-align:left`)
57
+ md.renderer.rules.td_open = (tokens, idx) => styledTag("td", tokens[idx]?.attrs, `${CELL_BORDER};padding:6px 10px`)
58
+
59
+ const MONO = "font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"
60
+ md.renderer.rules.fence = (tokens, idx) => {
61
+ const token = tokens[idx]!
62
+ const language = token.info.trim().split(/\s+/)[0] ?? ""
63
+ const cls = language ? ` class="language-${md.utils.escapeHtml(language)}"` : ""
64
+ return (
65
+ `<pre style="background:#f6f8fa;${CELL_BORDER};border-radius:4px;padding:10px;overflow:auto;${MONO};font-size:13px">` +
66
+ `<code${cls}>${md.utils.escapeHtml(token.content)}</code></pre>`
67
+ )
68
+ }
69
+ md.renderer.rules.code_inline = (tokens, idx) =>
70
+ `<code style="background:#f2f4f7;border-radius:3px;padding:1px 4px;${MONO};font-size:13px">${md.utils.escapeHtml(tokens[idx]!.content)}</code>`
71
+
72
+
73
+ /** Inline Markdown (no block structure). */
74
+ export function renderInline(text: string): string {
75
+ return md.renderInline(text)
76
+ }
77
+
78
+ /** Block Markdown as HTML, trimmed so callers can concatenate it. */
79
+ export function renderMarkdown(text: string): string {
80
+ return md.render(text).trim()
81
+ }
82
+
83
+ function escapeHtml(value: string): string {
84
+ return value
85
+ .replace(/&/g, "&amp;")
86
+ .replace(/</g, "&lt;")
87
+ .replace(/>/g, "&gt;")
88
+ .replace(/"/g, "&quot;")
89
+ .replace(/'/g, "&#39;")
90
+ }
91
+
92
+ /** The RFC 3676 signature delimiter, which our own body uses before its metadata. */
93
+ const FOOTER_SPLIT = /^--$/m
94
+
95
+ /**
96
+ * Render an outbound body: Markdown above the `--` delimiter, literal text below it.
97
+ *
98
+ * The footer is deliberately not Markdown. A line of `--` after a text line is a setext
99
+ * heading underline, so rendering it would swallow our metadata into an `<h2>`.
100
+ */
101
+ export function renderBody(text: string): string {
102
+ const index = text.search(FOOTER_SPLIT)
103
+ if (index === -1) return renderMarkdown(text)
104
+
105
+ const head = text.slice(0, index)
106
+ const tailLines = text.slice(index).split("\n")
107
+ // The `--` line itself is dropped in HTML: the top border is the divider there. It
108
+ // stays in the text part, where RFC 3676 needs it and quote stripping relies on it.
109
+ const tail = (tailLines[0]?.trim() === "--" ? tailLines.slice(1) : tailLines).join("\n")
110
+ // A visible divider, but literal text: rendering `--` as Markdown would make it a
111
+ // setext heading underline and swallow the body above it.
112
+ const signatureStyle = "font:inherit;opacity:.75;margin:18px 0 0;padding-top:10px;border-top:1px solid #d8dee4"
113
+ return `${renderMarkdown(head)}\n<pre class="signature" style="${signatureStyle}">${escapeHtml(tail)}</pre>`
114
+ }
@@ -0,0 +1,195 @@
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
+
19
+ import type { TokenProvider } from "./oauth.ts"
20
+
21
+ export interface OAuthConfig {
22
+ /** Issuer / discovery base URL, e.g. https://login.microsoftonline.com/consumers/v2.0 */
23
+ issuer: string
24
+ clientId: string
25
+ /** Omit for a public client using PKCE-style device flow. */
26
+ clientSecret?: string
27
+ /** Space-separated scopes, e.g. "offline_access https://outlook.office.com/IMAP.AccessAsUser.All" */
28
+ scope: string
29
+ }
30
+
31
+ export interface OAuthTokens {
32
+ accessToken: string
33
+ refreshToken?: string
34
+ /** Epoch ms when accessToken expires. */
35
+ expiresAt: number
36
+ }
37
+
38
+ /** What the human has to see to approve the login. */
39
+ export interface DeviceAuthorization {
40
+ verificationUri: string
41
+ /** Short code to type, if the provider uses one. */
42
+ userCode: string
43
+ }
44
+
45
+ /** The OAuth client surface this module needs. */
46
+ export interface OAuthClient {
47
+ deviceAuthorization(): Promise<DeviceAuthorization>
48
+ /** Polls until the human approves or the code expires. */
49
+ pollDeviceAuthorization(): Promise<OAuthTokens>
50
+ refresh(refreshToken: string): Promise<OAuthTokens>
51
+ }
52
+
53
+ /** Persisted tokens. Only the refresh token matters long-term. */
54
+ export interface StoredTokens {
55
+ refreshToken?: string
56
+ accessToken?: string
57
+ expiresAt?: number
58
+ }
59
+
60
+ export interface TokenStore {
61
+ read(): StoredTokens | undefined
62
+ write(tokens: StoredTokens): void
63
+ }
64
+
65
+ export interface DeviceCodeProviderOptions {
66
+ client: OAuthClient
67
+ store: TokenStore
68
+ now?: () => number
69
+ /** Refresh this long before expiry rather than at it. */
70
+ skewMs?: number
71
+ }
72
+
73
+ const DEFAULT_SKEW_MS = 60_000
74
+
75
+ /**
76
+ * A `TokenProvider` backed by the device-code grant.
77
+ *
78
+ * Resolution order: cached access token -> stored refresh token -> fail with an
79
+ * instruction to run the login command. It never silently degrades to a password:
80
+ * half-configured auth is worse than a clear error.
81
+ */
82
+ /** Consecutive refresh failures tolerated before the provider disables itself. */
83
+ const MAX_REFRESH_FAILURES = 3
84
+
85
+ /**
86
+ * Errors that mean the grant itself is gone, so retrying cannot help.
87
+ * Everything else (DNS, TLS, timeout, 5xx) is treated as transient.
88
+ */
89
+ function isCredentialError(error: unknown): boolean {
90
+ const text = error instanceof Error ? `${error.name} ${error.message}` : String(error)
91
+ return /invalid_grant|invalid_request|unauthorized_client|consent_required|interaction_required|refresh token (?:is )?(?:invalid|expired|revoked)/i.test(
92
+ text,
93
+ )
94
+ }
95
+
96
+ export function createDeviceCodeProvider(options: DeviceCodeProviderOptions): TokenProvider & {
97
+ /** Run the interactive login once and persist the refresh token. */
98
+ login(): Promise<DeviceAuthorization>
99
+ } {
100
+ const { client, store } = options
101
+ const now = options.now ?? (() => Date.now())
102
+ const skew = options.skewMs ?? DEFAULT_SKEW_MS
103
+
104
+ let cached: OAuthTokens | undefined
105
+ let failures = 0
106
+ let disabled = false
107
+ let inFlight: Promise<string> | undefined
108
+
109
+ function fresh(tokens: StoredTokens | OAuthTokens | undefined): boolean {
110
+ return Boolean(tokens?.accessToken && tokens.expiresAt && tokens.expiresAt - skew > now())
111
+ }
112
+
113
+ async function acquire(): Promise<string> {
114
+ const stored = store.read()
115
+
116
+ if (fresh(stored)) {
117
+ cached = { accessToken: stored!.accessToken!, expiresAt: stored!.expiresAt! }
118
+ return cached.accessToken
119
+ }
120
+
121
+ if (!stored?.refreshToken) {
122
+ disabled = true
123
+ throw new Error(
124
+ "pi-mailbridge: not authorised yet — run `pi-mailbridge --auth` once to approve device login, or set mail.*.tokenCommand",
125
+ )
126
+ }
127
+
128
+ try {
129
+ const refreshed = await client.refresh(stored.refreshToken)
130
+ cached = refreshed
131
+ store.write({
132
+ ...(refreshed.refreshToken ? { refreshToken: refreshed.refreshToken } : { refreshToken: stored.refreshToken }),
133
+ accessToken: refreshed.accessToken,
134
+ expiresAt: refreshed.expiresAt,
135
+ })
136
+ failures = 0
137
+ return refreshed.accessToken
138
+ } catch (error) {
139
+ failures++
140
+ // A revoked grant is permanent: say so now. A network failure is not - disabling
141
+ // on the first DNS blip stopped every notification until a human restarted pi.
142
+ const fatal = isCredentialError(error) || failures >= MAX_REFRESH_FAILURES
143
+ if (fatal) disabled = true
144
+ const reason = error instanceof Error ? error.message : String(error)
145
+ const suffix = fatal
146
+ ? " — re-run `pi-mailbridge --auth`"
147
+ : ` — retrying on next use (${failures}/${MAX_REFRESH_FAILURES})`
148
+ throw new Error(`pi-mailbridge: OAuth2 token refresh failed: ${reason}${suffix}`)
149
+ }
150
+ }
151
+
152
+ return {
153
+ async getToken() {
154
+ if (cached && fresh(cached)) return cached.accessToken
155
+ if (disabled) {
156
+ throw new Error(
157
+ `pi-mailbridge: OAuth2 is disabled after ${failures} failure(s) — re-run \`pi-mailbridge --auth\``,
158
+ )
159
+ }
160
+ if (!inFlight) {
161
+ inFlight = acquire().finally(() => {
162
+ inFlight = undefined
163
+ })
164
+ }
165
+ return inFlight
166
+ },
167
+
168
+ invalidate() {
169
+ cached = undefined
170
+ const stored = store.read()
171
+ if (stored) store.write({ ...stored, accessToken: undefined, expiresAt: undefined })
172
+ },
173
+
174
+ isDisabled() {
175
+ return disabled
176
+ },
177
+
178
+ state() {
179
+ return { cached: Boolean(cached && fresh(cached)), disabled, failures }
180
+ },
181
+
182
+ async login() {
183
+ const authorization = await client.deviceAuthorization()
184
+ const tokens = await client.pollDeviceAuthorization()
185
+ if (!tokens.refreshToken) {
186
+ throw new Error(
187
+ "pi-mailbridge: the provider returned no refresh_token — make sure `offline_access` is in oauth.scope",
188
+ )
189
+ }
190
+ cached = tokens
191
+ store.write({ refreshToken: tokens.refreshToken, accessToken: tokens.accessToken, expiresAt: tokens.expiresAt })
192
+ return authorization
193
+ },
194
+ }
195
+ }
@@ -0,0 +1,61 @@
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
+
7
+ import * as oidc from "openid-client"
8
+
9
+ import type { DeviceAuthorization, OAuthClient, OAuthConfig, OAuthTokens } from "./oauth-device.ts"
10
+
11
+ type DeviceResponse = Awaited<ReturnType<typeof oidc.initiateDeviceAuthorization>>
12
+ type TokenResponse = Awaited<ReturnType<typeof oidc.pollDeviceAuthorizationGrant>>
13
+
14
+ /** Map an OAuth token response onto our shape. */
15
+ export function toOAuthTokens(tokens: TokenResponse, now: number = Date.now()): OAuthTokens {
16
+ const lifetime = typeof tokens.expires_in === "number" ? tokens.expires_in : 3600
17
+ return {
18
+ accessToken: tokens.access_token,
19
+ ...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
20
+ expiresAt: now + lifetime * 1000,
21
+ }
22
+ }
23
+
24
+ /** Human-facing part of a device authorization response. */
25
+ export function toDeviceAuthorization(response: DeviceResponse): DeviceAuthorization {
26
+ return {
27
+ // Prefer the combined URL when the provider offers one: fewer typos.
28
+ verificationUri: response.verification_uri_complete ?? response.verification_uri,
29
+ userCode: response.user_code,
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Build a real OAuth client for `config`.
35
+ * Async because discovery fetches the provider metadata.
36
+ */
37
+ export async function createOpenidClient(config: OAuthConfig, now: () => number = Date.now): Promise<OAuthClient> {
38
+ const issuer = new URL(config.issuer)
39
+ const configuration = await oidc.discovery(issuer, config.clientId, config.clientSecret)
40
+
41
+ let pending: DeviceResponse | undefined
42
+
43
+ return {
44
+ async deviceAuthorization() {
45
+ pending = await oidc.initiateDeviceAuthorization(configuration, { scope: config.scope })
46
+ return toDeviceAuthorization(pending)
47
+ },
48
+
49
+ async pollDeviceAuthorization() {
50
+ if (!pending) throw new Error("pi-mailbridge: deviceAuthorization() must run before polling")
51
+ const tokens = await oidc.pollDeviceAuthorizationGrant(configuration, pending)
52
+ return toOAuthTokens(tokens, now())
53
+ },
54
+
55
+ async refresh(refreshToken: string) {
56
+ const tokens = await oidc.refreshTokenGrant(configuration, refreshToken)
57
+ return toOAuthTokens(tokens, now())
58
+ },
59
+ }
60
+ }
61
+
@@ -0,0 +1,182 @@
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
+
16
+ import { execFile } from "node:child_process"
17
+
18
+ export interface ExecResult {
19
+ stdout: string
20
+ }
21
+
22
+ export type ExecFileLike = (file: string, args: string[]) => Promise<ExecResult>
23
+
24
+ const DEFAULT_TIMEOUT_MS = 10_000
25
+
26
+ /** Default executor: argv only, bounded output, hard timeout. */
27
+ export const defaultExec: ExecFileLike = (file, args) =>
28
+ new Promise((resolve, reject) => {
29
+ execFile(file, args, { timeout: DEFAULT_TIMEOUT_MS, maxBuffer: 1 << 20, shell: false }, (error, stdout) => {
30
+ if (error) {
31
+ reject(error)
32
+ return
33
+ }
34
+ resolve({ stdout: stdout.toString() })
35
+ })
36
+ })
37
+
38
+ export interface TokenProvider {
39
+ /** Current access token, fetching only when stale. */
40
+ getToken(): Promise<string>
41
+ /** Force the next getToken() to re-run the command (after a 401). */
42
+ invalidate(): void
43
+ /** True once the command has failed past the retry budget. */
44
+ isDisabled(): boolean
45
+ /** Diagnostics for `/mailbridge`: never the token itself. */
46
+ state(): { cached: boolean; disabled: boolean; failures: number }
47
+ }
48
+
49
+ export interface CommandTokenProviderOptions {
50
+ /** argv; argv[0] is the executable. */
51
+ command: readonly string[]
52
+ exec?: ExecFileLike
53
+ now?: () => number
54
+ /** How long a fetched token is considered fresh. Default 50 minutes. */
55
+ ttlMs?: number
56
+ /** Consecutive failures allowed before disabling. Default 3. */
57
+ maxConsecutiveFailures?: number
58
+ }
59
+
60
+ const DEFAULT_TTL_MS = 50 * 60 * 1000
61
+
62
+ export function createCommandTokenProvider(options: CommandTokenProviderOptions): TokenProvider {
63
+ const { command } = options
64
+ const exec = options.exec ?? defaultExec
65
+ const now = options.now ?? (() => Date.now())
66
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
67
+ // "A broken command should not be retried forever" is right; disabling after ONE failure
68
+ // is not - a DNS blip or a momentary exec failure then stops all mail until a restart.
69
+ // Three consecutive failures is still quick to notice and survives a transient one.
70
+ const maxFailures = options.maxConsecutiveFailures ?? 3
71
+
72
+ let token: string | undefined
73
+ let fetchedAt = 0
74
+ let failures = 0
75
+ let disabled = false
76
+ let inFlight: Promise<string> | undefined
77
+
78
+ function describeCommand(): string {
79
+ // Only argv[0]; later args may contain secrets (client ids, paths).
80
+ return command[0] ? `${command[0]} …` : "(empty command)"
81
+ }
82
+
83
+ async function fetchToken(): Promise<string> {
84
+ if (!command.length) {
85
+ disabled = true
86
+ throw new Error("pi-mailbridge: mail.smtp.tokenCommand is empty but authType is XOAUTH2")
87
+ }
88
+ try {
89
+ const { stdout } = await exec(command[0]!, command.slice(1))
90
+ const value = stdout.trim().split(/\r?\n/).pop()?.trim() ?? ""
91
+ if (!value) throw new Error("command produced no output")
92
+ failures = 0
93
+ token = value
94
+ fetchedAt = now()
95
+ return value
96
+ } catch (error) {
97
+ failures++
98
+ if (failures >= maxFailures) disabled = true
99
+ // Node's execFile message is "Command failed: <file> <all args>\n<stderr>", and the
100
+ // argv tail is exactly what describeCommand() refuses to print because it may hold a
101
+ // client secret. Keep the exit shape, drop the rest.
102
+ const reason = (() => {
103
+ const raw = error instanceof Error ? error.message : String(error)
104
+ const code = (error as NodeJS.ErrnoException | undefined)?.code
105
+ const firstLine = raw.split("\n")[0] ?? ""
106
+ const withoutArgv = firstLine.replace(/^Command failed: .*$/i, "command failed")
107
+ const stderr = raw.split("\n").slice(1).join(" ").trim()
108
+ const detail = stderr ? `${withoutArgv}: ${stderr.slice(0, 200)}` : withoutArgv
109
+ return code && !detail.includes(code) ? `${detail} (${code})` : detail
110
+ })()
111
+ const suffix = disabled
112
+ ? " — OAuth2 is now disabled for this session; fix mail.smtp.tokenCommand and restart"
113
+ : ` — will retry on next use (${failures}/${maxFailures})`
114
+ throw new Error(`pi-mailbridge: OAuth2 token command failed (${describeCommand()}): ${reason}${suffix}`)
115
+ }
116
+ }
117
+
118
+ return {
119
+ async getToken() {
120
+ if (disabled) {
121
+ throw new Error(
122
+ `pi-mailbridge: OAuth2 token provider disabled after ${failures} failure(s) (${describeCommand()}); re-authenticate and restart`,
123
+ )
124
+ }
125
+ if (token && now() - fetchedAt < ttlMs) return token
126
+ // Collapse concurrent callers onto one exec.
127
+ if (!inFlight) {
128
+ inFlight = fetchToken().finally(() => {
129
+ inFlight = undefined
130
+ })
131
+ }
132
+ return inFlight
133
+ },
134
+
135
+ invalidate() {
136
+ token = undefined
137
+ fetchedAt = 0
138
+ },
139
+
140
+ isDisabled() {
141
+ return disabled
142
+ },
143
+
144
+ state() {
145
+ return { cached: token !== undefined && now() - fetchedAt < ttlMs, disabled, failures }
146
+ },
147
+ }
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Auth-failure detection + one-shot refresh
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /** SMTP/IMAP auth failures as surfacing from the transport or a provider API. */
155
+ const AUTH_ERROR_RE = /(\b401\b|\b535\b|\b534\b|invalid.?credentials?|InvalidAuthenticationToken|AUTHENTICATIONFAILED|token.?(expired|invalid))/i
156
+
157
+ export function isAuthError(error: unknown): boolean {
158
+ const text = error instanceof Error ? `${error.name} ${error.message}` : String(error)
159
+ return AUTH_ERROR_RE.test(text)
160
+ }
161
+
162
+ /**
163
+ * Run `use(token)`; on an auth error invalidate and retry exactly once.
164
+ *
165
+ * One retry, not a loop: a second failure means the credential is genuinely bad,
166
+ * and retrying forever would hammer the provider and get the account locked.
167
+ */
168
+ export async function withFreshToken<T>(
169
+ provider: TokenProvider,
170
+ use: (accessToken: string) => Promise<T>,
171
+ isAuth: (error: unknown) => boolean = isAuthError,
172
+ ): Promise<T> {
173
+ const first = await provider.getToken()
174
+ try {
175
+ return await use(first)
176
+ } catch (error) {
177
+ if (!isAuth(error)) throw error
178
+ provider.invalidate()
179
+ const second = await provider.getToken()
180
+ return use(second)
181
+ }
182
+ }
@@ -0,0 +1,74 @@
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
+ import type { ResolvedConfig } from "./config.ts"
13
+
14
+ export type PrimePlan =
15
+ | { ok: false; currentUid: number }
16
+ | { ok: true; highest: number; count: number; currentUid: number }
17
+
18
+ /**
19
+ * Decide whether to prime, given the stored cursor and the mailbox's UIDs.
20
+ *
21
+ * Pure: the callers do the IMAP work, so the rule is testable without a server.
22
+ */
23
+ export function planPrime(currentUid: number, uids: readonly number[], alreadyWatching: boolean): PrimePlan {
24
+ // Neither "the file exists" nor "uid > 0" answers this on its own. The daemon writes the file
25
+ // on its first connect, before reading anything (a fresh install wants prime); a renumbering
26
+ // resets the uid to 0 while the mailbox is already being watched (prime would skip exactly
27
+ // the mail the reset was meant to re-read). The caller passes a flag set by prime itself.
28
+ if (alreadyWatching) return { ok: false, currentUid }
29
+ const highest = uids.length > 0 ? Math.max(...uids) : 0
30
+ return { ok: true, highest, count: uids.length, currentUid }
31
+ }
32
+
33
+ /** The message shown when priming is refused, in both surfaces. */
34
+ export function alreadyPrimedMessage(currentUid: number): string {
35
+ return (
36
+ `already watching from uid ${currentUid} - prime is a first-run step, and moving the cursor ` +
37
+ `now would skip any reply that arrived while the bridge was not scanning. ` +
38
+ `To re-prime deliberately, remove uid-cursor.json from the state directory.`
39
+ )
40
+ }
41
+
42
+ export interface PrimeDeps {
43
+ /** Injected so the two surfaces can share this without importing each other's plumbing. */
44
+ openMailbox: (config: ResolvedConfig) => Promise<{
45
+ search: () => Promise<number[]>
46
+ close: () => Promise<void>
47
+ }>
48
+ readCursor: () => number
49
+ /** Whether this mailbox is already being watched; see isPrimed. */
50
+ isPrimed: () => boolean
51
+ /** Record that prime ran, so the next attempt refuses. */
52
+ markPrimed: () => void
53
+ advanceCursor: (uid: number) => void
54
+ }
55
+
56
+ /**
57
+ * Run the check, and only touch the cursor when the plan allows it.
58
+ *
59
+ * The mailbox is still searched when priming is refused: the count is what makes the refusal
60
+ * concrete ("3 messages are in that range"), and a search is cheap.
61
+ */
62
+ export async function runPrime(config: ResolvedConfig, deps: PrimeDeps): Promise<PrimePlan> {
63
+ const mailbox = await deps.openMailbox(config)
64
+ try {
65
+ const plan = planPrime(deps.readCursor(), await mailbox.search(), deps.isPrimed())
66
+ if (plan.ok) {
67
+ deps.advanceCursor(plan.highest)
68
+ deps.markPrimed()
69
+ }
70
+ return plan
71
+ } finally {
72
+ await mailbox.close().catch(() => undefined)
73
+ }
74
+ }