@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +824 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
@@ -0,0 +1,153 @@
1
+ // Reply parsing: turn a raw mail body into the single line of human intent.
2
+ //
3
+ // Quoted-text stripping is the fragile part of any email bridge — every client
4
+ // quotes differently. The approach here is deliberately blunt: cut at the first
5
+ // recognizable quote boundary and accept the remainder. Being clever (nested
6
+ // quoting, locale heuristics) loses more than it gains.
7
+ //
8
+ // Outbound mail instructs the human to reply *above* the quoted text, so the
9
+ // top of the body is the answer.
10
+
11
+ /** Markers that begin a quoted/original section. Matched case-insensitively. */
12
+ const QUOTE_BOUNDARIES: RegExp[] = [
13
+ // "On Tue, 3 Jun 2025 at 09:12, pi <pi@example.com> wrote:"
14
+ /^\s*On\b.{0,200}\bwrote:\s*$/i,
15
+ // Outlook / web clients
16
+ /^\s*-{2,}\s*Original Message\s*-{2,}\s*$/i,
17
+ // QQ Mail web (Chinese UI) - the primary client for the primary target users:
18
+ // "------------------ 原始邮件 ------------------" and its 163/126 cousins.
19
+ /^\s*-{2,}\s*原始邮件\s*-{2,}\s*$/,
20
+ /^\s*-{2,}\s*原邮件\s*-{2,}\s*$/,
21
+ // "在 2025年6月3日 09:12,pi <pi@example.com> 写道:"
22
+ /^\s*在\s.{0,120}写道[::]\s*$/,
23
+ // "发件人: pi <pi@example.com>" (QQ/163 header block)
24
+ /^\s*发件人[::]\s*.+$/,
25
+ /^\s*_{10,}\s*$/,
26
+ /^\s*From:\s.+$/i,
27
+ // "> " quoted block start (BEFORE the boundary scan strips lines)
28
+ /^\s*>/,
29
+ // Forwarded-message banner
30
+ /^\s*-{2,}\s*Forwarded message\s*-{2,}\s*$/i,
31
+ // Mobile clients
32
+ /^\s*Sent from my\b/i,
33
+ // Outlook mobile app signature, in either the English or the Chinese build.
34
+ /^\s*(?:获取|Get)\s*Outlook\s*for\s+(?:Android|iOS|iPhone|iPad)\b/i,
35
+ ]
36
+
37
+ /** Signature delimiter per RFC 3676 §4.3 — "dash dash space". */
38
+ const SIGNATURE_DELIM = /^--\s*$/
39
+
40
+ function normalizeNewlines(text: string): string {
41
+ return text.replace(/\r\n?/g, "\n")
42
+ }
43
+
44
+ /**
45
+ * Strip quoted text and signature from a reply body.
46
+ *
47
+ * Returns the top-of-mail content, or `""` when the message carried nothing
48
+ * new (e.g. the body was entirely quoted). Callers must treat `""` as "no
49
+ * instruction" and reject — re-injecting the quoted original into the agent
50
+ * would just replay the agent's own output back at it.
51
+ */
52
+ export function stripQuoted(raw: string): string {
53
+ if (!raw) return ""
54
+ const text = normalizeNewlines(raw)
55
+ const lines = text.split("\n")
56
+ const kept: string[] = []
57
+
58
+ for (let i = 0; i < lines.length; i++) {
59
+ const line = lines[i]!
60
+ if (SIGNATURE_DELIM.test(line)) break
61
+ if (QUOTE_BOUNDARIES.some((re) => re.test(line))) break
62
+ kept.push(line)
63
+ }
64
+
65
+ // Drop trailing blank lines.
66
+ while (kept.length > 0 && kept[kept.length - 1]!.trim() === "") kept.pop()
67
+
68
+ return kept.join("\n").trim()
69
+ }
70
+
71
+ export interface HeaderLike {
72
+ from?: string
73
+ subject?: string
74
+ /** Every `Authentication-Results` value, in the order received (top first). */
75
+ authResults?: string[]
76
+ /** `Message-ID` of this message. Used to recognise our own mail when it comes back. */
77
+ messageId?: string
78
+ /** Marker header we stamp on every notification; its presence alone means "ours". */
79
+ piMailbridge?: string
80
+ /** `In-Reply-To`, the message this one directly answers. */
81
+ inReplyTo?: string
82
+ /** `References`, the ancestor chain. */
83
+ references?: string[]
84
+ /** `Auto-Submitted`, `Precedence`, `X-Auto-Response-Suppress`, `List-Id`. */
85
+ autoSubmitted?: string
86
+ precedence?: string
87
+ autoResponseSuppress?: string
88
+ listId?: string
89
+ }
90
+
91
+ /**
92
+ * Reduce `"Name" <User@Example.COM>` to `user@example.com`.
93
+ * Handles bare addresses, angle-addresses, and display names containing commas.
94
+ */
95
+ export function normalizeAddress(input: string | undefined | null): string | undefined {
96
+ if (!input) return undefined
97
+ const angle = /<([^<>]+)>\s*$/.exec(input)
98
+ const candidate = (angle?.[1] ?? input).trim()
99
+ // Anything with whitespace left is a display name we could not parse.
100
+ if (!candidate || /\s/.test(candidate)) return undefined
101
+ if (!/^[^@\s]+@[^@\s]+$/.test(candidate)) return undefined
102
+ return candidate.toLowerCase()
103
+ }
104
+
105
+ /**
106
+ * Is this message machine-generated? Replying to one of these is how mail loops
107
+ * start (out-of-office → us → out-of-office → …).
108
+ */
109
+ export function isAutoGenerated(headers: HeaderLike): boolean {
110
+ const auto = headers.autoSubmitted?.trim().toLowerCase()
111
+ if (auto && auto !== "no") return true
112
+ const prec = headers.precedence?.trim().toLowerCase()
113
+ if (prec === "bulk" || prec === "junk" || prec === "list") return true
114
+ // Not "any non-empty value": Outlook and corporate gateways stamp this header on ordinary
115
+ // mail too, and treating presence as machine-generated rejected genuine replies.
116
+ const suppress = headers.autoResponseSuppress?.trim().toLowerCase() ?? ""
117
+ if (/\b(oof|autoreply|auto_reply|dr|rn|nrn)\b/.test(suppress)) return true
118
+ if (headers.listId?.trim()) return true
119
+ return false
120
+ }
121
+
122
+ /**
123
+ * Find the `Authentication-Results` header that our own relay added.
124
+ *
125
+ * Headers are prepended as a message traverses MTAs, so the TOPMOST
126
+ * `Authentication-Results` is the one the final hop (your provider) wrote on
127
+ * delivery. A sender that includes a forged header ends up *below* it, which is
128
+ * why topmost-wins defeats header forgery — see docs/SECURITY.md §2.1.
129
+ *
130
+ * `authservId` pins it further when a provider writes one. It is optional
131
+ * because Microsoft's consumer relay emits bare headers with no authserv-id
132
+ * (`Authentication-Results: spf=pass ...; dmarc=pass ...`), and requiring a match
133
+ * there would reject every piece of genuine mail.
134
+ */
135
+ export function findTrustedAuthResults(headers: HeaderLike, authservId?: string): string | undefined {
136
+ const values = headers.authResults ?? []
137
+ if (values.length === 0) return undefined
138
+
139
+ const wanted = authservId?.trim().toLowerCase()
140
+ if (!wanted) return values[0] // topmost = the delivering relay's
141
+
142
+ for (const value of values) {
143
+ const id = /^\s*([^\s;]+)/.exec(value)?.[1]?.toLowerCase()
144
+ if (id === wanted) return value
145
+ }
146
+ // Pinned but absent: do not silently fall back to an unverified header.
147
+ return undefined
148
+ }
149
+
150
+ /** `dmarc=pass` inside a specific AR header value. */
151
+ export function dmarcPasses(authResultsValue: string): boolean {
152
+ return /\bdmarc\s*=\s*pass\b/i.test(authResultsValue)
153
+ }
@@ -0,0 +1,164 @@
1
+ // Record the Message-ID the provider actually delivered.
2
+ //
3
+ // We mint a Message-ID and stamp it, but some providers (QQ among them) replace it on
4
+ // submission: a live test showed our `<uuid@qq.com>` stored in the Sent folder as
5
+ // `<tencent_…@qq.com>`, and the human's reply carried the tencent id in In-Reply-To.
6
+ //
7
+ // The reply-chain fallback matches In-Reply-To against ids we recorded, so on such a provider
8
+ // it never matches - the subject tag is the only remaining route.
9
+ //
10
+ // The just-sent copy is found by the `X-Pi-Mailbridge-Id` header, which carries the id we
11
+ // minted. Two things learned the hard way (a live review caught both):
12
+ // - the copy is appended *after* the send completes, so this must run after it, and must
13
+ // retry: QQ took 20-30 s in one measurement;
14
+ // - matching on the marker header alone records a *different* notification's id when two
15
+ // sessions send close together, which routes a reply into the wrong session.
16
+
17
+ import type { ResolvedConfig } from "./config.ts"
18
+ import { recordOutbound } from "../store/threads.ts"
19
+
20
+ export interface SentLookupClient {
21
+ connect(): Promise<void>
22
+ mailboxOpen(path: string): Promise<unknown>
23
+ search(query: Record<string, unknown>, options?: { uid?: boolean }): Promise<number[]>
24
+ fetchOne(uid: number, query: Record<string, unknown>, options?: { uid?: boolean }): Promise<unknown>
25
+ logout(): Promise<void>
26
+ }
27
+
28
+ /** Look back far enough to see a send that is still being appended. */
29
+ const CAPTURE_WINDOW_MS = 30 * 60_000
30
+ /** How many recent sent messages to inspect per attempt. */
31
+ const CAPTURE_SCAN = 20
32
+ /** Backoff between attempts: providers append asynchronously. */
33
+ const DEFAULT_RETRY_DELAYS_MS = [2_000, 5_000, 15_000, 30_000, 30_000, 30_000] as const
34
+
35
+ export interface CaptureOptions {
36
+ config: ResolvedConfig
37
+ /** The id we minted and stamped into `X-Pi-Mailbridge-Id`. */
38
+ mintedId: string
39
+ sessionId: string
40
+ subject: string
41
+ env?: NodeJS.ProcessEnv
42
+ /** XOAUTH2 accounts need a token for this connection too, exactly like the watcher. */
43
+ getAccessToken?: () => Promise<string>
44
+ /** Injected for tests. */
45
+ connect?: () => Promise<SentLookupClient>
46
+ record?: typeof recordOutbound
47
+ retryDelaysMs?: readonly number[]
48
+ sleep?: (ms: number) => Promise<void>
49
+ }
50
+
51
+ /** Pull the first `Message-ID:` out of a header block. */
52
+ export function messageIdFromHeaders(headers: string): string | undefined {
53
+ const unfolded = headers.replace(/\r?\n[ \t]+/g, " ")
54
+ for (const line of unfolded.split(/\r?\n/)) {
55
+ const match = /^message-id:\s*<([^>]+)>/i.exec(line.trim())
56
+ if (match) return match[1]!.trim()
57
+ }
58
+ return undefined
59
+ }
60
+
61
+ /** `X-Pi-Mailbridge-Id: <uuid@domain>` — normalised, without angle brackets. */
62
+ export function stampedIdFromHeaders(headers: string): string | undefined {
63
+ const unfolded = headers.replace(/\r?\n[ \t]+/g, " ")
64
+ for (const line of unfolded.split(/\r?\n/)) {
65
+ const match = /^x-pi-mailbridge-id:\s*<?([^>\s]+)>?/i.exec(line.trim())
66
+ if (match) return match[1]!.trim()
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ /**
72
+ * Read the just-sent message back from the Sent folder and record its Message-ID for the
73
+ * session. Best-effort: a failure never fails the send, and one capture runs at a time.
74
+ */
75
+ export async function captureDeliveredMessageId(options: CaptureOptions): Promise<string | undefined> {
76
+ const folder = options.config.mail.imap.sentFolder
77
+ if (!folder) return undefined
78
+ return serialise(options.mintedId, () => attempt(options, folder))
79
+ }
80
+
81
+ /**
82
+ * One capture in flight at a time, and identical requests share it.
83
+ *
84
+ * A burst of notifications must not open one connection each, and a capture can hold the
85
+ * queue for a minute; coalescing means a repeated request for the same minted id costs
86
+ * nothing extra.
87
+ */
88
+ let queue: Promise<unknown> = Promise.resolve()
89
+ const inFlightByMintedId = new Map<string, Promise<string | undefined>>()
90
+
91
+ function serialise<T>(key: string, run: () => Promise<T>): Promise<T> {
92
+ const existing = inFlightByMintedId.get(key)
93
+ if (existing) return existing as Promise<T>
94
+ const next = queue.then(run, run)
95
+ queue = next.catch(() => undefined)
96
+ inFlightByMintedId.set(key, next as Promise<string | undefined>)
97
+ next.catch(() => undefined).finally(() => inFlightByMintedId.delete(key))
98
+ return next
99
+ }
100
+
101
+ async function attempt(options: CaptureOptions, folder: string): Promise<string | undefined> {
102
+ const env = options.env ?? process.env
103
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
104
+ const delays = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS
105
+ const connect = options.connect ?? (await defaultConnect(options.config, options.getAccessToken))
106
+ const wanted = options.mintedId.replace(/[<>]/g, "")
107
+
108
+ let client: SentLookupClient | undefined
109
+ try {
110
+ client = await connect()
111
+ await client.mailboxOpen(folder)
112
+ for (const delay of delays) {
113
+ if (delay > 0) await sleep(delay)
114
+ const since = new Date(Date.now() - CAPTURE_WINDOW_MS)
115
+ const uids = await client.search({ since }, { uid: true })
116
+ if (!uids.length) continue
117
+ const newestFirst = uids.slice().sort((a, b) => b - a).slice(0, CAPTURE_SCAN)
118
+ for (const uid of newestFirst) {
119
+ const fetched = (await client.fetchOne(uid, { source: true }, { uid: true })) as
120
+ | { source?: Buffer }
121
+ | false
122
+ | undefined
123
+ if (!fetched || !fetched.source) continue
124
+ const head = (fetched.source.toString("utf8").split(/\r?\n\r?\n/)[0] ?? "").replace(/\r?\n[ \t]+/g, " ")
125
+ // Only the copy of the message we just sent: the header carries our minted id.
126
+ if (stampedIdFromHeaders(head) !== wanted) continue
127
+ const messageId = messageIdFromHeaders(head)
128
+ if (!messageId) continue
129
+ ;(options.record ?? recordOutbound)(env, {
130
+ messageId,
131
+ sessionId: options.sessionId,
132
+ ...(options.config.device.name ? { device: options.config.device.name } : {}),
133
+ subject: options.subject,
134
+ })
135
+ return messageId
136
+ }
137
+ }
138
+ return undefined
139
+ } catch {
140
+ return undefined
141
+ } finally {
142
+ await client?.logout().catch(() => undefined)
143
+ }
144
+ }
145
+
146
+ async function defaultConnect(
147
+ config: ResolvedConfig,
148
+ getAccessToken?: () => Promise<string>,
149
+ ): Promise<() => Promise<SentLookupClient>> {
150
+ const { createImapflowFactory } = await import("../daemon/imapflow.ts")
151
+ return async () => {
152
+ const client = await createImapflowFactory({ ...(getAccessToken ? { getAccessToken } : {}) })(config, {
153
+ idleRenewMs: config.tuning.idleRenewMs,
154
+ })
155
+ try {
156
+ await client.connect()
157
+ } catch (error) {
158
+ // Do not leak the socket of a client whose connect failed.
159
+ await client.logout().catch(() => undefined)
160
+ throw error
161
+ }
162
+ return client as unknown as SentLookupClient
163
+ }
164
+ }
@@ -0,0 +1,179 @@
1
+ // Settle notification: the email you get when the agent stops.
2
+ //
3
+ // Three rules earn this module its existence:
4
+ // 1. It fires on `agent_settled` only. `agent_end` precedes retries,
5
+ // compaction and queued follow-ups, so mailing there would send you a
6
+ // "finished" message for work that is still running.
7
+ // 2. It is debounced. Settle → you type again → settle would otherwise mail
8
+ // you a half-finished answer.
9
+ // 3. An aborted settle (Escape) is not "finished" and is skipped by default.
10
+
11
+ import type { Mode } from "../store/mode-store.ts"
12
+ import { stampSubject } from "./token.ts"
13
+
14
+ export interface SettleContext {
15
+ sessionId: string
16
+ sessionName?: string
17
+ /** Machine the session is running on. Named in subject and body. */
18
+ device?: string
19
+ cwd: string
20
+ /** Completed turns in this session, when known. */
21
+ turns?: number
22
+ /** The assistant's last text, when known. */
23
+ lastAssistantText?: string
24
+ }
25
+
26
+ export const REPLY_HINT = "Reply above this line to continue the session."
27
+
28
+ /**
29
+ * Subject: which session, on which device, with the routing id that makes the
30
+ * reply land back here. Both facts are in the subject because that is all a
31
+ * notification preview shows on a phone, tablet or another PC.
32
+ */
33
+ export function buildSettleSubject(
34
+ context: SettleContext,
35
+ secret: Buffer,
36
+ tag?: string,
37
+ tokenTtlHours?: number,
38
+ ): string {
39
+ const label = context.sessionName?.trim() || context.cwd.split("/").filter(Boolean).pop() || "session"
40
+ const device = context.device?.trim()
41
+ const where = device ? ` @ ${device}` : ""
42
+ const body = `pi: ${label}${where} is waiting for you`
43
+ // A short local id keeps the subject readable; the signed token remains the
44
+ // fallback for callers that have no store to register against.
45
+ if (tag) return `[pi:${tag}] ${body}`
46
+ return stampSubject(context.sessionId, body, secret, {
47
+ ...(device ? { device } : {}),
48
+ ...(tokenTtlHours === undefined ? {} : { ttlHours: tokenTtlHours }),
49
+ })
50
+ }
51
+
52
+ /** Cap on the quoted agent text, so a long answer cannot become a 200-line email. */
53
+ export const MAX_BODY_CHARS = 4_000
54
+
55
+ function quoteAnswer(text: string | undefined): string {
56
+ const trimmed = text?.trim()
57
+ if (!trimmed) return "(the agent produced no text this turn)"
58
+ if (trimmed.length <= MAX_BODY_CHARS) return trimmed
59
+ return `${trimmed.slice(0, MAX_BODY_CHARS)}\n\n…truncated (${trimmed.length - MAX_BODY_CHARS} more characters)`
60
+ }
61
+
62
+ /** Plain-text body. Deliberately boring: it must survive arbitrary mail clients. */
63
+ export function buildSettleBody(context: SettleContext): string {
64
+ const lines: string[] = []
65
+ lines.push(quoteAnswer(context.lastAssistantText))
66
+ lines.push("")
67
+ lines.push("--")
68
+ if (context.device?.trim()) lines.push(`device: ${context.device.trim()}`)
69
+ // Only name the session when it has a name; otherwise this line repeated the id.
70
+ if (context.sessionName?.trim()) lines.push(`session: ${context.sessionName.trim()}`)
71
+ lines.push(`session id: ${context.sessionId}`)
72
+ lines.push(`project: ${context.cwd}`)
73
+ if (typeof context.turns === "number") lines.push(`turns: ${context.turns}`)
74
+ lines.push("")
75
+ lines.push(REPLY_HINT)
76
+ return lines.join("\n")
77
+ }
78
+
79
+ export interface SettleNotifyConfig {
80
+ onSettled: boolean
81
+ debounceMs: number
82
+ notifyOnAborted: boolean
83
+ /** Lifetime of the fallback signed token; routing ids use the same value. */
84
+ tokenTtlHours?: number
85
+ }
86
+
87
+ export interface SettleNotifierDeps {
88
+ mode: () => Mode
89
+ /** Current session context, or undefined when there is no session. */
90
+ context: () => SettleContext | undefined
91
+ send: (message: { to: string; subject: string; text: string }) => Promise<void>
92
+ secret: () => Buffer
93
+ config: SettleNotifyConfig
94
+ timers: {
95
+ setTimeout(handler: () => void, ms: number): unknown
96
+ clearTimeout(handle: unknown): void
97
+ }
98
+ onError?: (error: Error) => void
99
+ /** Mint a short routing id for this notification. Falls back to a signed token. */
100
+ tagFor?: (context: SettleContext) => string | undefined
101
+ }
102
+
103
+ export type SettleOutcome = "scheduled" | "mode-off" | "aborted" | "disabled" | "no-context"
104
+
105
+ export interface SettleNotifier {
106
+ /** Call from `agent_settled`. Returns why it did or did not schedule. */
107
+ settled(event?: { aborted?: boolean }): SettleOutcome
108
+ /** Send any pending notification immediately (tests, shutdown). */
109
+ flush(): Promise<void>
110
+ /** True while a debounced send is waiting. */
111
+ isPending(): boolean
112
+ /** Drop a pending send without sending (session shutdown). */
113
+ cancel(): void
114
+ }
115
+
116
+ export function createSettleNotifier(deps: SettleNotifierDeps): SettleNotifier {
117
+ let handle: unknown
118
+ let pendingContext: SettleContext | undefined
119
+
120
+ async function deliver(context: SettleContext): Promise<void> {
121
+ const secret = deps.secret()
122
+ try {
123
+ await deps.send({
124
+ to: "", // the daemon fills in mail.to
125
+ subject: buildSettleSubject(context, secret, deps.tagFor?.(context), deps.config.tokenTtlHours),
126
+ text: buildSettleBody(context),
127
+ })
128
+ } catch (error) {
129
+ deps.onError?.(error instanceof Error ? error : new Error(String(error)))
130
+ }
131
+ }
132
+
133
+ function cancel(): void {
134
+ if (handle !== undefined) {
135
+ deps.timers.clearTimeout(handle)
136
+ handle = undefined
137
+ }
138
+ pendingContext = undefined
139
+ }
140
+
141
+ return {
142
+ settled(event) {
143
+ if (deps.mode() !== "on") return "mode-off"
144
+ if (event?.aborted && !deps.config.notifyOnAborted) return "aborted"
145
+ if (!deps.config.onSettled) return "disabled"
146
+
147
+ const context = deps.context()
148
+ if (!context) return "no-context"
149
+
150
+ // Debounce: a later settle replaces the earlier one entirely.
151
+ cancel()
152
+ pendingContext = context
153
+ const delay = Math.max(0, deps.config.debounceMs)
154
+ handle = deps.timers.setTimeout(() => {
155
+ handle = undefined
156
+ const pending = pendingContext
157
+ pendingContext = undefined
158
+ // The mode can change during the debounce (a `/mailbridge off` typed at t+4s, a
159
+ // timer firing at t+10s). Scheduling proved nothing about when it fires.
160
+ if (pending && deps.mode() === "on") void deliver(pending)
161
+ }, delay)
162
+ return "scheduled"
163
+ },
164
+
165
+ async flush() {
166
+ const context = pendingContext
167
+ cancel()
168
+ // The same gate the debounce callback applies, for the same reason: the mode can have
169
+ // been turned off during the window, and a flush must not bypass that.
170
+ if (context && deps.mode() === "on") await deliver(context)
171
+ },
172
+
173
+ isPending() {
174
+ return pendingContext !== undefined
175
+ },
176
+
177
+ cancel,
178
+ }
179
+ }
@@ -0,0 +1,139 @@
1
+ // Setup: address in, runnable config out.
2
+ //
3
+ // Shared by `/mailbridge setup` (inside pi) and `--setup` (headless), so the two
4
+ // entry points cannot drift. It writes the config file and reports what it did;
5
+ // it never starts a process and never prompts.
6
+
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
8
+ import { dirname } from "node:path"
9
+
10
+ import { shortHostname } from "./config.ts"
11
+ import { buildSetupConfig, discoverDomain, domainOf, type Discovered } from "./discover.ts"
12
+ import { configPath } from "../store/paths.ts"
13
+
14
+ export interface SetupOptions {
15
+ address: string
16
+ /** Where the human reads mail. Defaults to `address` (single-mailbox mode). */
17
+ to?: string
18
+ /** Overwrite an existing config. */
19
+ force?: boolean
20
+ env?: NodeJS.ProcessEnv
21
+ fetchImpl?: typeof fetch
22
+ }
23
+
24
+ export interface SetupResult {
25
+ ok: boolean
26
+ /** Human-readable progress, in order. */
27
+ steps: string[]
28
+ /** Where the config was written, when it was. */
29
+ path?: string
30
+ discovered?: Discovered
31
+ /** The exact next action, or the reason it failed. */
32
+ next?: string
33
+ }
34
+
35
+ export async function runSetup(options: SetupOptions): Promise<SetupResult> {
36
+ const env = options.env ?? process.env
37
+ const steps: string[] = []
38
+ const address = options.address.trim()
39
+ const to = (options.to ?? address).trim()
40
+
41
+ const domain = domainOf(address)
42
+ if (!domain) return { ok: false, steps: [`not an email address: ${address}`] }
43
+ if (!domain.includes(".")) {
44
+ return {
45
+ ok: false,
46
+ steps: [`"${domain}" is not a domain — it has no dot`],
47
+ next: `addresses need a full domain — write something@${domain}.com`,
48
+ }
49
+ }
50
+
51
+ // The human's address is where every notification goes and the only sender the allowlist
52
+ // accepts. A typo here is a config that mails nowhere and rejects every reply, so check it
53
+ // rather than writing it.
54
+ if (!domainOf(to)) {
55
+ return { ok: false, steps: [`not an email address: ${to}`], next: "pass `to <your-address>`" }
56
+ }
57
+
58
+ const target = configPath(env)
59
+ // A device name is an identity, not a mail setting: `force` must not rename the machine,
60
+ // because records already scanned under the old name would never be offered again.
61
+ const previousDevice = (() => {
62
+ try {
63
+ const parsed = JSON.parse(readFileSync(target, "utf8")) as { device?: { name?: unknown } }
64
+ const name = parsed?.device?.name
65
+ return typeof name === "string" && name.trim() ? name.trim() : undefined
66
+ } catch {
67
+ return undefined
68
+ }
69
+ })()
70
+
71
+ if (existsSync(target) && !options.force) {
72
+ return {
73
+ ok: false,
74
+ steps: [`${target} already exists`],
75
+ path: target,
76
+ next: "run `/mailbridge setup … force` to overwrite",
77
+ }
78
+ }
79
+
80
+ steps.push(`discovering ${domain}…`)
81
+ const discovered = await discoverDomain(domain, options.fetchImpl ? { fetchImpl: options.fetchImpl } : {})
82
+ if (!discovered) {
83
+ return {
84
+ ok: false,
85
+ steps: [...steps, `no published mail settings for ${domain}`],
86
+ next: "the domain is not in the autoconfig database; write config.json by hand (config.example.json)",
87
+ }
88
+ }
89
+
90
+ steps.push(`imap ${discovered.imap.host}:${discovered.imap.port} (${discovered.imap.secure ? "TLS" : "STARTTLS"})`)
91
+ steps.push(`smtp ${discovered.smtp.host}:${discovered.smtp.port} (${discovered.smtp.secure ? "TLS" : "STARTTLS"})`)
92
+ steps.push(`auth ${discovered.authType}${discovered.provider !== "other" ? ` (${discovered.provider})` : ""}`)
93
+
94
+ const built = buildSetupConfig(address, to, discovered)
95
+ const config = {
96
+ ...built,
97
+ device: { name: previousDevice ?? shortHostname() },
98
+ daemon: { host: "127.0.0.1", port: 4177 },
99
+ notify: { onSettled: true, debounceMs: 10_000, notifyOnAborted: false },
100
+ tuning: { idleRenewMs: 25 * 60 * 1000, backoffInitialMs: 1000, backoffMaxMs: 60_000 },
101
+ }
102
+
103
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 })
104
+ // Written atomically: an automatic reload can read this file at any moment, and a torn
105
+ // write parses as {} (readJson falls back to defaults), which would silently replace the
106
+ // user's config with an empty mailbox.
107
+ const tmp = `${target}.${process.pid}.tmp`
108
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
109
+ renameSync(tmp, target)
110
+ steps.push(`wrote ${target}`)
111
+
112
+ if (built.security.allowSelfReplies) {
113
+ steps.push(`one mailbox for both roles — replies from ${to} are accepted only when they are real replies`)
114
+ steps.push(
115
+ "if this mailbox has other addresses that reach it (a QQ number, a foxmail alias), add each " +
116
+ "one to security.allowFrom - the allowlist matches the exact address you reply from",
117
+ )
118
+ steps.push(
119
+ "if the mailbox already has mail you do not want the agent to read, run /mailbridge prime " +
120
+ "before your first /mailbridge on",
121
+ )
122
+ steps.push(
123
+ "DMARC checks are off for this shape: a message from your address to the same address is " +
124
+ "delivered by your own provider and may carry no Authentication-Results header, so requiring " +
125
+ "one rejects every reply. The sender allowlist and the self-echo guards still apply.",
126
+ )
127
+ }
128
+
129
+ return {
130
+ ok: true,
131
+ steps,
132
+ path: target,
133
+ discovered,
134
+ next:
135
+ discovered.authType === "XOAUTH2"
136
+ ? "put your app client id in the oauth block, then `/mailbridge auth`"
137
+ : "put the account app password (QQ/163 call it 授权码) in the mail block, then `/mailbridge smoke`",
138
+ }
139
+ }