Skip to content

feat(account): defer immediate erase for accounts that recently sent externally - #1059

Merged
jiashuoz merged 13 commits into
mainfrom
feat/defer-erase-recent-senders
Sep 29, 2026
Merged

jiashuoz merged 13 commits into
mainfrom
feat/defer-erase-recent-senders

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Why

Account deletion (v1.11.0) defaults to a 30-day trash. While an account is in the trash, its account_sending_controls row and its bounce/complaint aggregates survive and keep updating from late SES feedback. DELETE /v1/account?confirm=DELETE&permanent=true skips that window and purges at once.

Abusive senders can exploit this: send a burst, then erase the account immediately. Complaints arrive hours to days after a send, so they land after the account is gone and count against nothing. Until now the only thing that refused an immediate erase was an active sending pause (409 erase_held).

Contract (additive)

A permanent erase of an account that sent to an external recipient within the last N days is deferred. The account goes to the same trash the default delete uses, and the janitor purges it at the normal end of the account trash window. From the caller's side this is a success, not an error:

{ "deleted": true, "mode": "trash", "erase_deferred": true,
  "purge_after": "2026-10-28T12:00:00Z",
  "message": "This account emailed external recipients recently, so it is kept in the trash until purge_after ...",
  "user_deleted": false, "messages_deleted": 0, "agents_deleted": 2 }
  • New optional response fields on DeleteUserDataResult: erase_deferred, message. mode and purge_after already exist. oasdiff reports no breaking changes.
  • External recipient follows the external-sending-access rule: anyone other than the account's own agents, its verified owner mailbox (valid proof for its current email), or an address on the deployment's shared agent domains (verified domains rows with no owner).
  • Precedence: a paused account still gets 409 erase_held, which is checked first.
  • Ordering: the check runs after the trash commits, so no send can settle between the check and the purge. If the check itself errors, the erase is deferred rather than performed, which keeps the evidence.
  • Restore interstitial: "erase now" is deferred the same way. The restricted session is kept, so the restore offer still works.
  • Billing receives the normal account-state trash notice. The purge notice follows from the janitor.
  • Restore works as for any trashed account. Operator force-purge (backdate deleted_at) still works (tested).

Data source: message_recipients. The send path writes one row per normalized envelope recipient exactly when the provider or relay accepts the message, so drafts, holds, refusals and queued mail never count. The query goes through the account's agents and idx_messages_agent_created, and EXISTS stops at the first hit. The send time is the latest of created_at, provider_accepted_at, reviewed_at and scheduled_at, so a scheduled or review-held message that was submitted recently still counts. No new table. Alternatives rejected: usage_events has no recipient addresses (it can't tell external sends from agent-to-agent ones) and isn't written for non-standard account classes. The sending_feedback_* ledger stores recipients only as keyed digests.

Config

trash.recent_sender_erase_defer_days (default 14, 0 disables; env E2A_TRASH_RECENT_SENDER_ERASE_DEFER_DAYS; negative values are rejected at startup). It has no effect when account trash is disabled (account_retention_days: 0). It is documented in config.example.yaml.

Symmetric client changes

  • OpenAPI: operation and permanent param descriptions plus the new response fields. Both SDK bases regenerated with make generate-sdk (OAG v7.16.0).
  • TS client.account.delete() and Python client.account.delete() docstrings describe the deferred case.
  • CLI e2a account delete --permanent prints "Permanent erasure deferred: …" and the purge date. --json is unchanged (raw receipt).
  • MCP: there is no delete-account tool, so nothing changes there (the agent-level change updates delete_agent; see below).
  • Web: the Settings danger zone shows a status panel ("moved to the trash … until … sign in again before then to restore it") before signing out, and the "Erase permanently now" option explains the rule. The restore interstitial stays on the trashed-account screen with a notice and the restore button still available.
  • Docs: docs/api.md, docs/data-handling.md, and a new docs/design/account-soft-deletion.md. Code already cited that file, but it was never committed. For now it holds only a short pointer plus the "Deferred erase for recent senders" section, which now includes the agent and message levels.

Test evidence

  • internal/identity (DB): external send 1 day ago → trashed, erase_deferred, purge_after == deleted_at + retention, control row, aggregates and messages intact, a second erase is deferred again with zero counts, restore works. Last external send 15 days ago → purged. Sends only to own agents, the verified owner mailbox, or another shared-domain agent → purged. An unverified owner mailbox counts as external. A message created 20 days ago but accepted 1 hour ago → deferred. Window 0 → purged. Paused → ErrEraseHeld and the account is untouched. Backdate plus janitor → purged.
  • internal/agent: a deferred erase notifies billing with trash at /account-state and never calls the cancel hook.
  • internal/httpapi: 200 deferred receipt shape. The interstitial keeps the restricted cookie.
  • internal/config: default, zero, negative and env override.
  • Agent/message level (DB):
    • An agent with an external send 1 day ago: permanent delete is deferred (agent trashed, purge_after = deleted_at + trash retention, recipient evidence intact). A second delete is deferred again, restore works, and a later account erase is still deferred.
    • Internal-only sender → purged. External send outside the window → purged. Paused account → erase_held and the agent is not trashed.
    • Message: an externally sent trashed message is deferred with the right purge_after, and an internal-only message is purged.
    • Window 0 → agent and message are both purged.
    • The original bypass end to end (purge the message, purge the agent, erase the account) → every step is deferred and the evidence survives.
  • httpapi: receipt shapes for deferred agent and message deletes, and a purged receipt carries no deferral fields. Python unit tests cover the wire shape and the new permanent flag. The web trash-view tests cover the deferral notice.
  • Hardening tests (DB):
    • Simulator recipients never defer.
    • The configured exempt list replaces the default.
    • An owned shared domain is internal only when exempted by name; the test shows the precondition.
    • system and internal classes are never deferred.
    • A quoted-local-part address is external.
    • Unsettled provider-accepted sends are classified by their own recipients (internal-only → not deferred), and so are claimed-but-unaccepted sends.
    • An old held message approved an hour ago counts (arm B).
    • A deferred agent delete cancels the agent's scheduled job, the message ends failed with the deferred-purge detail, and a later restore doesn't re-arm it.
    • Config: the window must fit the trash windows, the unset default is lowered, and exempt domains are validated and can be set by env.
    • Prober sweep: deferred purges are counted apart. Harness: deferred deletes are reported, not retried, and untracked.
    • Two existing stale-lease tests (TestPurgeMessage_AllowsStaleOrphanedSendClaim, TestDeleteAgent_AllowsStaleOrphanedSendClaim) now run with the deferral disabled. Their stale claim to an external address is now evidence by design.
  • Contract: new re-runnable permanent_agent_and_message_delete_deferred_for_recent_sender scenario on a new seeded account (E2A_TEST_DEFERRED_PURGE_API_KEY). It passes on the Go, TS and Python runners.
  • Contract: new account_delete_permanent_deferred_for_recent_sender scenario on a new seeded disposable account (E2A_TEST_DISPOSABLE_DEFERRED_ERASE_API_KEY, which CI picks up through the env file). It passes on the Go, TS and Python runners against a live contract server.
  • Gates run locally: go build ./..., go vet on the touched non-test packages, go test -tags integration for identity/agent/httpapi/apiserver/config/janitor/auth/cmd/tests/contract, make spec-check, make openapi-compat-check (no breaking changes), make generate-sdk plus the generator unit tests, SDK/CLI vitest, TS/Python contract, pytest plus mypy, web jest (1096 passed) plus lint, and the repo text integrity check.
  • These fail locally and fail the same way on a clean origin/main checkout, so this PR doesn't cause them:
    • TestInboundReviewApprovalAtomicallyAdvancesAuthenticatedEngagement (identity)
    • TestMarkSentIsNotDoubleCountedOnRedrive and TestMarkSentUpdatesOutreachFromToRecipients (agent; the latter is flaky on both trees)
    • 11 internal/e2e tests (outreach, webhooks, threading, eval runner)

Agent and message level (closes the evidence-removal bypass)

The account check reads message_recipients, which cascades from messages. Before this change an account could permanently delete the agents (or sent messages) that emailed externally and then erase itself immediately. Every on-demand path that purges sent mail now follows the same rule, using the same predicate scoped to one agent or one message:

Path Deferred to Receipt (additive)
DELETE /v1/account?permanent=true, restore interstitial "erase now" account trash mode: trash, erase_deferred, purge_after, message
DELETE /v1/agents/{email}?confirm=DELETE&permanent=true (live or trashed agent) agent trash (trash.retention_days) erase_deferred, purge_after, message, messages_deleted: 0
DELETE …/messages/{id}?permanent=true&confirm=DELETE (trashed message) message trash (trash.retention_days) erase_deferred, purge_after, message
  • The agent decision runs under the agent row lock, after the pause check and the send-lease check, and the deferral trashes the agent in the same transaction. The message decision runs under the message row lock.
  • Window 0 disables all three. The paused-account 409 erase_held still comes first. Read-only accounts cannot reach these writes.
  • Audit of the other purge paths: there are no bulk "empty trash" endpoints. The janitor purges only after the trash windows expire. Domain delete is refused while any live or trashed agent remains. MCP exposes no permanent message delete, and its delete_agent goes through the same /v1 operation. No path purges sent mail immediately without a trash.
  • The store's int-returning wrappers (DeleteAgent, DeleteAgentIncarnation, PurgeMessage) return identity.ErrPurgeDeferred when a purge is deferred, so no internal caller can silently skip the rule.
  • Surfaces:
    • Spec: new optional fields on DeleteAgentResult and DeleteMessageResult, plus updated operation and param docs. oasdiff reports no breaking changes. Both SDKs regenerated.
    • SDK docs: the TS and Python agent/message delete docs describe the deferral. Python agents.delete now takes the permanent flag the TS SDK already had.
    • MCP: the delete_agent description is updated. delete_message is soft-only, so it is unchanged.
    • Web: both trash views ("Delete forever" on inboxes and messages) explain a deferral and keep the row restorable.
    • CLI: there are no agent or message delete commands, so nothing changes.

Review hardening (third round)

  • Exemptions, which unblock the prober and e2e cleanup. The standing prober mails success@simulator.amazonses.com every tick, and the e2e harness permanently deletes throwaway agents that did the same. Both would have piled deferred rows into the trash until the storage cap blocked the probe. Now exempt:
    • recipient domains in trash.erase_defer_exempt_domains (default simulator.amazonses.com; env E2A_TRASH_ERASE_DEFER_EXEMPT_DOMAINS);
    • the configured shared_domain, matched by name even when an account owns its domains row;
    • system and internal account classes.
  • Prober and harness handling. The prober sweep counts erase_deferred purges as deferred, never as purged. The e2e harness reports deferred permanent deletes in a new deferred list, untracks them, and doesn't retry.
  • Domain parsing. The recipient domain is the part after the LAST @, so "x@<shared>@y"@victim.example is external.
  • Race. The account deferral is decided inside purgeAccount's claim transaction, under the user FOR NO KEY UPDATE lock, next to the pause re-check.
  • Unsettled sends. A message the provider accepted but that hasn't settled yet (a provider message id with no recipient rows), or a send claimed inside the window, counts. Its own to/cc/bcc lists decide whether it's external.
  • Config. An explicit recent_sender_erase_defer_days larger than retention_days or account_retention_days is refused at startup. When unset, the effective value is 14, lowered to the shortest trash window, so existing short-trash configs still start.
  • Performance. The lookup is now two index range scans per agent:
    • ordinary sends through idx_messages_agent_created, with a 14-day retry-lag lower bound;
    • scheduled or held sends through a new partial expression index, idx_messages_agent_delayed_outbound on (agent_id, GREATEST(scheduled_at, reviewed_at)), added by migration 125 (CREATE INDEX CONCURRENTLY, no-transaction).
    • EXPLAIN ANALYZE on an agent seeded with 300,000 old outbound messages (5% scheduled, no recent send): both arms are index scans touching 3 and 2 buffers, and the whole query ran in 0.57 ms. Before the index it was 33–42 ms: a 15,000-row bitmap heap scan while the agent row was locked.
  • Scheduled sends. A deferred agent delete cancels the agent's pending scheduled sends inside the same transaction. They are finalized as failed with a specific delivery detail and email.failed, so a restore can't re-arm them. FinalizeScheduledCancellationTx now takes the detail string.
  • Docs. Deferred items keep counting toward storage until purged. A deferred agent keeps its address and blocks domain delete. Operators can force-purge by backdating deleted_at.
  • Merge. origin/main (migrations 123/124) is merged into the branch. This was a normal merge, with no history rewrite.

Final review batch

  • Copy. The MCP delete_agent and delete_domain descriptions, and the deleteDomain operation description in the spec, now say that a deferred agent keeps its domain blocked (domain_has_agents) until purge_after. Both SDKs are regenerated.
  • New tests from the mutation review:
    • provider evidence alone defers, even with a NULL or old claim on a failed row;
    • a stale owner-mailbox proof, after an email change, counts as external;
    • with account trash disabled, a recent sender is erased immediately;
    • each trash window is validated and lowered on its own, and the error names the exact field;
    • Config.EraseDeferExemptDomainList() appends the shared domain (it now feeds cmd/e2a).
  • Retry lag. identity.EraseDeferRetryLag (14 days) is now exported. The send worker derives its longest hold deadline from the constant outboundsend.PolicyBudgetHoldHorizon (7 days) and never reads the runtime policy's budget_hold_max_days. A test in outboundsend fails if that horizon ever outgrows the lag minus a day.
  • Fail-to-defer path. A comment now explains why a failed deferral check defers the erase: erasure is irreversible and a deferral is not.

Remaining limit

The janitor's time-based purge is intentionally not deferred; config validation now keeps the defer window within both trash windows. Recipient lists on unsettled messages are compared verbatim, so display-name forms count as external (the conservative direction).

Ops note

The hosted privacy page (ops repo, legal/) needs a matching line: permanent deletion of an account, inbox or sent message that recently emailed external recipients is completed at the end of the trash window rather than immediately.

🤖 Generated with Claude Code

https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW

jiashuoz and others added 13 commits September 28, 2026 19:48
A permanent account erase (DELETE /v1/account?permanent=true, or the
restore interstitial's erase now) of an account that sent to an external
recipient within trash.recent_sender_erase_defer_days (default 14, 0
disables) now leaves the account in the trash and returns a trash
receipt with erase_deferred, purge_after and a message. Late provider
feedback keeps landing on the sending controls and aggregates until the
janitor purges at the end of the window. The paused-account erase_held
refusal is unchanged and checked first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Regenerated the TS and Python bases, documented the deferred erase on
both client wrappers, and added a shared contract scenario run by the
Go, TS and Python runners against a new seeded disposable account.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
The CLI prints that permanent erasure was deferred and when purge
happens. The settings page and restore interstitial tell the user the
account stays in the trash until purge_after and can still be restored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…enders

Permanently deleting the agents (or trashed messages) that emailed
external recipients removed the message_recipients evidence the account
erase check reads, so an account could purge its sends and then erase
itself immediately. The same window now applies one level down: a
permanent agent delete moves the agent to (or keeps it in) the agent
trash, and a permanent delete of an externally sent trashed message
leaves it in the message trash. Both receipts gain erase_deferred,
purge_after and message; window 0 disables everywhere and the paused
account erase_held refusal still comes first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Regenerated both SDK bases, documented the deferral on the TS and Python
agent/message delete wrappers, gave the Python agents.delete the
permanent flag the TS SDK already had, and added a re-runnable shared
contract scenario on a new seeded account.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
delete_agent describes the deferral; the dashboard trash views explain
a deferred Delete forever and keep the row restorable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Review follow-ups:
- exempt provider test domains (trash.erase_defer_exempt_domains,
  default simulator.amazonses.com), the shared agent domain by name,
  and system/internal account classes, so the prober and e2e cleanups
  no longer pile deferred rows into the trash;
- parse the recipient domain after the LAST @, so a quoted local part
  cannot pose as an internal domain;
- decide the account deferral inside purgeAccount's claim transaction,
  under the user row lock;
- count provider-accepted but unsettled sends and sends claimed inside
  the window, classified by their own recipient lists;
- refuse an explicit defer window longer than either trash window and
  lower the unset default to the shortest window;
- bound the lookup with index range scans (retry-lag lower bound plus a
  partial expression index for scheduled/held sends, migration 125);
- cancel a deferred agent's pending scheduled sends.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
The prober's message sweep counts erase_deferred purges apart instead of
booking them as purged, and the e2e harness reports deferred permanent
deletes separately without retrying them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
- MCP delete_agent/delete_domain and the deleteDomain operation explain
  that a deferred agent keeps its domain blocked until purge_after
  (SDKs regenerated).
- Tests for surviving mutants: provider evidence alone defers, a stale
  owner-mailbox proof counts as external, account trash disabled erases
  immediately, and each trash window is validated and lowered on its
  own with the exact field named.
- Config.EraseDeferExemptDomainList appends the shared domain (tested)
  and cmd/e2a uses it.
- EraseDeferRetryLag is exported and pinned against the send worker's
  longest hold horizon by a test in outboundsend.
- Explain why a failed deferral check defers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
@jiashuoz
jiashuoz merged commit 4daff5b into main Sep 29, 2026
47 of 48 checks passed
@jiashuoz
jiashuoz deleted the feat/defer-erase-recent-senders branch September 29, 2026 14:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant