Clean up mailbox state, attachment blobs, and agent history on mailbox delete#1
Open
stevenobiajulu wants to merge 1 commit intocloudflare:mainfrom
Open
Conversation
…x 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 <steven@usejunior.com>
aadarshbk
approved these changes
Apr 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the existing
TODOatworkers/index.ts:140:The current
DELETE /api/v1/mailboxes/:mailboxIdhandler removes only the R2 settings JSON. Three categories of state are leaked:MailboxDOSQLite storage — all emails, folders, threads, attachment rows, and thed1_migrationstracker.attachments/{emailId}/{attId}/{filename}from every email ever received or sent.EmailAgentDO state — the chat table (cf_ai_chat_agent_messages), the runtime tables (cf_agents_state,cf_agents_schedules,cf_agents_queues,cf_agents_workflows), plus any scheduled alarms.Because
idFromName(email)is deterministic, a mailbox re-created with the same address inherits the old DO state from both Durable Objects. Over time R2 usage grows without bound.Change
Two files touched.
workers/agent/index.tsis unchanged —EmailAgentinherits the upstreamAIChatAgent.destroy()method, which already does the right thing.workers/durableObject/index.ts— newdestroyMailbox()RPC onMailboxDO:SELECT email_id, id, filename FROM attachments).this.ctx.storage.deleteAll()to wipe SQLite (emails, folders, attachments, threading, migrations tracker).this.ctx.abort("destroyed")viasetTimeout(fn, 0)so the RPC response flushes before the uncatchable abort fires. The abort ensures a subsequent request hits a fresh DO instance (which re-runs migrations on a new DB) instead of reusing the live instance with an empty schema.Attachment deletion happens inside the DO rather than shipping a
string[]back over RPC, so the operation is atomic from the caller's perspective and scales to mailboxes with many attachments.workers/index.ts— DELETE handler now:mailboxStub.destroyMailbox().agentStub.destroy()— the upstreamAIChatAgent.destroy()(seecloudflare/agentspackages/ai-chat/src/index.ts) dropscf_agents_*/cf_ai_chat_agent_messages, runsdeleteAlarm(), disposes resources, callsdeleteAll(), and aborts the DO.Why not hand-roll agent cleanup
An earlier draft of this change proposed adding a
/destroyroute onEmailAgentthat calledthis.ctx.storage.deleteAll(). A review of the upstreamagentspackage source showed that would have been incorrect:AIChatAgent.destroy()does more than a raw storage wipe — it also aborts the DO, disposes resources, and cancels scheduled alarms explicitly.compatibility_dateis2025-11-28. Cloudflare madedeleteAll()implicitly clear alarms only on2026-02-24+. A hand-rolled path on this compat date would have leaked alarms.Delegating to the upstream method sidesteps both issues.
Scope — best-effort, not linearizable
This is best-effort cleanup, not full deletion linearizability.
receiveEmail()checksBUCKET.head()once atworkers/index.ts:367and then writes attachments + DO rows. A concurrent inbound delivery that passed its HEAD check before we delete the settings JSON can still land in the freshly-aborted-then-re-instantiated DO. Closing that race requires coordination (e.g. adeleting: trueflag in settings JSON thatreceiveEmailreads) and is intentionally left for a follow-up — happy to take that on as a separate PR if wanted.Verification
Full E2E locally via
npm run dev:test@example.com.HTTP 200, size 30when fetching the attachment blob confirms it's in R2.DELETE /api/v1/mailboxes/test@example.com→HTTP 204.[].HTTP 201(would have been409 Mailbox already existsif settings had leaked).{"emails":[],"totalCount":0}, proving the DO was drained and the new instance re-ran migrations from scratch.Also deployed the branch to my own Cloudflare account via
wrangler deploywith one-click Access enabled; worker deploys cleanly and Access gating is functional (unauthenticated requests return302to the login redirect). The code path is identical to the local run — the fix exercises DO storage primitives + R2 batch delete + upstreamAIChatAgent.destroy(), none of which differ between miniflare and production.Out of scope (intentional)
receiveEmail()race described above.DraftBody.toemail validation atworkers/index.ts:34— happy to submit separately.*.test.ts, notests/dir, novitest.config.*). Adding Vitest + Miniflare would expand the review surface and open tooling debates. Happy to follow up if a test harness PR would be welcome.DCO
I certify that this contribution is made under the terms of the Developer Certificate of Origin 1.1 (https://developercertificate.org). The commit is signed off with
Signed-off-by: Steven Obiajulu <steven@usejunior.com>.