@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.
- package/CHANGELOG.md +824 -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,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
|
+
}
|