How Cora Deleted Sent Email
A forensic walkthrough: how a cleanup job built to tidy stale drafts ended up permanently deleting customers' sent replies, how a two-second timestamp match proved it, and why the fix isn't yet complete.
1. The report nobody wants
On July 22 Cole Morgan posted publicly that Cora had been deleting his sent emails. Not misfiling, not archiving: deleting. Replies to clients vanished from his Sent folder, contracts stalled, and for a week he assumed he was misremembering his own outbox. Then he pulled the Google Workspace Gmail audit logs.
The logs were specific in a way user reports almost never are. Seven Delete events between July 15 and 22, each with Application = Cora, Client type = API, OAuth project 522193436440. Each landed on a reply he had just sent; the clearest one 12 seconds after send. The messages did not pass through Trash. Recovery required Google Workspace Admin data restoration, which only works for 25 days.
That audit shape is a gift: very few API calls can make a Gmail message disappear without touching Trash.
2. The suspect pool is smaller than the codebase
Gmail's API has exactly three families of calls that remove mail, and they differ in the one property the audit log pinned down: whether Trash is involved.
| Call | Trash? | Cora production callers |
|---|---|---|
messages.delete / messages.batchDelete | Bypasses Trash | None |
threads.delete | Bypasses Trash | None |
messages.trash | Goes to Trash | DeleteGmailForBriefJob only |
drafts.delete | Bypasses Trash | DeleteGmailDraftJob + two repair jobs |
A grep across the app settles the first two rows: Cora never calls hard message or thread deletion anywhere. The trash path exists but is gated on a per-account preference list (gmail_deletion_preferences), and Cole's was empty, with zero deleted_from_gmail rows on his account. Exonerated by data, not by assumption.
That leaves one suspect standing: drafts.delete, the only permanent, Trash-bypassing call Cora ever issues. Which sounds absurd as an explanation for deleted sent mail, because a draft delete should only be able to kill a draft. The rest of this piece is about why that assumption fails: first the timestamp that caught it, then the mechanism that makes it possible.
3. The two-second match
Cora's own database carried the receipt. When Cora discards a Gmail draft, it writes a tombstone row to discarded_gmail_drafts before enqueuing the remote delete. Cole's account had a tombstone created at 13:16:57 UTC on July 22. His audit log shows the Cora delete event at 13:16:59 UTC, on the reply he sent moments earlier. Two seconds apart, in the right order. His account has roughly twenty tombstones across July 13 to 22, bracketing all seven reported deletions.
The chain those two seconds connect, in code order:
- Cora's auto-drafting feature writes a genuinely real Gmail draft reply on a thread (the EPS row sits at
response_generated). - The user sends a reply on that thread. Gmail pubsub pushes the change; Cora imports the sent message within seconds.
CreateEmailProcessingStateServicecallsEmailDrafts::DiscardStaleGeneratedReplyDrafts: any older generated draft on this thread is now stale, clean it up.- That calls
DiscardInboxDraft, which writes the tombstone and enqueuesDeleteGmailDraftJob. - The job calls Gmail
drafts.delete.
Every step is individually reasonable. The bug is not in any step. It is in what the draft handle points to by the time step 5 runs.
4. Why a draft delete kills sent mail
Here is the identity problem. Cora's auto-draft is not a suggestion rendered in Cora's UI; it is a native Gmail draft, sitting in the user's Drafts folder, attached to the thread. So when the user opens that thread in Gmail to reply, Gmail helpfully pre-fills the reply box with Cora's draft. The user edits it, hits Send, and has now sent Cora's draft. The draft's underlying message is promoted to the sent message.
Seconds later, Cora's cleanup arrives to delete "its" draft. In the window right after a UI send, the draft handle still resolves on Gmail's side, and the message behind that handle is now the user's sent reply. drafts.delete is documented as "immediately and permanently deletes the specified draft": no Trash, no undo, and in this window, no draft either. It deletes the sent message. The Workspace audit dutifully records a permanent API delete by Cora's OAuth app, 12 seconds after send.
The cruelest part is the selection effect: the users who most trust Cora's drafting, the ones who send Cora's drafts as written, are precisely the ones exposed. Ignore the auto-draft and write your own reply, and the cleanup correctly deletes a genuinely stale draft. Use it, and the cleanup follows two seconds behind your send like a shredder.
5. Blast radius, stated honestly
The stale-cleanup path shipped on May 7 (PR #2399) and runs for every account on every imported thread message; no feature flag gates it. Fleet telemetry from the tombstone table: roughly 1,400 remote draft deletions per day across about 500 accounts. A loose upper bound on the dangerous shape (a tombstone within 90 seconds after a SENT import on the same account) counts 12,149 events across 1,238 accounts since June 22.
What we know for certain: the one user who checked was seven for seven, and every affected customer is racing a 25-day Admin-restore clock. The 12,149 needs its gating filters said out loud, because it is an exposure proxy, not a casualty count. Actual harm requires all three: an auto-draft existed on the thread, the user sent that specific draft rather than writing their own, and Gmail's consistency window still resolved the handle when the delete landed. Our database cannot see the third condition at all; only Gmail-side audit logs can confirm a kill.
6. The fix, and the hole in it
PR #3084 landed the same day with four defenses:
- Label gate inside
delete_draft: skip when the resolved message carriesSENT. - 30-second deferral on
DeleteGmailDraftJob: an in-flight send finishes and the delete 404s harmlessly. - Send-receipt short-circuit: skip the remote delete when a recent Cora send claims that draft identity.
- Protected-label filter on the brief trash job: never trash
SENT/DRAFT/SPAM/TRASH.
Layered defenses are right, but the layers do not cover the same ground. The deferral assumes the send was a drafts.send that consumes the handle; the receipt check assumes Cora performed the send and wrote a receipt. Cole sent from the Gmail web UI. No Cora receipt exists, and nothing guarantees Gmail's consistency window is shorter than 30 seconds. For the exact scenario from the incident, the label gate is the only guard on duty.
And the label gate as written fails open: if label_ids.any? && !label_ids.include?("DRAFT") lets the delete proceed whenever the label list comes back empty, which drafts.list responses routinely do. A safety gate whose unknown-state answer is "fire" is a logging feature, not a gate. The review asked for fail-closed: no DRAFT label visible, no delete, with a messages.get fallback to resolve labels when the draft payload omits them.
7. What this bug generalizes to
Strip the Gmail specifics and the shape is: a background cleanup deleting a resource by a handle captured earlier, where the resource can change identity between capture and delete. The stale-draft cleanup was correct on the day it was written; the user's send is what mutates the handle's meaning, and the cleanup never re-checks. The durable rule: any destructive call made from a stored handle must re-verify, at delete time, that the thing behind the handle is still the thing it decided to delete. Deferrals and receipts shrink the window; only re-verification closes it.
The second lesson is about audit asymmetry. Cora had no log line for its single most dangerous API call, so the customer's audit trail was better than ours; the investigation hinged on a tombstone table that exists for a different purpose. The fix's [GMAIL_DRAFT_DELETE] decision= logging turns that around, and every future skipped_sent_label line is both a sent email saved and a data point for the real incidence rate.
One background job, quietly holding the only permanent-delete capability in the system: that is where the audit started, and where every audit of destructive calls should start.
Grounded in the Cora codebase (GmailClientMk2, DiscardStaleGeneratedReplyDrafts, DeleteGmailDraftJob, PR #3084), production read-replica queries, and Cole Morgan's Workspace audit timestamps, July 2026.