@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,104 @@
|
|
|
1
|
+
// Acked UIDs — durable replay protection.
|
|
2
|
+
//
|
|
3
|
+
// The cursor says what we have *seen*; the journal says what a session has *acked*. A crash
|
|
4
|
+
// between the two is why this file exists.
|
|
5
|
+
//
|
|
6
|
+
// UIDs are only meaningful within one UIDVALIDITY, so the journal stores the one it belongs
|
|
7
|
+
// to. A re-indexed mailbox renumbers from 1, and an old journal then suppresses brand-new
|
|
8
|
+
// mail as "already acked" - so a validity mismatch means the journal is not consulted.
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
11
|
+
import { dirname } from "node:path"
|
|
12
|
+
|
|
13
|
+
import { statePath } from "./paths.ts"
|
|
14
|
+
import { StateVersionError, checkStateVersion, stamp } from "./schema.ts"
|
|
15
|
+
|
|
16
|
+
/** Keep the last N acked UIDs. */
|
|
17
|
+
export const JOURNAL_CAP = 5_000
|
|
18
|
+
|
|
19
|
+
interface JournalFile {
|
|
20
|
+
v?: number
|
|
21
|
+
uids: number[]
|
|
22
|
+
/** The UIDVALIDITY these UIDs were acked under. */
|
|
23
|
+
uidValidity?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function path(env: NodeJS.ProcessEnv): string {
|
|
27
|
+
return statePath(env, "journal.json")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadFile(env: NodeJS.ProcessEnv): JournalFile {
|
|
31
|
+
const p = path(env)
|
|
32
|
+
try {
|
|
33
|
+
if (!existsSync(p)) return { uids: [] }
|
|
34
|
+
const parsed = JSON.parse(readFileSync(p, "utf8")) as Partial<JournalFile>
|
|
35
|
+
checkStateVersion("journal.json", parsed)
|
|
36
|
+
return {
|
|
37
|
+
uids: Array.isArray(parsed?.uids) ? parsed.uids.filter((n) => typeof n === "number") : [],
|
|
38
|
+
...(typeof parsed?.uidValidity === "number" ? { uidValidity: parsed.uidValidity } : {}),
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error instanceof StateVersionError) throw error
|
|
42
|
+
return { uids: [] }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function save(env: NodeJS.ProcessEnv, file: JournalFile): void {
|
|
47
|
+
const p = path(env)
|
|
48
|
+
mkdirSync(dirname(p), { recursive: true, mode: 0o700 })
|
|
49
|
+
const tmp = `${p}.${process.pid}.tmp`
|
|
50
|
+
const trimmed = file.uids.length > JOURNAL_CAP ? file.uids.slice(file.uids.length - JOURNAL_CAP) : file.uids
|
|
51
|
+
writeFileSync(tmp, JSON.stringify({ ...stamp(), ...file, uids: trimmed } satisfies JournalFile), { mode: 0o600 })
|
|
52
|
+
renameSync(tmp, p)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Has this UID been acked?
|
|
57
|
+
*
|
|
58
|
+
* When the caller knows the mailbox's UIDVALIDITY and it differs from the journal's, the
|
|
59
|
+
* answer is "no": those UIDs belonged to a different numbering and say nothing about this one.
|
|
60
|
+
*/
|
|
61
|
+
export function isJournaled(env: NodeJS.ProcessEnv, uid: number, uidValidity?: number): boolean {
|
|
62
|
+
const file = loadFile(env)
|
|
63
|
+
if (uidValidity !== undefined) {
|
|
64
|
+
// The caller knows the live numbering. A journal tagged with a different space cannot
|
|
65
|
+
// answer for it - and neither can an untagged one, written before the field existed:
|
|
66
|
+
// after a renumbering its old UIDs are reused, and "already seen" would let the cursor
|
|
67
|
+
// advance past genuinely new mail.
|
|
68
|
+
if (file.uidValidity === undefined) return false
|
|
69
|
+
if (file.uidValidity !== uidValidity) return false
|
|
70
|
+
}
|
|
71
|
+
return file.uids.includes(uid)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Append `uid` (idempotent). Returns false when it was already journaled. */
|
|
75
|
+
export function journal(env: NodeJS.ProcessEnv, uid: number, uidValidity?: number): boolean {
|
|
76
|
+
const file = loadFile(env)
|
|
77
|
+
// A new numbering starts a new journal: keeping old entries would suppress new mail.
|
|
78
|
+
const sameSpace = uidValidity === undefined || file.uidValidity === undefined || file.uidValidity === uidValidity
|
|
79
|
+
const uids = sameSpace ? file.uids : []
|
|
80
|
+
// Never erase a validity we already know: an ack from a caller that could not report one
|
|
81
|
+
// must not blind the next comparison.
|
|
82
|
+
// The caller's space wins, because claim/ack refuse a record from another space: an ack
|
|
83
|
+
// that reaches here is for the live numbering, and the file must adopt it. (Preferring the
|
|
84
|
+
// file's space made it write-once - after the first renumbering every later ack was
|
|
85
|
+
// journaled under the dead numbering and dedupe was permanently inert.)
|
|
86
|
+
const known = uidValidity ?? file.uidValidity
|
|
87
|
+
const next = { uids, ...(known === undefined ? {} : { uidValidity: known }) }
|
|
88
|
+
if (uids.includes(uid)) {
|
|
89
|
+
save(env, next)
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
uids.push(uid)
|
|
93
|
+
save(env, next)
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function journalSize(env: NodeJS.ProcessEnv): number {
|
|
98
|
+
return loadFile(env).uids.length
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Test helper: forget every ack. */
|
|
102
|
+
export function resetJournal(env: NodeJS.ProcessEnv): void {
|
|
103
|
+
save(env, { uids: [] })
|
|
104
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Global email mode: "am I away from the keyboard?"
|
|
2
|
+
//
|
|
3
|
+
// One human, one mailbox, one switch. It is deliberately global rather than
|
|
4
|
+
// per-session: you are either at the desk or you are not, and pretending
|
|
5
|
+
// otherwise makes the agent guess.
|
|
6
|
+
//
|
|
7
|
+
// Persisted so a daemon restart cannot silently re-enable emailing, and default
|
|
8
|
+
// OFF so installing the package never starts mailing you unprompted.
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
11
|
+
import { dirname } from "node:path"
|
|
12
|
+
|
|
13
|
+
import { statePath } from "./paths.ts"
|
|
14
|
+
|
|
15
|
+
export type Mode = "on" | "off"
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_MODE: Mode = "off"
|
|
18
|
+
|
|
19
|
+
interface ModeFile {
|
|
20
|
+
mode: Mode
|
|
21
|
+
updatedAt: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function path(env: NodeJS.ProcessEnv): string {
|
|
25
|
+
return statePath(env, "mode.json")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isMode(value: unknown): value is Mode {
|
|
29
|
+
return value === "on" || value === "off"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Current mode; a missing or corrupt file means off. */
|
|
33
|
+
export function readMode(env: NodeJS.ProcessEnv = process.env): Mode {
|
|
34
|
+
const p = path(env)
|
|
35
|
+
try {
|
|
36
|
+
if (!existsSync(p)) return DEFAULT_MODE
|
|
37
|
+
const parsed = JSON.parse(readFileSync(p, "utf8")) as Partial<ModeFile>
|
|
38
|
+
return isMode(parsed?.mode) ? parsed.mode : DEFAULT_MODE
|
|
39
|
+
} catch {
|
|
40
|
+
return DEFAULT_MODE
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Persist the mode. Returns the value actually stored. */
|
|
45
|
+
export function writeMode(env: NodeJS.ProcessEnv, mode: Mode, now: number = Date.now()): Mode {
|
|
46
|
+
const p = path(env)
|
|
47
|
+
mkdirSync(dirname(p), { recursive: true, mode: 0o700 })
|
|
48
|
+
const tmp = `${p}.${process.pid}.tmp`
|
|
49
|
+
writeFileSync(tmp, JSON.stringify({ mode, updatedAt: now } satisfies ModeFile), { mode: 0o600 })
|
|
50
|
+
renameSync(tmp, p)
|
|
51
|
+
return mode
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The single rule for whether the daemon may touch the mailbox. `/mailbridge off` is documented as
|
|
56
|
+
* closing the control channel, so this is the gate that makes that true.
|
|
57
|
+
*/
|
|
58
|
+
export function scanningEnabled(mode: Mode): boolean {
|
|
59
|
+
return mode === "on"
|
|
60
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Persistence for OAuth tokens.
|
|
2
|
+
//
|
|
3
|
+
// Only the refresh token is precious: access tokens are short-lived and cheap to
|
|
4
|
+
// re-mint. The file is 0600 inside the 0700 state dir, and it is never logged.
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
7
|
+
import { dirname } from "node:path"
|
|
8
|
+
|
|
9
|
+
import { statePath } from "./paths.ts"
|
|
10
|
+
import type { StoredTokens, TokenStore } from "../core/oauth-device.ts"
|
|
11
|
+
|
|
12
|
+
function path(env: NodeJS.ProcessEnv): string {
|
|
13
|
+
return statePath(env, "oauth.json")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** File-backed token store. Atomic replace, mode 0600. */
|
|
17
|
+
export function fileTokenStore(env: NodeJS.ProcessEnv = process.env): TokenStore {
|
|
18
|
+
const file = path(env)
|
|
19
|
+
return {
|
|
20
|
+
read() {
|
|
21
|
+
try {
|
|
22
|
+
if (!existsSync(file)) return undefined
|
|
23
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as StoredTokens
|
|
24
|
+
if (typeof parsed?.refreshToken !== "string" && typeof parsed?.accessToken !== "string") return undefined
|
|
25
|
+
return parsed
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
write(tokens) {
|
|
32
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
|
|
33
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
34
|
+
writeFileSync(tmp, JSON.stringify(tokens), { mode: 0o600 })
|
|
35
|
+
renameSync(tmp, file)
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** In-memory store for tests and for `--auth` dry runs. */
|
|
41
|
+
export function memoryTokenStore(initial?: StoredTokens): TokenStore & { value(): StoredTokens | undefined } {
|
|
42
|
+
let tokens = initial
|
|
43
|
+
return {
|
|
44
|
+
read: () => tokens,
|
|
45
|
+
write(next) {
|
|
46
|
+
tokens = next
|
|
47
|
+
},
|
|
48
|
+
value: () => tokens,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True when a refresh token is on disk, i.e. login already happened. */
|
|
53
|
+
export function hasStoredRefreshToken(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
54
|
+
return Boolean(fileTokenStore(env).read()?.refreshToken)
|
|
55
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// State + config locations. Everything the package writes lives under one
|
|
2
|
+
// directory so a user can inspect or delete it in one step.
|
|
3
|
+
//
|
|
4
|
+
// $XDG_STATE_HOME/pi-mailbridge/ (default ~/.local/state/pi-mailbridge)
|
|
5
|
+
// secret.key shared HMAC + daemon bearer key, mode 0600
|
|
6
|
+
// uid-cursor.json IMAP UID cursor + the mailbox UIDVALIDITY it belongs to
|
|
7
|
+
// pending.json durable pending deliveries
|
|
8
|
+
// journal.json UIDs already acked (replay protection)
|
|
9
|
+
// mode.json global on/off
|
|
10
|
+
// routes.json short routing ids (TTL-bounded)
|
|
11
|
+
// threads.json outbound Message-IDs: threading + reply chain
|
|
12
|
+
// oauth.json refresh token, 0600
|
|
13
|
+
//
|
|
14
|
+
// Config is read from, in order, each deep-merged over the previous:
|
|
15
|
+
// 1. built-in defaults
|
|
16
|
+
// 2. $XDG_CONFIG_HOME/pi-mailbridge/config.json
|
|
17
|
+
// 3. PI_MAILBRIDGE_* environment variables
|
|
18
|
+
|
|
19
|
+
import { homedir } from "node:os"
|
|
20
|
+
import { join } from "node:path"
|
|
21
|
+
|
|
22
|
+
export function stateDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
23
|
+
const base = env.XDG_STATE_HOME || join(homedir(), ".local", "state")
|
|
24
|
+
return join(base, "pi-mailbridge")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function configDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
28
|
+
const base = env.XDG_CONFIG_HOME || join(homedir(), ".config")
|
|
29
|
+
return join(base, "pi-mailbridge")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function statePath(env: NodeJS.ProcessEnv, ...parts: string[]): string {
|
|
33
|
+
return join(stateDir(env), ...parts)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function configPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
37
|
+
return join(configDir(env), "config.json")
|
|
38
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Durable pending deliveries.
|
|
2
|
+
//
|
|
3
|
+
// Written by the daemon immediately after a message is parsed and authorized —
|
|
4
|
+
// *before* any session is told about it. That ordering is the whole point: a
|
|
5
|
+
// crash after this write still leaves the reply available for redelivery, while
|
|
6
|
+
// the mail stays unseen on the server.
|
|
7
|
+
//
|
|
8
|
+
// Single-writer model: only the daemon mutates this file, so no inter-process
|
|
9
|
+
// lock is required. Atomic rename protects against torn reads.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
|
|
12
|
+
import { dirname } from "node:path"
|
|
13
|
+
|
|
14
|
+
import { statePath } from "./paths.ts"
|
|
15
|
+
import { StateVersionError, checkStateVersion, stamp } from "./schema.ts"
|
|
16
|
+
|
|
17
|
+
/** Bound the store so a stuck mailbox cannot grow it without limit. */
|
|
18
|
+
export const PENDING_CAP = 1_000
|
|
19
|
+
/** A claim older than this is considered abandoned and may be re-taken. */
|
|
20
|
+
export const CLAIM_TTL_MS = 120_000
|
|
21
|
+
|
|
22
|
+
export interface PendingRecord {
|
|
23
|
+
uid: number
|
|
24
|
+
sessionId: string
|
|
25
|
+
from: string
|
|
26
|
+
subject: string
|
|
27
|
+
body: string
|
|
28
|
+
/** Already allowlisted and capped by core/attachments before persisting. */
|
|
29
|
+
attachments?: { name: string; mimeType: string; size: number; data: string }[]
|
|
30
|
+
receivedAt: number
|
|
31
|
+
/** The UIDVALIDITY this UID belongs to, when known. */
|
|
32
|
+
uidValidity?: number
|
|
33
|
+
/**
|
|
34
|
+
* The device that scanned it. Two machines sharing one state directory (or one device.name)
|
|
35
|
+
* must not deliver each other's replies, so /pending filters and /claim refuses on this.
|
|
36
|
+
*/
|
|
37
|
+
device?: string
|
|
38
|
+
claimedBy?: string
|
|
39
|
+
claimedAt?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface PendingFile {
|
|
43
|
+
v?: number
|
|
44
|
+
items: PendingRecord[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ClaimResult =
|
|
48
|
+
| { ok: true }
|
|
49
|
+
| { ok: false; reason: "not-found" | "claimed-by-other" | "stale-space" | "foreign-device" }
|
|
50
|
+
|
|
51
|
+
function path(env: NodeJS.ProcessEnv): string {
|
|
52
|
+
return statePath(env, "pending.json")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parsed-file cache, keyed by the file's mtime+size.
|
|
57
|
+
*
|
|
58
|
+
* /health is unauthenticated and reads the queue length on every request; every poll tick
|
|
59
|
+
* and every claim/ack re-reads it too. Without a cache, one large queued reply is re-parsed
|
|
60
|
+
* (and re-serialised to JSON) many times a minute.
|
|
61
|
+
*/
|
|
62
|
+
let cached: { path: string; stamp: string; items: PendingRecord[] } | undefined
|
|
63
|
+
|
|
64
|
+
function load(env: NodeJS.ProcessEnv): PendingRecord[] {
|
|
65
|
+
const p = path(env)
|
|
66
|
+
try {
|
|
67
|
+
if (!existsSync(p)) return []
|
|
68
|
+
const stat = statSync(p)
|
|
69
|
+
const stamp = `${stat.mtimeMs}:${stat.size}`
|
|
70
|
+
if (cached && cached.path === p && cached.stamp === stamp) return cached.items
|
|
71
|
+
const parsed = JSON.parse(readFileSync(p, "utf8")) as Partial<PendingFile>
|
|
72
|
+
// Refuses a file from a newer schema rather than filtering records it cannot read.
|
|
73
|
+
checkStateVersion("pending.json", parsed)
|
|
74
|
+
if (!Array.isArray(parsed?.items)) return []
|
|
75
|
+
const items = parsed.items.filter(
|
|
76
|
+
(r): r is PendingRecord =>
|
|
77
|
+
typeof r?.uid === "number" && typeof r?.sessionId === "string" && typeof r?.body === "string",
|
|
78
|
+
)
|
|
79
|
+
cached = { path: p, stamp, items }
|
|
80
|
+
return items
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof StateVersionError) throw error
|
|
83
|
+
// Unparsable is not empty. Returning [] lets the next addPending rewrite the file with a
|
|
84
|
+
// single record, discarding a queue whose replies the cursor has already passed. Keep the
|
|
85
|
+
// bytes where a human can recover them and start clean.
|
|
86
|
+
try {
|
|
87
|
+
renameSync(p, `${p}.corrupt-${Date.now()}`)
|
|
88
|
+
} catch {
|
|
89
|
+
/* if it cannot be moved, the caller still gets an empty queue rather than a crash */
|
|
90
|
+
}
|
|
91
|
+
return []
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function save(env: NodeJS.ProcessEnv, items: PendingRecord[]): void {
|
|
96
|
+
const p = path(env)
|
|
97
|
+
mkdirSync(dirname(p), { recursive: true, mode: 0o700 })
|
|
98
|
+
const tmp = `${p}.${process.pid}.tmp`
|
|
99
|
+
// Never trim here. These records were accepted and the cursor has already moved past
|
|
100
|
+
// them, so dropping one is silent, unrecoverable mail loss. The cap is enforced at
|
|
101
|
+
// insertion instead, where the caller can refuse and leave the cursor alone.
|
|
102
|
+
writeFileSync(tmp, JSON.stringify({ ...stamp(), items } satisfies PendingFile), { mode: 0o600 })
|
|
103
|
+
renameSync(tmp, p)
|
|
104
|
+
// The process that wrote knows the truth; an mtime+size stamp can collide when two writes
|
|
105
|
+
// land in the same millisecond with the same length (measured on ext4).
|
|
106
|
+
try {
|
|
107
|
+
const stat = statSync(p)
|
|
108
|
+
cached = { path: p, stamp: `${stat.mtimeMs}:${stat.size}`, items }
|
|
109
|
+
} catch {
|
|
110
|
+
cached = undefined
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function listPending(env: NodeJS.ProcessEnv = process.env): PendingRecord[] {
|
|
115
|
+
return load(env)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Only the records this device scanned; absent device means "from before the field". */
|
|
119
|
+
export function listPendingForDevice(env: NodeJS.ProcessEnv, device: string): PendingRecord[] {
|
|
120
|
+
return load(env).filter((r) => r.device === undefined || r.device === device)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function listPendingForSession(env: NodeJS.ProcessEnv, sessionId: string): PendingRecord[] {
|
|
124
|
+
return load(env).filter((r) => r.sessionId === sessionId)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getPending(env: NodeJS.ProcessEnv, uid: number): PendingRecord | undefined {
|
|
128
|
+
return load(env).find((r) => r.uid === uid)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type AddResult = { ok: true; record: PendingRecord } | { ok: false; reason: "full" }
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Add or replace the record for `uid`. Idempotent on replay.
|
|
135
|
+
*
|
|
136
|
+
* Refuses a NEW record once the store is full rather than making room by discarding an
|
|
137
|
+
* older one: every record here was already accepted, and the caller's cursor is about to
|
|
138
|
+
* move past it. Replacing an existing uid (replay) is always allowed.
|
|
139
|
+
*/
|
|
140
|
+
export function addPending(env: NodeJS.ProcessEnv, record: PendingRecord): AddResult {
|
|
141
|
+
const existing = load(env)
|
|
142
|
+
const without = existing.filter((r) => r.uid !== record.uid)
|
|
143
|
+
if (without.length >= PENDING_CAP) return { ok: false, reason: "full" }
|
|
144
|
+
without.push(record)
|
|
145
|
+
save(env, without)
|
|
146
|
+
return { ok: true, record }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Claim a delivery for `instanceId`.
|
|
151
|
+
*
|
|
152
|
+
* Succeeds when the record is unclaimed, already claimed by this same instance,
|
|
153
|
+
* or its claim has expired (abandoned instance).
|
|
154
|
+
*/
|
|
155
|
+
export function claimPending(
|
|
156
|
+
env: NodeJS.ProcessEnv,
|
|
157
|
+
uid: number,
|
|
158
|
+
instanceId: string,
|
|
159
|
+
now: number = Date.now(),
|
|
160
|
+
uidValidity?: number,
|
|
161
|
+
liveValidity?: number,
|
|
162
|
+
device?: string,
|
|
163
|
+
): ClaimResult {
|
|
164
|
+
const items = load(env)
|
|
165
|
+
const record = items.find((r) => r.uid === uid)
|
|
166
|
+
if (!record) return { ok: false, reason: "not-found" }
|
|
167
|
+
// Defence in depth for a shared state directory: /pending already filters by device, but a
|
|
168
|
+
// caller that names a uid it should not have (an older client, or a direct POST) is refused.
|
|
169
|
+
if (record.device !== undefined && device !== undefined && record.device !== device) {
|
|
170
|
+
return { ok: false, reason: "foreign-device" }
|
|
171
|
+
}
|
|
172
|
+
// A caller holding a record from a previous UID numbering must not act on whatever now
|
|
173
|
+
// occupies that UID: after a renumbering it is a different message.
|
|
174
|
+
if (record.uidValidity !== undefined && uidValidity !== undefined && record.uidValidity !== uidValidity) {
|
|
175
|
+
return { ok: false, reason: "stale-space" }
|
|
176
|
+
}
|
|
177
|
+
// Also compare against the mailbox itself: a caller and a record can agree with each other
|
|
178
|
+
// while both belong to a numbering the mailbox has already left.
|
|
179
|
+
if (record.uidValidity !== undefined && liveValidity !== undefined && record.uidValidity !== liveValidity) {
|
|
180
|
+
return { ok: false, reason: "stale-space" }
|
|
181
|
+
}
|
|
182
|
+
if (record.claimedBy && record.claimedBy !== instanceId) {
|
|
183
|
+
const age = now - (record.claimedAt ?? 0)
|
|
184
|
+
if (age < CLAIM_TTL_MS) return { ok: false, reason: "claimed-by-other" }
|
|
185
|
+
}
|
|
186
|
+
record.claimedBy = instanceId
|
|
187
|
+
record.claimedAt = now
|
|
188
|
+
save(env, items)
|
|
189
|
+
return { ok: true }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Remove a delivery. Only the claimant may ack.
|
|
194
|
+
*
|
|
195
|
+
* An unclaimed record must NOT be ackable: with two sessions on one machine, any
|
|
196
|
+
* authenticated client could otherwise delete, journal and mark `\Seen` another
|
|
197
|
+
* session's reply - and nothing would re-read it, because the cursor has already moved.
|
|
198
|
+
* Returns the record's session so the caller releases the right decision slot instead of
|
|
199
|
+
* trusting whatever session id the request body carried.
|
|
200
|
+
*/
|
|
201
|
+
export type AckResult =
|
|
202
|
+
| { ok: true; sessionId: string; uidValidity?: number }
|
|
203
|
+
| { ok: false; reason: "not-found" | "not-claimed" | "stale-space" }
|
|
204
|
+
|
|
205
|
+
export function ackPending(
|
|
206
|
+
env: NodeJS.ProcessEnv,
|
|
207
|
+
uid: number,
|
|
208
|
+
instanceId: string,
|
|
209
|
+
uidValidity?: number,
|
|
210
|
+
liveValidity?: number,
|
|
211
|
+
): AckResult {
|
|
212
|
+
const items = load(env)
|
|
213
|
+
const record = items.find((r) => r.uid === uid)
|
|
214
|
+
if (!record) return { ok: false, reason: "not-found" }
|
|
215
|
+
// Same rule as claim: an ack from another UID space would journal a UID that now means
|
|
216
|
+
// something else.
|
|
217
|
+
if (record.uidValidity !== undefined && uidValidity !== undefined && record.uidValidity !== uidValidity) {
|
|
218
|
+
return { ok: false, reason: "stale-space" }
|
|
219
|
+
}
|
|
220
|
+
if (record.uidValidity !== undefined && liveValidity !== undefined && record.uidValidity !== liveValidity) {
|
|
221
|
+
return { ok: false, reason: "stale-space" }
|
|
222
|
+
}
|
|
223
|
+
if (record.claimedBy !== instanceId) return { ok: false, reason: "not-claimed" }
|
|
224
|
+
save(env, items.filter((r) => r.uid !== uid))
|
|
225
|
+
return { ok: true, sessionId: record.sessionId, ...(record.uidValidity === undefined ? {} : { uidValidity: record.uidValidity }) }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Drop queued records that belong to a different UID numbering.
|
|
230
|
+
*
|
|
231
|
+
* A re-indexed mailbox renumbers from 1, so an old UID can collide with a new one and
|
|
232
|
+
* `addPending` would silently replace a queued reply. The dropped mail is *not* lost: it was
|
|
233
|
+
* never acked, so it is still unread in the mailbox, and the cursor reset that accompanies
|
|
234
|
+
* this re-reads it.
|
|
235
|
+
*
|
|
236
|
+
* @returns how many records were dropped.
|
|
237
|
+
*/
|
|
238
|
+
export function dropPendingFromOtherSpaces(
|
|
239
|
+
env: NodeJS.ProcessEnv,
|
|
240
|
+
uidValidity: number,
|
|
241
|
+
options: { includeUnknown?: boolean } = {},
|
|
242
|
+
): PendingRecord[] {
|
|
243
|
+
const items = load(env)
|
|
244
|
+
// On a detected renumbering, a record that carries no space cannot be shown to belong to the
|
|
245
|
+
// new numbering - and a live review caught one being claimed there and injecting a different
|
|
246
|
+
// message's body. Its mail is unread and the reset cursor re-reads it.
|
|
247
|
+
const keep = (r: PendingRecord): boolean =>
|
|
248
|
+
r.uidValidity === uidValidity || (r.uidValidity === undefined && !options.includeUnknown)
|
|
249
|
+
const kept = items.filter(keep)
|
|
250
|
+
const dropped = items.filter((r) => !keep(r))
|
|
251
|
+
if (dropped.length > 0) save(env, kept)
|
|
252
|
+
// Returned, not just counted: the caller releases exactly these sessions' decision slots,
|
|
253
|
+
// and a second pass over the file could disagree with what was actually removed.
|
|
254
|
+
return dropped
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Records older than this, for sessions that are gone, are expired under queue pressure only
|
|
258
|
+
* (see `process.ts`): never on connect, where a `/mailbridge off` plus a holiday would delete replies. */
|
|
259
|
+
export const PENDING_MAX_AGE_MS = 7 * 24 * 3600_000
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Drop records nothing can deliver any more.
|
|
263
|
+
*
|
|
264
|
+
* A record whose session never comes back is refused by every runner (`foreign-session`) and
|
|
265
|
+
* removed by nothing else, so a handful of them can fill `PENDING_CAP` - and at the cap the
|
|
266
|
+
* scanner stops advancing the cursor, which stalls *all* inbound mail. Expiring them is a
|
|
267
|
+
* deliberate, bounded loss: their mail stays unread in the mailbox, but the cursor has moved
|
|
268
|
+
* past it, so it will not be re-read.
|
|
269
|
+
*
|
|
270
|
+
* @returns the dropped records, so the caller can release their decision slots.
|
|
271
|
+
*/
|
|
272
|
+
export function expirePending(env: NodeJS.ProcessEnv, maxAgeMs = PENDING_MAX_AGE_MS, now = Date.now()): PendingRecord[] {
|
|
273
|
+
const items = load(env)
|
|
274
|
+
const expired = items.filter((r) => now - r.receivedAt > maxAgeMs)
|
|
275
|
+
if (expired.length === 0) return []
|
|
276
|
+
save(env, items.filter((r) => now - r.receivedAt <= maxAgeMs))
|
|
277
|
+
return expired
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function pendingSize(env: NodeJS.ProcessEnv): number {
|
|
281
|
+
return load(env).length
|
|
282
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// One-outstanding-decision registry.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: `request_decision` emails the human and ends the turn. If the
|
|
4
|
+
// agent calls it twice for the same session, the human gets two emails for one
|
|
5
|
+
// decision and can only answer one of them. The registry reserves a slot per
|
|
6
|
+
// session so the second call is refused before any mail is sent.
|
|
7
|
+
//
|
|
8
|
+
// Scope note: this is intentionally **in memory**, owned by the daemon. A daemon
|
|
9
|
+
// restart drops reservations — that is acceptable, because a reply is keyed by
|
|
10
|
+
// its own UID in the pending store, not by a reservation. A dropped reservation
|
|
11
|
+
// can therefore never lose a reply; it can only allow a duplicate *question*.
|
|
12
|
+
//
|
|
13
|
+
// Bounds: entries expire by TTL and the map has a hard cap, because the
|
|
14
|
+
// /register endpoint is reachable by any local session.
|
|
15
|
+
|
|
16
|
+
export const REGISTRY_TTL_MS = 24 * 60 * 60 * 1000
|
|
17
|
+
export const DEFAULT_MAX_ENTRIES = 10_000
|
|
18
|
+
|
|
19
|
+
export interface RegisterResult {
|
|
20
|
+
/** The session already had an outstanding decision. */
|
|
21
|
+
alreadyPending: boolean
|
|
22
|
+
/** The cap was hit; the new entry was NOT admitted. */
|
|
23
|
+
full: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Registry {
|
|
27
|
+
register(sessionId: string): RegisterResult
|
|
28
|
+
release(sessionId: string): void
|
|
29
|
+
has(sessionId: string): boolean
|
|
30
|
+
size(): number
|
|
31
|
+
/** Live session ids, for `/mailbridge` output. */
|
|
32
|
+
list(): string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RegistryOptions {
|
|
36
|
+
ttlMs?: number
|
|
37
|
+
maxEntries?: number
|
|
38
|
+
now?: () => number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createRegistry(options: RegistryOptions = {}): Registry {
|
|
42
|
+
const ttlMs = options.ttlMs ?? REGISTRY_TTL_MS
|
|
43
|
+
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
|
|
44
|
+
const now = options.now ?? (() => Date.now())
|
|
45
|
+
|
|
46
|
+
/** sessionId -> last refresh time */
|
|
47
|
+
const entries = new Map<string, number>()
|
|
48
|
+
|
|
49
|
+
/** Lazy prune: a dead entry can never be served after its TTL. */
|
|
50
|
+
function prune(): void {
|
|
51
|
+
const cutoff = now() - ttlMs
|
|
52
|
+
for (const [id, at] of entries) {
|
|
53
|
+
if (at < cutoff) entries.delete(id)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
register(sessionId) {
|
|
59
|
+
prune()
|
|
60
|
+
const existing = entries.get(sessionId)
|
|
61
|
+
if (existing !== undefined) {
|
|
62
|
+
entries.set(sessionId, now()) // refresh TTL
|
|
63
|
+
return { alreadyPending: true, full: false }
|
|
64
|
+
}
|
|
65
|
+
if (entries.size >= maxEntries) {
|
|
66
|
+
return { alreadyPending: false, full: true }
|
|
67
|
+
}
|
|
68
|
+
entries.set(sessionId, now())
|
|
69
|
+
return { alreadyPending: false, full: false }
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
release(sessionId) {
|
|
73
|
+
entries.delete(sessionId)
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
has(sessionId) {
|
|
77
|
+
prune()
|
|
78
|
+
return entries.has(sessionId)
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
size() {
|
|
82
|
+
prune()
|
|
83
|
+
return entries.size
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
list() {
|
|
87
|
+
prune()
|
|
88
|
+
return [...entries.keys()]
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|