#!/usr/bin/env tsx /** * `pnpm reingest:mailbox` — rebuilds a mailbox Durable Object from the raw MIME * that is still sitting in R2, by replaying each message through the *real* * inbound path instead of writing DO rows by hand. * * Why this exists: a Durable Object's id is derived from its jurisdiction, so * pinning mailboxes to `eu` (see src/lib/mailbox-stub.ts) does not move the old * object — it addresses a different, empty one. R2 still holds every inbound * message's raw MIME, and `message_index` in the *old* D1 still enumerates it, so * the mail can be put back. Everything else the old DO held (outbound bodies, * drafts, mailbox_meta) has no source outside it and does not come back; see * scripts/verify-mailbox-rebuild.ts, which names those losses instead of quietly * counting around them. * * ## How the replay works * * Each rebuildable row is turned back into the `InboundEmailQueueMessage` that * `src/cloudflare/email-handler.ts` would have produced, and pushed onto the same * inbound queue the Email Routing worker publishes to. From there nothing is * special-cased: `handleInboundQueue` heads the R2 object, calls the DO's * `/ingest`, and `ingestInboundEmail` re-parses the MIME, re-resolves the thread, * rewrites the FTS row and re-extracts attachments. No parsing, threading or * indexing logic is reimplemented here — the payload is validated against the * production `inboundEmailQueueMessageSchema` before it is sent, so a payload the * consumer would reject cannot leave this script. * * The queue is the only way in. The DO's `/ingest` route is not reachable over * HTTP (nothing proxies it), and `POST /api/debug/phase0/email` — the other * end-to-end path — re-derives `receivedAt` from the clock, which would restamp * twenty months-old messages as having arrived today and write a second copy of * every raw object under a new key. Replaying the queue payload keeps the * original `receivedAt`, `rawR2Key` and `rawSha256`. * * ## Idempotency * * Not invented here, and not a second mechanism layered on top: `ingestInboundEmail` * looks up `ingest_events.idempotency_key` before it does anything else and returns * `duplicate` without inserting. The key is rebuilt with the very same * `inboundIdempotencyKey()` the original delivery used, over the same mailbox id, * Message-ID and raw sha256, so it comes out byte-identical. Two runs therefore * leave one message. On top of that this script pre-checks the *target* D1's * `ingest_events` and skips keys already recorded as processed, so a second run is * also a no-op at the queue. * * ## Ordering * * Messages are pushed one at a time, oldest first, each awaited to completion. * Threading is order-dependent — `resolveThreadId` matches an incoming message * against the messages already stored — and Queues makes no ordering promise, so a * parallel replay could group a conversation differently than it was grouped the * first time. Twenty sequential round-trips is a cheap price for a deterministic * rebuild. * * SAFETY: dry-run by default. The dry run reads exactly what the apply run reads * (same remote D1, same remote R2) and prints the payload it would push, so the * preview is the plan. `--apply` is the only thing that writes. * * Usage: * pnpm reingest:mailbox --env dev --mailbox mbx_... --source-d1 * pnpm reingest:mailbox --env dev --mailbox mbx_... --source-d1 --apply * * Requires: `wrangler` auth (remote D1 + R2 reads) and, for `--apply`, a * CLOUDFLARE_API_TOKEN with Queues:Edit plus CLOUDFLARE_ACCOUNT_ID (or a * `wrangler whoami` that resolves one) — pushing to a queue has no wrangler command. */ import { execFileSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { InboundEmailQueueMessage } from "../src/cloudflare/types"; import { inboundEmailQueueMessageSchema } from "../src/cloudflare/types"; import { randomTraceId, sha256Hex } from "../src/lib/crypto"; import { normalizeMessageId } from "../src/lib/email-metadata"; import { inboundIdempotencyKey } from "../src/lib/idempotency"; import { parseMimeBytes } from "../src/lib/mime"; /** Mirrors MAX_QUEUE_BYTES in src/cloudflare/email-handler.ts (not exported). */ const MAX_QUEUE_BYTES = 128 * 1024; type WranglerBlock = { r2_buckets?: Array<{ binding: string; bucket_name: string; jurisdiction?: string }>; queues?: { producers?: Array<{ binding: string; queue: string }> }; d1_databases?: Array<{ binding: string; database_name: string }>; }; type WranglerConfig = WranglerBlock & { env?: Record }; type MessageIndexRow = { message_local_id: string; rfc_message_id: string | null; subject: string | null; from_addr: string; received_at: string; labels_json: string; state: string; raw_r2_key: string; raw_sha256: string; }; type IngestEventRow = { idempotency_key: string; message_local_id: string | null; status: string; error_code: string | null; }; type Rebuildable = { row: MessageIndexRow; payload: InboundEmailQueueMessage; rawSize: number; notes: string[]; }; type Unrebuildable = { row: MessageIndexRow; reason: string; }; type Outcome = { row: MessageIndexRow; status: | "inserted" | "duplicate" | "conflict" | "timeout" | "push_failed" | "skipped" | "unverified"; detail: string; newLocalId: string | null; }; function parseArgs(argv: string[]): Record { const args: Record = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg?.startsWith("--")) continue; const [rawKey, inlineValue] = arg.slice(2).split("=", 2); if (!rawKey) continue; if (inlineValue !== undefined) { args[rawKey] = inlineValue; continue; } const next = argv[i + 1]; if (!next || next.startsWith("--")) { args[rawKey] = "true"; continue; } args[rawKey] = next; i += 1; } return args; } function fail(message: string): never { console.error(`reingest:mailbox: ${message}`); process.exit(1); } function stripJsonc(input: string): string { return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); } /** SQLite string literal. The only untrusted values reaching SQL are labels_json * and state, both read back out of D1, but they still go through here. */ function sqlString(value: string): string { return `'${value.replace(/'/g, "''")}'`; } function wrangler(args: string[]): string { return execFileSync("pnpm", ["wrangler", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024, }); } /** * `wrangler d1 execute --json` prefixes its JSON with a human banner on some * versions, so cut to the first `[` rather than parsing the whole stream — the * same shape doctor.ts relies on. */ // wrangler uploads a `--file` query to R2 before running it, and the JSON it // returns for an uploaded query carries a *summary* ("Total queries executed", // "Rows read", ...) in `results` instead of the rows themselves. Silently, with // success: true. So SELECTs go through `--command`, whose response really does // carry the rows. The queries here are small enough that the argv limit is not a // concern, and a query large enough to hit it would be the wrong shape for this // script anyway. function d1Query(database: string, sql: string, extraArgs: string[] = []): T[] { const raw = wrangler([ "d1", "execute", database, "--remote", "--json", "--command", sql, ...extraArgs, ]); const start = raw.indexOf("["); if (start === -1) { throw new Error(`unexpected wrangler d1 output:\n${raw}`); } const parsed = JSON.parse(raw.slice(start)) as Array<{ results?: T[] }>; return parsed[0]?.results ?? []; } function d1Exec(database: string, sql: string, extraArgs: string[] = []): void { d1Query(database, sql, extraArgs); } /** * Reads one raw object. Returns null only for a genuine miss — any other wrangler * failure is rethrown, because "R2 said no" and "your token expired" must not both * present as "this message is unrecoverable". */ function fetchRawObject( bucket: string, key: string, dir: string, jurisdiction: string | undefined, ): Uint8Array | null { const file = join(dir, "object.eml"); // Every download reuses this path, so clear it first: a wrangler run that exits // zero without writing would otherwise hand back the *previous* message's bytes. // The sha256 check downstream would catch it, but only as a mystery. rmSync(file, { force: true }); try { // A jurisdiction-restricted bucket is invisible without the flag: wrangler // looks in the default jurisdiction, finds no such bucket, and reports every // key as missing. That reads identically to "this mail is unrecoverable", // which is the most dangerous wrong answer this script can give. wrangler([ "r2", "object", "get", `${bucket}/${key}`, "--remote", "--file", file, ...(jurisdiction ? ["--jurisdiction", jurisdiction] : []), ]); } catch (error) { const output = [ error instanceof Error ? error.message : String(error), (error as { stdout?: Buffer | string }).stdout?.toString() ?? "", (error as { stderr?: Buffer | string }).stderr?.toString() ?? "", ].join("\n"); if (/not\s*found|404|NoSuchKey|does not exist/i.test(output)) { return null; } throw new Error(`r2 get failed for ${key}:\n${output}`); } return new Uint8Array(readFileSync(file)); } async function cfApi(token: string, path: string, init?: RequestInit): Promise { const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, { ...init, headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...(init?.headers ?? {}), }, }); const payload = (await response.json()) as { success?: boolean; result?: T; errors?: Array<{ message?: string; code?: number }>; }; if (!response.ok || payload.success !== true) { const detail = (payload.errors ?? []) .map((item) => `${item.message ?? "unknown error"}${item.code ? ` [${item.code}]` : ""}`) .join("; "); throw new Error(detail || `Cloudflare API request failed (${response.status})`); } return payload.result as T; } /** Env var first, then the account `wrangler whoami` is already authenticated as. */ function resolveAccountId(): string | undefined { const fromEnv = process.env.CLOUDFLARE_ACCOUNT_ID?.trim(); if (fromEnv) return fromEnv; try { const payload = JSON.parse(wrangler(["whoami", "--json"])) as { account?: { id?: string }; accounts?: Array<{ id?: string }>; }; return payload.account?.id ?? payload.accounts?.[0]?.id; } catch { return undefined; } } async function resolveQueueId( token: string, accountId: string, queueName: string, ): Promise { // Field names have drifted across Queues API versions (queue_id/queue_name vs // id/name); accept either rather than pinning to whichever is current today. const queues = await cfApi>>( token, `/accounts/${accountId}/queues`, ); for (const queue of queues) { const name = (queue.queue_name ?? queue.name) as string | undefined; const id = (queue.queue_id ?? queue.id) as string | undefined; if (name === queueName && id) return id; } throw new Error(`queue ${queueName} not found in account ${accountId}`); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } const args = parseArgs(process.argv.slice(2)); const envName = args.env ?? "dev"; const mailboxId = args.mailbox?.trim(); const apply = args.apply === "true"; const force = args.force === "true"; const timeoutMs = Number(args["timeout-ms"] ?? "60000"); const limit = args.limit ? Number(args.limit) : Number.POSITIVE_INFINITY; if (!mailboxId) { fail("pass --mailbox ."); } if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { fail("--timeout-ms must be a positive number of milliseconds."); } const config = JSON.parse( stripJsonc(readFileSync(join(process.cwd(), "wrangler.jsonc"), "utf8")), ) as WranglerConfig; const block: WranglerBlock | undefined = envName === "production" ? config : config.env?.[envName]; if (!block) { fail(`wrangler.jsonc has no env "${envName}".`); } const targetDb = block.d1_databases?.find((db) => db.binding === "INDEX_DB")?.database_name; const bucketBinding = block.r2_buckets?.find((r2) => r2.binding === "MAIL_OBJECTS"); const bucket = bucketBinding?.bucket_name; const bucketJurisdiction = bucketBinding?.jurisdiction; const queueName = block.queues?.producers?.find( (producer) => producer.binding === "INBOUND_EMAIL_QUEUE", )?.queue; if (!targetDb || !bucket || !queueName) { fail(`env "${envName}" is missing an INDEX_DB, MAIL_OBJECTS or INBOUND_EMAIL_QUEUE binding.`); } // `production` is the top-level config block, not an entry under `env`, so wrangler // rejects `--env production`. Only named environments get the flag. const envArgs = envName === "production" ? [] : ["--env", envName]; // The migration that motivated this script also moved D1, so what the mailbox used // to contain and where it is being rebuilt are two different databases. Defaulting // to the target keeps the ordinary "replay into the same env" case a one-flag run. const sourceDb = args["source-d1"]?.trim() || targetDb; console.log("Mailbox re-ingest"); console.log(` Env: ${envName}`); console.log(` Mailbox: ${mailboxId}`); console.log(` Source D1: ${sourceDb}${sourceDb === targetDb ? " (same as target)" : ""}`); console.log(` Target D1: ${targetDb}`); console.log( ` R2 bucket: ${bucket}${bucketJurisdiction ? ` (jurisdiction ${bucketJurisdiction})` : ""}`, ); console.log(` Inbound queue: ${queueName}`); console.log(` Mode: ${apply ? "APPLY" : "DRY RUN (pass --apply to write)"}`); console.log(); const mailboxRows = d1Query<{ primary_address: string }>( sourceDb, `SELECT primary_address FROM mailboxes WHERE mailbox_id = ${sqlString(mailboxId)};`, ); const primaryAddress = mailboxRows[0]?.primary_address?.trim().toLowerCase(); if (!primaryAddress) { fail(`no mailboxes row for ${mailboxId} in ${sourceDb}.`); } // The address the mail was actually delivered to is not stored anywhere — R2's // custom metadata does not carry it and message_index only keeps the parsed To. // The primary address is the closest honest stand-in, and the only thing the DO // does with it is derive the mailbox's own domain for the subject-threading // fallback (see mailboxOwnDomains), where any address on the mailbox is equivalent. const recipient = primaryAddress; const domain = recipient.split("@")[1] ?? ""; const indexRows = d1Query( sourceDb, `SELECT message_local_id, rfc_message_id, subject, from_addr, received_at, labels_json, state, raw_r2_key, raw_sha256 FROM message_index WHERE mailbox_id = ${sqlString(mailboxId)} ORDER BY received_at ASC;`, ); if (indexRows.length === 0) { fail(`message_index in ${sourceDb} has no rows for ${mailboxId}; nothing to re-ingest.`); } console.log(`Enumerated ${indexRows.length} indexed message(s) in ${sourceDb}.`); console.log(); const workDir = mkdtempSync(join(tmpdir(), "reccado-reingest-raw-")); const rebuildable: Rebuildable[] = []; const unrebuildable: Unrebuildable[] = []; try { for (const row of indexRows) { if (rebuildable.length >= limit) break; const rawBytes = fetchRawObject(bucket, row.raw_r2_key, workDir, bucketJurisdiction); if (!rawBytes) { unrebuildable.push({ row, reason: `no object at R2 key ${row.raw_r2_key}` }); continue; } const notes: string[] = []; const actualSha = await sha256Hex(rawBytes); if (actualSha !== row.raw_sha256) { // The raw object is the rebuild source and its digest is half the // idempotency key. A mismatch means the object under this key is not the // message the index describes, and replaying it would store something // nobody asked for under a key that claims otherwise. unrebuildable.push({ row, reason: `raw_sha256 mismatch: index says ${row.raw_sha256}, R2 object hashes to ${actualSha}`, }); continue; } const parsed = await parseMimeBytes(rawBytes); const indexedMessageId = normalizeMessageId(row.rfc_message_id); const parsedMessageId = normalizeMessageId(parsed.messageId); if (indexedMessageId && parsedMessageId && indexedMessageId !== parsedMessageId) { notes.push(`Message-ID differs (index ${indexedMessageId}, MIME ${parsedMessageId})`); } // The index value wins: it is what the original idempotency key was computed // from, so using it is what makes the replay dedupe against the first delivery. const messageId = indexedMessageId ?? parsedMessageId; // raw_r2_key embeds the original receivedAt as epoch ms (see rawEmailR2Key), so // key and index are two independent records of the same instant and can be // checked against each other for free. const keyEpoch = Number(row.raw_r2_key.split("/").pop()?.split("-")[0]); if (Number.isFinite(keyEpoch) && Math.abs(keyEpoch - Date.parse(row.received_at)) > 1000) { notes.push( `received_at ${row.received_at} disagrees with the timestamp in raw_r2_key (${new Date(keyEpoch).toISOString()})`, ); } const idempotencyKey = inboundIdempotencyKey({ mailboxId, messageId, rawSha256: row.raw_sha256, }); const payload = inboundEmailQueueMessageSchema.parse({ schemaVersion: 1, eventType: "email.received.v1", traceId: randomTraceId(), enqueuedAt: new Date().toISOString(), receivedAt: row.received_at, mailboxId, domain, recipient, sender: row.from_addr, rawR2Key: row.raw_r2_key, rawSha256: row.raw_sha256, rawSize: rawBytes.byteLength, messageId, headers: { subject: parsed.subject ?? row.subject, // The original stored the verbatim `Date:` header line; postal-mime // normalizes it to ISO-8601. Same instant, different spelling, and // `date_header` is only ever displayed or quoted — noted, not hidden. date: parsed.date, inReplyTo: normalizeMessageId(parsed.inReplyTo), references: parsed.references .map((reference) => normalizeMessageId(reference)) .filter((reference): reference is string => Boolean(reference)), }, routing: { ruleId: null, action: "store", matchedAlias: recipient }, idempotencyKey, }) satisfies InboundEmailQueueMessage; const payloadBytes = new TextEncoder().encode(JSON.stringify(payload)).byteLength; if (payloadBytes > MAX_QUEUE_BYTES) { unrebuildable.push({ row, reason: `queue payload would be ${payloadBytes} bytes, over the ${MAX_QUEUE_BYTES} limit`, }); continue; } rebuildable.push({ row, payload, rawSize: rawBytes.byteLength, notes }); } } finally { rmSync(workDir, { recursive: true, force: true }); } const duplicateKeys = new Map(); for (const candidate of rebuildable) { const seen = duplicateKeys.get(candidate.payload.idempotencyKey) ?? []; seen.push(candidate.row.message_local_id); duplicateKeys.set(candidate.payload.idempotencyKey, seen); } const collisions = [...duplicateKeys.entries()].filter(([, ids]) => ids.length > 1); // What the target already knows. `ingest_events` is written by the queue consumer // after the DO answers, for both `inserted` and `duplicate`, so a row here means // the message is in the rebuilt DO and there is nothing to push. const alreadyProcessed = new Map(); if (rebuildable.length > 0) { const keys = rebuildable.map((candidate) => sqlString(candidate.payload.idempotencyKey)); const rows = d1Query( targetDb, `SELECT idempotency_key, message_local_id, status, error_code FROM ingest_events WHERE idempotency_key IN (${keys.join(", ")});`, envArgs, ); for (const row of rows) { alreadyProcessed.set(row.idempotency_key, row); } } console.log("Rebuildable from R2"); console.log("-------------------"); rebuildable.forEach((candidate, position) => { const { row, payload } = candidate; const existing = alreadyProcessed.get(payload.idempotencyKey); const plan = existing ? force ? `RE-PUSH (already ${existing.status} in ${targetDb}; --force)` : `SKIP (already ${existing.status} in ${targetDb} as ${existing.message_local_id ?? "?"})` : "PUSH"; console.log( ` [${String(position + 1).padStart(2, " ")}/${rebuildable.length}] ${row.received_at} ${plan}`, ); console.log(` subject ${row.subject ?? "(none)"}`); console.log(` from ${row.from_addr}`); console.log(` raw ${row.raw_r2_key} (${candidate.rawSize} bytes)`); console.log(` idem ${payload.idempotencyKey}`); console.log(` old id ${row.message_local_id} state=${row.state}`); for (const note of candidate.notes) { console.log(` note ${note}`); } }); if (rebuildable.length === 0) { console.log(" (none)"); } console.log(); console.log("Not rebuildable — no raw MIME to replay"); console.log("--------------------------------------"); for (const item of unrebuildable) { const outbound = item.row.state === "sent" || item.row.raw_r2_key.startsWith("sent/"); console.log(` ${item.row.message_local_id} state=${item.row.state} ${item.row.received_at}`); console.log(` subject ${item.row.subject ?? "(none)"}`); console.log(` reason ${item.reason}`); // Outbound messages never had a raw object: the send path computes // `sent/` as a key and writes nothing there. Anything else missing its // raw object is a loss nobody has accounted for, and saying so here is cheaper // than discovering it during verification. console.log( outbound ? " expected yes — outbound, its body only ever lived in the old DO" : " expected NO — inbound message with no raw object; investigate before you accept this", ); } if (unrebuildable.length === 0) { console.log(" (none)"); } console.log(); for (const [key, ids] of collisions) { console.log(`WARNING: ${ids.length} rows share idempotency key ${key}: ${ids.join(", ")}`); console.log(" Only one of them can exist in the DO; the rest will report as duplicates."); } if (collisions.length > 0) console.log(); const toPush = rebuildable.filter( (candidate) => force || !alreadyProcessed.has(candidate.payload.idempotencyKey), ); console.log("Plan"); console.log("----"); console.log(` indexed ${indexRows.length}`); console.log(` rebuildable ${rebuildable.length}`); console.log(` not rebuildable ${unrebuildable.length}`); console.log(` already in target ${rebuildable.length - toPush.length}`); console.log(` to push ${toPush.length}`); const stateRestores = rebuildable.filter( (candidate) => candidate.row.state !== "inbox" || candidate.row.labels_json !== "[]", ); const doStateDivergence = rebuildable.filter((candidate) => candidate.row.state !== "inbox"); console.log(` state/labels to restore in ${targetDb}: ${stateRestores.length}`); console.log( ` messages whose DO state is not the ingest default 'inbox': ${doStateDivergence.length}`, ); console.log(); if (!apply) { console.log("DRY RUN: nothing was pushed and no row was written."); console.log("Re-run with --apply to replay the messages listed above."); process.exit(0); } // Two ways to get a message onto the queue, because publishing has no wrangler // command. The REST API is the direct one but needs an API token with Queues:Edit, // which is a credential someone has to mint. `--push-url` is the alternative: a // Worker holding a producer binding to this queue, which needs nothing but the // wrangler session that deployed it. Both end at the same `send()`. const pushUrl = args["push-url"]?.trim(); const pushToken = (args["push-token"] ?? process.env.RECCADO_PUSH_TOKEN)?.trim(); let publish: (payload: unknown) => Promise; if (pushUrl) { console.log(`Pushing to queue ${queueName} via ${new URL(pushUrl).origin}.`); publish = async (payload) => { const response = await fetch(pushUrl, { method: "POST", headers: { "content-type": "application/json", ...(pushToken ? { authorization: `Bearer ${pushToken}` } : {}), }, body: JSON.stringify(payload), }); if (!response.ok) { throw new Error(`push endpoint returned ${response.status}: ${await response.text()}`); } }; } else { const apiToken = process.env.CLOUDFLARE_API_TOKEN?.trim(); if (!apiToken) { fail( "--apply needs CLOUDFLARE_API_TOKEN (Queues:Edit), or --push-url pointing at a Worker with a producer binding for this queue.", ); } const accountId = resolveAccountId(); if (!accountId) { fail("could not resolve an account id; set CLOUDFLARE_ACCOUNT_ID."); } const queueId = await resolveQueueId(apiToken, accountId, queueName); console.log(`Pushing to queue ${queueName} (${queueId}) in account ${accountId}.`); publish = async (payload) => { await cfApi(apiToken, `/accounts/${accountId}/queues/${queueId}/messages`, { method: "POST", body: JSON.stringify({ body: payload, content_type: "json" }), }); }; } console.log(); const outcomes: Outcome[] = []; for (const candidate of rebuildable) { const { row, payload } = candidate; const label = `${row.received_at} ${row.subject ?? "(no subject)"}`; if (!force && alreadyProcessed.has(payload.idempotencyKey)) { const existing = alreadyProcessed.get(payload.idempotencyKey)!; outcomes.push({ row, status: "skipped", detail: `already ${existing.status} in ${targetDb}`, newLocalId: existing.message_local_id, }); console.log(` SKIP ${label} — already ${existing.status} in ${targetDb}`); continue; } try { await publish(payload); } catch (error) { outcomes.push({ row, status: "push_failed", detail: error instanceof Error ? error.message : String(error), newLocalId: null, }); console.log(` PUSH-FAIL ${label} — ${error instanceof Error ? error.message : error}`); continue; } // Wait for this message to land before pushing the next one; see the ordering // note at the top of this file. // A row carrying this idempotency key may already exist before we push -- the // projection is rebuildable and can be restored from a backup that predates the // Durable Object it describes, which is exactly the state a jurisdiction move // leaves behind. Waiting for "a row exists" would then be satisfied instantly by // that stale row and report work that never happened, reusing the old message id. // So completion means the id *changed*, not that a row is present. const priorLocalId = alreadyProcessed.get(payload.idempotencyKey)?.message_local_id ?? null; const deadline = Date.now() + timeoutMs; let settled: IngestEventRow | undefined; while (Date.now() < deadline) { await sleep(1500); const rows = d1Query( targetDb, `SELECT idempotency_key, message_local_id, status, error_code FROM ingest_events WHERE idempotency_key = ${sqlString(payload.idempotencyKey)};`, envArgs, ); const row0 = rows[0]; if (!row0 || (row0.status !== "processed" && row0.status !== "failed")) continue; if (priorLocalId !== null && row0.message_local_id === priorLocalId) continue; settled = row0; break; } // Timing out against an unchanged pre-existing row is genuinely ambiguous: the // Durable Object either deduplicated this message or never processed it, and a // stale projection row cannot tell those apart. Say so instead of picking one. if (!settled && priorLocalId !== null) { outcomes.push({ row, status: "unverified", detail: `a pre-existing ${targetDb} row for this key was unchanged after ${timeoutMs}ms — the DO either deduplicated it or never processed it, and this row predates the run`, newLocalId: priorLocalId, }); console.log( ` UNVERIFIED ${label} — pre-existing row unchanged; cannot distinguish dedupe from no-op`, ); continue; } if (!settled) { outcomes.push({ row, status: "timeout", detail: `no ingest_events row in ${targetDb} after ${timeoutMs}ms`, newLocalId: null, }); console.log(` TIMEOUT ${label} — no ingest_events row in ${targetDb} after ${timeoutMs}ms`); continue; } if (settled.status === "failed") { outcomes.push({ row, status: "conflict", detail: settled.error_code ?? "failed", newLocalId: null, }); console.log(` CONFLICT ${label} — ${settled.error_code ?? "failed"}`); continue; } // `duplicate` is only claimed when the Durable Object itself reported one, never // inferred from a projection row we did not watch appear. const reused = settled.message_local_id === priorLocalId; outcomes.push({ row, status: reused ? "duplicate" : "inserted", detail: settled.message_local_id ?? "", newLocalId: settled.message_local_id, }); console.log( ` ${reused ? "DUPLICATE" : "INSERTED "} ${label} → ${settled.message_local_id ?? "?"}`, ); } console.log(); // Restore what the ingest path cannot know. A replayed message is inbound and lands // in 'inbox' with no labels, because that is what an arriving email is; the folder it // had been filed into and the labels put on it live only in message_index, so they // are copied forward onto the row the consumer just wrote under the new local id. const restorable = outcomes.filter( (outcome) => outcome.newLocalId && (outcome.row.state !== "inbox" || outcome.row.labels_json !== "[]") && (outcome.status === "inserted" || outcome.status === "duplicate" || outcome.status === "skipped"), ); if (restorable.length > 0) { const now = new Date().toISOString(); const statements = restorable.map( (outcome) => `UPDATE message_index SET state = ${sqlString(outcome.row.state)}, ` + `labels_json = ${sqlString(outcome.row.labels_json)}, updated_at = ${sqlString(now)} ` + `WHERE mailbox_id = ${sqlString(mailboxId)} AND message_local_id = ${sqlString(outcome.newLocalId!)};`, ); d1Exec(targetDb, statements.join("\n"), envArgs); console.log(`Restored state/labels on ${restorable.length} message_index row(s) in ${targetDb}.`); } else { console.log("No state/labels to restore: every replayed message was 'inbox' with no labels."); } console.log(); const counts = new Map(); for (const outcome of outcomes) { counts.set(outcome.status, (counts.get(outcome.status) ?? 0) + 1); } console.log("Summary"); console.log("-------"); for (const status of [ "inserted", "duplicate", "unverified", "skipped", "conflict", "timeout", "push_failed", ] as const) { console.log(` ${status.padEnd(12, " ")} ${counts.get(status) ?? 0}`); } console.log(` not rebuildable ${unrebuildable.length}`); console.log(); const notClean = outcomes.filter( (outcome) => outcome.status === "conflict" || outcome.status === "timeout" || outcome.status === "push_failed", ); if (notClean.length > 0) { console.log("Did not re-ingest"); console.log("-----------------"); for (const outcome of notClean) { console.log(` ${outcome.status.toUpperCase()} ${outcome.row.message_local_id}`); console.log(` subject ${outcome.row.subject ?? "(none)"}`); console.log(` detail ${outcome.detail}`); } console.log(); } // The DO stores state too, and nothing reachable from here can set it: the only // route that moves a message between folders is the Access-protected // POST /api/mailboxes/:id/messages/:id/actions. Rather than pretend the restore // above covered it, print the exact calls that finish the job. const doFollowUps = outcomes.filter( (outcome) => outcome.newLocalId && outcome.row.state !== "inbox", ); if (doFollowUps.length > 0) { console.log("Durable Object state still needs a follow-up"); console.log("-------------------------------------------"); console.log( "message_index now says these are filed elsewhere, but the DO — which is what the UI reads —", ); console.log("has them in 'inbox'. Apply each with an authenticated request:"); for (const outcome of doFollowUps) { const action = outcome.row.state === "archive" ? "archive" : outcome.row.state === "trash" ? "trash" : `(no action maps to state '${outcome.row.state}')`; console.log( ` POST /api/mailboxes/${mailboxId}/messages/${outcome.newLocalId}/actions {"action":"${action}"}`, ); } console.log(); } if (sourceDb === targetDb) { const superseded = outcomes .filter((outcome) => outcome.newLocalId && outcome.newLocalId !== outcome.row.message_local_id) .map((outcome) => outcome.row.message_local_id); if (superseded.length > 0) { console.log("Superseded message_index rows"); console.log("----------------------------"); console.log( "Re-ingest mints a new message_local_id, so the pre-rebuild rows are still in this", ); console.log("database pointing at DO messages that no longer exist. Delete them when you have"); console.log("confirmed the rebuild (verify-mailbox-rebuild.ts), not before:"); console.log( ` DELETE FROM message_index WHERE mailbox_id = ${sqlString(mailboxId)} AND message_local_id IN (${superseded .map((id) => sqlString(id)) .join(", ")});`, ); console.log(); } } const failed = (counts.get("conflict") ?? 0) + (counts.get("timeout") ?? 0) + (counts.get("push_failed") ?? 0); if (failed > 0) { console.error(`FAIL: ${failed} message(s) did not re-ingest cleanly. See above.`); process.exit(1); } console.log( `PASS: ${counts.get("inserted") ?? 0} inserted, ${counts.get("duplicate") ?? 0} already present, ` + `${counts.get("skipped") ?? 0} skipped. Now run pnpm verify:mailbox-rebuild.`, );