Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions workers/durableObject/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,4 +869,46 @@ export class MailboxDO extends DurableObject<Env> {
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<void> {
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);
}
}
21 changes: 20 additions & 1 deletion workers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down