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); });