diff --git a/src/cli/commands/send-controlled.test.ts b/src/cli/commands/send-controlled.test.ts new file mode 100644 index 00000000..53375571 --- /dev/null +++ b/src/cli/commands/send-controlled.test.ts @@ -0,0 +1,434 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { startV1Stub, type V1Stub } from "../../test-support/v1-stub.js"; + +type TerminalState = + | "sent" + | "idempotent_replay" + | "in_progress" + | "failed_retry_safe" + | "failed_do_not_retry" + | "rejected"; + +interface SafeReceipt { + request_id: string; + terminal_state: TerminalState; + provider_result_state: string; + message_id: string | null; + idempotent_replay: boolean; + receipt_path: string; + started_at: string; + completed_at: string; +} + +const PRIVATE_SENTINELS = [ + "FROM_PRIVATE_SENTINEL", + "TO_PRIVATE_SENTINEL", + "CC_PRIVATE_SENTINEL", + "BCC_PRIVATE_SENTINEL", + "REPLY_PRIVATE_SENTINEL", + "SUBJECT_PRIVATE_SENTINEL", + "BODY_PRIVATE_SENTINEL", + "HTML_PRIVATE_SENTINEL", + "ATTACHMENT_PATH_PRIVATE_SENTINEL", + "ATTACHMENT_CONTENT_PRIVATE_SENTINEL", + "IDEMPOTENCY_PRIVATE_SENTINEL", +] as const; + +const tempDirs: string[] = []; +let stub: V1Stub; + +function cliEnv(): NodeJS.ProcessEnv { + const { EMAILS_DB_PATH: _ignoredDbPath, ...environment } = process.env; + return { + ...environment, + EMAILS_SELF_HOSTED_URL: stub.baseUrl, + EMAILS_SELF_HOSTED_API_KEY: stub.apiKey, + NO_COLOR: "1", + }; +} + +function runCli(args: string[]) { + return { + args, + result: Bun.spawnSync({ + cmd: ["bun", "src/cli/index.tsx", ...args], + cwd: process.cwd(), + env: cliEnv(), + stdout: "pipe", + stderr: "pipe", + }), + }; +} + +function spawnCli(args: string[]) { + return { + args, + process: Bun.spawn({ + cmd: ["bun", "src/cli/index.tsx", ...args], + cwd: process.cwd(), + env: cliEnv(), + stdout: "pipe", + stderr: "pipe", + }), + }; +} + +function text(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +function assertNoPrivateSentinel(value: string): void { + for (const sentinel of PRIVATE_SENTINELS) expect(value).not.toContain(sentinel); +} + +function privateDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + chmodSync(dir, 0o700); + return dir; +} + +function writePrivateJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); +} + +function fixture(overrides: Record = {}) { + const dir = privateDir("emails-controlled-send-"); + const bodyPath = join(dir, "body.txt"); + const attachmentPath = join(dir, "ATTACHMENT_PATH_PRIVATE_SENTINEL.txt"); + writeFileSync(bodyPath, "BODY_PRIVATE_SENTINEL", { mode: 0o600 }); + writeFileSync(attachmentPath, "ATTACHMENT_CONTENT_PRIVATE_SENTINEL", { mode: 0o600 }); + const requestId = "req_controlled_001"; + const descriptorPath = join(dir, "request.json"); + const descriptor = { + request_id: requestId, + idempotency_key: "IDEMPOTENCY_PRIVATE_SENTINEL", + from: "FROM_PRIVATE_SENTINEL@sender.example", + to: ["TO_PRIVATE_SENTINEL@recipient.example"], + cc: ["CC_PRIVATE_SENTINEL@recipient.example"], + bcc: ["BCC_PRIVATE_SENTINEL@recipient.example"], + reply_to: "REPLY_PRIVATE_SENTINEL@sender.example", + subject: "SUBJECT_PRIVATE_SENTINEL", + text_file: bodyPath, + html: "

HTML_PRIVATE_SENTINEL

", + attachments: [{ + path: attachmentPath, + filename: "synthetic.txt", + content_type: "text/plain", + }], + ...overrides, + }; + writePrivateJson(descriptorPath, descriptor); + return { + dir, + requestId, + descriptorPath, + receiptPath: join(dir, "receipt.json"), + secondReceiptPath: join(dir, "receipt-replay.json"), + readbackReceiptPath: join(dir, "receipt-readback.json"), + descriptor, + }; +} + +function parseReceipt(path: string): SafeReceipt { + return JSON.parse(readFileSync(path, "utf8")) as SafeReceipt; +} + +function safeReceiptKeys(receipt: SafeReceipt): string[] { + return Object.keys(receipt).sort(); +} + +beforeAll(async () => { + stub = await startV1Stub(); +}); + +afterAll(() => stub.stop()); + +beforeEach(async () => { + await stub.reset(); + stub.applyEnv(); +}); + +afterEach(() => { + stub.clearEnv(); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("emails send-controlled", () => { + it("sends once, replays the same key without a second provider call, and proves readback", async () => { + const files = fixture(); + const first = runCli([ + "send-controlled", "apply", + "--descriptor", files.descriptorPath, + "--request-id", files.requestId, + "--receipt", files.receiptPath, + ]); + const firstStdout = text(first.result.stdout); + const firstStderr = text(first.result.stderr); + expect(first.result.exitCode, firstStderr).toBe(0); + expect(firstStdout).toBe(`${files.requestId} sent\n`); + expect(firstStderr).toBe(""); + const firstReceipt = parseReceipt(files.receiptPath); + expect(firstReceipt).toMatchObject({ + request_id: files.requestId, + terminal_state: "sent", + provider_result_state: "accepted_and_recorded", + idempotent_replay: false, + receipt_path: files.receiptPath, + }); + expect(firstReceipt.message_id).toMatch(/^[0-9a-f-]{36}$/); + expect(safeReceiptKeys(firstReceipt)).toEqual([ + "completed_at", + "idempotent_replay", + "message_id", + "provider_result_state", + "receipt_path", + "request_id", + "started_at", + "terminal_state", + ]); + + const replay = runCli([ + "--json", "send-controlled", "apply", + "--descriptor", files.descriptorPath, + "--request-id", files.requestId, + "--receipt", files.secondReceiptPath, + ]); + const replayStdout = text(replay.result.stdout); + const replayStderr = text(replay.result.stderr); + expect(replay.result.exitCode, replayStderr).toBe(0); + expect(replayStderr).toBe(""); + const replayJson = JSON.parse(replayStdout) as SafeReceipt; + expect(replayJson).toMatchObject({ + request_id: files.requestId, + terminal_state: "idempotent_replay", + provider_result_state: "accepted_and_recorded", + idempotent_replay: true, + receipt_path: files.secondReceiptPath, + message_id: firstReceipt.message_id, + }); + expect(parseReceipt(files.secondReceiptPath)).toEqual(replayJson); + + const readback = runCli([ + "send-controlled", "readback", + "--descriptor", files.descriptorPath, + "--request-id", files.requestId, + "--receipt", files.readbackReceiptPath, + ]); + expect(readback.result.exitCode, text(readback.result.stderr)).toBe(0); + expect(text(readback.result.stdout)).toBe(`${files.requestId} sent\n`); + expect(parseReceipt(files.readbackReceiptPath)).toMatchObject({ + request_id: files.requestId, + terminal_state: "sent", + provider_result_state: "accepted_and_recorded", + idempotent_replay: false, + message_id: firstReceipt.message_id, + }); + + expect(await stub.sendStats()).toEqual({ providerCalls: 1 }); + expect(await stub.list("messages")).toHaveLength(1); + + const allPublicSurfaces = [ + first.args.join("\0"), + firstStdout, + firstStderr, + JSON.stringify(firstReceipt), + replay.args.join("\0"), + replayStdout, + replayStderr, + JSON.stringify(replayJson), + readback.args.join("\0"), + text(readback.result.stdout), + text(readback.result.stderr), + readFileSync(files.readbackReceiptPath, "utf8"), + ].join("\n"); + assertNoPrivateSentinel(allPublicSurfaces); + }); + + it("maps provider acceptance followed by a ledger warning to failed_do_not_retry and never sends twice", async () => { + await stub.setSendBehavior("post_send_warning"); + const files = fixture(); + const first = runCli([ + "send-controlled", "apply", + "--descriptor", files.descriptorPath, + "--request-id", files.requestId, + "--receipt", files.receiptPath, + ]); + expect(first.result.exitCode, text(first.result.stderr)).toBe(1); + expect(text(first.result.stdout)).toBe(`${files.requestId} failed_do_not_retry\n`); + expect(text(first.result.stderr)).toBe(""); + expect(parseReceipt(files.receiptPath)).toMatchObject({ + terminal_state: "failed_do_not_retry", + provider_result_state: "accepted_unrecorded", + idempotent_replay: false, + }); + + const second = runCli([ + "send-controlled", "apply", + "--descriptor", files.descriptorPath, + "--request-id", files.requestId, + "--receipt", files.secondReceiptPath, + ]); + expect(second.result.exitCode, text(second.result.stderr)).toBe(1); + expect(parseReceipt(files.secondReceiptPath)).toMatchObject({ + terminal_state: "failed_do_not_retry", + provider_result_state: "outcome_uncertain", + }); + expect(await stub.sendStats()).toEqual({ providerCalls: 1 }); + assertNoPrivateSentinel([ + text(first.result.stdout), + text(first.result.stderr), + readFileSync(files.receiptPath, "utf8"), + text(second.result.stdout), + text(second.result.stderr), + readFileSync(files.secondReceiptPath, "utf8"), + ].join("\n")); + }); + + it("publishes one complete mode-0600 receipt atomically after the provider call finishes", async () => { + await stub.setSendBehavior("delayed_success"); + const files = fixture(); + const child = spawnCli([ + "send-controlled", "apply", + "--descriptor", files.descriptorPath, + "--request-id", files.requestId, + "--receipt", files.receiptPath, + ]); + + let providerCalls = 0; + for (let attempt = 0; attempt < 100; attempt++) { + providerCalls = (await stub.sendStats()).providerCalls; + if (providerCalls === 1) break; + await Bun.sleep(10); + } + expect(providerCalls).toBe(1); + expect(existsSync(files.receiptPath)).toBe(false); + + const [exitCode, stdout, stderr] = await Promise.all([ + child.process.exited, + new Response(child.process.stdout).text(), + new Response(child.process.stderr).text(), + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toBe(`${files.requestId} sent\n`); + expect(stderr).toBe(""); + expect(statSync(files.receiptPath).mode & 0o777).toBe(0o600); + expect(parseReceipt(files.receiptPath).terminal_state).toBe("sent"); + expect(readdirSync(files.dir).some((name) => name.endsWith(".pending"))).toBe(false); + }); + + it("rejects insecure, symlinked, non-regular, malformed, and colliding inputs before any send", async () => { + const cases: Array<{ + name: string; + prepare: () => { descriptorPath: string; requestId: string; receiptPath: string }; + }> = [ + { + name: "group-readable descriptor", + prepare: () => { + const files = fixture(); + chmodSync(files.descriptorPath, 0o640); + return files; + }, + }, + { + name: "symlink descriptor", + prepare: () => { + const files = fixture(); + const link = join(files.dir, "request-link.json"); + symlinkSync(files.descriptorPath, link); + return { ...files, descriptorPath: link }; + }, + }, + { + name: "non-regular descriptor", + prepare: () => { + const files = fixture(); + const directory = join(files.dir, "request-directory"); + mkdirSync(directory, { mode: 0o700 }); + return { ...files, descriptorPath: directory }; + }, + }, + { + name: "missing idempotency key", + prepare: () => { + const files = fixture({ idempotency_key: undefined }); + return files; + }, + }, + { + name: "schema error", + prepare: () => { + const files = fixture({ to: [{ private: "TO_PRIVATE_SENTINEL" }] }); + return files; + }, + }, + { + name: "receipt collision", + prepare: () => { + const files = fixture(); + writeFileSync(files.receiptPath, "existing", { mode: 0o600 }); + return files; + }, + }, + { + name: "insecure receipt directory", + prepare: () => { + const files = fixture(); + const outputDir = privateDir("emails-controlled-receipt-"); + chmodSync(outputDir, 0o750); + return { ...files, receiptPath: join(outputDir, "receipt.json") }; + }, + }, + ]; + + for (const testCase of cases) { + await stub.reset(); + const files = testCase.prepare(); + const result = runCli([ + "--json", "send-controlled", "apply", + "--descriptor", files.descriptorPath, + "--request-id", files.requestId, + "--receipt", files.receiptPath, + ]); + expect(result.result.exitCode, testCase.name).toBe(1); + const publicOutput = `${text(result.result.stdout)}\n${text(result.result.stderr)}`; + assertNoPrivateSentinel(publicOutput); + expect(await stub.sendStats(), testCase.name).toEqual({ providerCalls: 0 }); + expect(await stub.list("messages"), testCase.name).toHaveLength(0); + if (testCase.name === "receipt collision") { + expect(readFileSync(files.receiptPath, "utf8")).toBe("existing"); + } else if (testCase.name !== "insecure receipt directory") { + const receipt = parseReceipt(files.receiptPath); + expect(receipt.terminal_state, testCase.name).toBe("rejected"); + assertNoPrivateSentinel(JSON.stringify(receipt)); + } + } + }); + + it("preserves the existing inline send command", () => { + const result = runCli([ + "send", + "--from", "inline@sender.example", + "--to", "inline@recipient.example", + "--subject", "inline compatibility", + "--body", "synthetic body", + ]); + expect(result.result.exitCode, text(result.result.stderr)).toBe(0); + expect(text(result.result.stdout)).toContain("Email sent to inline@recipient.example"); + }); +}); diff --git a/src/cli/commands/send-controlled.ts b/src/cli/commands/send-controlled.ts new file mode 100644 index 00000000..9d75206c --- /dev/null +++ b/src/cli/commands/send-controlled.ts @@ -0,0 +1,45 @@ +import type { Command } from "commander"; +import { executeControlledSend } from "../../lib/controlled-send-envelope.js"; +import { handleError, isCliJsonOutput } from "../utils.js"; + +type OutputFn = (data: unknown, formatted: string) => void; + +interface ControlledSendOptions { + descriptor: string; + requestId: string; + receipt: string; +} + +export function registerControlledSendCommands(program: Command, output: OutputFn): void { + const command = program + .command("send-controlled") + .description("Send from a private descriptor and write an owner-only terminal receipt"); + + for (const operation of ["apply", "readback"] as const) { + command + .command(operation) + .description(operation === "apply" + ? "Apply one idempotent send request from a private descriptor" + : "Read back one send intent without sending") + .requiredOption("--descriptor ", "Owner-only mode 0600 request descriptor") + .requiredOption("--request-id ", "Opaque request identifier") + .requiredOption("--receipt ", "New owner-only receipt path") + .action(async (opts: ControlledSendOptions) => { + try { + const result = await executeControlledSend(operation, { + descriptorPath: opts.descriptor, + requestId: opts.requestId, + receiptPath: opts.receipt, + }); + output( + result.receipt, + `${result.receipt.request_id} ${result.receipt.terminal_state}`, + ); + if (result.diagnostic && !isCliJsonOutput()) console.error(result.diagnostic); + if (result.exitCode !== 0) process.exitCode = result.exitCode; + } catch (error) { + handleError(error); + } + }); + } +} diff --git a/src/cli/commands/send.ts b/src/cli/commands/send.ts index 5720813e..e77c06c7 100644 --- a/src/cli/commands/send.ts +++ b/src/cli/commands/send.ts @@ -19,6 +19,7 @@ import { evaluateAttachmentCaps, evaluateSenderPreflight, } from "../../lib/send-preflight.js"; +import { registerControlledSendCommands } from "./send-controlled.js"; const MAX_ATTACHMENT_SIZE = LOCAL_SEND_ATTACHMENT_LIMITS.maxBytesPerFile; const MAX_ATTACHMENT_COUNT = LOCAL_SEND_ATTACHMENT_LIMITS.maxFiles; @@ -103,7 +104,8 @@ function readSendAttachments(paths: string[] | undefined): MailSendAttachment[] return attachments; } -export function registerSendCommands(program: Command, _output: (data: unknown, formatted: string) => void): void { +export function registerSendCommands(program: Command, output: (data: unknown, formatted: string) => void): void { + registerControlledSendCommands(program, output); program .command("send") .description("Send an email") diff --git a/src/cli/router.ts b/src/cli/router.ts index ba0038b7..beaefddc 100644 --- a/src/cli/router.ts +++ b/src/cli/router.ts @@ -41,6 +41,7 @@ export const knownCommandNames = new Set([ "address", "addresses", "send", + "send-controlled", "email", "log", "search", @@ -116,7 +117,8 @@ export function commandModulesFor(args: string[]): readonly CommandModule[] { case "domains": return ["domain"]; case "address": case "addresses": return ["address"]; - case "send": return ["send"]; + case "send": + case "send-controlled": return ["send"]; case "email": case "log": case "search": diff --git a/src/lib/completion.ts b/src/lib/completion.ts index 1753b4af..3d22c935 100644 --- a/src/lib/completion.ts +++ b/src/lib/completion.ts @@ -25,6 +25,7 @@ const COMMAND_DESCRIPTIONS: Record = { addresses: "List sender addresses", forwarding: "Manage app-level forwarding rules", send: "Send an email", + "send-controlled": "Private descriptor send with terminal receipt", email: "Sent email log, search, and history", inbox: "Sync and browse inbound emails", pull: "Sync events from providers", @@ -91,6 +92,7 @@ const SUBCOMMANDS: Record = { schedule: "list cancel run", alias: "add catch-all global list remove resolve", sendkey: "create list revoke check", + "send-controlled": "apply readback", owner: "register list addresses", inbox: "list unread-count search read open mailboxes sources status sync-status mark-read archive star label attachments delete clear sync-s3 code links wait", email: "list search show replies thread send", diff --git a/src/lib/controlled-send-envelope.ts b/src/lib/controlled-send-envelope.ts new file mode 100644 index 00000000..c7faa843 --- /dev/null +++ b/src/lib/controlled-send-envelope.ts @@ -0,0 +1,723 @@ +import { createHash } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { link, lstat, open, realpath, unlink, type FileHandle } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { selfHostedApiRequest } from "../db/self-hosted-store.js"; +import { SELF_HOSTED_SEND_ATTACHMENT_LIMITS } from "./send-attachment-limits.js"; + +const MAX_DESCRIPTOR_BYTES = 128 * 1024; +const MAX_BODY_SOURCE_BYTES = 2 * 1024 * 1024; +const PRIVATE_DIRECTORY_MODE = 0o700; +const PRIVATE_FILE_MODE = 0o600; +const MIME_TYPE_RE = /^[A-Za-z0-9!#$&^_.+~-]+\/[A-Za-z0-9!#$&^_.+~-]+$/; +const OPAQUE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const SAFE_MESSAGE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; +const CONTROL_OR_BIDI_RE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u; + +export type ControlledSendTerminalState = + | "sent" + | "idempotent_replay" + | "in_progress" + | "failed_retry_safe" + | "failed_do_not_retry" + | "rejected"; + +export interface ControlledSendReceipt { + request_id: string; + terminal_state: ControlledSendTerminalState; + provider_result_state: + | "accepted_and_recorded" + | "accepted_unrecorded" + | "in_progress" + | "not_sent" + | "outcome_uncertain" + | "not_attempted"; + message_id: string | null; + idempotent_replay: boolean; + receipt_path: string; + started_at: string; + completed_at: string; +} + +export interface ControlledSendExecution { + receipt: ControlledSendReceipt; + exitCode: 0 | 1; + /** Safe human diagnostic; never contains descriptor values. */ + diagnostic?: string; +} + +export class ControlledSendPathError extends Error { + constructor(message: string) { + super(message); + this.name = "ControlledSendPathError"; + } +} + +class ControlledDescriptorError extends Error { + constructor(message: string) { + super(message); + this.name = "ControlledDescriptorError"; + } +} + +interface ControlledDescriptorIdentity { + requestId: string; + idempotencyKey: string; + record: Record; +} + +interface ControlledSendPayload { + from: string; + to: string[]; + cc?: string[]; + bcc?: string[]; + reply_to?: string; + subject: string; + text?: string; + html?: string; + attachments?: Array<{ filename: string; content: string; content_type: string }>; + idempotency_key: string; +} + +interface SafeOutcome { + terminalState: ControlledSendTerminalState; + providerResultState: ControlledSendReceipt["provider_result_state"]; + messageId: string | null; + idempotentReplay: boolean; + diagnostic?: string; +} + +function effectiveUid(): number { + if (process.platform !== "linux" || typeof process.geteuid !== "function") { + throw new ControlledSendPathError("controlled send files require Linux owner and no-follow checks"); + } + const uid = process.geteuid(); + if (!Number.isSafeInteger(uid) || uid < 0) { + throw new ControlledSendPathError("controlled send files require a valid effective user id"); + } + return uid; +} + +function sameIdentity(left: { dev: number; ino: number }, right: { dev: number; ino: number }): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function schemaError(path: string, expectation: string): ControlledDescriptorError { + return new ControlledDescriptorError(`descriptor ${path} ${expectation}`); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertExactKeys( + record: Record, + allowed: readonly string[], + path: string, +): void { + const allowedSet = new Set(allowed); + if (Object.keys(record).some((key) => !allowedSet.has(key))) { + throw schemaError(path, "contains an unsupported field"); + } +} + +function wellFormedString(value: unknown, path: string, maxChars: number): string { + if (typeof value !== "string" || value.length === 0 || value.length > maxChars) { + throw schemaError(path, `must be a non-empty string of at most ${maxChars} characters`); + } + if (CONTROL_OR_BIDI_RE.test(value)) throw schemaError(path, "contains unsafe control characters"); + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw schemaError(path, "contains invalid Unicode"); + index++; + } else if (code >= 0xdc00 && code <= 0xdfff) { + throw schemaError(path, "contains invalid Unicode"); + } + } + return value; +} + +function opaqueRequestId(value: unknown, path: string): string { + const requestId = wellFormedString(value, path, 128); + if (!OPAQUE_ID_RE.test(requestId)) { + throw schemaError(path, "must be an opaque terminal-safe identifier"); + } + return requestId; +} + +function idempotencyKey(value: unknown): string { + const key = wellFormedString(value, "$.idempotency_key", 200).trim(); + if (!key) throw schemaError("$.idempotency_key", "is required"); + return key; +} + +function stringList(value: unknown, path: string, required: boolean): string[] { + if (value === undefined && !required) return []; + if (!Array.isArray(value) || (required && value.length === 0)) { + throw schemaError(path, required ? "must be a non-empty array" : "must be an array"); + } + return value.map((entry, index) => wellFormedString(entry, `${path}[${index}]`, 320)); +} + +async function inspectPrivateDirectory(path: string, label: string, uid: number): Promise { + let before: Stats; + let after: Stats; + let canonical: string; + try { + before = await lstat(path); + canonical = await realpath(path); + after = await lstat(path); + } catch { + throw new ControlledSendPathError(`${label} parent directory must exist and be owner-only mode 0700`); + } + if (before.isSymbolicLink() + || after.isSymbolicLink() + || !before.isDirectory() + || !after.isDirectory() + || !sameIdentity(before, after) + || canonical !== path + || after.uid !== uid + || (after.mode & 0o777) !== PRIVATE_DIRECTORY_MODE) { + throw new ControlledSendPathError(`${label} parent directory must be a real owner-only mode 0700 directory`); + } + return after; +} + +async function readPrivateFile(pathValue: unknown, label: string, maxBytes: number): Promise { + if (typeof pathValue !== "string" || !pathValue.trim()) { + throw new ControlledDescriptorError(`${label} must be a private file path`); + } + const uid = effectiveUid(); + let path: string; + try { + path = resolve(pathValue); + } catch { + throw new ControlledDescriptorError(`${label} must be a private file path`); + } + const parent = dirname(path); + const parentBefore = await inspectPrivateDirectory(parent, label, uid) + .catch((error) => { + throw new ControlledDescriptorError(error instanceof Error ? error.message : `${label} parent directory is invalid`); + }); + + let pathBefore: Stats; + try { + pathBefore = await lstat(path); + } catch { + throw new ControlledDescriptorError(`${label} must be a readable regular owner-only mode 0600 file`); + } + if (pathBefore.isSymbolicLink() + || !pathBefore.isFile() + || pathBefore.uid !== uid + || (pathBefore.mode & 0o777) !== PRIVATE_FILE_MODE + || pathBefore.size > maxBytes) { + throw new ControlledDescriptorError( + `${label} must be a regular owner-only mode 0600 file no larger than ${maxBytes} bytes`, + ); + } + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + if (!noFollow) throw new ControlledDescriptorError(`${label} requires no-follow filesystem support`); + + let file: FileHandle; + try { + file = await open(path, constants.O_RDONLY | noFollow); + } catch { + throw new ControlledDescriptorError(`${label} must be a readable regular owner-only mode 0600 file`); + } + try { + const opened = await file.stat(); + if (!opened.isFile() + || !sameIdentity(pathBefore, opened) + || opened.uid !== uid + || (opened.mode & 0o777) !== PRIVATE_FILE_MODE + || opened.size > maxBytes) { + throw new ControlledDescriptorError(`${label} changed during private-file validation`); + } + const bytes = Buffer.alloc(opened.size + 1); + const { bytesRead } = await file.read(bytes, 0, bytes.byteLength, 0); + if (bytesRead !== opened.size) { + throw new ControlledDescriptorError(`${label} changed while it was being read`); + } + const [openedAfter, pathAfter, parentAfter] = await Promise.all([ + file.stat(), + lstat(path).catch(() => null), + inspectPrivateDirectory(parent, label, uid).catch(() => null), + ]); + if (!openedAfter.isFile() + || openedAfter.size !== opened.size + || !sameIdentity(opened, openedAfter) + || !pathAfter + || pathAfter.isSymbolicLink() + || !pathAfter.isFile() + || !sameIdentity(opened, pathAfter) + || !parentAfter + || !sameIdentity(parentBefore, parentAfter)) { + throw new ControlledDescriptorError(`${label} changed while it was being read`); + } + return bytes.subarray(0, bytesRead); + } finally { + await file.close(); + } +} + +function parseDescriptorIdentity(bytes: Buffer, expectedRequestId: string): ControlledDescriptorIdentity { + let parsed: unknown; + try { + parsed = JSON.parse(bytes.toString("utf8")); + } catch { + throw schemaError("$", "must be valid JSON"); + } + if (!isRecord(parsed)) throw schemaError("$", "must be an object"); + assertExactKeys(parsed, [ + "attachments", + "bcc", + "cc", + "from", + "html", + "html_file", + "idempotency_key", + "reply_to", + "request_id", + "subject", + "text", + "text_file", + "to", + ], "$"); + const descriptorRequestId = opaqueRequestId(parsed["request_id"], "$.request_id"); + if (descriptorRequestId !== expectedRequestId) { + throw schemaError("$.request_id", "must match --request-id"); + } + return { + requestId: descriptorRequestId, + idempotencyKey: idempotencyKey(parsed["idempotency_key"]), + record: parsed, + }; +} + +async function bodySource( + record: Record, + inlineKey: "text" | "html", + fileKey: "text_file" | "html_file", +): Promise { + const inline = record[inlineKey]; + const filePath = record[fileKey]; + if (inline !== undefined && filePath !== undefined) { + throw schemaError(`$.${inlineKey}`, `cannot be combined with $.${fileKey}`); + } + if (inline !== undefined) { + if (typeof inline !== "string") throw schemaError(`$.${inlineKey}`, "must be a string"); + if (Buffer.byteLength(inline, "utf8") > MAX_BODY_SOURCE_BYTES) { + throw schemaError(`$.${inlineKey}`, `must be at most ${MAX_BODY_SOURCE_BYTES} UTF-8 bytes`); + } + return inline; + } + if (filePath !== undefined) { + return (await readPrivateFile(filePath, `descriptor $.${fileKey}`, MAX_BODY_SOURCE_BYTES)).toString("utf8"); + } + return undefined; +} + +async function parseSendPayload(identity: ControlledDescriptorIdentity): Promise { + const record = identity.record; + const from = wellFormedString(record["from"], "$.from", 320); + const to = stringList(record["to"], "$.to", true); + const cc = stringList(record["cc"], "$.cc", false); + const bcc = stringList(record["bcc"], "$.bcc", false); + const subject = wellFormedString(record["subject"], "$.subject", 998); + const replyTo = record["reply_to"] === undefined + ? undefined + : wellFormedString(record["reply_to"], "$.reply_to", 320); + const text = await bodySource(record, "text", "text_file"); + const html = await bodySource(record, "html", "html_file"); + if (text === undefined && html === undefined) { + throw schemaError("$.text", "or $.html must provide a message body source"); + } + + const rawAttachments = record["attachments"]; + let attachments: ControlledSendPayload["attachments"]; + if (rawAttachments !== undefined) { + if (!Array.isArray(rawAttachments)) throw schemaError("$.attachments", "must be an array"); + if (rawAttachments.length > SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxFiles) { + throw schemaError("$.attachments", `must contain at most ${SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxFiles} files`); + } + let totalBytes = 0; + attachments = []; + for (const [index, value] of rawAttachments.entries()) { + const path = `$.attachments[${index}]`; + if (!isRecord(value)) throw schemaError(path, "must be an object"); + assertExactKeys(value, ["content_type", "filename", "path"], path); + const content = await readPrivateFile( + value["path"], + `descriptor ${path}.path`, + SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxBytesPerFile, + ); + totalBytes += content.byteLength; + if (totalBytes > SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxTotalBytes) { + throw schemaError("$.attachments", `must total at most ${SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxTotalBytes} bytes`); + } + const filename = value["filename"] === undefined + ? basename(String(value["path"])) + : wellFormedString(value["filename"], `${path}.filename`, 255); + if (!filename || filename === "." || filename === "..") { + throw schemaError(`${path}.filename`, "must be a safe filename"); + } + const contentType = value["content_type"] === undefined + ? "application/octet-stream" + : wellFormedString(value["content_type"], `${path}.content_type`, 255); + if (!MIME_TYPE_RE.test(contentType)) { + throw schemaError(`${path}.content_type`, "must be a safe MIME type"); + } + attachments.push({ + filename, + content: content.toString("base64"), + content_type: contentType, + }); + } + } + + return { + from, + to, + ...(cc.length ? { cc } : {}), + ...(bcc.length ? { bcc } : {}), + ...(replyTo ? { reply_to: replyTo } : {}), + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), + ...(attachments?.length ? { attachments } : {}), + idempotency_key: identity.idempotencyKey, + }; +} + +function safeMessageId(payload: unknown): string | null { + if (!isRecord(payload)) return null; + const message = payload["message"]; + if (!isRecord(message)) return null; + const id = message["id"]; + return typeof id === "string" && SAFE_MESSAGE_ID_RE.test(id) ? id : null; +} + +function sendOutcome(status: number, payload: unknown): SafeOutcome { + const body = isRecord(payload) ? payload : {}; + const messageId = safeMessageId(body); + if (status >= 200 && status < 300) { + if (body["in_progress"] === true) { + return { + terminalState: "in_progress", + providerResultState: "in_progress", + messageId, + idempotentReplay: false, + }; + } + if (body["sent"] === true && typeof body["warning"] === "string") { + return { + terminalState: "failed_do_not_retry", + providerResultState: "accepted_unrecorded", + messageId, + idempotentReplay: false, + }; + } + if (body["sent"] === true && body["idempotent_replay"] === true) { + return { + terminalState: "idempotent_replay", + providerResultState: "accepted_and_recorded", + messageId, + idempotentReplay: true, + }; + } + if (body["sent"] === true) { + return { + terminalState: "sent", + providerResultState: "accepted_and_recorded", + messageId, + idempotentReplay: false, + }; + } + return { + terminalState: "failed_do_not_retry", + providerResultState: "outcome_uncertain", + messageId, + idempotentReplay: false, + }; + } + if (body["sent"] === false && body["retry_safe"] === true) { + return { + terminalState: "failed_retry_safe", + providerResultState: "not_sent", + messageId, + idempotentReplay: false, + }; + } + if (body["sent"] === null || body["retry_safe"] === false || status >= 500) { + return { + terminalState: "failed_do_not_retry", + providerResultState: "outcome_uncertain", + messageId, + idempotentReplay: false, + }; + } + return { + terminalState: "rejected", + providerResultState: "not_attempted", + messageId, + idempotentReplay: false, + diagnostic: "the server rejected the controlled request before provider send", + }; +} + +function readbackOutcome(status: number, payload: unknown): SafeOutcome { + if (status < 200 || status >= 300 || !isRecord(payload)) { + return { + terminalState: status >= 500 ? "failed_do_not_retry" : "rejected", + providerResultState: status >= 500 ? "outcome_uncertain" : "not_attempted", + messageId: null, + idempotentReplay: false, + }; + } + const lookup = payload["send_intent"]; + if (!isRecord(lookup) || lookup["found"] !== true) { + return { + terminalState: "failed_retry_safe", + providerResultState: "not_sent", + messageId: null, + idempotentReplay: false, + }; + } + const message = isRecord(lookup["message"]) ? lookup["message"] : {}; + const rawId = message["id"]; + const messageId = typeof rawId === "string" && SAFE_MESSAGE_ID_RE.test(rawId) ? rawId : null; + const sendState = typeof message["send_state"] === "string" ? message["send_state"] : ""; + if (sendState === "sent") { + return { + terminalState: "sent", + providerResultState: "accepted_and_recorded", + messageId, + idempotentReplay: false, + }; + } + if (sendState === "pending" || sendState === "sending") { + return { + terminalState: "in_progress", + providerResultState: "in_progress", + messageId, + idempotentReplay: false, + }; + } + if (sendState === "failed") { + return { + terminalState: "failed_retry_safe", + providerResultState: "not_sent", + messageId, + idempotentReplay: false, + }; + } + if (sendState === "uncertain" || lookup["reconciliation_required"] === true) { + return { + terminalState: "failed_do_not_retry", + providerResultState: "outcome_uncertain", + messageId, + idempotentReplay: false, + }; + } + return { + terminalState: "rejected", + providerResultState: "not_attempted", + messageId, + idempotentReplay: false, + }; +} + +class ReceiptReservation { + constructor( + readonly path: string, + private readonly reservationPath: string, + private readonly file: FileHandle, + private readonly identity: { dev: number; ino: number }, + ) {} + + async finalize(receipt: ControlledSendReceipt): Promise { + const serialized = `${JSON.stringify(receipt, null, 2)}\n`; + let fileClosed = false; + try { + await this.file.writeFile(serialized, { encoding: "utf8" }); + await this.file.sync(); + await this.file.chmod(PRIVATE_FILE_MODE); + const after = await this.file.stat(); + const reservationAfter = await lstat(this.reservationPath).catch(() => null); + if (!after.isFile() + || !sameIdentity(this.identity, after) + || (after.mode & 0o777) !== PRIVATE_FILE_MODE + || after.size !== Buffer.byteLength(serialized) + || !reservationAfter + || reservationAfter.isSymbolicLink() + || !reservationAfter.isFile() + || !sameIdentity(this.identity, reservationAfter)) { + throw new Error("receipt reservation changed during finalization"); + } + await this.file.close(); + fileClosed = true; + + // A hard link publishes the already-complete inode atomically and refuses + // to overwrite an existing terminal receipt. The deterministic private + // reservation remains outside the public receipt path if the process dies. + await link(this.reservationPath, this.path); + const [published, reservationPublished] = await Promise.all([ + lstat(this.path), + lstat(this.reservationPath), + ]); + if (!published.isFile() + || published.isSymbolicLink() + || !sameIdentity(this.identity, published) + || (published.mode & 0o777) !== PRIVATE_FILE_MODE + || published.size !== Buffer.byteLength(serialized) + || !reservationPublished.isFile() + || reservationPublished.isSymbolicLink() + || !sameIdentity(this.identity, reservationPublished)) { + throw new Error("receipt publication did not preserve the completed reservation"); + } + await unlink(this.reservationPath).catch(() => undefined); + } catch { + throw new ControlledSendPathError( + "controlled receipt could not be finalized; read back the same request before any retry", + ); + } finally { + if (!fileClosed) await this.file.close().catch(() => undefined); + } + } +} + +async function reserveReceipt(pathValue: string): Promise { + if (!pathValue.trim()) throw new ControlledSendPathError("--receipt is required"); + const uid = effectiveUid(); + let path: string; + try { + path = resolve(pathValue); + } catch { + throw new ControlledSendPathError("--receipt must be a valid owner-only path"); + } + await inspectPrivateDirectory(dirname(path), "--receipt", uid); + try { + await lstat(path); + throw new ControlledSendPathError("--receipt already exists; controlled receipts never overwrite"); + } catch (error) { + if (error instanceof ControlledSendPathError) throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new ControlledSendPathError("--receipt collision check failed before send"); + } + } + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + if (!noFollow) throw new ControlledSendPathError("--receipt requires no-follow filesystem support"); + const reservationName = `.controlled-send-${createHash("sha256").update(path).digest("hex").slice(0, 32)}.pending`; + const reservationPath = join(dirname(path), reservationName); + let file: FileHandle; + try { + file = await open( + reservationPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, + PRIVATE_FILE_MODE, + ); + } catch { + throw new ControlledSendPathError( + "--receipt already exists or has an unfinished controlled-send reservation", + ); + } + try { + await file.chmod(PRIVATE_FILE_MODE); + const stat = await file.stat(); + if (!stat.isFile() + || stat.uid !== uid + || (stat.mode & 0o777) !== PRIVATE_FILE_MODE + || stat.size !== 0) { + throw new ControlledSendPathError("--receipt could not be reserved as an owner-only mode 0600 file"); + } + try { + await lstat(path); + throw new ControlledSendPathError("--receipt already exists; controlled receipts never overwrite"); + } catch (error) { + if (error instanceof ControlledSendPathError) throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new ControlledSendPathError("--receipt collision check failed before send"); + } + } + return new ReceiptReservation(path, reservationPath, file, { dev: stat.dev, ino: stat.ino }); + } catch (error) { + await file.close(); + await unlink(reservationPath).catch(() => undefined); + throw error; + } +} + +function receiptFor( + requestId: string, + receiptPath: string, + startedAt: string, + outcome: SafeOutcome, +): ControlledSendReceipt { + return { + request_id: requestId, + terminal_state: outcome.terminalState, + provider_result_state: outcome.providerResultState, + message_id: outcome.messageId, + idempotent_replay: outcome.idempotentReplay, + receipt_path: receiptPath, + started_at: startedAt, + completed_at: new Date().toISOString(), + }; +} + +export async function executeControlledSend( + operation: "apply" | "readback", + options: { descriptorPath: string; requestId: string; receiptPath: string }, +): Promise { + const requestId = opaqueRequestId(options.requestId, "--request-id"); + const startedAt = new Date().toISOString(); + const reservation = await reserveReceipt(options.receiptPath); + let outcome: SafeOutcome; + try { + const descriptorBytes = await readPrivateFile( + options.descriptorPath, + "descriptor path", + MAX_DESCRIPTOR_BYTES, + ); + const identity = parseDescriptorIdentity(descriptorBytes, requestId); + if (operation === "readback") { + const response = selfHostedApiRequest("POST", "/messages/send-intents/lookup", { + idempotency_key: identity.idempotencyKey, + }); + outcome = readbackOutcome(response.status, response.json); + } else { + const payload = await parseSendPayload(identity); + const response = selfHostedApiRequest("POST", "/messages/send", payload); + outcome = sendOutcome(response.status, response.json); + } + } catch (error) { + if (error instanceof ControlledDescriptorError) { + outcome = { + terminalState: "rejected", + providerResultState: "not_attempted", + messageId: null, + idempotentReplay: false, + diagnostic: error.message, + }; + } else { + // Once descriptor validation has completed, a transport/response failure + // cannot prove that the server did not claim or send the request. + outcome = { + terminalState: "failed_do_not_retry", + providerResultState: "outcome_uncertain", + messageId: null, + idempotentReplay: false, + }; + } + } + + const receipt = receiptFor(requestId, reservation.path, startedAt, outcome); + await reservation.finalize(receipt); + return { + receipt, + exitCode: outcome.terminalState === "sent" || outcome.terminalState === "idempotent_replay" ? 0 : 1, + ...(outcome.diagnostic ? { diagnostic: outcome.diagnostic } : {}), + }; +} diff --git a/src/server/self-hosted/sender.test.ts b/src/server/self-hosted/sender.test.ts index 3b8b6c8a..5d952542 100644 --- a/src/server/self-hosted/sender.test.ts +++ b/src/server/self-hosted/sender.test.ts @@ -5,7 +5,11 @@ // "uncertain" and require reconciliation. import { describe, expect, it } from "bun:test"; -import { buildSelfHostedSender, classifyProviderSendError } from "./sender.js"; +import { + buildSelfHostedSender, + classifyProviderSendError, + providerSendLogFields, +} from "./sender.js"; function awsError(name: string, message: string, httpStatusCode: number | undefined, fault?: "client" | "server"): Error { const err = new Error(message); @@ -62,6 +66,18 @@ describe("classifyProviderSendError", () => { const outcome = classifyProviderSendError(awsError("MessageRejected", "x".repeat(10_000), 400, "client")); expect(outcome.detail.length).toBeLessThanOrEqual(600); }); + + it("keeps provider detail and private mailbox text out of structured server logs", () => { + const privateSentinel = "PRIVATE_RECIPIENT_SENTINEL@external.example"; + const outcome = classifyProviderSendError(awsError( + "MessageRejected", + `Email address is not verified: ${privateSentinel}`, + 400, + "client", + )); + expect(outcome.detail).toContain(privateSentinel); + expect(JSON.stringify(providerSendLogFields(outcome))).not.toContain(privateSentinel); + }); }); // ── which identity the self-hosted sender signs with ──────────────────────── diff --git a/src/server/self-hosted/sender.ts b/src/server/self-hosted/sender.ts index 7ad8c139..844bbe03 100644 --- a/src/server/self-hosted/sender.ts +++ b/src/server/self-hosted/sender.ts @@ -50,6 +50,26 @@ export type ProviderSendErrorOutcome = { httpStatus?: number; }; +/** + * Safe structured fields for provider-failure logs. The provider's message can + * contain sender or recipient addresses, so it stays in the authenticated API + * response for ordinary inline clients but is never copied into server logs. + */ +export function providerSendLogFields(outcome: ProviderSendErrorOutcome): { + outcome: ProviderSendErrorOutcome["kind"]; + provider_error: string; + http_status: number | null; +} { + const providerError = /^[A-Za-z0-9_.:-]{1,100}$/.test(outcome.providerErrorName) + ? outcome.providerErrorName + : "ProviderError"; + return { + outcome: outcome.kind, + provider_error: providerError, + http_status: outcome.httpStatus ?? null, + }; +} + const PROVIDER_ERROR_DETAIL_MAX_CHARS = 600; /** Read a numeric HTTP status from the shapes real provider SDKs throw. */ diff --git a/src/server/self-hosted/service.ts b/src/server/self-hosted/service.ts index a3aadbb8..83e8d971 100644 --- a/src/server/self-hosted/service.ts +++ b/src/server/self-hosted/service.ts @@ -57,7 +57,11 @@ import { RESEND_INBOUND_V1_WEBHOOK_PATH, SES_INBOUND_V1_WEBHOOK_PATH, } from "../webhooks/receivers.js"; -import { classifyProviderSendError, type SelfHostedSender } from "./sender.js"; +import { + classifyProviderSendError, + providerSendLogFields, + type SelfHostedSender, +} from "./sender.js"; import { isTenantOperator, resolveRequestContext, @@ -1439,10 +1443,7 @@ export async function handleSelfHostedRequest( console.error("[emails-self-hosted] provider send failed", { message_id: claimed.id, provider: deps.sender.provider, - outcome: outcome.kind, - provider_error: outcome.providerErrorName, - http_status: outcome.httpStatus ?? null, - detail: outcome.detail, + ...providerSendLogFields(outcome), }); if (outcome.kind === "rejected") { // The provider REFUSED the request (4xx): nothing was sent. This is diff --git a/src/test-support/v1-stub.ts b/src/test-support/v1-stub.ts index 2537d4ca..5adaeada 100644 --- a/src/test-support/v1-stub.ts +++ b/src/test-support/v1-stub.ts @@ -144,6 +144,10 @@ export interface V1Stub { list(resource: string): Promise>>; /** Read the entire store back from the stub. */ dump(): Promise; + /** Select a deterministic send outcome for controlled-send regressions. */ + setSendBehavior(behavior: "normal" | "delayed_success" | "post_send_warning"): Promise; + /** Read the number of provider-send calls made since the last reset. */ + sendStats(): Promise<{ providerCalls: number }>; /** * Emulate a non-total list ORDER BY: before every list window the named * resources (or all of them) are rotated by `rotate` positions, so a @@ -312,6 +316,10 @@ let bootstrapped = false; let listRotate = 0; let listRotateResources = null; let listRotateCalls = {}; +// Test-only send controls. They stay outside the dumped resource store so the +// fixture cannot accidentally expose them as product data. +let sendBehavior = "normal"; +let providerSendCalls = 0; // Declared ORDER BY per generic resource, injected from the server's own registry // (SELF_HOSTED_RESOURCES + resourceListOrderBy) so the stub orders lists the way the // real route does. Shape: { resource: [{ column, desc }, ...] }. @@ -780,6 +788,13 @@ function detailRow(row) { return out; } +function publicSendRow(row) { + const out = detailRow(row); + delete out.idempotency_key; + delete out.send_payload_hash; + return out; +} + function messageCounts() { const messages = rowsFor("messages"); const inboxRows = messages.filter(function (r) { @@ -825,11 +840,25 @@ const server = Bun.serve({ listRotateResources = null; listRotateCalls = {}; listQueries = {}; + sendBehavior = "normal"; + providerSendCalls = 0; return json({ ok: true }); } if (req.method === "GET" && parts[0] === "v1" && parts[1] === "__dump") { return json({ resources: store }); } + if (req.method === "POST" && parts[0] === "v1" && parts[1] === "__send_behavior") { + const body = await req.json().catch(function () { return {}; }); + const next = String(body.behavior || ""); + if (next !== "normal" && next !== "delayed_success" && next !== "post_send_warning") { + return json({ error: "unsupported send behavior" }, 400); + } + sendBehavior = next; + return json({ ok: true }); + } + if (req.method === "GET" && parts[0] === "v1" && parts[1] === "__send_stats") { + return json({ provider_calls: providerSendCalls }); + } // Test-only: the query string of every generic list request for a resource, in // order. A client that filters only in memory shows nothing but limit/offset. if (req.method === "GET" && parts[0] === "v1" && parts[1] === "__list_queries") { @@ -1070,6 +1099,31 @@ const server = Bun.serve({ } if (resource === "messages" && sub === "send" && req.method === "POST") { const body = await req.json().catch(function () { return {}; }); + const key = typeof body.idempotency_key === "string" ? body.idempotency_key : ""; + const existing = rowsFor("messages").find(function (row) { + return key && row.idempotency_key === key; + }); + if (existing) { + if (existing.send_state === "sent") { + return json({ + message: publicSendRow(existing), + provider: "stub", + sent: true, + idempotent_replay: true, + provider_message_id: existing.provider_message_id, + }); + } + return json({ + error: "send outcome is uncertain; reconcile the provider message before any retry", + reason: "provider_outcome_uncertain", + sent: null, + message: detailRow(existing), + retry_safe: false, + reconciliation_required: true, + }, 409); + } + providerSendCalls += 1; + if (sendBehavior === "delayed_success") await Bun.sleep(300); const now = new Date().toISOString(); const providerMessageId = "stub-provider-" + (rowsFor("messages").length + 1); const rec = normalizeMessageRow({ @@ -1085,18 +1139,51 @@ const server = Bun.serve({ provider_message_id: providerMessageId, message_id: "stub-" + (rowsFor("messages").length + 1), is_read: true, - send_state: "sent", + send_state: sendBehavior === "post_send_warning" ? "uncertain" : "sent", + idempotency_key: key, created_at: now, updated_at: now, }); rowsFor("messages").push(rec); + if (sendBehavior === "post_send_warning") { + return json({ + message: publicSendRow(rec), + provider: "stub", + sent: true, + provider_message_id: providerMessageId, + warning: "the provider accepted the message but the terminal ledger write failed", + retry_safe: false, + }, 202); + } return json({ - message: detailRow(rec), + message: publicSendRow(rec), provider: "stub", sent: true, provider_message_id: providerMessageId, }, 202); } + if (resource === "messages" && sub === "send-intents" && parts[3] === "lookup" && req.method === "POST") { + const body = await req.json().catch(function () { return {}; }); + const key = typeof body.idempotency_key === "string" ? body.idempotency_key : ""; + const existing = rowsFor("messages").find(function (row) { + return key && row.idempotency_key === key; + }); + return json({ + send_intent: existing + ? { + found: true, + tombstoned: false, + reconciliation_required: existing.send_state === "uncertain", + message: { id: existing.id, send_state: existing.send_state }, + } + : { + found: false, + tombstoned: false, + reconciliation_required: false, + message: null, + }, + }); + } // Send-intent reconciliation: enough of the real contract for CLI tests. if (resource === "messages" && sub === "send-intents" && parts[3] === "uncertain" && req.method === "GET") { const stuck = rowsFor("messages").filter(function (r) { return r.send_state === "uncertain"; }); @@ -1410,6 +1497,20 @@ export async function startV1Stub(options: V1StubOptions = {}): Promise const body = (await res.json()) as { resources?: V1StubResources }; return body.resources ?? {}; }, + async setSendBehavior(behavior) { + const res = await fetch(`${baseUrl}/v1/__send_behavior`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ behavior }), + }); + if (!res.ok) throw new Error(`v1-stub __send_behavior failed: HTTP ${res.status}`); + }, + async sendStats() { + const res = await fetch(`${baseUrl}/v1/__send_stats`); + if (!res.ok) throw new Error(`v1-stub __send_stats failed: HTTP ${res.status}`); + const body = (await res.json()) as { provider_calls?: number }; + return { providerCalls: body.provider_calls ?? 0 }; + }, async setListOrderInstability(rotate, resources) { const res = await fetch(`${baseUrl}/v1/__list_order`, { method: "POST",