@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,550 @@
|
|
|
1
|
+
// Daemon HTTP service.
|
|
2
|
+
//
|
|
3
|
+
// Transport-neutral: construction takes its collaborators, so tests boot the real
|
|
4
|
+
// server against fake mail/source and assert the whole contract over HTTP.
|
|
5
|
+
//
|
|
6
|
+
// Auth: every route except /health requires `Authorization: Bearer <token>`,
|
|
7
|
+
// compared in constant time. Loopback is not authorization.
|
|
8
|
+
//
|
|
9
|
+
// Bounds: request body, SSE clients, registry entries. A local process should not
|
|
10
|
+
// be able to exhaust the daemon.
|
|
11
|
+
|
|
12
|
+
import http from "node:http"
|
|
13
|
+
import { timingSafeEqual } from "node:crypto"
|
|
14
|
+
|
|
15
|
+
import type { ResolvedConfig } from "../core/config.ts"
|
|
16
|
+
import type { ScanSummary } from "../core/status.ts"
|
|
17
|
+
|
|
18
|
+
type ScanSummaryLike = ScanSummary
|
|
19
|
+
import { CAPABILITIES } from "./api.ts"
|
|
20
|
+
import { createSendBudget } from "./send-budget.ts"
|
|
21
|
+
import type { MailSource } from "./process.ts"
|
|
22
|
+
import { ackPending, claimPending, listPending } from "../store/pending-store.ts"
|
|
23
|
+
import { journal } from "../store/journal.ts"
|
|
24
|
+
import { recordOutbound, removeOutbound, sessionThread } from "../store/threads.ts"
|
|
25
|
+
import { generateMessageId } from "../core/mailer.ts"
|
|
26
|
+
import { readMode, writeMode, isMode, type Mode } from "../store/mode-store.ts"
|
|
27
|
+
import { createRegistry, type Registry } from "../store/registry.ts"
|
|
28
|
+
|
|
29
|
+
export const MAX_BODY_BYTES = 1_000_000
|
|
30
|
+
/** A body that is not JSON: the caller's fault, and the only 400 the handler emits. */
|
|
31
|
+
class BadBodyError extends Error {}
|
|
32
|
+
|
|
33
|
+
async function readJson(req: import("node:http").IncomingMessage): Promise<unknown> {
|
|
34
|
+
const text = await readBody(req)
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(text)
|
|
37
|
+
} catch {
|
|
38
|
+
throw new BadBodyError("body is not valid JSON")
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const MAX_SSE_CLIENTS = 100
|
|
43
|
+
/** Heartbeat entries kept; sessions that vanished are pruned on /health anyway. */
|
|
44
|
+
export const MAX_PINGS = 256
|
|
45
|
+
|
|
46
|
+
export type DaemonEvent =
|
|
47
|
+
| { type: "delivery"; pending: ReturnType<typeof listPending>[number] }
|
|
48
|
+
| { type: "mode"; mode: Mode }
|
|
49
|
+
|
|
50
|
+
export interface DaemonDeps {
|
|
51
|
+
config: ResolvedConfig
|
|
52
|
+
token: string
|
|
53
|
+
source: MailSource
|
|
54
|
+
/** Sends mail. Injected so tests never open a socket. */
|
|
55
|
+
send: (message: {
|
|
56
|
+
to: string
|
|
57
|
+
subject: string
|
|
58
|
+
text: string
|
|
59
|
+
cwd?: string
|
|
60
|
+
sessionId?: string
|
|
61
|
+
/** RFC 5322 threading, derived here from the outbound id registry. */
|
|
62
|
+
inReplyTo?: string
|
|
63
|
+
references?: string[]
|
|
64
|
+
/** Minted and recorded before this is called; the transport must stamp it. */
|
|
65
|
+
messageId: string
|
|
66
|
+
}) => Promise<{ messageId?: string } | void>
|
|
67
|
+
/**
|
|
68
|
+
* Called after a successful send with the assigned Message-ID, so the caller can
|
|
69
|
+
* record it for reply-chain routing. Kept out of this module so the service does
|
|
70
|
+
* not need the routing secret.
|
|
71
|
+
*/
|
|
72
|
+
onSent?: (info: { messageId?: string; subject: string; sessionId?: string }) => void
|
|
73
|
+
/**
|
|
74
|
+
* Re-read config and restart the watcher. Called after `/mailbridge setup` or
|
|
75
|
+
* `auth` so a fresh config or token takes effect without killing the daemon.
|
|
76
|
+
*/
|
|
77
|
+
reload?: () => Promise<{ ok: boolean; error?: string; warning?: string }>
|
|
78
|
+
/**
|
|
79
|
+
* Ask the daemon to exit so a supervisor can start a fresh process.
|
|
80
|
+
* `/reload` re-reads config but cannot replace running code, so an updated
|
|
81
|
+
* package would otherwise keep executing the previous version until reboot.
|
|
82
|
+
*/
|
|
83
|
+
shutdown?: (reason?: string) => void
|
|
84
|
+
/**
|
|
85
|
+
* Called after a mode change. Turning mode on must trigger a scan: the only other
|
|
86
|
+
* triggers are an IMAP `exists` (a NEW arrival) and reconnect, so without this a reply
|
|
87
|
+
* that arrived while the channel was closed would wait for unrelated mail.
|
|
88
|
+
*/
|
|
89
|
+
onModeChange?: (mode: Mode) => void
|
|
90
|
+
/**
|
|
91
|
+
* Live config accessor. /reload swaps the config, so anything that reads it per
|
|
92
|
+
* request (recipient, Message-ID domain, device name, listen address) must ask here
|
|
93
|
+
* rather than capture the object this service was built with.
|
|
94
|
+
*/
|
|
95
|
+
getConfig?: () => ResolvedConfig
|
|
96
|
+
/** Outcome of the most recent scan, for diagnostics. */
|
|
97
|
+
scanSummary?: () => ScanSummaryLike | undefined
|
|
98
|
+
/** mtime of the config file the daemon is running on (0 when there was no file). */
|
|
99
|
+
configLoadedAt?: () => number
|
|
100
|
+
/** Is the IMAP watcher connected? A revoked credential shows here, not in the process. */
|
|
101
|
+
imapHealthy?: () => boolean
|
|
102
|
+
/** This build's version. Diagnostic only - compatibility is `apiVersion`. */
|
|
103
|
+
version?: string
|
|
104
|
+
/** The wire contract this daemon implements. */
|
|
105
|
+
apiVersion?: number
|
|
106
|
+
/** How long after the last ping to exit. Default 120s. */
|
|
107
|
+
idleTimeoutMs?: number
|
|
108
|
+
env?: NodeJS.ProcessEnv
|
|
109
|
+
now?: () => number
|
|
110
|
+
/** Diagnostics: the daemon's log. */
|
|
111
|
+
log?: (message: string) => void
|
|
112
|
+
registry?: Registry
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface DaemonService {
|
|
116
|
+
server: http.Server
|
|
117
|
+
listen(port?: number, host?: string): Promise<{ port: number; host: string }>
|
|
118
|
+
close(): Promise<void>
|
|
119
|
+
/** Push an event to every connected SSE client. */
|
|
120
|
+
broadcast(event: DaemonEvent): void
|
|
121
|
+
readonly sseClients: number
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
125
|
+
const ab = Buffer.from(a)
|
|
126
|
+
const bb = Buffer.from(b)
|
|
127
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
let size = 0
|
|
133
|
+
const chunks: Buffer[] = []
|
|
134
|
+
req.on("data", (chunk: Buffer) => {
|
|
135
|
+
size += chunk.length
|
|
136
|
+
if (size > MAX_BODY_BYTES) {
|
|
137
|
+
reject(new Error("request body too large"))
|
|
138
|
+
req.destroy()
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
chunks.push(chunk)
|
|
142
|
+
})
|
|
143
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
|
|
144
|
+
req.on("error", reject)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
149
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function createDaemonService(deps: DaemonDeps): DaemonService {
|
|
153
|
+
const env = deps.env ?? process.env
|
|
154
|
+
const now = deps.now ?? (() => Date.now())
|
|
155
|
+
const registry = deps.registry ?? createRegistry()
|
|
156
|
+
|
|
157
|
+
/** live SSE response objects */
|
|
158
|
+
const streams = new Set<http.ServerResponse>()
|
|
159
|
+
|
|
160
|
+
// Sliding one-minute budget for outbound mail. Ten notifications a minute is far above any
|
|
161
|
+
// human-answered flow; the point is that a loop cannot spend the account's quota.
|
|
162
|
+
const sendBudget = createSendBudget({ limit: 10, windowMs: 60_000 })
|
|
163
|
+
|
|
164
|
+
// Liveness of pi sessions. A session pings on its poll tick; when every session has gone quiet
|
|
165
|
+
// the daemon stops - always, and by design: an updated package cannot leave an orphan running
|
|
166
|
+
// old code, and nothing reads a mailbox no session will consume. A daemon nobody ever pings
|
|
167
|
+
// (started by hand, and pi never attached) stops on the same clock.
|
|
168
|
+
const pings = new Map<string, number>()
|
|
169
|
+
// Idleness is measured from the daemon's own start when nobody has pinged yet, so a fresh
|
|
170
|
+
// daemon gets the full grace period rather than looking infinitely stale.
|
|
171
|
+
const startedAt = Date.now()
|
|
172
|
+
const idleTimeout = deps.idleTimeoutMs ?? 120_000
|
|
173
|
+
|
|
174
|
+
const idleCheck = setInterval(() => {
|
|
175
|
+
const newest = Math.max(startedAt, ...pings.values())
|
|
176
|
+
if (Date.now() - newest <= idleTimeout) return
|
|
177
|
+
deps.shutdown?.(`idle: no ping for ${Math.round(idleTimeout / 1000)}s`)
|
|
178
|
+
}, Math.min(15_000, Math.max(1_000, Math.floor(idleTimeout / 4))))
|
|
179
|
+
// Never hold the process open just for this timer.
|
|
180
|
+
if (typeof idleCheck.unref === "function") idleCheck.unref()
|
|
181
|
+
|
|
182
|
+
function broadcast(event: DaemonEvent): void {
|
|
183
|
+
const payload = `data: ${JSON.stringify(event)}\n\n`
|
|
184
|
+
for (const res of streams) {
|
|
185
|
+
try {
|
|
186
|
+
res.write(payload)
|
|
187
|
+
} catch {
|
|
188
|
+
streams.delete(res)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The handler is a separate function so a throw anywhere in it - including the URL parse,
|
|
194
|
+
// which runs before authentication - becomes a 500 rather than an unhandled rejection that
|
|
195
|
+
// kills the daemon. Node accepts absolute-form request targets, so `new URL` can throw on a
|
|
196
|
+
// single unauthenticated request.
|
|
197
|
+
const dispatch = async (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse): Promise<void> => {
|
|
198
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1")
|
|
199
|
+
const send = (status: number, body: unknown) => {
|
|
200
|
+
const payload = JSON.stringify(body)
|
|
201
|
+
res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) })
|
|
202
|
+
res.end(payload)
|
|
203
|
+
}
|
|
204
|
+
const fail = (status: number, code: string, extra?: Record<string, unknown>) =>
|
|
205
|
+
send(status, { error: { code, ...extra } })
|
|
206
|
+
|
|
207
|
+
// Liveness is unauthenticated so a supervisor can probe without a secret.
|
|
208
|
+
if (url.pathname === "/health") {
|
|
209
|
+
// Prune here as well as on /bye: a crashed session never sends one, and an
|
|
210
|
+
// unbounded map plus a spread into Math.max is its own failure mode.
|
|
211
|
+
const nowMs = now()
|
|
212
|
+
const stale = nowMs - (deps.idleTimeoutMs ?? 120_000) * 4
|
|
213
|
+
for (const [id, at] of pings) if (at < stale) pings.delete(id)
|
|
214
|
+
// Track the maximum by iteration: spreading an unbounded map into Math.max throws
|
|
215
|
+
// RangeError (and kills the daemon) long before the map is "large".
|
|
216
|
+
let newestPing = 0
|
|
217
|
+
for (const at of pings.values()) if (at > newestPing) newestPing = at
|
|
218
|
+
return send(200, {
|
|
219
|
+
ok: true,
|
|
220
|
+
mode: readMode(env),
|
|
221
|
+
pending: listPending(env).length,
|
|
222
|
+
// `sessions` is the decision registry (sessions that were told about a
|
|
223
|
+
// notification), not connected clients — a raw payload showed `sessions: 0`
|
|
224
|
+
// next to `clients: 2`. Both keys, so neither reading is wrong.
|
|
225
|
+
sessions: registry.list().length,
|
|
226
|
+
registry: registry.list().length,
|
|
227
|
+
sseClients: streams.size,
|
|
228
|
+
// Liveness: how many sessions are pinging, and how long ago the last one did.
|
|
229
|
+
clients: pings.size,
|
|
230
|
+
lastPingAgoMs: newestPing ? Math.max(0, now() - newestPing) : null,
|
|
231
|
+
lastScan: deps.scanSummary?.() ?? null,
|
|
232
|
+
// Diagnostic (which build), and the contract (which protocol). The session
|
|
233
|
+
// replaces a daemon whose apiVersion differs; the version is for the panel.
|
|
234
|
+
version: deps.version,
|
|
235
|
+
apiVersion: deps.apiVersion,
|
|
236
|
+
capabilities: CAPABILITIES,
|
|
237
|
+
configLoadedAt: deps.configLoadedAt?.() ?? null,
|
|
238
|
+
imap: deps.imapHealthy ? (deps.imapHealthy() ? "connected" : "disconnected") : "unknown",
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const header = req.headers.authorization ?? ""
|
|
243
|
+
const presented = header.startsWith("Bearer ") ? header.slice(7) : ""
|
|
244
|
+
if (!constantTimeEqual(presented, deps.token)) return fail(401, "unauthorized")
|
|
245
|
+
|
|
246
|
+
const route = `${req.method} ${url.pathname}`
|
|
247
|
+
try {
|
|
248
|
+
switch (route) {
|
|
249
|
+
case "GET /events": {
|
|
250
|
+
if (streams.size >= MAX_SSE_CLIENTS) return fail(503, "too_many_clients")
|
|
251
|
+
res.writeHead(200, {
|
|
252
|
+
"content-type": "text/event-stream",
|
|
253
|
+
"cache-control": "no-cache",
|
|
254
|
+
connection: "keep-alive",
|
|
255
|
+
})
|
|
256
|
+
res.write(": connected\n\n")
|
|
257
|
+
// Tell a fresh client the current mode immediately.
|
|
258
|
+
res.write(`data: ${JSON.stringify({ type: "mode", mode: readMode(env) })}\n\n`)
|
|
259
|
+
streams.add(res)
|
|
260
|
+
req.on("close", () => streams.delete(res))
|
|
261
|
+
return undefined
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
case "GET /pending": {
|
|
265
|
+
// Filtered: a whole-file payload of another machine's mail is both wrong to
|
|
266
|
+
// deliver and wastefully large. The daemon's own live config is the authority -
|
|
267
|
+
// the same one /claim uses - so a client holding a stale device name (renamed,
|
|
268
|
+
// or rewritten by `setup`) cannot ask for records it will then be refused.
|
|
269
|
+
const mine = deps.getConfig?.()?.device.name
|
|
270
|
+
const all = listPending(env)
|
|
271
|
+
const offered = mine ? all.filter((r) => r.device === undefined || r.device === mine) : all
|
|
272
|
+
return send(200, {
|
|
273
|
+
pending: offered,
|
|
274
|
+
// A count, never the content: these are another device's replies, and after a
|
|
275
|
+
// rename they are the user's own mail sitting here invisible.
|
|
276
|
+
withheld: all.length - offered.length,
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case "GET /mode":
|
|
281
|
+
return send(200, { mode: readMode(env) })
|
|
282
|
+
|
|
283
|
+
case "POST /claim": {
|
|
284
|
+
const body = asRecord(await readJson(req))
|
|
285
|
+
const uid = Number(body?.uid)
|
|
286
|
+
const instanceId = typeof body?.instanceId === "string" ? body.instanceId : ""
|
|
287
|
+
if (!Number.isInteger(uid) || !instanceId) return fail(400, "bad_request")
|
|
288
|
+
const uidValidity =
|
|
289
|
+
typeof body?.uidValidity === "number" && Number.isFinite(body.uidValidity) ? body.uidValidity : undefined
|
|
290
|
+
const result = claimPending(
|
|
291
|
+
env,
|
|
292
|
+
uid,
|
|
293
|
+
instanceId,
|
|
294
|
+
now(),
|
|
295
|
+
uidValidity,
|
|
296
|
+
deps.source.uidValidity?.(),
|
|
297
|
+
deps.getConfig?.()?.device.name,
|
|
298
|
+
)
|
|
299
|
+
if (!result.ok) return fail(result.reason === "not-found" ? 404 : 409, result.reason)
|
|
300
|
+
return send(200, { ok: true })
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
case "POST /ack": {
|
|
304
|
+
const body = asRecord(await readJson(req))
|
|
305
|
+
const uid = Number(body?.uid)
|
|
306
|
+
const instanceId = typeof body?.instanceId === "string" ? body.instanceId : ""
|
|
307
|
+
if (!Number.isInteger(uid) || !instanceId) return fail(400, "bad_request")
|
|
308
|
+
|
|
309
|
+
// Remove the record, THEN journal, then touch the mail. The order looks
|
|
310
|
+
// backwards but is the safe one: the journal is read at scan time, so
|
|
311
|
+
// journalling while the record still exists would leave a window where a
|
|
312
|
+
// polling session claims and injects the same reply twice. Losing the
|
|
313
|
+
// journal write after a successful ack only re-opens dedupe for a UID that
|
|
314
|
+
// is no longer in `pending`, which the scanner never reads again.
|
|
315
|
+
const uidValidity =
|
|
316
|
+
typeof body?.uidValidity === "number" && Number.isFinite(body.uidValidity) ? body.uidValidity : undefined
|
|
317
|
+
const acked = ackPending(env, uid, instanceId, uidValidity, deps.source.uidValidity?.())
|
|
318
|
+
if (!acked.ok) return fail(acked.reason === "not-found" ? 404 : 409, acked.reason)
|
|
319
|
+
// The record's space, never the watcher's: after a renumbering the two differ,
|
|
320
|
+
// and journaling under the live space would suppress a brand-new message at
|
|
321
|
+
// that UID. A record predating UIDVALIDITY carries none, and journal() then
|
|
322
|
+
// keeps the space the file already records.
|
|
323
|
+
journal(env, uid, acked.uidValidity)
|
|
324
|
+
// The record's session, never the request body's: a client that acks
|
|
325
|
+
// another session's mail must not also free its decision slot.
|
|
326
|
+
registry.release(acked.sessionId)
|
|
327
|
+
|
|
328
|
+
// Mailbox hygiene only. A failure here must not resurrect the delivery.
|
|
329
|
+
try {
|
|
330
|
+
await deps.source.markSeen?.(uid)
|
|
331
|
+
} catch {
|
|
332
|
+
/* logged by the caller in production; delivery is already acked */
|
|
333
|
+
}
|
|
334
|
+
return send(200, { ok: true })
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
case "POST /register": {
|
|
338
|
+
const body = asRecord(await readJson(req))
|
|
339
|
+
const sessionId = typeof body?.sessionId === "string" ? body.sessionId : ""
|
|
340
|
+
if (!sessionId) return fail(400, "bad_request")
|
|
341
|
+
const result = registry.register(sessionId)
|
|
342
|
+
if (result.full) return fail(503, "registry_full")
|
|
343
|
+
return send(200, result)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
case "DELETE /register": {
|
|
347
|
+
const body = asRecord(await readJson(req))
|
|
348
|
+
const sessionId = typeof body?.sessionId === "string" ? body.sessionId : ""
|
|
349
|
+
if (!sessionId) return fail(400, "bad_request")
|
|
350
|
+
registry.release(sessionId)
|
|
351
|
+
return send(200, { ok: true })
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
case "POST /ping": {
|
|
355
|
+
const body = asRecord(await readJson(req))
|
|
356
|
+
const instanceId = typeof body?.instanceId === "string" ? body.instanceId : ""
|
|
357
|
+
if (!instanceId) return fail(400, "bad_request")
|
|
358
|
+
pings.set(instanceId, now())
|
|
359
|
+
// Bounded: a client that keeps changing its id must not grow this map without
|
|
360
|
+
// limit. Oldest entries are dropped first.
|
|
361
|
+
while (pings.size > MAX_PINGS) {
|
|
362
|
+
const oldest = pings.keys().next().value
|
|
363
|
+
if (oldest === undefined) break
|
|
364
|
+
pings.delete(oldest)
|
|
365
|
+
}
|
|
366
|
+
return send(200, { ok: true, clients: pings.size })
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case "POST /bye": {
|
|
370
|
+
const body = asRecord(await readJson(req))
|
|
371
|
+
const instanceId = typeof body?.instanceId === "string" ? body.instanceId : ""
|
|
372
|
+
if (!instanceId) return fail(400, "bad_request")
|
|
373
|
+
pings.delete(instanceId)
|
|
374
|
+
return send(200, { ok: true, clients: pings.size })
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case "POST /shutdown": {
|
|
378
|
+
if (!deps.shutdown) return fail(501, "not_supported")
|
|
379
|
+
// Answer first, then exit: a client that got no response would report
|
|
380
|
+
// a failure for a restart that actually worked.
|
|
381
|
+
send(200, { ok: true })
|
|
382
|
+
setTimeout(() => deps.shutdown?.(), 50)
|
|
383
|
+
return undefined
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
case "POST /reload": {
|
|
387
|
+
if (!deps.reload) return fail(501, "not_supported")
|
|
388
|
+
try {
|
|
389
|
+
const result = await deps.reload()
|
|
390
|
+
if (!result.ok) return fail(500, "reload_failed", { message: result.error ?? "unknown" })
|
|
391
|
+
return send(200, { ok: true, ...(result.warning ? { warning: result.warning } : {}) })
|
|
392
|
+
} catch (error) {
|
|
393
|
+
return fail(500, "reload_failed", { message: (error as Error).message })
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
case "POST /mode": {
|
|
398
|
+
const body = asRecord(await readJson(req))
|
|
399
|
+
if (!isMode(body?.mode)) return fail(400, "bad_request")
|
|
400
|
+
const mode = writeMode(env, body.mode, now())
|
|
401
|
+
broadcast({ type: "mode", mode })
|
|
402
|
+
deps.onModeChange?.(mode)
|
|
403
|
+
return send(200, { mode })
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
case "POST /send": {
|
|
407
|
+
const body = asRecord(await readJson(req))
|
|
408
|
+
const subject = typeof body?.subject === "string" ? body.subject : ""
|
|
409
|
+
const text = typeof body?.text === "string" ? body.text : ""
|
|
410
|
+
const config = deps.getConfig?.() ?? deps.config
|
|
411
|
+
const to = typeof body?.to === "string" && body.to ? body.to : config.mail.to
|
|
412
|
+
// cwd lets the mailer resolve relative image paths from the agent's project.
|
|
413
|
+
const cwd = typeof body?.cwd === "string" ? body.cwd : undefined
|
|
414
|
+
// sessionId lets the sender thread this mail into the session's conversation.
|
|
415
|
+
const sessionId = typeof body?.sessionId === "string" ? body.sessionId : undefined
|
|
416
|
+
if (!subject || !text) return fail(400, "bad_request")
|
|
417
|
+
// A budget, not a rate limiter: an injected agent can ask for unbounded mail.
|
|
418
|
+
// A settle notification is exempt - it is the one mail that says the agent is
|
|
419
|
+
// waiting, and a legitimate `notify_user` burst must not silence it.
|
|
420
|
+
const kind =
|
|
421
|
+
body?.kind === "settle" ? "settle" : body?.kind === "decision" ? "decision" : "notification"
|
|
422
|
+
// Only unbounded, model-driven notifications are budgeted: a settle says the
|
|
423
|
+
// agent is waiting, and a decision is already capped at one outstanding per
|
|
424
|
+
// session by the registry.
|
|
425
|
+
if (kind === "notification" && !sendBudget.take(Date.now())) {
|
|
426
|
+
return fail(429, "send_budget")
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Thread every notification for a session into one conversation: reply to
|
|
430
|
+
// the previous message, reference the session root. Read BEFORE sending,
|
|
431
|
+
// because recording happens after.
|
|
432
|
+
// Mint and record BEFORE calling deps.send. The scan can see the mail come
|
|
433
|
+
// back into INBOX as soon as SMTP accepts it, so an id recorded after the
|
|
434
|
+
// send leaves the self-echo guard empty for exactly that window.
|
|
435
|
+
// Read the thread BEFORE recording: recording moves lastMessageId to the new
|
|
436
|
+
// id, and a message must answer its predecessor, not itself.
|
|
437
|
+
const thread = sessionId ? sessionThread(env, sessionId) : {}
|
|
438
|
+
const messageId = generateMessageId(config.mail.smtp.user ?? "")
|
|
439
|
+
if (sessionId) {
|
|
440
|
+
recordOutbound(env, {
|
|
441
|
+
messageId,
|
|
442
|
+
sessionId,
|
|
443
|
+
device: config.device.name,
|
|
444
|
+
subject,
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
const threading = {
|
|
448
|
+
...(thread.lastMessageId ? { inReplyTo: `<${thread.lastMessageId}>` } : {}),
|
|
449
|
+
...(thread.rootMessageId ? { references: [`<${thread.rootMessageId}>`] } : {}),
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const result = await deps.send({
|
|
453
|
+
to,
|
|
454
|
+
subject,
|
|
455
|
+
text,
|
|
456
|
+
...(cwd ? { cwd } : {}),
|
|
457
|
+
...(sessionId ? { sessionId } : {}),
|
|
458
|
+
messageId,
|
|
459
|
+
...threading,
|
|
460
|
+
})
|
|
461
|
+
// Sends were invisible in daemon.log: "did my notification go out?" had no
|
|
462
|
+
// answer. Kind and size only - never the subject, per AGENTS invariant 8.
|
|
463
|
+
deps.log?.(`send ok (${kind}, ${text.length} chars)`)
|
|
464
|
+
// Record the outbound id so a reply reaches this session even if a
|
|
465
|
+
// client or a human rewrites the subject token away.
|
|
466
|
+
} catch (error) {
|
|
467
|
+
// The id was recorded before the send, because the mail can come back
|
|
468
|
+
// before the transport resolves. It will not come back now, so drop it:
|
|
469
|
+
// otherwise the next notification threads against a parent that never
|
|
470
|
+
// existed.
|
|
471
|
+
removeOutbound(env, messageId)
|
|
472
|
+
return fail(502, "send_failed", { message: (error as Error).message })
|
|
473
|
+
}
|
|
474
|
+
// Bookkeeping only, and outside the failure path: a recording error here must
|
|
475
|
+
// not report a delivered mail as undelivered.
|
|
476
|
+
try {
|
|
477
|
+
deps.onSent?.({ messageId, subject, ...(sessionId ? { sessionId } : {}) })
|
|
478
|
+
} catch {
|
|
479
|
+
/* the thread store is an optimisation; the mail is already gone */
|
|
480
|
+
}
|
|
481
|
+
return send(200, { ok: true })
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
default:
|
|
485
|
+
return fail(404, "not_found")
|
|
486
|
+
}
|
|
487
|
+
} catch (error) {
|
|
488
|
+
const message = (error as Error).message
|
|
489
|
+
if (message === "request body too large") return fail(413, "body_too_large")
|
|
490
|
+
// Classified by cause, not by prose: a store write failure mentions `threads.json`,
|
|
491
|
+
// and a substring match on "json" reported it as the client's fault.
|
|
492
|
+
if (error instanceof BadBodyError) return fail(400, "bad_request", { message })
|
|
493
|
+
return fail(500, "internal", { message })
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const server = http.createServer((req, res) => {
|
|
498
|
+
void dispatch(req, res).catch(() => {
|
|
499
|
+
// A malformed request must not take the process down with it.
|
|
500
|
+
try {
|
|
501
|
+
if (!res.headersSent) {
|
|
502
|
+
res.writeHead(500, { "content-type": "application/json" })
|
|
503
|
+
res.end(JSON.stringify({ error: { code: "internal" } }))
|
|
504
|
+
} else {
|
|
505
|
+
res.destroy()
|
|
506
|
+
}
|
|
507
|
+
} catch {
|
|
508
|
+
/* the socket is already gone */
|
|
509
|
+
}
|
|
510
|
+
})
|
|
511
|
+
})
|
|
512
|
+
// Unparseable HTTP never reaches the handler at all.
|
|
513
|
+
server.on("clientError", (_error, socket) => {
|
|
514
|
+
socket.destroy()
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
server,
|
|
519
|
+
get sseClients() {
|
|
520
|
+
return streams.size
|
|
521
|
+
},
|
|
522
|
+
broadcast,
|
|
523
|
+
// Never fall back to the unspecified address: `undefined` binds 0.0.0.0, and the port is
|
|
524
|
+
// what keeps one daemon per machine. loadConfig repairs this, so this is a belt.
|
|
525
|
+
listen(port = deps.config.daemon.port, host = deps.config.daemon.host || "127.0.0.1") {
|
|
526
|
+
return new Promise((resolve, reject) => {
|
|
527
|
+
server.once("error", reject)
|
|
528
|
+
server.listen(port, host, () => {
|
|
529
|
+
server.removeListener("error", reject)
|
|
530
|
+
const address = server.address()
|
|
531
|
+
resolve({ port: typeof address === "object" && address ? address.port : port, host })
|
|
532
|
+
})
|
|
533
|
+
})
|
|
534
|
+
},
|
|
535
|
+
close() {
|
|
536
|
+
clearInterval(idleCheck)
|
|
537
|
+
return new Promise((resolve) => {
|
|
538
|
+
for (const res of streams) {
|
|
539
|
+
try {
|
|
540
|
+
res.end()
|
|
541
|
+
} catch {
|
|
542
|
+
/* already gone */
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
streams.clear()
|
|
546
|
+
server.close(() => resolve())
|
|
547
|
+
})
|
|
548
|
+
},
|
|
549
|
+
}
|
|
550
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The MailSource the daemon actually hands to the scanner and the HTTP service.
|
|
2
|
+
//
|
|
3
|
+
// This is a seam, not a convenience: it exists so a test can exercise exactly what production
|
|
4
|
+
// wires up. Three rounds of "the guard is inert in production" came from code that was only
|
|
5
|
+
// ever tested through a hand-built fixture, so the proxy and the UIDVALIDITY bookkeeping live
|
|
6
|
+
// here, in one small file, where they can be driven directly.
|
|
7
|
+
|
|
8
|
+
import { dropPendingFromOtherSpaces, listPending } from "../store/pending-store.ts"
|
|
9
|
+
import { syncUidValidity } from "../store/uid-cursor.ts"
|
|
10
|
+
import type { MailSource } from "./process.ts"
|
|
11
|
+
|
|
12
|
+
/** What the proxy needs from the watcher. */
|
|
13
|
+
export interface ProxyTarget extends MailSource {
|
|
14
|
+
uidValidity(): number | undefined
|
|
15
|
+
uidNext(): number | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SourceProxyDeps {
|
|
19
|
+
/** The current watcher, or undefined while it is stopped or being replaced. */
|
|
20
|
+
watcher: () => ProxyTarget | undefined
|
|
21
|
+
env?: NodeJS.ProcessEnv
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createSourceProxy(deps: SourceProxyDeps): MailSource {
|
|
25
|
+
const target = (): ProxyTarget => {
|
|
26
|
+
const watcher = deps.watcher()
|
|
27
|
+
if (!watcher) throw new Error("pi-mailbridge: imap watcher not started")
|
|
28
|
+
return watcher
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
searchAfter: (uid) => target().searchAfter(uid),
|
|
32
|
+
fetch: (uid) => target().fetch(uid),
|
|
33
|
+
markSeen: async (uid) => {
|
|
34
|
+
await deps.watcher()?.markSeen?.(uid)
|
|
35
|
+
},
|
|
36
|
+
// Every call reads the live watcher: the daemon swaps watchers on reload, and a
|
|
37
|
+
// captured reference would answer from the old connection.
|
|
38
|
+
uidValidity: () => deps.watcher()?.uidValidity(),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UidValidityOutcome {
|
|
43
|
+
reset: boolean
|
|
44
|
+
validity: number | undefined
|
|
45
|
+
dropped: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compare the mailbox's UIDVALIDITY with the recorded one, and reset what a renumbering
|
|
50
|
+
* invalidates.
|
|
51
|
+
*
|
|
52
|
+
* Called on every successful connect. A renumbering cannot be detected by the cursor alone,
|
|
53
|
+
* so this is the only place it is noticed - and the only place its consequences are undone.
|
|
54
|
+
*/
|
|
55
|
+
export function applyUidValidity(
|
|
56
|
+
env: NodeJS.ProcessEnv,
|
|
57
|
+
watcher: ProxyTarget | undefined,
|
|
58
|
+
onDropped?: (sessionId: string) => void,
|
|
59
|
+
): UidValidityOutcome {
|
|
60
|
+
const validity = watcher?.uidValidity()
|
|
61
|
+
if (validity === undefined) return { reset: false, validity, dropped: 0 }
|
|
62
|
+
const reset = syncUidValidity(env, validity, watcher?.uidNext())
|
|
63
|
+
|
|
64
|
+
// Drop on EVERY connect, not only on a reset: a crash between the cursor write and the
|
|
65
|
+
// drop leaves records from another space that nothing can claim (the guard refuses them)
|
|
66
|
+
// and nothing would ever remove, because the cursor already carries the new validity.
|
|
67
|
+
// Queued records from the wrong numbering could collide with new UIDs and be replaced
|
|
68
|
+
// silently. Dropping them is safe: they were never acked, so the mail is still unread.
|
|
69
|
+
// On a reset the cursor re-reads it; on the orphan path (no reset) the message is behind
|
|
70
|
+
// an already-advanced cursor, which is why the drop runs every connect. Their decision
|
|
71
|
+
// slots are freed, or that session would be told it has an outstanding decision for a day.
|
|
72
|
+
// Only records from another UID space are dropped here. Age-based expiry used to run on every
|
|
73
|
+
// connect, which deleted replies for sessions that were merely quiet - a user on `off` for a
|
|
74
|
+
// week came home to nothing to drain, with the cursor already past the mail. Expiry now
|
|
75
|
+
// happens under queue pressure only (see process.ts).
|
|
76
|
+
// On a reset, unknown-space records go too: see dropPendingFromOtherSpaces.
|
|
77
|
+
const dropped = dropPendingFromOtherSpaces(env, validity, { includeUnknown: reset })
|
|
78
|
+
if (dropped.length > 0) {
|
|
79
|
+
// Only free a slot the dropped record was holding: a session that still has a live
|
|
80
|
+
// record in the current space must keep its reservation, or it could be granted a
|
|
81
|
+
// second decision while the first is unanswered.
|
|
82
|
+
const stillQueued = new Set(listPending(env).map((r) => r.sessionId))
|
|
83
|
+
for (const record of dropped) {
|
|
84
|
+
if (!stillQueued.has(record.sessionId)) onDropped?.(record.sessionId)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { reset, validity, dropped: dropped.length }
|
|
88
|
+
}
|