@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,207 @@
|
|
|
1
|
+
// Live connectivity probe, shared by `/mailbridge smoke` and the headless
|
|
2
|
+
// `--smoke` entry point so the two can never drift.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately does NOT touch the cursor, the journal or any pending record: a
|
|
5
|
+
// smoke test must never disturb real deliveries.
|
|
6
|
+
|
|
7
|
+
import type { ResolvedConfig } from "./config.ts"
|
|
8
|
+
import { sendMail } from "./mailer.ts"
|
|
9
|
+
import { createImapflowFactory } from "../daemon/imapflow.ts"
|
|
10
|
+
import { messageIdFromHeaders, stampedIdFromHeaders } from "./sent-index.ts"
|
|
11
|
+
|
|
12
|
+
export interface SmokeStep {
|
|
13
|
+
ok: boolean
|
|
14
|
+
/** What was attempted, e.g. "smtp". */
|
|
15
|
+
step: "smtp" | "imap" | "echo"
|
|
16
|
+
/** Human-readable outcome. */
|
|
17
|
+
detail: string
|
|
18
|
+
/** Hint shown when the failure looks like the personal-Outlook problem. */
|
|
19
|
+
hint?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SmokeResult {
|
|
23
|
+
ok: boolean
|
|
24
|
+
steps: SmokeStep[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SmokeDeps {
|
|
28
|
+
getAccessToken?: () => Promise<string>
|
|
29
|
+
/** Injected for tests. */
|
|
30
|
+
send?: typeof sendMail
|
|
31
|
+
connect?: (config: ResolvedConfig, getAccessToken?: () => Promise<string>) => Promise<{
|
|
32
|
+
count(): Promise<number>
|
|
33
|
+
/** Raw source of the messages seen since `sinceMs`, for the echo probe. */
|
|
34
|
+
recentSources?(sinceMs: number): Promise<Buffer[]>
|
|
35
|
+
close(): Promise<void>
|
|
36
|
+
}>
|
|
37
|
+
/** Retry delays for the echo probe: the provider delivers the copy asynchronously. */
|
|
38
|
+
echoRetryDelaysMs?: readonly number[]
|
|
39
|
+
sleep?: (ms: number) => Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const defaultConnect: NonNullable<SmokeDeps["connect"]> = async (config, getAccessToken) => {
|
|
43
|
+
const factory = createImapflowFactory({ getAccessToken })
|
|
44
|
+
const client = await factory(config, { idleRenewMs: config.tuning.idleRenewMs })
|
|
45
|
+
await client.connect()
|
|
46
|
+
await client.mailboxOpen(config.mail.imap.folder || "INBOX")
|
|
47
|
+
return {
|
|
48
|
+
count: async () => (await client.search({ uid: "1:*" }, { uid: true })).length,
|
|
49
|
+
recentSources: async (sinceMs: number) => {
|
|
50
|
+
const uids = await client.search({ since: new Date(sinceMs) }, { uid: true })
|
|
51
|
+
const newest = uids.slice().sort((a, b) => b - a).slice(0, 20)
|
|
52
|
+
const sources: Buffer[] = []
|
|
53
|
+
for (const uid of newest) {
|
|
54
|
+
const fetched = (await client.fetchOne(uid, { source: true }, { uid: true })) as
|
|
55
|
+
| { source?: Buffer }
|
|
56
|
+
| false
|
|
57
|
+
| undefined
|
|
58
|
+
if (fetched && fetched.source) sources.push(fetched.source)
|
|
59
|
+
}
|
|
60
|
+
return sources
|
|
61
|
+
},
|
|
62
|
+
close: async () => {
|
|
63
|
+
try {
|
|
64
|
+
await client.logout()
|
|
65
|
+
} catch {
|
|
66
|
+
/* already gone */
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The personal-Outlook failure modes, which mean "use Graph", not "retry". */
|
|
73
|
+
export function looksLikeOutlookRefusal(message: string): boolean {
|
|
74
|
+
return /authenticated but not connected|smtpclientauthentication is disabled|535 authentication unsuccessful/i.test(message)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function runSmoke(config: ResolvedConfig, deps: SmokeDeps = {}): Promise<SmokeResult> {
|
|
78
|
+
const steps: SmokeStep[] = []
|
|
79
|
+
const send = deps.send ?? sendMail
|
|
80
|
+
/** The Message-ID we stamped, so the echo probe can look for it. */
|
|
81
|
+
let lastMessageId: string | undefined
|
|
82
|
+
|
|
83
|
+
// --- SMTP ---
|
|
84
|
+
try {
|
|
85
|
+
const stamp = new Date().toISOString()
|
|
86
|
+
const sent = await send(
|
|
87
|
+
config,
|
|
88
|
+
{
|
|
89
|
+
to: config.mail.to,
|
|
90
|
+
subject: `pi-mailbridge smoke test ${stamp}`,
|
|
91
|
+
text: `This is a smoke test from ${config.device.name}.\n\nIf you can read this, SMTP works.\n`,
|
|
92
|
+
},
|
|
93
|
+
{ getAccessToken: deps.getAccessToken },
|
|
94
|
+
)
|
|
95
|
+
lastMessageId = sent.messageId
|
|
96
|
+
steps.push({
|
|
97
|
+
ok: true,
|
|
98
|
+
step: "smtp",
|
|
99
|
+
detail: `sent to ${config.mail.to}${sent.messageId ? ` (${sent.messageId})` : ""}`,
|
|
100
|
+
})
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const detail = (error as Error).message
|
|
103
|
+
steps.push({
|
|
104
|
+
ok: false,
|
|
105
|
+
step: "smtp",
|
|
106
|
+
detail,
|
|
107
|
+
...(looksLikeOutlookRefusal(detail) ? { hint: "the account refuses SMTP AUTH; the Graph transport is the fix — see docs/OAUTH.md" } : {}),
|
|
108
|
+
})
|
|
109
|
+
return { ok: false, steps }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- IMAP ---
|
|
113
|
+
const folder = config.mail.imap.folder || "INBOX"
|
|
114
|
+
let connection:
|
|
115
|
+
| { count(): Promise<number>; recentSources?(sinceMs: number): Promise<Buffer[]>; close(): Promise<void> }
|
|
116
|
+
| undefined
|
|
117
|
+
try {
|
|
118
|
+
connection = await (deps.connect ?? defaultConnect)(config, deps.getAccessToken)
|
|
119
|
+
const count = await connection.count()
|
|
120
|
+
steps.push({ ok: true, step: "imap", detail: `${count} message(s) visible in ${folder}` })
|
|
121
|
+
|
|
122
|
+
// --- echo probe ---
|
|
123
|
+
//
|
|
124
|
+
// Only meaningful in the single-mailbox layout: there the notification is delivered into
|
|
125
|
+
// the folder we watch, so we can see what the provider did to our headers. In layout A it
|
|
126
|
+
// goes to the human's mailbox and anything found here is unrelated.
|
|
127
|
+
//
|
|
128
|
+
// The copy arrives asynchronously (QQ: 20-30 s) and is identified by the
|
|
129
|
+
// `X-Pi-Mailbridge-Id` we stamped — reading "the newest message" once produced a false
|
|
130
|
+
// alarm on a live account.
|
|
131
|
+
const sameMailbox =
|
|
132
|
+
Boolean(config.mail.to) &&
|
|
133
|
+
config.mail.to.trim().toLowerCase() === (config.mail.imap.user ?? "").trim().toLowerCase()
|
|
134
|
+
if (sameMailbox && lastMessageId && connection.recentSources) {
|
|
135
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
|
|
136
|
+
const delays = deps.echoRetryDelaysMs ?? [2_000, 5_000, 15_000, 30_000]
|
|
137
|
+
const wanted = lastMessageId.replace(/[<>]/g, "")
|
|
138
|
+
const since = Date.now() - 15 * 60_000
|
|
139
|
+
let seen: "none" | "marker" | "id" | "rewritten" = "none"
|
|
140
|
+
for (const delay of delays) {
|
|
141
|
+
await sleep(delay)
|
|
142
|
+
for (const source of await connection.recentSources(since)) {
|
|
143
|
+
const head = (source.toString("utf8").split(/\r?\n\r?\n/)[0] ?? "").replace(/\r?\n[ \t]+/g, " ")
|
|
144
|
+
// The stamp is how we find the copy, but a provider can strip it while leaving
|
|
145
|
+
// our marker. Treat that copy as ours too, or the probe reports "did not come
|
|
146
|
+
// back" for a notification the scanner would recognise perfectly well.
|
|
147
|
+
const stamped = stampedIdFromHeaders(head)
|
|
148
|
+
const marked = /^x-pi-mailbridge:\s*notification/im.test(head)
|
|
149
|
+
if (stamped !== wanted && !(marked && stamped === undefined)) continue
|
|
150
|
+
// Classify on what the scanner actually uses: the marker, then our
|
|
151
|
+
// Message-ID. Claiming the Message-ID survived without checking it was the
|
|
152
|
+
// bug the live account exposed.
|
|
153
|
+
if (/^x-pi-mailbridge:\s*notification/im.test(head)) seen = "marker"
|
|
154
|
+
else if (messageIdFromHeaders(head) === wanted) seen = "id"
|
|
155
|
+
else seen = "rewritten"
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
if (seen !== "none") break
|
|
159
|
+
}
|
|
160
|
+
steps.push(
|
|
161
|
+
(() => {
|
|
162
|
+
if (seen === "none") {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
step: "echo" as const,
|
|
166
|
+
detail:
|
|
167
|
+
"our notification did not come back to this folder — single-mailbox mode relies on it returning with a header it can recognise",
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (seen === "marker") {
|
|
171
|
+
return { ok: true, step: "echo" as const, detail: "our marker header survives delivery" }
|
|
172
|
+
}
|
|
173
|
+
if (seen === "id") {
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
step: "echo" as const,
|
|
177
|
+
detail: "marker header was stripped, but our Message-ID survived — the guard still holds",
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// The provider rewrote both. Whether that is safe depends entirely on the
|
|
181
|
+
// sent-copy read-back, which is what then records the delivered id.
|
|
182
|
+
const covered = Boolean(config.mail.imap.sentFolder)
|
|
183
|
+
return {
|
|
184
|
+
ok: covered,
|
|
185
|
+
step: "echo" as const,
|
|
186
|
+
detail: covered
|
|
187
|
+
? "the provider rewrote both our headers; the sentFolder read-back records the delivered id, so the registry still catches our own mail"
|
|
188
|
+
: "the provider rewrote both our headers and mail.imap.sentFolder is not set — our own mail is not recognisable; set it",
|
|
189
|
+
}
|
|
190
|
+
})(),
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const detail = (error as Error).message
|
|
196
|
+
steps.push({
|
|
197
|
+
ok: false,
|
|
198
|
+
step: "imap",
|
|
199
|
+
detail,
|
|
200
|
+
...(looksLikeOutlookRefusal(detail) ? { hint: "the account refuses IMAP for this token; the Graph transport is the fix — see docs/OAUTH.md" } : {}),
|
|
201
|
+
})
|
|
202
|
+
} finally {
|
|
203
|
+
await connection?.close()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { ok: steps.every((s) => s.ok), steps }
|
|
207
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Turning a scan summary into the lines `/mailbridge status` shows.
|
|
2
|
+
//
|
|
3
|
+
// Pure on purpose: this is the answer to "why did my reply not arrive", and it lived inline
|
|
4
|
+
// in the extension where no test could reach it - which is how an unconditional "queue full"
|
|
5
|
+
// line shipped in its first version.
|
|
6
|
+
|
|
7
|
+
export interface ScanSummary {
|
|
8
|
+
/** When the last scan that saw mail ran; absent until one has. */
|
|
9
|
+
at?: number
|
|
10
|
+
/** When the daemon last *checked*, which is later than `at` on a quiet mailbox. */
|
|
11
|
+
checkedAt?: number
|
|
12
|
+
examined?: number
|
|
13
|
+
persisted?: number
|
|
14
|
+
rejected?: number
|
|
15
|
+
reasons?: Record<string, number>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Counter keys that are not refusals.
|
|
20
|
+
*
|
|
21
|
+
* `routed` / `threaded` / `journaled` / `vanished` are successful paths. `route-miss` is a
|
|
22
|
+
* miss on the subject id that still routed by token or chain. `attachment-*` drops one file
|
|
23
|
+
* but keeps the reply.
|
|
24
|
+
*/
|
|
25
|
+
const HANDLED = new Set([
|
|
26
|
+
"routed",
|
|
27
|
+
"threaded",
|
|
28
|
+
"journaled",
|
|
29
|
+
"vanished",
|
|
30
|
+
"route-miss",
|
|
31
|
+
// In single-mailbox mode our own notifications come back on every send. Rejecting them is
|
|
32
|
+
// the system working, so counting them as rejections made a healthy install look broken.
|
|
33
|
+
"self-echo",
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
/** Deferred, not dropped: the queue was full and the same UID is retried. */
|
|
37
|
+
const DEFERRED = new Set(["pending-full"])
|
|
38
|
+
|
|
39
|
+
export function formatScan(summary: ScanSummary | null | undefined): string[] {
|
|
40
|
+
if (!summary) return ["last scan: (never — is the daemon running?)"]
|
|
41
|
+
if (summary.at === undefined) {
|
|
42
|
+
// No scan has seen mail yet. Say when we last looked: otherwise a healthy quiet
|
|
43
|
+
// mailbox is indistinguishable from a dead poll loop.
|
|
44
|
+
const checked = summary.checkedAt ? new Date(summary.checkedAt).toLocaleTimeString() : "unknown"
|
|
45
|
+
return [`no mail yet — last checked ${checked}`]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const entries = Object.entries(summary.reasons ?? {}).filter(([, n]) => Number(n) > 0)
|
|
49
|
+
const list = (pick: (reason: string) => boolean): string =>
|
|
50
|
+
entries
|
|
51
|
+
.filter(([reason]) => pick(reason))
|
|
52
|
+
.map(([reason, n]) => `${reason} ${n}`)
|
|
53
|
+
.join(", ")
|
|
54
|
+
|
|
55
|
+
const refused = list((r) => !HANDLED.has(r) && !DEFERRED.has(r) && !r.startsWith("attachment-"))
|
|
56
|
+
const handled = list((r) => HANDLED.has(r))
|
|
57
|
+
const dropped = list((r) => r.startsWith("attachment-"))
|
|
58
|
+
const deferred = entries.some(([reason]) => DEFERRED.has(reason))
|
|
59
|
+
|
|
60
|
+
// Freshness stays visible after the first message: "last scan with mail: 10:00" alone cannot
|
|
61
|
+
// tell a live poll loop from one that died at 10:05.
|
|
62
|
+
const checked =
|
|
63
|
+
summary.checkedAt && summary.checkedAt - summary.at > 60_000
|
|
64
|
+
? ` (last checked ${new Date(summary.checkedAt).toLocaleTimeString()})`
|
|
65
|
+
: ""
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
`last scan with mail: ${new Date(summary.at).toLocaleTimeString()}${checked} — examined ${summary.examined}, kept ${summary.persisted}, rejected ${summary.rejected}`,
|
|
69
|
+
`rejections: ${refused || "none"}`,
|
|
70
|
+
...(handled ? [`handled: ${handled}`] : []),
|
|
71
|
+
...(dropped ? [`attachments dropped: ${dropped}`] : []),
|
|
72
|
+
// Conditional, and it says what the reader should do about it.
|
|
73
|
+
...(deferred ? ["queue full: replies are deferred, not dropped — start a session so they drain"] : []),
|
|
74
|
+
]
|
|
75
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Which token source this installation uses.
|
|
2
|
+
//
|
|
3
|
+
// Shared by the daemon and the in-pi commands so `/mailbridge smoke` and the
|
|
4
|
+
// background daemon can never authenticate differently.
|
|
5
|
+
|
|
6
|
+
import type { ResolvedConfig } from "./config.ts"
|
|
7
|
+
import { createCommandTokenProvider, type TokenProvider } from "./oauth.ts"
|
|
8
|
+
import { createDeviceCodeProvider } from "./oauth-device.ts"
|
|
9
|
+
import { createOpenidClient } from "./oauth-openid.ts"
|
|
10
|
+
import { fileTokenStore } from "../store/oauth-store.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Precedence: built-in device-code OAuth2 (self-refreshing) -> a configured
|
|
14
|
+
* command -> none (the account uses LOGIN).
|
|
15
|
+
* Returning undefined is not an error: it means XOAUTH2 is not in play.
|
|
16
|
+
*/
|
|
17
|
+
export async function buildTokenProvider(
|
|
18
|
+
config: ResolvedConfig,
|
|
19
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
20
|
+
): Promise<TokenProvider | undefined> {
|
|
21
|
+
if (config.oauth?.issuer && config.oauth.clientId) {
|
|
22
|
+
const client = await createOpenidClient(config.oauth)
|
|
23
|
+
return createDeviceCodeProvider({ client, store: fileTokenStore(env) })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// An empty array is not nullish: the shipped example has `mail.smtp.tokenCommand: []`, so
|
|
27
|
+
// `??` picked the empty SMTP list and ignored a perfectly good IMAP command - XOAUTH2 then
|
|
28
|
+
// failed at connect time while `--check` said the config was fine.
|
|
29
|
+
const smtp = config.mail.smtp.tokenCommand ?? []
|
|
30
|
+
const command = smtp.length > 0 ? smtp : (config.mail.imap.tokenCommand ?? [])
|
|
31
|
+
if (command.length > 0) return createCommandTokenProvider({ command })
|
|
32
|
+
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// HMAC-signed routing tokens.
|
|
2
|
+
//
|
|
3
|
+
// A signed token is the fallback routing form: it is minted when no local route id is
|
|
4
|
+
// available (see store/routes.ts), and lifted back out of replies. It answers exactly one question: "which session did this mail come from?"
|
|
5
|
+
// It is ROUTING, not AUTHORIZATION — see docs/SECURITY.md §2.2.
|
|
6
|
+
//
|
|
7
|
+
// Two wire formats, because subjects are user-visible and the first one was ugly:
|
|
8
|
+
//
|
|
9
|
+
// v2 (current) [pi:<payload>.<mac>]
|
|
10
|
+
// payload = base64url( [0x02][16 session uuid][3 expiry minutes][2 device hash] )
|
|
11
|
+
// mac = base64url(HMAC-SHA256(secret, payload)) truncated to 6 bytes
|
|
12
|
+
// → 30 + 1 + 8 = 39 characters, so a whole subject stays under ~90
|
|
13
|
+
//
|
|
14
|
+
// v2-legacy (21-byte payload, minted before expiry went minute-granular)
|
|
15
|
+
// payload = base64url( [0x02][16 session uuid][2 expiry days][2 device hash] )
|
|
16
|
+
// → 28 + 1 + 8 = 37 characters. Still verified: a notification sent minutes before
|
|
17
|
+
// an upgrade keeps working.
|
|
18
|
+
//
|
|
19
|
+
// v1 (legacy) base64url(JSON {s,e,d}) + '.' + 16-byte mac
|
|
20
|
+
// → ~130 characters. Still verified, so tokens already in flight keep working.
|
|
21
|
+
//
|
|
22
|
+
// A non-UUID session id (tests, synthetic sessions) has no compact form, so those
|
|
23
|
+
// keep using v1.
|
|
24
|
+
|
|
25
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"
|
|
26
|
+
|
|
27
|
+
/** MAC length for v2. 48 bits is ample for an online-verifiable routing token. */
|
|
28
|
+
const MAC_BYTES_V2 = 6
|
|
29
|
+
/** MAC length for v1, kept for compatibility with tokens already sent. */
|
|
30
|
+
const MAC_BYTES_V1 = 16
|
|
31
|
+
/** Tokens older than this are refused even when the MAC is valid. */
|
|
32
|
+
export const DEFAULT_TOKEN_TTL_HOURS = 30 * 24
|
|
33
|
+
|
|
34
|
+
const V2 = 0x02
|
|
35
|
+
/**
|
|
36
|
+
* [version][16 session][3 expiry][2 device]
|
|
37
|
+
*
|
|
38
|
+
* Expiry is minutes since the epoch: three bytes covers ~31 years, and a day of
|
|
39
|
+
* granularity (two bytes) let a token expire up to 24 h earlier than configured.
|
|
40
|
+
*/
|
|
41
|
+
const V2_PAYLOAD_BYTES = 1 + 16 + 3 + 2
|
|
42
|
+
/**
|
|
43
|
+
* Expiry is minutes since 2020, not since 1970: three bytes reaches ~2052 from here, where
|
|
44
|
+
* minutes since 1970 topped out in 2001 and silently clamped every token to "expired".
|
|
45
|
+
*/
|
|
46
|
+
const MINUTE_EPOCH = Date.UTC(2020, 0, 1)
|
|
47
|
+
|
|
48
|
+
export interface TokenBody {
|
|
49
|
+
/** session id */
|
|
50
|
+
s: string
|
|
51
|
+
/** expiry, epoch ms */
|
|
52
|
+
e: number
|
|
53
|
+
/** minting device, when known */
|
|
54
|
+
d?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const TOKEN_RE = /\[\s*pi:([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\s*\]/i
|
|
58
|
+
|
|
59
|
+
function mac(secret: Buffer, payload: string, bytes: number): string {
|
|
60
|
+
return createHmac("sha256", secret).update(payload).digest().subarray(0, bytes).toString("base64url")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 32 hex characters (with or without dashes) → 16 bytes, else undefined. */
|
|
64
|
+
function uuidToBytes(sessionId: string): Buffer | undefined {
|
|
65
|
+
const hex = sessionId.replace(/-/g, "")
|
|
66
|
+
if (!/^[0-9a-fA-F]{32}$/.test(hex)) return undefined
|
|
67
|
+
return Buffer.from(hex, "hex")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function bytesToUuid(bytes: Buffer): string {
|
|
71
|
+
const hex = bytes.toString("hex")
|
|
72
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Stable 2-byte fingerprint of a device name; enough to route, not to identify.
|
|
77
|
+
* All-zero means "no device claim", which must not read as a mismatch.
|
|
78
|
+
*/
|
|
79
|
+
function deviceHash(device: string): Buffer {
|
|
80
|
+
return createHash("sha256").update(device.toLowerCase()).digest().subarray(0, 2)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const NO_DEVICE = Buffer.alloc(2)
|
|
84
|
+
|
|
85
|
+
export interface SignOptions {
|
|
86
|
+
ttlHours?: number
|
|
87
|
+
now?: number
|
|
88
|
+
device?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function signToken(sessionId: string, secret: Buffer, opts: SignOptions = {}): string {
|
|
92
|
+
const now = opts.now ?? Date.now()
|
|
93
|
+
const expiresAt = now + (opts.ttlHours ?? DEFAULT_TOKEN_TTL_HOURS) * 3600_000
|
|
94
|
+
|
|
95
|
+
const compactSession = uuidToBytes(sessionId)
|
|
96
|
+
if (compactSession) {
|
|
97
|
+
const payload = Buffer.alloc(V2_PAYLOAD_BYTES)
|
|
98
|
+
payload[0] = V2
|
|
99
|
+
compactSession.copy(payload, 1)
|
|
100
|
+
const minutes = Math.floor((expiresAt - MINUTE_EPOCH) / 60_000)
|
|
101
|
+
payload.writeUIntBE(Math.max(0, Math.min(0xffffff, minutes)), 17, 3)
|
|
102
|
+
;(opts.device ? deviceHash(opts.device) : NO_DEVICE).copy(payload, 20)
|
|
103
|
+
const encoded = payload.toString("base64url")
|
|
104
|
+
return `${encoded}.${mac(secret, encoded, MAC_BYTES_V2)}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// v1: no compact form for an arbitrary session id.
|
|
108
|
+
const body: TokenBody = { s: sessionId, e: expiresAt, ...(opts.device ? { d: opts.device } : {}) }
|
|
109
|
+
const payload = Buffer.from(JSON.stringify(body)).toString("base64url")
|
|
110
|
+
return `${payload}.${mac(secret, payload, MAC_BYTES_V1)}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type VerifyResult =
|
|
114
|
+
| {
|
|
115
|
+
ok: true
|
|
116
|
+
sessionId: string
|
|
117
|
+
/** Full device name, when the format carries one (v1). */
|
|
118
|
+
device?: string
|
|
119
|
+
/**
|
|
120
|
+
* True/false when the caller supplied its own device to compare against;
|
|
121
|
+
* undefined when the token or the caller carries no device, meaning "no
|
|
122
|
+
* opinion" rather than "mismatch".
|
|
123
|
+
*/
|
|
124
|
+
deviceMatches?: boolean
|
|
125
|
+
}
|
|
126
|
+
| { ok: false; reason: "malformed" | "bad-mac" | "expired" }
|
|
127
|
+
|
|
128
|
+
function splitToken(token: string): { payload: string; given: string } | undefined {
|
|
129
|
+
const dot = token.lastIndexOf(".")
|
|
130
|
+
if (dot <= 0 || dot === token.length - 1) return undefined
|
|
131
|
+
return { payload: token.slice(0, dot), given: token.slice(dot + 1) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function macMatches(given: string, expected: string): boolean {
|
|
135
|
+
const a = Buffer.from(given)
|
|
136
|
+
const b = Buffer.from(expected)
|
|
137
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function verifyToken(token: string, secret: Buffer, now: number = Date.now(), device?: string): VerifyResult {
|
|
141
|
+
const parts = splitToken(token)
|
|
142
|
+
if (!parts) return { ok: false, reason: "malformed" }
|
|
143
|
+
const { payload, given } = parts
|
|
144
|
+
|
|
145
|
+
let bytes: Buffer
|
|
146
|
+
try {
|
|
147
|
+
bytes = Buffer.from(payload, "base64url")
|
|
148
|
+
} catch {
|
|
149
|
+
return { ok: false, reason: "malformed" }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const fingerprintOf = (at: number): Buffer => bytes.subarray(at, at + 2)
|
|
153
|
+
|
|
154
|
+
// --- v2: compact binary payload ---
|
|
155
|
+
if (bytes.length === V2_PAYLOAD_BYTES && bytes[0] === V2) {
|
|
156
|
+
if (!macMatches(given, mac(secret, payload, MAC_BYTES_V2))) return { ok: false, reason: "bad-mac" }
|
|
157
|
+
const expiresAt = MINUTE_EPOCH + bytes.readUIntBE(17, 3) * 60_000
|
|
158
|
+
if (expiresAt <= now) return { ok: false, reason: "expired" }
|
|
159
|
+
const fingerprint = fingerprintOf(20)
|
|
160
|
+
const claimsDevice = !fingerprint.equals(NO_DEVICE)
|
|
161
|
+
const deviceMatches = device && claimsDevice ? fingerprint.equals(deviceHash(device)) : undefined
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
sessionId: bytesToUuid(bytes.subarray(1, 17)),
|
|
165
|
+
...(deviceMatches === undefined ? {} : { deviceMatches }),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- v2-legacy: same shape with two-byte day expiry, minted before the layout change ---
|
|
170
|
+
if (bytes.length === 21 && bytes[0] === V2) {
|
|
171
|
+
if (!macMatches(given, mac(secret, payload, MAC_BYTES_V2))) return { ok: false, reason: "bad-mac" }
|
|
172
|
+
const expiresAt = bytes.readUInt16BE(17) * 86_400_000
|
|
173
|
+
if (expiresAt <= now) return { ok: false, reason: "expired" }
|
|
174
|
+
const fingerprint = fingerprintOf(19)
|
|
175
|
+
const claimsDevice = !fingerprint.equals(NO_DEVICE)
|
|
176
|
+
const deviceMatches = device && claimsDevice ? fingerprint.equals(deviceHash(device)) : undefined
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
sessionId: bytesToUuid(bytes.subarray(1, 17)),
|
|
180
|
+
...(deviceMatches === undefined ? {} : { deviceMatches }),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- v1: JSON payload ---
|
|
185
|
+
if (!macMatches(given, mac(secret, payload, MAC_BYTES_V1))) return { ok: false, reason: "bad-mac" }
|
|
186
|
+
let body: TokenBody
|
|
187
|
+
try {
|
|
188
|
+
body = JSON.parse(bytes.toString("utf8")) as TokenBody
|
|
189
|
+
} catch {
|
|
190
|
+
return { ok: false, reason: "malformed" }
|
|
191
|
+
}
|
|
192
|
+
if (typeof body?.s !== "string" || typeof body?.e !== "number") return { ok: false, reason: "malformed" }
|
|
193
|
+
if (body.e <= now) return { ok: false, reason: "expired" }
|
|
194
|
+
const claimedDevice = typeof body.d === "string" && body.d ? body.d : undefined
|
|
195
|
+
const deviceMatches = claimedDevice && device ? claimedDevice === device : undefined
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
sessionId: body.s,
|
|
199
|
+
...(claimedDevice ? { device: claimedDevice } : {}),
|
|
200
|
+
...(deviceMatches === undefined ? {} : { deviceMatches }),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Prefix a human subject with the signed token. */
|
|
205
|
+
export function stampSubject(sessionId: string, subject: string, secret: Buffer, opts?: SignOptions): string {
|
|
206
|
+
return `[pi:${signToken(sessionId, secret, opts)}] ${subject}`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Pull a verified routing token out of a subject line.
|
|
211
|
+
* Tolerates client prefixes (`Re:`, `RE:`, `AW:`, `Re[2]:`) because the token is
|
|
212
|
+
* matched anywhere in the subject, not anchored.
|
|
213
|
+
*/
|
|
214
|
+
export function tokenFromSubject(
|
|
215
|
+
subject: string | undefined | null,
|
|
216
|
+
secret: Buffer,
|
|
217
|
+
now?: number,
|
|
218
|
+
device?: string,
|
|
219
|
+
): { sessionId: string; device?: string; deviceMatches?: boolean } | undefined {
|
|
220
|
+
if (!subject) return undefined
|
|
221
|
+
const m = TOKEN_RE.exec(subject)
|
|
222
|
+
if (!m?.[1]) return undefined
|
|
223
|
+
const result = verifyToken(m[1], secret, now, device)
|
|
224
|
+
if (!result.ok) return undefined
|
|
225
|
+
return {
|
|
226
|
+
sessionId: result.sessionId,
|
|
227
|
+
...(result.device ? { device: result.device } : {}),
|
|
228
|
+
...(result.deviceMatches === undefined ? {} : { deviceMatches: result.deviceMatches }),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Convenience wrapper for callers that only need the session id. */
|
|
233
|
+
export function sessionIdFromSubject(subject: string | undefined | null, secret: Buffer, now?: number): string | undefined {
|
|
234
|
+
return tokenFromSubject(subject, secret, now)?.sessionId
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Shared secret material. 32 bytes. */
|
|
238
|
+
export function newSecret(): Buffer {
|
|
239
|
+
return randomBytes(32)
|
|
240
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Which build is this? Diagnostic only - compatibility is `daemon/api.ts` (API_VERSION).
|
|
2
|
+
//
|
|
3
|
+
// The extension is loaded by pi and the daemon is a child process; after `pi update` the child
|
|
4
|
+
// can still be executing the previous build. `/health` carries this string so a session can
|
|
5
|
+
// notice and replace it, instead of talking to a daemon that mints routing ids it cannot resolve.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs"
|
|
8
|
+
import { dirname, join } from "node:path"
|
|
9
|
+
import { fileURLToPath } from "node:url"
|
|
10
|
+
|
|
11
|
+
const cache = new Map<string, string>()
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The version from the package.json that owns this module.
|
|
15
|
+
*
|
|
16
|
+
* `dist/daemon/main.js` and `src/daemon/main.ts` are both two levels below the package root, so
|
|
17
|
+
* one lookup works for a checkout and for an installed build. Unknown is reported as `"unknown"`
|
|
18
|
+
* rather than guessing: a caller that cannot compare should do nothing.
|
|
19
|
+
*/
|
|
20
|
+
export function packageVersion(moduleUrl: string | URL): string {
|
|
21
|
+
const key = String(moduleUrl)
|
|
22
|
+
const cached = cache.get(key)
|
|
23
|
+
if (cached !== undefined) return cached
|
|
24
|
+
|
|
25
|
+
// Walk up rather than assuming a depth: `src/extension.ts` is one level below the package
|
|
26
|
+
// root, `src/daemon/main.ts` and its compiled twin are two, and a test file is one.
|
|
27
|
+
let version = "unknown"
|
|
28
|
+
let dir = dirname(fileURLToPath(moduleUrl))
|
|
29
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { version?: unknown }
|
|
32
|
+
if (typeof parsed.version === "string" && parsed.version.length > 0) {
|
|
33
|
+
version = parsed.version
|
|
34
|
+
break
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
/* keep walking */
|
|
38
|
+
}
|
|
39
|
+
const parent = dirname(dir)
|
|
40
|
+
if (parent === dir) break
|
|
41
|
+
dir = parent
|
|
42
|
+
}
|
|
43
|
+
cache.set(key, version)
|
|
44
|
+
return version
|
|
45
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The wire contract between the extension and the daemon.
|
|
2
|
+
//
|
|
3
|
+
// Two numbers, two clocks, deliberately separate (AGENTS.md "Compatibility"):
|
|
4
|
+
//
|
|
5
|
+
// API_VERSION - the HTTP surface: endpoints, payload fields, semantics. Bumped by any
|
|
6
|
+
// breaking change to it; additive changes are gated by CAPABILITIES instead.
|
|
7
|
+
// package version (version.ts) - diagnostic only: which build someone is running.
|
|
8
|
+
//
|
|
9
|
+
// The daemon and the extension are spawned from one package, so a mismatch is recovered by
|
|
10
|
+
// replacing the process rather than negotiating - but the *number* compared must be the
|
|
11
|
+
// protocol, not the build, or a harmless patch bump would restart a working daemon.
|
|
12
|
+
|
|
13
|
+
/** Bumped only for a breaking change to an endpoint, a payload shape, or a meaning. */
|
|
14
|
+
export const API_VERSION = 1
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Additive features inside API_VERSION.
|
|
18
|
+
*
|
|
19
|
+
* A client must check for a capability before using anything newer than the base contract, and
|
|
20
|
+
* must not fake it when absent (no fallback paths: tell the user to update, or skip the feature).
|
|
21
|
+
*/
|
|
22
|
+
export const CAPABILITIES = ["health", "version", "pending", "claim", "ack", "send"] as const
|
|
23
|
+
|
|
24
|
+
export type Capability = (typeof CAPABILITIES)[number]
|
|
25
|
+
|
|
26
|
+
/** Every endpoint, in one place, so the schema test can freeze the surface. */
|
|
27
|
+
export const ENDPOINTS = [
|
|
28
|
+
"GET /events",
|
|
29
|
+
"GET /health",
|
|
30
|
+
"GET /pending",
|
|
31
|
+
"GET /mode",
|
|
32
|
+
"POST /claim",
|
|
33
|
+
"POST /ack",
|
|
34
|
+
"POST /register",
|
|
35
|
+
"DELETE /register",
|
|
36
|
+
"POST /ping",
|
|
37
|
+
"POST /bye",
|
|
38
|
+
"POST /shutdown",
|
|
39
|
+
"POST /reload",
|
|
40
|
+
"POST /mode",
|
|
41
|
+
"POST /send",
|
|
42
|
+
] as const
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Is a daemon speaking `theirs` usable by this build?
|
|
46
|
+
*
|
|
47
|
+
* Missing means "from before the field existed" - that is protocol 0, which this build cannot
|
|
48
|
+
* assume anything about.
|
|
49
|
+
*/
|
|
50
|
+
export function apiCompatible(mine: number, theirs: number | undefined): boolean {
|
|
51
|
+
return typeof theirs === "number" && theirs === mine
|
|
52
|
+
}
|