@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,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, "&")
|
|
86
|
+
.replace(/</g, "<")
|
|
87
|
+
.replace(/>/g, ">")
|
|
88
|
+
.replace(/"/g, """)
|
|
89
|
+
.replace(/'/g, "'")
|
|
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
|
+
}
|