@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,348 @@
|
|
|
1
|
+
// Scan → fetch → authorize → parse → persist. Port of opencode-afk `core/process.js`.
|
|
2
|
+
//
|
|
3
|
+
// THE ORDERING HERE IS THE CORRECTNESS PROPERTY (docs/ARCHITECTURE.md §2):
|
|
4
|
+
//
|
|
5
|
+
// 0. skip everything if mode is off `/mailbridge off` must close the channel, not just mute it
|
|
6
|
+
// 1. search `UID <cursor>+1:*` UID cursor, never \Seen, never SUBJECT
|
|
7
|
+
// 2. fetch envelope + body
|
|
8
|
+
// 3. self-echo, part 1: the marker header (before authorization, because in single-mailbox
|
|
9
|
+
// mode the sender IS the human and would otherwise pass every later gate)
|
|
10
|
+
// 4. authorize (loop → robot → allowlist → DMARC)
|
|
11
|
+
// 5. self-echo, part 2: our own recorded Message-ID
|
|
12
|
+
// 5. route: short id, then legacy signed token, then the reply chain (routes, does not authorize)
|
|
13
|
+
// 6. strip quoted text, then filter attachments
|
|
14
|
+
// 7. persist to the durable pending store <- nothing is lost after this
|
|
15
|
+
// 8. advance the cursor <- only after persist
|
|
16
|
+
//
|
|
17
|
+
// Marking \Seen and journaling happen later, in the daemon's /ack handler, once a
|
|
18
|
+
// session has injected the reply. Doing it here would lose replies on a crash.
|
|
19
|
+
|
|
20
|
+
import type { ResolvedConfig } from "../core/config.ts"
|
|
21
|
+
import { authorize } from "../core/authz.ts"
|
|
22
|
+
import { stripQuoted, type HeaderLike } from "../core/reply-parse.ts"
|
|
23
|
+
import { tokenFromSubject } from "../core/token.ts"
|
|
24
|
+
import { selectAttachments, type RawAttachment } from "../core/attachments.ts"
|
|
25
|
+
import { findOutbound, parseMessageIds, lookupChain } from "../store/threads.ts"
|
|
26
|
+
import { routeIdFromSubject, resolveRoute } from "../store/routes.ts"
|
|
27
|
+
import { addPending, expirePending, listPending, type PendingRecord } from "../store/pending-store.ts"
|
|
28
|
+
import { isJournaled } from "../store/journal.ts"
|
|
29
|
+
import { advanceCursor, readCursor } from "../store/uid-cursor.ts"
|
|
30
|
+
|
|
31
|
+
/** Longest body we keep. Well above any real instruction; far below "a whole newsletter". */
|
|
32
|
+
export const MAX_BODY_CHARS = 100_000
|
|
33
|
+
|
|
34
|
+
export interface RawMessage {
|
|
35
|
+
uid: number
|
|
36
|
+
from: string | undefined
|
|
37
|
+
subject: string | undefined
|
|
38
|
+
/** Plain-text body. */
|
|
39
|
+
text: string
|
|
40
|
+
/** Decoded attachments; the pipeline applies the allowlist and caps. */
|
|
41
|
+
attachments?: RawAttachment[]
|
|
42
|
+
headers: HeaderLike
|
|
43
|
+
receivedAt: number
|
|
44
|
+
/** Set when the server reported a message larger than we will parse. */
|
|
45
|
+
tooLarge?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Minimal mail source. `watcher.ts` implements this over imapflow; tests
|
|
50
|
+
* implement it over an in-memory fixture, so the pipeline never needs a socket.
|
|
51
|
+
*/
|
|
52
|
+
export interface MailSource {
|
|
53
|
+
/** The mailbox's UIDVALIDITY, or undefined when the source cannot report one. */
|
|
54
|
+
uidValidity(): number | undefined
|
|
55
|
+
/** UIDs strictly greater than `afterUid`, ascending. */
|
|
56
|
+
searchAfter(afterUid: number): Promise<number[]>
|
|
57
|
+
/** The message, or undefined when it vanished between search and fetch. */
|
|
58
|
+
fetch(uid: number): Promise<RawMessage | undefined>
|
|
59
|
+
/**
|
|
60
|
+
* Mark the message seen on the server. Called only from `/ack`, i.e. after a
|
|
61
|
+
* session has injected the reply — never at parse time.
|
|
62
|
+
*/
|
|
63
|
+
markSeen?(uid: number): Promise<void>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ScanDeps {
|
|
67
|
+
source: MailSource
|
|
68
|
+
/** HMAC key for routing-token verification. */
|
|
69
|
+
secret: Buffer
|
|
70
|
+
env?: NodeJS.ProcessEnv
|
|
71
|
+
now?: () => number
|
|
72
|
+
/** Release a decision slot whose record was dropped, when nothing else holds it. */
|
|
73
|
+
onDropped?: (sessionId: string) => void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ScanResult {
|
|
77
|
+
/** UIDs the source returned this scan. */
|
|
78
|
+
examined: number
|
|
79
|
+
/** Deliveries persisted to the pending store. */
|
|
80
|
+
persisted: number
|
|
81
|
+
/** Rejected: not for us, unauthorized, or machine-generated. */
|
|
82
|
+
rejected: number
|
|
83
|
+
/** Already journaled (acked earlier) — dedupe, not a rejection. */
|
|
84
|
+
skipped: number
|
|
85
|
+
/** Per-reason rejection counts, for logs and `/mailbridge status`. */
|
|
86
|
+
reasons: Record<string, number>
|
|
87
|
+
/** Cursor value after the scan. */
|
|
88
|
+
cursor: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function bump(reasons: Record<string, number>, key: string): void {
|
|
92
|
+
reasons[key] = (reasons[key] ?? 0) + 1
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One incremental scan.
|
|
97
|
+
*
|
|
98
|
+
* A message is "handled" (and the cursor moves past it) when it is persisted,
|
|
99
|
+
* rejected, skipped, or has vanished. Only a thrown error from the source or the
|
|
100
|
+
* pending store stops the scan with the cursor left before the offending UID, so
|
|
101
|
+
* the next scan retries it.
|
|
102
|
+
*/
|
|
103
|
+
export async function scanAndProcess(config: ResolvedConfig, deps: ScanDeps): Promise<ScanResult> {
|
|
104
|
+
const env = deps.env ?? process.env
|
|
105
|
+
const now = deps.now ?? (() => Date.now())
|
|
106
|
+
|
|
107
|
+
const startCursor = readCursor(env)
|
|
108
|
+
const uidValidity = deps.source.uidValidity?.()
|
|
109
|
+
const uids = (await deps.source.searchAfter(startCursor)).slice().sort((a, b) => a - b)
|
|
110
|
+
|
|
111
|
+
const result: ScanResult = {
|
|
112
|
+
examined: uids.length,
|
|
113
|
+
persisted: 0,
|
|
114
|
+
rejected: 0,
|
|
115
|
+
skipped: 0,
|
|
116
|
+
reasons: {},
|
|
117
|
+
cursor: startCursor,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const uid of uids) {
|
|
121
|
+
// Dedupe: an acked UID must never be re-offered, whatever the server says.
|
|
122
|
+
if (isJournaled(env, uid, uidValidity)) {
|
|
123
|
+
result.skipped++
|
|
124
|
+
bump(result.reasons, "journaled")
|
|
125
|
+
result.cursor = advanceCursor(env, uid)
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const message = await deps.source.fetch(uid)
|
|
130
|
+
if (!message) {
|
|
131
|
+
// Deleted between search and fetch. Nothing to do; do not block the cursor.
|
|
132
|
+
result.skipped++
|
|
133
|
+
bump(result.reasons, "vanished")
|
|
134
|
+
result.cursor = advanceCursor(env, uid)
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The marker is stamped on every notification we send. Seeing it inbound means the
|
|
139
|
+
// mail is ours coming back - check before authorization, because in single-mailbox
|
|
140
|
+
// mode it is From: the human and would otherwise pass every gate.
|
|
141
|
+
if (message.headers.piMailbridge) {
|
|
142
|
+
result.rejected++
|
|
143
|
+
bump(result.reasons, "self-echo")
|
|
144
|
+
result.cursor = advanceCursor(env, uid)
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The watcher refuses to download anything over the size ceiling and marks it here.
|
|
149
|
+
// Reject before authorization: this is our resource guard, not the sender's fault.
|
|
150
|
+
if (message.tooLarge) {
|
|
151
|
+
result.rejected++
|
|
152
|
+
bump(result.reasons, "too-large")
|
|
153
|
+
result.cursor = advanceCursor(env, uid)
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const authz = authorize({ from: message.from, headers: message.headers }, config)
|
|
158
|
+
if (!authz.ok) {
|
|
159
|
+
result.rejected++
|
|
160
|
+
bump(result.reasons, authz.reason)
|
|
161
|
+
result.cursor = advanceCursor(env, uid)
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Self-echo, before routing: never treat our own notification as a reply. It arrives
|
|
166
|
+
// From: the mailbox itself, and because notifications now carry In-Reply-To (threading)
|
|
167
|
+
// the single-mailbox guard in authorize() can no longer tell them from a genuine
|
|
168
|
+
// reply. The outbound registry can: if this message's own Message-ID is one we sent,
|
|
169
|
+
// it is ours, whatever its headers say.
|
|
170
|
+
const ownIds = parseMessageIds(message.headers.messageId)
|
|
171
|
+
if (ownIds.length > 0 && ownIds.some((id) => findOutbound(env, id))) {
|
|
172
|
+
result.rejected++
|
|
173
|
+
bump(result.reasons, "self-echo")
|
|
174
|
+
result.cursor = advanceCursor(env, uid)
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Routing, in order of preference:
|
|
179
|
+
// 1. a short local route id [pi:a1b2c3] — what new notifications carry
|
|
180
|
+
// 2. a signed token [pi:<base64>.<mac>] — messages already in flight
|
|
181
|
+
// 3. the reply chain In-Reply-To / References → recorded Message-ID
|
|
182
|
+
// All three are routing only; authorization happened above.
|
|
183
|
+
let sessionId: string | undefined
|
|
184
|
+
const routeId = routeIdFromSubject(message.subject)
|
|
185
|
+
|
|
186
|
+
if (routeId) {
|
|
187
|
+
// Short ids are local, so an id minted on another machine - or one whose 30-day
|
|
188
|
+
// TTL has passed - does not resolve here. Fall through to the older rules rather
|
|
189
|
+
// than dropping the reply: a human answering an old notification should still
|
|
190
|
+
// reach the session through the token or the reply chain.
|
|
191
|
+
const route = resolveRoute(routeId, { device: config.device.name, now: now(), env })
|
|
192
|
+
if (route) {
|
|
193
|
+
sessionId = route.sessionId
|
|
194
|
+
bump(result.reasons, "routed")
|
|
195
|
+
} else {
|
|
196
|
+
bump(result.reasons, "route-miss")
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!sessionId) {
|
|
201
|
+
const token = tokenFromSubject(message.subject, deps.secret, now(), config.device.name)
|
|
202
|
+
if (token) {
|
|
203
|
+
// deviceMatches === false means the token was minted for another machine.
|
|
204
|
+
// undefined means the format carries no device, which is not a mismatch.
|
|
205
|
+
if (token.deviceMatches === false || (token.device && token.device !== config.device.name)) {
|
|
206
|
+
result.rejected++
|
|
207
|
+
bump(result.reasons, "other-device")
|
|
208
|
+
result.cursor = advanceCursor(env, uid)
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
sessionId = token.sessionId
|
|
212
|
+
} else if (config.security.allowHeaderThreading !== false) {
|
|
213
|
+
// The reply chain: a client or human that rewrote the subject still leaves
|
|
214
|
+
// In-Reply-To/References intact, which is how "reply to this mail" works.
|
|
215
|
+
const ids = [
|
|
216
|
+
...parseMessageIds(message.headers.inReplyTo),
|
|
217
|
+
...parseMessageIds((message.headers.references ?? []).join(" ")),
|
|
218
|
+
]
|
|
219
|
+
const thread = lookupChain(env, ids, { now: now(), ttlHours: config.security.tokenTtlHours })
|
|
220
|
+
if (thread?.device && thread.device !== config.device.name) {
|
|
221
|
+
result.rejected++
|
|
222
|
+
bump(result.reasons, "other-device")
|
|
223
|
+
result.cursor = advanceCursor(env, uid)
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
if (thread) {
|
|
227
|
+
sessionId = thread.sessionId
|
|
228
|
+
bump(result.reasons, "threaded")
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!sessionId) {
|
|
234
|
+
result.rejected++
|
|
235
|
+
bump(result.reasons, "no-token")
|
|
236
|
+
result.cursor = advanceCursor(env, uid)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A body cap as well as a message cap: the pending store is re-read on every poll and
|
|
241
|
+
// on every /health, so one enormous reply would be paid for repeatedly.
|
|
242
|
+
const stripped = stripQuoted(message.text)
|
|
243
|
+
// Marked, not silently cut: the agent must be able to tell that its instruction is
|
|
244
|
+
// incomplete (the outbound truncation note does the same).
|
|
245
|
+
const body =
|
|
246
|
+
stripped.length > MAX_BODY_CHARS
|
|
247
|
+
? `${stripped.slice(0, MAX_BODY_CHARS)}\n\n[truncated: ${stripped.length - MAX_BODY_CHARS} more characters]`
|
|
248
|
+
: stripped
|
|
249
|
+
|
|
250
|
+
// Attachments are filtered BEFORE persisting; a dropped one is recorded with
|
|
251
|
+
// a reason so `/mailbridge` can explain why the agent did not see it.
|
|
252
|
+
const { accepted, dropped } = selectAttachments(message.attachments ?? [])
|
|
253
|
+
for (const item of dropped) bump(result.reasons, `attachment-${item.reason}`)
|
|
254
|
+
|
|
255
|
+
// A reply is empty only if it has neither prose nor a surviving attachment. Judging
|
|
256
|
+
// the body first discarded the image-from-a-phone case: nothing persisted, the
|
|
257
|
+
// attachment left unread, and the cursor moved past it.
|
|
258
|
+
//
|
|
259
|
+
// The same applies when the *only* content was an attachment that got dropped (a phone
|
|
260
|
+
// image over the 1 MB total, say). Rejecting it loses the human's whole reply with
|
|
261
|
+
// no trace, so it is persisted with a note naming the drop and delivered: the agent can
|
|
262
|
+
// ask for the text, and the mail is no longer sitting past the cursor unread.
|
|
263
|
+
if (!body && accepted.length === 0) {
|
|
264
|
+
const reasons = [...new Set(dropped.map((d) => d.reason))].join(", ")
|
|
265
|
+
if (reasons.length > 0) {
|
|
266
|
+
const note = `[pi-mailbridge: ${dropped.length} attachment(s) dropped (${reasons}); this reply had no text]`
|
|
267
|
+
// Not counted as rejected: the reply *is* delivered, as a note. The reason is
|
|
268
|
+
// named so the panel can say the content was lost, without claiming the mail was.
|
|
269
|
+
bump(result.reasons, "attachment-only-dropped")
|
|
270
|
+
const stub: PendingRecord = {
|
|
271
|
+
uid,
|
|
272
|
+
sessionId,
|
|
273
|
+
from: authz.from,
|
|
274
|
+
subject: message.subject ?? "",
|
|
275
|
+
body: note,
|
|
276
|
+
receivedAt: message.receivedAt ?? now(),
|
|
277
|
+
...(uidValidity === undefined ? {} : { uidValidity }),
|
|
278
|
+
device: config.device.name,
|
|
279
|
+
}
|
|
280
|
+
const persisted = addPending(env, stub)
|
|
281
|
+
if (!persisted.ok) {
|
|
282
|
+
// Same rule as a real record: do not advance past it, retry when there is room.
|
|
283
|
+
bump(result.reasons, "pending-full")
|
|
284
|
+
break
|
|
285
|
+
}
|
|
286
|
+
result.persisted++
|
|
287
|
+
result.cursor = advanceCursor(env, uid)
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
result.rejected++
|
|
291
|
+
bump(result.reasons, "empty-body")
|
|
292
|
+
result.cursor = advanceCursor(env, uid)
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const record: PendingRecord = {
|
|
297
|
+
uid,
|
|
298
|
+
sessionId,
|
|
299
|
+
from: authz.from,
|
|
300
|
+
subject: message.subject ?? "",
|
|
301
|
+
body,
|
|
302
|
+
...(accepted.length ? { attachments: accepted } : {}),
|
|
303
|
+
receivedAt: message.receivedAt,
|
|
304
|
+
// Re-read per record: a renumber can land mid-scan, and stamping the scan's opening
|
|
305
|
+
// space would leave the tail of the batch unclaimable until the next connect.
|
|
306
|
+
...((deps.source.uidValidity?.() ?? uidValidity) === undefined
|
|
307
|
+
? {}
|
|
308
|
+
: { uidValidity: deps.source.uidValidity?.() ?? uidValidity }),
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Persist BEFORE the cursor moves. A crash here leaves the mail unseen.
|
|
312
|
+
const added = addPending(env, { ...record, device: config.device.name })
|
|
313
|
+
if (!added.ok) {
|
|
314
|
+
// The queue is full. Before stalling every future reply, drop records nothing can
|
|
315
|
+
// deliver any more - a session that has not come back in a week. This is the only
|
|
316
|
+
// place age-based expiry runs, because it is the only place the loss buys progress.
|
|
317
|
+
const expired = expirePending(env)
|
|
318
|
+
if (expired.length > 0) {
|
|
319
|
+
result.rejected++
|
|
320
|
+
bump(result.reasons, "expired")
|
|
321
|
+
// The same rule as the UID-space drop: free a slot only when nothing else is
|
|
322
|
+
// queued for that session, or a live record would lose its reservation.
|
|
323
|
+
const stillQueued = new Set(listPending(env).map((r) => r.sessionId))
|
|
324
|
+
for (const gone of expired) {
|
|
325
|
+
if (!stillQueued.has(gone.sessionId)) deps.onDropped?.(gone.sessionId)
|
|
326
|
+
}
|
|
327
|
+
const retry = addPending(env, { ...record, device: config.device.name })
|
|
328
|
+
if (retry.ok) {
|
|
329
|
+
result.persisted++
|
|
330
|
+
result.cursor = advanceCursor(env, uid)
|
|
331
|
+
continue
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// The queue is full. STOP the batch: advancing past this UID would lose it, and
|
|
335
|
+
// so would processing a later UID (the cursor is monotonic, so it would jump
|
|
336
|
+
// over this one). The mail stays unread and the whole batch is retried once
|
|
337
|
+
// space appears.
|
|
338
|
+
result.rejected++
|
|
339
|
+
bump(result.reasons, "pending-full")
|
|
340
|
+
break
|
|
341
|
+
}
|
|
342
|
+
result.persisted++
|
|
343
|
+
|
|
344
|
+
result.cursor = advanceCursor(env, uid)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return result
|
|
348
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// One scan at a time, with at most one queued behind it.
|
|
2
|
+
//
|
|
3
|
+
// Three things ask for a scan concurrently: an IMAP `exists`, a reconnect's catch-up, and
|
|
4
|
+
// mode turning on. Overlapping scans are not harmless - two that pass the journal check
|
|
5
|
+
// before either persists would deliver the same reply twice, which is the one outcome this
|
|
6
|
+
// design cannot undo. A burst of IDLE events also should not become a burst of full scans,
|
|
7
|
+
// so a request that arrives while one is running coalesces into a single follow-up.
|
|
8
|
+
|
|
9
|
+
export interface ScanQueue {
|
|
10
|
+
(why: string): Promise<void>
|
|
11
|
+
/** True while a scan is running or one is queued. */
|
|
12
|
+
busy(): boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createScanQueue(run: (why: string) => Promise<void>): ScanQueue {
|
|
16
|
+
let current: Promise<void> | undefined
|
|
17
|
+
let queued: string | undefined
|
|
18
|
+
|
|
19
|
+
const start = (why: string): Promise<void> => {
|
|
20
|
+
current = (async () => {
|
|
21
|
+
try {
|
|
22
|
+
await run(why)
|
|
23
|
+
} catch {
|
|
24
|
+
// A scan failure is logged by the caller; the queue must survive it.
|
|
25
|
+
} finally {
|
|
26
|
+
current = undefined
|
|
27
|
+
if (queued !== undefined) {
|
|
28
|
+
const next = queued
|
|
29
|
+
queued = undefined
|
|
30
|
+
void start(next)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
})()
|
|
34
|
+
return current
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const scan = (why: string): Promise<void> => {
|
|
38
|
+
if (!current) return start(why)
|
|
39
|
+
// Coalesce: one follow-up covers any number of requests that arrive mid-scan.
|
|
40
|
+
queued ??= why
|
|
41
|
+
return current
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return Object.assign(scan, { busy: () => current !== undefined })
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Which daemon entry should a session spawn?
|
|
2
|
+
//
|
|
3
|
+
// The pi manifest points at `src/extension.ts`, and pi loads it through jiti - so in a published
|
|
4
|
+
// install the loaded module is TypeScript *inside node_modules*, where Node refuses type
|
|
5
|
+
// stripping. The daemon beside it must therefore be the compiled `dist/` entry whenever one
|
|
6
|
+
// exists, whichever shape this module has.
|
|
7
|
+
//
|
|
8
|
+
// Pure and exported so both layouts can be tested without installing a package.
|
|
9
|
+
|
|
10
|
+
import { existsSync } from "node:fs"
|
|
11
|
+
import { dirname, join } from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the daemon script for the module at `moduleUrl`.
|
|
16
|
+
*
|
|
17
|
+
* - `dist/extension.js` -> `dist/daemon/main.js`
|
|
18
|
+
* - `src/extension.ts` -> `dist/daemon/main.js` when built (a published install)
|
|
19
|
+
* - `src/extension.ts` only -> `src/daemon/main.ts` (a checkout, run with the flag)
|
|
20
|
+
*/
|
|
21
|
+
export function resolveDaemonScript(moduleUrl: string | URL, exists: (path: string) => boolean = existsSync): string {
|
|
22
|
+
const here = fileURLToPath(moduleUrl)
|
|
23
|
+
const dir = dirname(here)
|
|
24
|
+
const root = dirname(dir)
|
|
25
|
+
const compiled = here.endsWith(".js")
|
|
26
|
+
const candidates = compiled
|
|
27
|
+
? [
|
|
28
|
+
// dist/extension.js -> dist/daemon/main.js
|
|
29
|
+
join(dir, "daemon", "main.js"),
|
|
30
|
+
join(root, "dist", "daemon", "main.js"),
|
|
31
|
+
join(dir, "daemon", "main.ts"),
|
|
32
|
+
]
|
|
33
|
+
: [
|
|
34
|
+
// src/extension.ts -> the build, when the package was published
|
|
35
|
+
join(root, "dist", "daemon", "main.js"),
|
|
36
|
+
join(dir, "daemon", "main.js"),
|
|
37
|
+
// a checkout without a build
|
|
38
|
+
join(dir, "daemon", "main.ts"),
|
|
39
|
+
]
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
if (exists(candidate)) return candidate
|
|
42
|
+
}
|
|
43
|
+
// Nothing exists: name the source path, so the failure mentions what the caller expected.
|
|
44
|
+
return join(dir, "daemon", "main.ts")
|
|
45
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// How much mail this daemon will send per minute.
|
|
2
|
+
//
|
|
3
|
+
// The delivery path is model-driven: `notify_user` and settle notifications can be produced by an
|
|
4
|
+
// agent, and an injected agent can ask for a great many. Providers answer a burst by throttling
|
|
5
|
+
// the account or refusing for the day, which silences the notifications that actually matter.
|
|
6
|
+
// A small sliding window keeps a loop from spending the quota.
|
|
7
|
+
//
|
|
8
|
+
// Deliberately not a queue: a refused send is reported to the caller (`429 send_budget`), so the
|
|
9
|
+
// agent sees it and the human is not mailed a backlog of stale notifications later.
|
|
10
|
+
|
|
11
|
+
export interface SendBudget {
|
|
12
|
+
/** True when a send is allowed now; consumes one slot. */
|
|
13
|
+
take(now: number): boolean
|
|
14
|
+
/** Slots used inside the current window, for tests and diagnostics. */
|
|
15
|
+
used(now: number): number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createSendBudget(options: { limit: number; windowMs: number }): SendBudget {
|
|
19
|
+
const sent: number[] = []
|
|
20
|
+
const prune = (now: number): void => {
|
|
21
|
+
const floor = now - options.windowMs
|
|
22
|
+
while (sent.length > 0 && sent[0]! <= floor) sent.shift()
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
take(now: number) {
|
|
26
|
+
prune(now)
|
|
27
|
+
if (sent.length >= options.limit) return false
|
|
28
|
+
sent.push(now)
|
|
29
|
+
return true
|
|
30
|
+
},
|
|
31
|
+
used(now: number) {
|
|
32
|
+
prune(now)
|
|
33
|
+
return sent.length
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|