From f80eb4a092d415b0df4206e530660fe2bf3efaad Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Thu, 16 Apr 2026 17:34:05 -0400 Subject: [PATCH] Clean up mailbox state, attachment blobs, and agent history on mailbox delete The DELETE /api/v1/mailboxes/:mailboxId handler only removed the R2 settings JSON, leaving the MailboxDO SQLite storage, all R2 attachment blobs under attachments/{emailId}/{attId}/{filename}, and the EmailAgent DO state (cf_ai_chat_agent_messages, cf_agents_* tables, alarms) behind. This is the existing TODO at workers/index.ts:140. Over time the leak grows without bound; a recreated mailbox with the same email address inherits the old DO state because idFromName() is deterministic. This change adds a destroyMailbox() RPC method on MailboxDO that enumerates attachments, batch-deletes their R2 blobs (1000 keys per call), wipes SQLite via ctx.storage.deleteAll(), and aborts the DO so the next access re-instantiates with a fresh migration run. Attachment deletion happens inside the DO so the operation is atomic from the caller and avoids shipping a potentially large string[] over RPC. For the agent side, the handler now calls the existing AIChatAgent.destroy() RPC method from the upstream agents package. Upstream destroy() drops cf_agents_* and cf_ai_chat_agent_messages tables, deletes alarms, runs dispose hooks, wipes storage, and aborts the DO. The compatibility_date of 2025-11-28 predates the change that made deleteAll() implicitly clear alarms, so delegating to upstream destroy() is both simpler and more correct than a hand-rolled path. The settings JSON is deleted last so partial failures leave the HEAD check passing and the handler can be safely retried. This is best-effort cleanup, not linearizable deletion -- a concurrent receiveEmail() that passed its own HEAD check can still repopulate the mailbox after drain. Closing that race requires coordination (e.g. a deleting-flag in settings read by the receive path) and is intentionally left for a follow-up. Verified locally via npm run dev: created a mailbox, posted an email with an attachment blob, verified the blob in R2, issued DELETE, and confirmed (a) the attachment blob is gone, (b) the settings JSON is gone, (c) recreating the same mailbox succeeds with an empty DO. Signed-off-by: Steven Obiajulu --- workers/durableObject/index.ts | 42 ++++++++++++++++++++++++++++++++++ workers/index.ts | 21 ++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/workers/durableObject/index.ts b/workers/durableObject/index.ts index e2cbbfa3..37d3e92e 100644 --- a/workers/durableObject/index.ts +++ b/workers/durableObject/index.ts @@ -869,4 +869,46 @@ export class MailboxDO extends DurableObject { this.db.insert(schema.attachments).values(attachments).run(); } } + + // ── Teardown ─────────────────────────────────────────────────────── + + /** + * Tear down this mailbox: enumerate attachments, delete their R2 blobs, + * wipe SQLite storage, and abort the DO so the next access gets a fresh + * instance (which re-runs migrations on an empty DB). + * + * Attachment blob deletion happens here so the whole operation is atomic + * from the caller's perspective and avoids shipping a potentially large + * string[] back over RPC. + */ + async destroyMailbox(): Promise { + const rows = [ + ...this.ctx.storage.sql.exec<{ + email_id: string; + id: string; + filename: string; + }>(`SELECT email_id, id, filename FROM attachments`), + ]; + const keys = rows.map( + (r) => `attachments/${r.email_id}/${r.id}/${r.filename}`, + ); + + // R2 batch delete cap is 1000 keys per call. + for (let i = 0; i < keys.length; i += 1000) { + const chunk = keys.slice(i, i + 1000); + if (chunk.length > 0) await this.env.BUCKET.delete(chunk); + } + + await this.ctx.storage.deleteAll(); + + // Abort so the next request against this mailbox-id gets a fresh DO + // instance and its constructor re-runs migrations on a new SQLite DB. + // Without this the live instance retains an empty DB without the + // d1_migrations tracker and schema-dependent methods would fail. + // `ctx.abort()` throws an uncatchable error, so defer to the event + // loop to let the RPC response flush first. + setTimeout(() => { + this.ctx.abort("destroyed"); + }, 0); + } } diff --git a/workers/index.ts b/workers/index.ts index fd3359ce..2edeb322 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -136,7 +136,26 @@ app.delete("/api/v1/mailboxes/:mailboxId", async (c) => { const mailboxId = c.req.param("mailboxId")!; const key = `mailboxes/${mailboxId}.json`; if (!(await c.env.BUCKET.head(key))) return c.json({ error: "Not found" }, 404); - await c.env.BUCKET.delete(key); // TODO: also delete DO data and R2 attachment blobs + + // Drain the mailbox DO: delete R2 attachment blobs, wipe SQLite storage, + // and abort so the next access gets a fresh instance. + const mailboxStub = c.env.MAILBOX.get(c.env.MAILBOX.idFromName(mailboxId)); + await (mailboxStub as any).destroyMailbox(); + + // Drain the EmailAgent DO via the inherited AIChatAgent.destroy() RPC. + // Upstream `Agent.destroy()` drops cf_agents_* tables, deletes the chat + // history (`cf_ai_chat_agent_messages`), deletes alarms, runs dispose + // hooks, wipes storage, and aborts the DO. + const agentStub = c.env.EMAIL_AGENT.get(c.env.EMAIL_AGENT.idFromName(mailboxId)); + await (agentStub as any).destroy() + .catch((e: Error) => console.error("EmailAgent destroy failed:", e.message)); + + // Delete settings JSON last. A partial failure leaves the HEAD check + // passing so the operation can be retried. This is best-effort cleanup + // — a concurrent receiveEmail() that passed its HEAD check before this + // delete can still repopulate the mailbox; closing that race needs a + // deleting-flag coordination protocol and is out of scope here. + await c.env.BUCKET.delete(key); return c.body(null, 204); });