diff --git a/api/openapi.yaml b/api/openapi.yaml index 28416a121..fb837e04f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1310,10 +1310,20 @@ components: email: description: Email address of the deleted agent. type: string + erase_deferred: + description: "True when permanent=true was requested but the agent emailed external recipients recently (within a deployment-configured window, 14 days by default), so it was moved to the trash instead of being deleted now: messages_deleted is 0, and the agent is purged at purge_after unless restored before then. Absent otherwise." + type: boolean + message: + description: Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred. + type: string messages_deleted: description: Number of messages permanently removed by the cascade; zero when the agent is moved to trash. format: int64 type: integer + purge_after: + description: When a deferred agent becomes eligible for permanent purge from the trash. Present only when erase_deferred is true. + format: date-time + type: string required: - deleted - email @@ -1407,9 +1417,19 @@ components: deleted: description: Always true — the message is deleted (moved to trash or purged). A failed delete is an error envelope, never deleted:false. type: boolean + erase_deferred: + description: True when permanent=true was requested but the message was sent to external recipients recently (within a deployment-configured window, 14 days by default), so it stays in the trash instead of being deleted now; it is purged at purge_after unless restored before then. Absent otherwise. + type: boolean id: description: ID of the deleted message. type: string + message: + description: Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred. + type: string + purge_after: + description: When a deferred message becomes eligible for permanent purge from the trash. Present only when erase_deferred is true. + format: date-time + type: string required: - deleted - id @@ -1462,6 +1482,12 @@ components: domains_deleted: format: int64 type: integer + erase_deferred: + description: "True when permanent=true was requested but the account emailed external recipients recently (within a deployment-configured window, 14 days by default), so it was moved to the trash instead of being erased now: mode is trash and the account is purged at purge_after. The account is already unusable; the owner can restore it by signing in before purge_after. Absent otherwise." + type: boolean + message: + description: Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred. + type: string messages_deleted: format: int64 type: integer @@ -5363,7 +5389,7 @@ openapi: 3.1.0 paths: /v1/account: delete: - description: "Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account's sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account's sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object." + description: "Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account's sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account's sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object." operationId: deleteAccount parameters: - description: Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible. @@ -5376,12 +5402,12 @@ paths: enum: - DELETE type: string - - description: Erase the account and all its data immediately instead of moving it to the trash. Irreversible. + - description: Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. explode: false in: query name: permanent schema: - description: Erase the account and all its data immediately instead of moving it to the trash. Irreversible. + description: Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. type: boolean responses: "200": @@ -5930,7 +5956,7 @@ paths: - agents /v1/agents/{email}: delete: - description: Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account's sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. + description: "Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account's sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash." operationId: deleteAgent parameters: - in: path @@ -5948,12 +5974,12 @@ paths: enum: - DELETE type: string - - description: Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. + - description: Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. explode: false in: query name: permanent schema: - description: Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. + description: Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. type: boolean responses: "200": @@ -6711,7 +6737,7 @@ paths: - messages /v1/agents/{email}/messages/{id}: delete: - description: Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash ("delete forever"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. + description: Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash ("delete forever"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. operationId: deleteMessage parameters: - description: The agent's full email address. @@ -8092,7 +8118,7 @@ paths: - domains /v1/domains/{domain}: delete: - description: "Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown})." + description: "Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown})." operationId: deleteDomain parameters: - in: path diff --git a/cli/src/__tests__/account.test.ts b/cli/src/__tests__/account.test.ts index d09da9a02..9d8f3eaa6 100644 --- a/cli/src/__tests__/account.test.ts +++ b/cli/src/__tests__/account.test.ts @@ -105,6 +105,25 @@ describe("account delete command", () => { expect(mockSaveConfig).toHaveBeenCalledWith({ api_key: "", key_scope: "" }); }); + it("--permanent on a recent external sender reports the deferred erase and the purge date", async () => { + mockAccountDelete.mockResolvedValue({ + ...TRASH_RECEIPT, + eraseDeferred: true, + message: "kept in the trash", + }); + const { accountDelete } = await import("../commands/account.js"); + await accountDelete({ yes: true, permanent: true }); + + expect(mockAccountDelete).toHaveBeenCalledWith({ permanent: true }); + const out = mockStdout.mock.calls.map((c: unknown[]) => c[0]).join(""); + expect(out).toContain("Permanent erasure deferred"); + expect(out).toContain("emailed external recipients recently"); + expect(out).toContain("Account moved to the trash."); + expect(out).toContain("2026-10-26T00:00:00.000Z"); + expect(out).not.toContain("permanently deleted"); + expect(mockSaveConfig).toHaveBeenCalledWith({ api_key: "", key_scope: "" }); + }); + it("--json prints the raw receipt and nothing else on stdout", async () => { mockAccountDelete.mockResolvedValue(TRASH_RECEIPT); const { accountDelete } = await import("../commands/account.js"); diff --git a/cli/src/commands/account.ts b/cli/src/commands/account.ts index 03c7245e3..2247842b9 100644 --- a/cli/src/commands/account.ts +++ b/cli/src/commands/account.ts @@ -76,6 +76,14 @@ export async function accountDelete(opts: AccountDeleteOptions): Promise { if (result.mode === "permanent") { process.stdout.write("Account permanently deleted. This cannot be undone.\n"); } else { + if (result.eraseDeferred) { + // --permanent was asked for, but the account emailed external + // recipients recently: the server kept it in the trash so late + // delivery feedback (complaints, bounces) still reaches it. + process.stdout.write( + "Permanent erasure deferred: this account emailed external recipients recently, so it is kept in the trash until the end of the trash window before it is erased.\n", + ); + } process.stdout.write("Account moved to the trash.\n"); if (result.purgeAfter) { process.stdout.write( diff --git a/cmd/e2a-contract-server/main.go b/cmd/e2a-contract-server/main.go index 575146a71..968d86c22 100644 --- a/cmd/e2a-contract-server/main.go +++ b/cmd/e2a-contract-server/main.go @@ -40,12 +40,18 @@ func main() { // reserved for the SDK suites' sending-access request lifecycle. // E2A_TEST_DISPOSABLE_{TRASH,ERASE}_API_KEY authenticate the two throwaway // accounts the account-deletion scenarios delete (once each per server). + // E2A_TEST_DISPOSABLE_DEFERRED_ERASE_API_KEY authenticates the throwaway + // account seeded with a recent external send, whose permanent erase is + // deferred to the trash (once per server). + // E2A_TEST_DEFERRED_PURGE_API_KEY authenticates the account whose agent + // and message permanent deletes are deferred (re-runnable scenario). // E2A_TEST_READONLY_API_KEY authenticates the abuse-paused (read-only) // account; its scenario trashes it at the end (once per server). envContent := fmt.Sprintf( - "E2A_TEST_BASE_URL=%s\nE2A_TEST_API_KEY=%s\nE2A_TEST_CAPPED_API_KEY=%s\nE2A_TEST_OVERCAP_API_KEY=%s\nE2A_TEST_RESTRICTED_API_KEY=%s\nE2A_TEST_DISPOSABLE_TRASH_API_KEY=%s\nE2A_TEST_DISPOSABLE_ERASE_API_KEY=%s\nE2A_TEST_READONLY_API_KEY=%s\nE2A_TEST_RESTRICTED_SDK_API_KEY=%s\n", + "E2A_TEST_BASE_URL=%s\nE2A_TEST_API_KEY=%s\nE2A_TEST_CAPPED_API_KEY=%s\nE2A_TEST_OVERCAP_API_KEY=%s\nE2A_TEST_RESTRICTED_API_KEY=%s\nE2A_TEST_DISPOSABLE_TRASH_API_KEY=%s\nE2A_TEST_DISPOSABLE_ERASE_API_KEY=%s\nE2A_TEST_READONLY_API_KEY=%s\nE2A_TEST_RESTRICTED_SDK_API_KEY=%s\nE2A_TEST_DISPOSABLE_DEFERRED_ERASE_API_KEY=%s\nE2A_TEST_DEFERRED_PURGE_API_KEY=%s\n", srv.BaseURL, srv.APIKey, srv.CappedAPIKey, srv.OverCapAPIKey, srv.RestrictedAPIKey, srv.DisposableTrashAPIKey, srv.DisposableEraseAPIKey, srv.ReadOnlyAPIKey, srv.RestrictedSDKAPIKey, + srv.DisposableDeferredEraseAPIKey, srv.DeferredPurgeAPIKey, ) if envFile != "" { if err := os.WriteFile(envFile, []byte(envContent), 0o600); err != nil { diff --git a/cmd/e2a-prober/serve.go b/cmd/e2a-prober/serve.go index 838fd71ea..e97e46600 100644 --- a/cmd/e2a-prober/serve.go +++ b/cmd/e2a-prober/serve.go @@ -68,8 +68,8 @@ func (p *prober) runOnce(ctx context.Context) run { // already fixed, so a cleanup failure cannot colour it. Without this the // probe agent accumulates every message the battery ever created (268k on // staging), until unrelated deletes on that instance start timing out. - if sw := probe.SweepMessages(); sw.Trashed > 0 || sw.Purged > 0 { - log.Printf("prober: message sweep trashed=%d purged=%d", sw.Trashed, sw.Purged) + if sw := probe.SweepMessages(); sw.Trashed > 0 || sw.Purged > 0 || sw.Deferred > 0 { + log.Printf("prober: message sweep trashed=%d purged=%d deferred=%d", sw.Trashed, sw.Purged, sw.Deferred) } return r } diff --git a/cmd/e2a/main.go b/cmd/e2a/main.go index 2b77d43ed..8920757c4 100644 --- a/cmd/e2a/main.go +++ b/cmd/e2a/main.go @@ -184,6 +184,13 @@ func main() { // Account trash window (trash.account_retention_days, default = // retention_days; 0 = DELETE /v1/account erases immediately). identity.AccountTrashRetention = time.Duration(cfg.Trash.AccountRetention()) * 24 * time.Hour + // Deferred erase for recent external senders + // (trash.recent_sender_erase_defer_days, default 14; 0 = never defer). + identity.RecentSenderEraseDefer = time.Duration(cfg.Trash.RecentSenderEraseDefer()) * 24 * time.Hour + // Recipient domains that never count as external: the configured test + // domains plus the shared agent domain by name (its domains row may be + // owned by the probe account, so the unowned-row rule alone misses it). + identity.EraseDeferExemptDomains = cfg.EraseDeferExemptDomainList() // Identity tombstones (hosted policy). The key is env-only; a malformed // value is fatal, an absent one leaves tombstone operations failing // closed (signup 503, purge skipped) while the flag is on. diff --git a/config.example.yaml b/config.example.yaml index 530a5ba39..085054a23 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -474,9 +474,32 @@ rate_limits: # -inspect-tombstone-keys shows the active version, the known versions and # how many live tombstones depend on each. # E2A_TRASH_IDENTITY_TOMBSTONES=true overrides the flag. +# +# recent_sender_erase_defer_days (default 14; 0 disables everywhere): an +# on-demand permanent delete of an account (DELETE /v1/account?permanent=true, +# or "erase now" on the restore screen), an agent (DELETE +# /v1/agents/{email}?permanent=true) or a trashed message (?permanent=true) +# that emailed an external recipient — anyone other than the account's own +# agents, its verified owner mailbox, or an address on the shared agent +# domain — within this many days is deferred: it goes to (or stays in) its +# normal trash (receipt erase_deferred: true, purge_after) and is purged when +# that trash window ends, so complaints and bounces that arrive days after a +# send still land on the account's sending controls. The account deferral has +# no effect when account trash is disabled. Unset means 14, lowered to the +# shortest trash window; an explicit value larger than retention_days or +# account_retention_days is refused at startup (the janitor purges at +# deleted_at + retention). System and internal account classes are never +# deferred. E2A_TRASH_RECENT_SENDER_ERASE_DEFER_DAYS overrides it. +# +# erase_defer_exempt_domains (default [simulator.amazonses.com]): recipient +# domains that never count as external — provider test mailboxes the prober +# and e2e harnesses send to. shared_domain is always exempt too. +# E2A_TRASH_ERASE_DEFER_EXEMPT_DOMAINS (comma-separated) overrides it. trash: retention_days: 30 # account_retention_days: 30 + # recent_sender_erase_defer_days: 14 + # erase_defer_exempt_domains: [simulator.amazonses.com] identity_tombstones: false # — Metrics (Prometheus) —————————————————————————————————————————————————————— diff --git a/docs/api.md b/docs/api.md index 08cd07175..bacaa60c7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -531,9 +531,22 @@ Workspace identity, plan limits, keys, suppressions, and data rights. default the account moves to the trash: it is unusable at once (keys, sessions and OAuth grants revoked, agents trashed, sending stopped, domains unverified), restorable by signing in to the dashboard until `purge_after`, - then purged permanently. `permanent=true` erases immediately. The receipt - carries `mode` (`trash` | `permanent`), `purge_after` (trash only) and - per-table counts. After any deletion the sign-in identity may be held for a + then purged permanently. `permanent=true` erases immediately — except for an + account that emailed an external recipient recently (within + `trash.recent_sender_erase_defer_days`, 14 days by default): that erase is + deferred, the account is moved to the trash like a default delete, and it is + purged at `purge_after`, so delivery feedback (spam complaints, bounces) + that arrives after a send still reaches it. The deferred case is a normal + 200 receipt with `mode: "trash"`, `erase_deferred: true`, `purge_after` and + a human-readable `message`; the owner can restore until `purge_after`. A + paused account still answers `409 erase_held`, which takes precedence. The + same rule applies to permanent agent and message deletes (below), so the + sent-mail evidence cannot be removed inside the window. A deferred account, + agent or message keeps counting toward `usage.storage_bytes` until it is + purged, and a deferred agent keeps its address and blocks deleting its + domain until then. The + receipt carries `mode` (`trash` | `permanent`), `purge_after` (trash only), + `erase_deferred` (deferred erase only) and per-table counts. After any deletion the sign-in identity may be held for a period and cannot immediately register a new account (`registration_refused`). - `GET /v1/account/export` — self-service account-data export supporting access requests: profile, agents, domains, API key metadata, messages, @@ -717,7 +730,11 @@ or on the deployment's shared domain (see `GET /v1/info`). retention window (30 days by default, deployment-configurable), after which it's purged permanently. Pass `?permanent=true` to skip the trash and delete irreversibly right away (accepts live and trashed agents; `409 erase_held` - while the account's sending is paused). + while the account's sending is paused). An agent that emailed an external + recipient within `trash.recent_sender_erase_defer_days` (14 days by default) + is not purged on demand: it is moved to the trash (or stays there) and the + 200 receipt adds `erase_deferred: true`, `purge_after` and a `message`; it + can be restored until `purge_after`. - `POST /v1/agents/{email}/restore` — bring a trashed agent back into service, messages and configuration intact. For drafts still held for review, `approval_expires_at` is shifted forward by the time the agent spent in trash @@ -828,6 +845,12 @@ declared stable. deployment-configurable). Ordinary message lists, conversations, reply targets, and forward targets hide trashed messages; use `GET …/messages?deleted=true` to enumerate the trash. +- `DELETE …/messages/{id}` — move a message to the trash (no confirmation). + `?permanent=true&confirm=DELETE` deletes an already-trashed message forever — + except a message sent to an external recipient within + `trash.recent_sender_erase_defer_days` (14 days by default), which stays in + the trash; the 200 receipt then adds `erase_deferred: true`, `purge_after` + and a `message`. - `POST …/messages/{id}/restore` — bring a trashed message back into the inbox. Restored message data is retained indefinitely unless deleted again. `409 not_in_trash` if the message isn't in the trash. diff --git a/docs/data-handling.md b/docs/data-handling.md index e42b0e0b0..649671469 100644 --- a/docs/data-handling.md +++ b/docs/data-handling.md @@ -43,6 +43,7 @@ The API exposes self-service export and deletion operations that support GDPR Ar - **Read-only accounts.** An account paused for abuse (`pause_class` `abuse`) is read-only: every write is refused with `403 account_read_only`, so its data — including messages that are evidence — cannot be changed or deleted by the account holder while the review lasts. Reads, the data export and moving the account to the trash stay available; the permanent erase is held (`erase_held`). See `docs/design/account-read-only.md`. - **`DELETE /v1/account?confirm=DELETE`** — moves the account to the **trash** (the default). The account becomes unusable at once — every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound refused), sending stops (the sending gate refuses a trashed account; an existing abuse pause and its class are left untouched), and every custom domain loses its verification and its SES identity is torn down — while its data stays in Postgres, restorable by signing in to the dashboard, until the janitor purges it after `trash.account_retention_days` (default: `trash.retention_days`, 30 days). A restore brings the account and its agents back; API keys stay revoked and domains must be re-verified. The receipt is `mode: "trash"` with `purge_after`; `messages_deleted` is 0 and the other counts describe rows trashed, revoked or unverified. - **`DELETE /v1/account?confirm=DELETE&permanent=true`** (and "erase now" in the restore interstitial) — erases immediately, except while the account's sending is paused (any pause class), which answers `409 erase_held` (the trash path stays available, and permanent agent deletion is held the same way): the account's agents are drained in bounded chunks and then the account row and every related row are deleted (cascade through the account-owned suppression/token tables and the contacts/engagements/import-batches/templates tables, plus explicit deletion of `usage_events`, whose FK is `ON DELETE SET NULL`). The janitor's purge after the trash window runs exactly the same path. The receipt is `mode: "permanent"` with `user_deleted: true` and selected per-table counts (including `agent_suppressions_deleted` and `agent_unsubscribe_tokens_deleted`); it does not separately count contacts, engagements, import batches, templates, webhooks, or webhook event/delivery rows, even though those rows are deleted. A deployment that sets `trash.account_retention_days: 0` makes every account deletion permanent. +- **Deferred erase for recent senders.** A permanent erase of an account that emailed an external recipient (anyone other than its own agents, its verified owner mailbox, or an address on the deployment's shared agent domain) within `trash.recent_sender_erase_defer_days` (default 14; `0` disables) is not performed at once: the account is moved to the trash exactly as by the default delete and purged by the janitor at the end of the account trash window. The receipt is `mode: "trash"` with `erase_deferred: true` and `purge_after`. This keeps the account's sending controls and bounce/complaint aggregates alive while late provider feedback arrives. The account is restorable until `purge_after` like any trashed account; the paused-account `409 erase_held` still takes precedence. No effect when account trash is disabled. The same rule applies one level down: `DELETE /v1/agents/{email}?permanent=true` for an agent that sent externally inside the window moves it to (or keeps it in) the agent trash, and a permanent delete of a trashed message that was sent externally inside the window leaves it in the message trash — both receipts carry `erase_deferred: true` and `purge_after`, and the janitor purges them at the end of the ordinary trash window (`trash.retention_days`). So the sent-mail records the account check reads cannot be removed on demand inside the window. A deferred agent's pending scheduled sends are canceled. System and internal account classes, the shared agent domain and the provider test domains in `trash.erase_defer_exempt_domains` (default `simulator.amazonses.com`) are exempt. Deferred items keep counting toward storage, and a deferred agent keeps its address and blocks domain deletion, until purged; operators can force the purge by backdating `deleted_at`. - **What outlives a purge.** The sending ledger (provider operations, budget counters, feedback provenance, control audit) keeps its own retention, as before. When the deployment enables `trash.identity_tombstones` (hosted policy; off by default) every purge also writes, before any row is deleted: **identity tombstones** — HMAC-SHA256 digests under a dedicated key (`E2A_TOMBSTONE_KEY`) of the account's login subject(s) and normalized email, held for the longer of the trash window and the rest of the current quota month (at least 30 days), plus — with a 2-year hold when the account was ever paused for abuse (the current pause class or any `abuse` event in the control audit) — its verified domains, every agent address it held on the shared domain, and its email domain unless that is a public webmail provider — so that identity cannot immediately register again (`registration_refused`; domains answer `domain_taken`); and a **deleted-account summary** (`deleted_account_summaries`) — counts, first/last send, a top-20 recipient-*domain* histogram, per-day send counts for the last 30 days, keyed digests of the top-20 subjects, of verified domains and of every identifier an abuse hold would close (so an operator can escalate a purged account to an abuse hold later with `-escalate-deleted-account-to-abuse`), and the pause state/class with an optional operator evidence reference (never the free-text pause reason); send counts are taken from `usage_events` as well as messages, so permanently deleting messages first does not erase them — kept for the same hold. Neither holds message bodies, recipient addresses, or the owner email in the clear; the summary is readable only through the operator command `-inspect-deleted-account`, never over HTTP or MCP. The janitor deletes both at `expires_at`. Both are scoped to the authenticated user — there's no path to target someone else's data. diff --git a/docs/design/account-soft-deletion.md b/docs/design/account-soft-deletion.md new file mode 100644 index 000000000..5846428ad --- /dev/null +++ b/docs/design/account-soft-deletion.md @@ -0,0 +1,176 @@ +# Account soft deletion + +Status: shipped in v1.11.0 (trash, restore, purge, identity tombstones). + +The normative behaviour of account deletion — trash by default, restore by +signing in, the janitor purge, identity tombstones and the deleted-account +summary — is documented in [data-handling.md](../data-handling.md) (the +`DELETE /v1/account` entries) and, for paused accounts, in +[account-read-only.md](account-read-only.md). Code comments cite this file +for the design rationale; this file currently carries the sections added +after the initial release. + +## Deferred erase for recent senders + +### Problem + +`DELETE /v1/account?confirm=DELETE` moves an account to the trash for the +account trash window (30 days by default). While it is trashed, the account's +`account_sending_controls` row and its bounce/complaint aggregates +(`account_sending_outcomes_daily`) survive and keep updating from late +provider feedback. `permanent=true` (and "erase now" on the restore +interstitial) skips that window and purges at once. + +Provider feedback is late: a complaint can arrive hours to days after the +send. An account that sends a burst and then erases itself immediately takes +its control row and aggregates with it before that feedback lands, so the +feedback counts against nothing — the abuse detector never sees the burst's +complaint rate, and an operator loses the evidence. Before this change the +only thing that refused an immediate erase was an active sending pause +(`409 erase_held`). + +### Rule + +A permanent erase of an account that **sent to an external recipient within +the last `trash.recent_sender_erase_defer_days` days** (default 14; `0` +disables) is deferred: the account is moved to the trash — the same trash the +default delete performs — and the janitor purges it at the normal end of the +account trash window. + +- **External recipient** uses the external-sending-access notion: any + recipient other than an agent of the same account, the account's verified + owner mailbox (valid proof for its current email), or an address on one of + the deployment's shared agent domains. Shared domains match by name (the + configured `shared_domain`, even when its `domains` row has been adopted by + an account such as the probe account) or as verified `domains` rows with no + owner. Provider test domains in `trash.erase_defer_exempt_domains` (default + `simulator.amazonses.com`, which the standing prober and the e2e harness + mail every run) are exempt as well. The domain is the part after the LAST + `@`, so a quoted local part containing `@` cannot pose as an internal + domain. +- **Exempt accounts.** System and internal account classes (synthetic probe + traffic, internal dogfooding) are never deferred — the same classes the + sending budgets exempt. +- **Source of truth** is `message_recipients`: a row is written exactly when + the provider (or relay) accepted the message, one per normalized envelope + recipient (to/cc/bcc). It therefore records real sends only — never drafts, + review holds, refusals or queued mail. A message the provider accepted + that has not settled yet (a provider message id with no recipient rows, or + a send claimed inside the window) is classified by its own to/cc/bcc lists. + The send instant is the latest of `created_at`, `provider_accepted_at`, + `reviewed_at`, `scheduled_at` and `send_claimed_at`, so a scheduled or + review-held message submitted recently counts even if it was created long + ago. `usage_events` was rejected as the source because it + carries no recipient addresses (it cannot tell an external send from an + agent-to-agent one) and is not written for non-standard account classes; + the deletion-resistant `sending_feedback_*` ledger was rejected because it + stores recipients only as keyed digests. +- **Cost.** The lookup is two index range scans per agent: ordinary sends + through `idx_messages_agent_created` bounded below by the window start minus + a 14-day retry lag (covers the 7-day budget hold), and scheduled/held sends + through the partial expression index `idx_messages_agent_delayed_outbound` + on `(agent_id, GREATEST(scheduled_at, reviewed_at))` (migration 125), with + an `EXISTS` that stops at the first hit. On an agent seeded with 300,000 + old outbound messages (5% scheduled) and no recent send, `EXPLAIN ANALYZE` + shows both arms as index scans touching 3 and 2 buffers; the whole query + took 0.6 ms. +- **Ordering.** The account decision is taken inside the purge claim + transaction, under the user row lock and next to the pause re-check, after + the trash committed — a trashed account cannot send. If the check itself + fails, the erase is deferred (the account is already in + the trash and will be purged at the end of the window): the failure mode + keeps evidence rather than destroying it. +- **Precedence.** The paused-account refusal (`409 erase_held`) is checked + first and is unchanged. +- **Scope.** The same rule, scoped to one agent or one message, applies to + permanent agent and message deletes (see "Agent and message level"), so + the evidence cannot be removed on demand inside the window. +- **No trash, no deferral.** With `trash.account_retention_days: 0` there is + no window to hold the account in, so the rule does not apply. + +### Contract + +The deferred erase is a success, not an error — the account *is* deleted from +the owner's point of view (keys, sessions and grants revoked, agents trashed, +sending stopped). The receipt is additive over the existing one: + +```json +{ + "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 +} +``` + +The counts are the trash counts when this request trashed the account, and +zero when the account was already in the trash (the restore interstitial's +"erase now"). On the interstitial the restricted session is kept, so the +restore offer keeps working. Billing is notified with the account-state +`trash` notice, exactly as for a default delete; the purge notice follows +from the janitor. + +### Agent and message level + +The account check reads `message_recipients`, which cascades from +`messages`. If agents or messages could be purged on demand, an account could +remove its own evidence first — permanently delete every agent that sent +externally (or each sent message), then erase itself — and the account erase +would no longer be deferred. So the same rule applies to every on-demand path +that purges sent mail: + +| Path | Deferred to | Receipt | +|---|---|---| +| `DELETE /v1/account?permanent=true`, restore interstitial "erase now" | account trash (`trash.account_retention_days`) | `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 check is the same predicate scoped to one agent, the message + check scoped to one message. The agent decision runs under the agent row + lock after the send-lease check, and the deferral trashes the agent in the + same transaction and cancels its pending scheduled sends (finalized as + failed with a submission-cancelled reason and an `email.failed` event), so + a later restore cannot re-arm mail the owner asked to delete permanently. + The message decision runs under the message row lock. +- The paused-account refusal (`409 erase_held`) still comes first on the + agent path. A read-only (abuse-paused) account cannot reach any of these + writes at all. +- `trash.recent_sender_erase_defer_days: 0` disables all three. +- Every other purge is time-based: the janitor purges trashed accounts, + agents and messages only after their trash windows, which are longer than + or equal to the look-back window by default. Domain deletion is refused + while any live or trashed agent remains on the domain, so it cannot purge + sent mail either. The MCP surface exposes no permanent message delete, and + its `delete_agent` goes through the same `/v1` operation. + +### What stays the same + +- Restore and the restricted restore session work exactly as for any trashed + account, agent or message. +- Operator force-purge (runbook: backdate `deleted_at` past the window and + let the janitor run) still purges a deferred account, agent or message. + +### Costs of a deferral for the owner + +- A deferred account, agent or message keeps counting toward the account's + storage (`usage.storage_bytes`) until it is purged. +- A deferred agent keeps its address reserved (`address_in_trash`) and, + like any trashed agent, blocks deleting its domain (`domain_has_agents`) + until it is purged. +- An operator can force the purge per the runbook: backdate `deleted_at` past + the trash window and let the janitor run. + +### Remaining limits + +- The check counts mail the provider or relay accepted (settled recipient + rows, or an accepted-but-unsettled message's own recipient lists); mail that + never left is not evidence. An unsettled message's recipient lists may carry + display-name forms, which are compared verbatim and so count as external — + the conservative direction. +- The janitor's time-based purge is intentionally not deferred. The server + therefore refuses an explicit `recent_sender_erase_defer_days` longer than + either trash window, and lowers the unset default to the shortest window. diff --git a/internal/agent/billing_hook_test.go b/internal/agent/billing_hook_test.go index eb7991d7f..255757edd 100644 --- a/internal/agent/billing_hook_test.go +++ b/internal/agent/billing_hook_test.go @@ -394,3 +394,57 @@ func TestPlainDeleteOfAPausedAccountIsRefusedWhenTrashIsDisabled(t *testing.T) { t.Fatal("billing was notified for a refused delete") } } + +// TestDeferredPermanentDeleteNotifiesTrashNotPurge: a permanent delete of an +// account that emailed an external recipient recently is deferred to the +// trash, so billing hears mode "trash" at the account-state path — never the +// cancel hook — and the account row survives. +func TestDeferredPermanentDeleteNotifiesTrashNotPurge(t *testing.T) { + api, store, rec := setupCoreAPIWithBillingHook(t, "secret", http.StatusNoContent) + ctx := context.Background() + user, err := store.CreateOrGetUser(ctx, "deferred@example.test", "Test", "google-deferred@example.test") + if err != nil { + t.Fatal(err) + } + if _, err := store.ClaimOrCreateDomain(ctx, "deferred.example.test", user.ID); err != nil { + t.Fatal(err) + } + ag, err := store.CreateAgent(ctx, "deferred-bot@deferred.example.test", "deferred.example.test", "Bot", "", "cloud", user.ID) + if err != nil { + t.Fatal(err) + } + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, ` + INSERT INTO messages (id, agent_id, direction, sender, recipient, subject, delivery_status, provider_accepted_at) + VALUES ('msg_billing_defer', $1, 'outbound', $1, 'someone@example.com', 'hi', 'sent', now())`, ag.ID); err != nil { + return err + } + _, err := tx.Exec(ctx, ` + INSERT INTO message_recipients (id, message_id, address, kind, status) + VALUES ('rcpt_billing_defer', 'msg_billing_defer', 'someone@example.com', 'to', 'sent')`) + return err + }); err != nil { + t.Fatal(err) + } + res, err := api.DeleteUserDataCore(ctx, user, true) + if err != nil { + t.Fatalf("DeleteUserDataCore(permanent): %v", err) + } + if res.Mode != identity.AccountDeleteModeTrash || !res.EraseDeferred || res.UserDeleted { + t.Fatalf("receipt = %+v, want a deferred trash receipt", res) + } + rec.mu.Lock() + defer rec.mu.Unlock() + var hookBody struct { + Mode string `json:"mode"` + } + if err := json.Unmarshal(rec.body, &hookBody); err != nil { + t.Fatalf("hook body not JSON: %v", err) + } + if hookBody.Mode != "trash" || rec.path != "/account-state" { + t.Fatalf("deferred erase notice = mode %q at %q, want mode trash at /account-state", hookBody.Mode, rec.path) + } + if u, err := store.GetUserByIDAnyState(ctx, user.ID); err != nil || u.DeletedAt == nil { + t.Fatalf("account should remain trashed: %+v err=%v", u, err) + } +} diff --git a/internal/agent/outbound_async.go b/internal/agent/outbound_async.go index 55b46411d..5a8f99e10 100644 --- a/internal/agent/outbound_async.go +++ b/internal/agent/outbound_async.go @@ -304,7 +304,9 @@ func (a *outboundSendStore) meterSentTx(ctx context.Context, tx pgx.Tx, info *id } // FinalizeScheduledCancellationTx performs the canonical guarded terminal -// transition for a scheduled message restored after its cutoff. Authoritative +// transition for a canceled scheduled message (restored after its cutoff, or +// its agent's permanent delete deferred to the trash); detail is recorded as +// the delivery detail. Authoritative // provider-accept evidence wins and is settled as sent; otherwise the // cancellation becomes failed with a deterministic email.failed event. func (a *outboundSendStore) FinalizeScheduledCancellationTx( @@ -313,6 +315,7 @@ func (a *outboundSendStore) FinalizeScheduledCancellationTx( messageID string, jobID int64, occurredAt time.Time, + detail string, ) error { info, providerID, err := a.store.ResolveOutboundProviderAcceptedTx(ctx, tx, messageID) if err != nil { @@ -322,7 +325,6 @@ func (a *outboundSendStore) FinalizeScheduledCancellationTx( return a.finalizeSentTx(ctx, tx, info, jobID, 0, info.ProviderAcceptedAt, providerID) } - const detail = "scheduled send canceled because it was restored after scheduled_at" finfo, err := a.store.MarkOutboundFailedTx(ctx, tx, messageID, detail, delivery.FailureSourceLocal) if err != nil { return err diff --git a/internal/agent/outbound_async_test.go b/internal/agent/outbound_async_test.go index 574fd3409..690d56919 100644 --- a/internal/agent/outbound_async_test.go +++ b/internal/agent/outbound_async_test.go @@ -1186,6 +1186,10 @@ func TestSendWorker_RetryBackoffReleasesClaimForPurge(t *testing.T) { } func TestPurgeMessage_AllowsStaleOrphanedSendClaim(t *testing.T) { + // A claimed send to an external recipient inside the window is evidence + // for the deferred-erase rule (identity.RecentSenderEraseDefer); this test + // pins the stale-lease path only, so it runs with the deferral disabled. + disableEraseDeferral(t) api, store, _, enq := setupAsyncAPI(t) ctx := context.Background() user, ag := selfAgent(t, store, "asyncstalepurge") @@ -1218,6 +1222,10 @@ func TestPurgeMessage_AllowsStaleOrphanedSendClaim(t *testing.T) { } func TestDeleteAgent_AllowsStaleOrphanedSendClaim(t *testing.T) { + // A claimed send to an external recipient inside the window is evidence + // for the deferred-erase rule (identity.RecentSenderEraseDefer); this test + // pins the stale-lease path only, so it runs with the deferral disabled. + disableEraseDeferral(t) api, store, _, enq := setupAsyncAPI(t) ctx := context.Background() user, ag := selfAgent(t, store, "asyncstaleagent") @@ -1392,3 +1400,12 @@ func TestSendWorker_ClaimCarriesSubmissionGatesIntoTerminalLatency(t *testing.T) }) } } + +// disableEraseDeferral turns off the recent-external-sender purge deferral for +// one test (package tests do not run in parallel). +func disableEraseDeferral(t *testing.T) { + t.Helper() + prev := identity.RecentSenderEraseDefer + identity.RecentSenderEraseDefer = 0 + t.Cleanup(func() { identity.RecentSenderEraseDefer = prev }) +} diff --git a/internal/agent/user_data_rights_api.go b/internal/agent/user_data_rights_api.go index 541be0a03..6d7047b81 100644 --- a/internal/agent/user_data_rights_api.go +++ b/internal/agent/user_data_rights_api.go @@ -61,7 +61,10 @@ func (a *API) ExportUserDataCore(ctx context.Context, userID string) (*identity. // signing in until purge_after, after which the janitor purges it. With // permanent=true — or on a deployment that disabled account trash // (trash.account_retention_days: 0) — it erases immediately -// (identity.EraseAccount), tombstones first. +// (identity.EraseAccount), tombstones first — unless the account emailed +// external recipients within trash.recent_sender_erase_defer_days, in which +// case the erase is deferred and the account stays in the trash (receipt mode +// "trash" with erase_deferred). // // The billing hook is notified only after the database change commits (a // send_in_progress refusal must not touch billing for an account that still @@ -93,7 +96,13 @@ func (a *API) DeleteUserDataCore(ctx context.Context, user *identity.User, perma return nil, err } } - if erase { + if erase && res.EraseDeferred { + // The account recently emailed external recipients: the erase was + // deferred and the account is in the trash (identity.EraseAccount). + // Billing hears "trash", exactly as for a default delete; the purge + // notice follows from the janitor at the end of the window. + a.notifyBilling(ctx, user.ID, billingModeTrash) + } else if erase { res.OAuthAuthCodesDeleted = oauthCounts.AuthCodes res.OAuthAccessTokensDeleted = oauthCounts.AccessTokens res.OAuthRefreshTokensDeleted = oauthCounts.RefreshTokens diff --git a/internal/apiserver/apiserver.go b/internal/apiserver/apiserver.go index d59a67a1a..1f13f62a4 100644 --- a/internal/apiserver/apiserver.go +++ b/internal/apiserver/apiserver.go @@ -193,13 +193,13 @@ func BuildDeps(p Params) httpapi.Deps { // Trash semantics (docs/design/trash-soft-delete.md): the default // delete is soft; the hard delete sits behind ?permanent=true. DeleteAgent: p.Store.SoftDeleteAgent, - PermanentDeleteAgent: p.Store.DeleteAgentIncarnation, + PermanentDeleteAgent: p.Store.PermanentDeleteAgentIncarnation, RestoreAgent: p.Store.RestoreAgent, GetAgentAnyState: p.Store.GetAgentByIDAnyState, ListDeletedAgents: p.Store.ListDeletedAgentsByUser, DeleteMessage: p.Store.SoftDeleteMessage, RestoreMessage: p.Store.RestoreMessage, - PurgeMessage: p.Store.PurgeMessage, + PurgeMessage: p.Store.PurgeMessageOrDefer, ListDomains: p.Store.ListDomainsByUser, SendingRampSnapshot: rampSnapshot, diff --git a/internal/config/config.go b/internal/config/config.go index b197cfdea..d3a6da3b3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -672,6 +672,33 @@ type TrashConfig struct { // account soft deletion existed. Override with // E2A_TRASH_ACCOUNT_RETENTION_DAYS. AccountRetentionDays *int `yaml:"account_retention_days"` + // RecentSenderEraseDeferDays defers an on-demand permanent erase of + // anything that emailed an external recipient within this many days: + // the account (DELETE /v1/account?permanent=true, or the restore + // interstitial's "erase now"), an agent (DELETE + // /v1/agents/{email}?permanent=true) or a message (permanent delete of a + // trashed message). Each goes to (or stays in) its normal trash and is + // purged at the end of that trash window, so late provider feedback + // (complaints, bounces) still lands on the account's sending controls and + // aggregates, and the evidence the account check reads cannot be removed + // inside the window. Default 14; 0 disables it everywhere. The account + // deferral has no effect when account trash is disabled + // (account_retention_days: 0). + // Override with E2A_TRASH_RECENT_SENDER_ERASE_DEFER_DAYS. + // + // Unset (nil) means 14, lowered to the shortest trash window when a + // deployment's trash is shorter, so existing configs keep starting. An + // explicit value longer than retention_days or account_retention_days is + // rejected: the janitor purges at deleted_at + retention, which would + // purge deferred evidence before the window ends. + RecentSenderEraseDeferDays *int `yaml:"recent_sender_erase_defer_days"` + // EraseDeferExemptDomains are recipient domains that never count as + // external for RecentSenderEraseDeferDays — provider test mailboxes the + // prober and e2e harnesses send to. The deployment's shared_domain is + // always exempt in addition. Default [simulator.amazonses.com]; an + // explicit empty list exempts only the shared domain. Override with + // E2A_TRASH_ERASE_DEFER_EXEMPT_DOMAINS (comma-separated). + EraseDeferExemptDomains []string `yaml:"erase_defer_exempt_domains"` // IdentityTombstones enables identity tombstones: every account purge // holds the account's login subject(s) and email (and, for an // abuse-paused account, its verified domains) as keyed digests so the @@ -682,6 +709,42 @@ type TrashConfig struct { IdentityTombstones bool `yaml:"identity_tombstones"` } +// DefaultRecentSenderEraseDeferDays is the deferral window when +// recent_sender_erase_defer_days is unset. +const DefaultRecentSenderEraseDeferDays = 14 + +// RecentSenderEraseDefer returns the effective deferral window in days +// (0 = disabled): the explicit value, or the default lowered to the +// shortest trash window. +func (t TrashConfig) RecentSenderEraseDefer() int { + if t.RecentSenderEraseDeferDays != nil { + return *t.RecentSenderEraseDeferDays + } + d := DefaultRecentSenderEraseDeferDays + if t.RetentionDays < d { + d = t.RetentionDays + } + if ar := t.AccountRetention(); ar > 0 && ar < d { + d = ar + } + return d +} + +// EraseDeferExemptDomainList is the full exempt-domain list the server uses +// for the recent-sender purge deferral: the configured test domains plus the +// deployment's shared agent domain by name (its domains row may be owned by +// the probe account, so the unowned-row rule alone would miss it). Entries are +// trimmed and lower-cased; empty ones are dropped. +func (c *Config) EraseDeferExemptDomainList() []string { + out := make([]string, 0, len(c.Trash.EraseDeferExemptDomains)+1) + for _, d := range append(append([]string(nil), c.Trash.EraseDeferExemptDomains...), c.SharedDomain) { + if d = strings.ToLower(strings.TrimSpace(d)); d != "" { + out = append(out, d) + } + } + return out +} + // AccountRetention returns the effective account trash window in days // (0 = account trash disabled). func (t TrashConfig) AccountRetention() int { @@ -758,7 +821,10 @@ func Load(path string) (*Config, error) { }, RateLimits: RateLimitsConfig{PollPerMinute: 240}, Metrics: MetricsConfig{ListenAddr: "127.0.0.1:9091"}, - Trash: TrashConfig{RetentionDays: 30}, + Trash: TrashConfig{ + RetentionDays: 30, + EraseDeferExemptDomains: []string{"simulator.amazonses.com"}, + }, // An absent sender_identity block keeps the fixture expiry default; // an explicit `fixture_ttl: 0` survives unmarshal and disables it. // The reclaim defaults are the SAFE ones: disarmed, no zones (which @@ -993,6 +1059,21 @@ func Load(path string) (*Config, error) { } cfg.Trash.AccountRetentionDays = &d } + if v := os.Getenv("E2A_TRASH_RECENT_SENDER_ERASE_DEFER_DAYS"); v != "" { + d, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return nil, fmt.Errorf("config: E2A_TRASH_RECENT_SENDER_ERASE_DEFER_DAYS must be a whole number of days, got %q", v) + } + cfg.Trash.RecentSenderEraseDeferDays = &d + } + if v, ok := os.LookupEnv("E2A_TRASH_ERASE_DEFER_EXEMPT_DOMAINS"); ok { + cfg.Trash.EraseDeferExemptDomains = nil + for _, d := range strings.Split(v, ",") { + if d = strings.TrimSpace(d); d != "" { + cfg.Trash.EraseDeferExemptDomains = append(cfg.Trash.EraseDeferExemptDomains, d) + } + } + } if v := os.Getenv("E2A_TRASH_IDENTITY_TOMBSTONES"); v != "" { b, err := strconv.ParseBool(strings.TrimSpace(v)) if err != nil { @@ -1064,6 +1145,25 @@ func (c *Config) Validate() error { if c.Trash.AccountRetentionDays != nil && *c.Trash.AccountRetentionDays < 0 { return fmt.Errorf("config: trash.account_retention_days must be 0 (erase immediately) or a positive number of days (got %d)", *c.Trash.AccountRetentionDays) } + if d := c.Trash.RecentSenderEraseDeferDays; d != nil { + if *d < 0 { + return fmt.Errorf("config: trash.recent_sender_erase_defer_days must be 0 (never defer) or a positive number of days (got %d)", *d) + } + // The janitor purges trashed items at deleted_at + retention, so a + // defer window longer than a trash window would let deferred + // evidence be purged before the window ends. + if *d > c.Trash.RetentionDays { + return fmt.Errorf("config: trash.recent_sender_erase_defer_days (%d) must not exceed trash.retention_days (%d): deferred agents and messages are purged when their trash window ends", *d, c.Trash.RetentionDays) + } + if ar := c.Trash.AccountRetention(); ar > 0 && *d > ar { + return fmt.Errorf("config: trash.recent_sender_erase_defer_days (%d) must not exceed trash.account_retention_days (%d): deferred accounts are purged when the account trash window ends", *d, ar) + } + } + for _, dom := range c.Trash.EraseDeferExemptDomains { + if dom = strings.TrimSpace(dom); dom == "" || strings.Contains(dom, "@") { + return fmt.Errorf("config: trash.erase_defer_exempt_domains entries must be bare domain names (got %q)", dom) + } + } if c.Trash.RetentionDays < 1 { return fmt.Errorf("config: trash.retention_days must be at least 1 (got %d) — the stable API promises soft-deleted resources stay restorable", c.Trash.RetentionDays) } diff --git a/internal/config/trash_account_test.go b/internal/config/trash_account_test.go index 82fb0c7bd..2f51778f0 100644 --- a/internal/config/trash_account_test.go +++ b/internal/config/trash_account_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -59,3 +60,145 @@ func TestAccountTrashEnvOverridesFailClosedWhenMalformed(t *testing.T) { t.Fatalf("env overrides = %d/%v", cfg.Trash.AccountRetention(), cfg.Trash.IdentityTombstones) } } + +func TestRecentSenderEraseDeferDefaultsTo14AndZeroDisables(t *testing.T) { + cfg, err := Load(writeTrashConfig(t, "trash:\n retention_days: 30\n")) + if err != nil { + t.Fatal(err) + } + if cfg.Trash.RecentSenderEraseDefer() != 14 { + t.Fatalf("recent_sender_erase_defer_days default = %d, want 14", cfg.Trash.RecentSenderEraseDefer()) + } + cfg, err = Load(writeTrashConfig(t, "trash:\n retention_days: 30\n recent_sender_erase_defer_days: 0\n")) + if err != nil { + t.Fatal(err) + } + if cfg.Trash.RecentSenderEraseDefer() != 0 { + t.Fatalf("explicit 0 = %d, want 0 (disabled)", cfg.Trash.RecentSenderEraseDefer()) + } + if _, err := Load(writeTrashConfig(t, "trash:\n retention_days: 30\n recent_sender_erase_defer_days: -1\n")); err == nil { + t.Fatal("a negative recent_sender_erase_defer_days was accepted") + } +} + +func TestRecentSenderEraseDeferEnvOverride(t *testing.T) { + p := writeTrashConfig(t, "") + t.Setenv("E2A_TRASH_RECENT_SENDER_ERASE_DEFER_DAYS", "two weeks") + if _, err := Load(p); err == nil { + t.Fatal("a malformed E2A_TRASH_RECENT_SENDER_ERASE_DEFER_DAYS was ignored") + } + t.Setenv("E2A_TRASH_RECENT_SENDER_ERASE_DEFER_DAYS", "3") + cfg, err := Load(p) + if err != nil { + t.Fatal(err) + } + if cfg.Trash.RecentSenderEraseDefer() != 3 { + t.Fatalf("env override = %d, want 3", cfg.Trash.RecentSenderEraseDefer()) + } +} + +func TestRecentSenderEraseDeferMustFitTheTrashWindows(t *testing.T) { + if _, err := Load(writeTrashConfig(t, "trash:\n retention_days: 10\n recent_sender_erase_defer_days: 11\n")); err == nil || + !strings.Contains(err.Error(), "retention_days") { + t.Fatalf("defer window longer than retention_days accepted: %v", err) + } + if _, err := Load(writeTrashConfig(t, "trash:\n retention_days: 30\n account_retention_days: 7\n recent_sender_erase_defer_days: 8\n")); err == nil || + !strings.Contains(err.Error(), "account_retention_days") { + t.Fatalf("defer window longer than account_retention_days accepted: %v", err) + } + // account trash disabled: only the agent/message trash window binds. + if _, err := Load(writeTrashConfig(t, "trash:\n retention_days: 30\n account_retention_days: 0\n recent_sender_erase_defer_days: 20\n")); err != nil { + t.Fatalf("defer window with account trash disabled rejected: %v", err) + } + // Unset: the default is lowered to the shortest window, never rejected. + cfg, err := Load(writeTrashConfig(t, "trash:\n retention_days: 7\n")) + if err != nil { + t.Fatalf("unset defer window with a 7-day trash rejected: %v", err) + } + if got := cfg.Trash.RecentSenderEraseDefer(); got != 7 { + t.Fatalf("effective default = %d, want 7 (the shorter trash window)", got) + } +} + +func TestEraseDeferExemptDomains(t *testing.T) { + cfg, err := Load(writeTrashConfig(t, "")) + if err != nil { + t.Fatal(err) + } + if len(cfg.Trash.EraseDeferExemptDomains) != 1 || cfg.Trash.EraseDeferExemptDomains[0] != "simulator.amazonses.com" { + t.Fatalf("default exempt domains = %v, want [simulator.amazonses.com]", cfg.Trash.EraseDeferExemptDomains) + } + cfg, err = Load(writeTrashConfig(t, "trash:\n retention_days: 30\n erase_defer_exempt_domains: [sim.example.test, mailbox.example.test]\n")) + if err != nil { + t.Fatal(err) + } + if len(cfg.Trash.EraseDeferExemptDomains) != 2 { + t.Fatalf("configured exempt domains = %v", cfg.Trash.EraseDeferExemptDomains) + } + if _, err := Load(writeTrashConfig(t, "trash:\n retention_days: 30\n erase_defer_exempt_domains: [\"x@example.test\"]\n")); err == nil { + t.Fatal("an address in erase_defer_exempt_domains was accepted") + } + t.Setenv("E2A_TRASH_ERASE_DEFER_EXEMPT_DOMAINS", " a.example.test , b.example.test ") + cfg, err = Load(writeTrashConfig(t, "")) + if err != nil { + t.Fatal(err) + } + if len(cfg.Trash.EraseDeferExemptDomains) != 2 || cfg.Trash.EraseDeferExemptDomains[1] != "b.example.test" { + t.Fatalf("env exempt domains = %v", cfg.Trash.EraseDeferExemptDomains) + } +} + +// C1/C1b/C2: the two trash windows are validated and lowered independently, +// and each error names its exact field. +func TestRecentSenderEraseDeferAgainstEachTrashWindow(t *testing.T) { + // account_retention_days larger than retention_days: retention_days binds. + _, err := Load(writeTrashConfig(t, "trash:\n retention_days: 10\n account_retention_days: 60\n recent_sender_erase_defer_days: 12\n")) + if err == nil || !strings.Contains(err.Error(), "must not exceed trash.retention_days (10)") || + strings.Contains(err.Error(), "account_retention_days") { + t.Fatalf("err = %v, want one naming trash.retention_days only", err) + } + // account_retention_days shorter than retention_days: it binds. + _, err = Load(writeTrashConfig(t, "trash:\n retention_days: 30\n account_retention_days: 9\n recent_sender_erase_defer_days: 10\n")) + if err == nil || !strings.Contains(err.Error(), "must not exceed trash.account_retention_days (9)") { + t.Fatalf("err = %v, want one naming trash.account_retention_days", err) + } + // Unset window, account trash shorter: lowered to account_retention_days. + cfg, err := Load(writeTrashConfig(t, "trash:\n retention_days: 30\n account_retention_days: 5\n")) + if err != nil { + t.Fatal(err) + } + if got := cfg.Trash.RecentSenderEraseDefer(); got != 5 { + t.Fatalf("effective window = %d, want 5 (account_retention_days)", got) + } + // Unset window, account trash disabled (0): only retention_days lowers it. + cfg, err = Load(writeTrashConfig(t, "trash:\n retention_days: 8\n account_retention_days: 0\n")) + if err != nil { + t.Fatal(err) + } + if got := cfg.Trash.RecentSenderEraseDefer(); got != 8 { + t.Fatalf("effective window = %d, want 8 (retention_days; account trash disabled)", got) + } + // Unset window, account_retention_days unset: it reuses retention_days. + cfg, err = Load(writeTrashConfig(t, "trash:\n retention_days: 40\n")) + if err != nil { + t.Fatal(err) + } + if got := cfg.Trash.RecentSenderEraseDefer(); got != DefaultRecentSenderEraseDeferDays { + t.Fatalf("effective window = %d, want the default %d", got, DefaultRecentSenderEraseDeferDays) + } +} + +func TestEraseDeferExemptDomainListAddsTheSharedDomain(t *testing.T) { + cfg, err := Load(writeTrashConfig(t, "shared_domain: Agents.Example.Test\ntrash:\n retention_days: 30\n erase_defer_exempt_domains: [\" Sim.Example.Test \"]\n")) + if err != nil { + t.Fatal(err) + } + got := cfg.EraseDeferExemptDomainList() + if len(got) != 2 || got[0] != "sim.example.test" || got[1] != "agents.example.test" { + t.Fatalf("exempt list = %v, want [sim.example.test agents.example.test]", got) + } + cfg.SharedDomain = "" + if got := cfg.EraseDeferExemptDomainList(); len(got) != 1 { + t.Fatalf("exempt list without a shared domain = %v, want only the configured entry", got) + } +} diff --git a/internal/httpapi/account.go b/internal/httpapi/account.go index 077d116e6..46e4e789e 100644 --- a/internal/httpapi/account.go +++ b/internal/httpapi/account.go @@ -108,7 +108,7 @@ func (s *Server) registerAccount() { registerOp(s.API, huma.Operation{ OperationID: "deleteAccount", Method: http.MethodDelete, Path: "/v1/account", Summary: "Delete your account (trash by default; permanent=true erases now)", Tags: []string{"account"}, - Description: "Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account's sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account's sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object.", + Description: "Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account's sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account's sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object.", Security: []map[string][]string{{"bearer": {}}}, Responses: map[string]*huma.Response{ "409": s.jsonResponse(reflect.TypeOf(ErrorEnvelope{}), "ErrorEnvelope", @@ -277,7 +277,7 @@ func (s *Server) handleExportUserData(ctx context.Context, _ *struct{}) (*export type deleteAccountInput struct { Confirm string `query:"confirm" enum:"DELETE" required:"true" doc:"Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible."` - Permanent bool `query:"permanent" doc:"Erase the account and all its data immediately instead of moving it to the trash. Irreversible."` + Permanent bool `query:"permanent" doc:"Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after."` } type deleteAccountOutput struct { diff --git a/internal/httpapi/account_restore.go b/internal/httpapi/account_restore.go index 5a50a7619..bec78b177 100644 --- a/internal/httpapi/account_restore.go +++ b/internal/httpapi/account_restore.go @@ -143,7 +143,10 @@ func (s *Server) handleAccountErase(w http.ResponseWriter, r *http.Request) { return } res.Deleted = true - if s.deps.ClearRestoreSessionCookie != nil { + // A deferred erase (the account recently emailed external recipients) + // leaves the account in the trash, still restorable: keep the restricted + // session so the interstitial can still offer the restore. + if s.deps.ClearRestoreSessionCookie != nil && !res.EraseDeferred { s.deps.ClearRestoreSessionCookie(w) } writeAccountJSON(w, http.StatusOK, res) diff --git a/internal/httpapi/account_trash_test.go b/internal/httpapi/account_trash_test.go index 8294ed07c..6aee26d9f 100644 --- a/internal/httpapi/account_trash_test.go +++ b/internal/httpapi/account_trash_test.go @@ -277,8 +277,8 @@ func TestAccountEraseThroughRestrictedSession(t *testing.T) { // ErrEraseHeld to 409 erase_held. func TestDeleteAgentPermanentIsHeldWhilePaused(t *testing.T) { srv := testServer(t, func(d *Deps) { - d.PermanentDeleteAgent = func(ctx context.Context, agentID, userID string, createdAt time.Time) (int64, error) { - return 0, identity.ErrEraseHeld + d.PermanentDeleteAgent = func(ctx context.Context, agentID, userID string, createdAt time.Time) (identity.AgentPurgeResult, error) { + return identity.AgentPurgeResult{}, identity.ErrEraseHeld } }) code, body := sendJSON(t, "DELETE", srv.URL+"/v1/agents/support%40acme.com?confirm=DELETE&permanent=true", "good", nil) @@ -300,3 +300,60 @@ func TestCreateAgentOnAHeldAddressIsAgentTaken(t *testing.T) { t.Fatalf("want 409 agent_taken, got %d %v", code, body) } } + +// A permanent erase deferred because the account recently emailed external +// recipients is a 200 trash receipt with the additive erase_deferred, +// purge_after and message fields — never an error. +func TestDeleteAccountReportsADeferredErase(t *testing.T) { + purgeAfter := time.Date(2026, 10, 26, 0, 0, 0, 0, time.UTC) + srv := testServer(t, func(d *Deps) { + d.DeleteUserData = func(context.Context, *identity.User, bool) (*identity.DeleteUserDataResult, error) { + return &identity.DeleteUserDataResult{ + Mode: identity.AccountDeleteModeTrash, PurgeAfter: &purgeAfter, AgentsDeleted: 1, + EraseDeferred: true, Message: identity.EraseDeferredMessage, + }, nil + } + }) + code, body := sendJSON(t, "DELETE", srv.URL+"/v1/account?confirm=DELETE&permanent=true", "good", nil) + if code != 200 || body["deleted"] != true || body["mode"] != "trash" || body["erase_deferred"] != true || + body["user_deleted"] != false || body["purge_after"] != "2026-10-26T00:00:00Z" || body["message"] != identity.EraseDeferredMessage { + t.Fatalf("deferred receipt = %d %v", code, body) + } +} + +// The interstitial's "erase now" on a recent external sender leaves the +// account in the trash: the restricted session survives so the restore +// offer keeps working. +func TestAccountEraseDeferredKeepsTheRestrictedSession(t *testing.T) { + deleted := time.Now().Add(-time.Hour) + purgeAfter := deleted.Add(30 * 24 * time.Hour) + srv := testServer(t, func(d *Deps) { + d.RestrictedSession = func(r *http.Request) (*identity.User, string, error) { + c, err := r.Cookie("e2a_restore_session") + if err != nil || c.Value != "sess_restricted" { + return nil, "", pgx.ErrNoRows + } + return &identity.User{ID: "u_trashed", Email: "gone@example.test", DeletedAt: &deleted}, c.Value, nil + } + d.RestoreAccount = func(context.Context, string, string) (*identity.User, error) { return nil, errors.New("unused") } + d.DeleteUserData = func(context.Context, *identity.User, bool) (*identity.DeleteUserDataResult, error) { + return &identity.DeleteUserDataResult{ + Mode: identity.AccountDeleteModeTrash, PurgeAfter: &purgeAfter, + EraseDeferred: true, Message: identity.EraseDeferredMessage, + }, nil + } + d.SameOriginRequest = func(r *http.Request) bool { return r.Header.Get("Origin") == "https://app.example.test" } + d.ClearRestoreSessionCookie = func(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: "e2a_restore_session", Value: "", MaxAge: -1}) + } + }) + code, body, resp := doCookie(t, srv.Client(), "POST", srv.URL+"/api/account/erase", "sess_restricted") + if code != 200 || body["mode"] != "trash" || body["erase_deferred"] != true || body["purge_after"] == nil { + t.Fatalf("deferred interstitial erase = %d %v", code, body) + } + for _, ck := range resp.Cookies() { + if ck.Name == "e2a_restore_session" && ck.MaxAge < 0 { + t.Fatal("a deferred erase cleared the restricted session; the account is still restorable") + } + } +} diff --git a/internal/httpapi/agents_write.go b/internal/httpapi/agents_write.go index a7ac806f5..d49d52937 100644 --- a/internal/httpapi/agents_write.go +++ b/internal/httpapi/agents_write.go @@ -116,7 +116,7 @@ func (s *Server) registerAgentWrites() { Method: http.MethodDelete, Path: "/v1/agents/{email}", Summary: "Delete an agent", - Description: "Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account's sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash.", + Description: "Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account's sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash.", Tags: []string{"agents"}, Security: []map[string][]string{{"bearer": {}}}, }, s.handleDeleteAgent) @@ -189,7 +189,7 @@ type deleteAgentOutput struct{ Body DeleteAgentResult } type deleteAgentInput struct { Address string `path:"email"` Confirm string `query:"confirm" enum:"DELETE" required:"true" doc:"Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible."` - Permanent bool `query:"permanent" doc:"Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents."` + Permanent bool `query:"permanent" doc:"Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after."` } func (s *Server) handleDeleteAgent(ctx context.Context, in *deleteAgentInput) (*deleteAgentOutput, error) { @@ -208,12 +208,12 @@ func (s *Server) handleDeleteAgent(ctx context.Context, in *deleteAgentInput) (* if err != nil { return nil, err } - var messagesDeleted int64 + var purge identity.AgentPurgeResult if in.Permanent { if s.deps.PermanentDeleteAgent == nil { return nil, NewError(http.StatusInternalServerError, "internal_error", "delete unavailable") } - messagesDeleted, err = s.deps.PermanentDeleteAgent(ctx, ag.ID, ag.UserID, ag.CreatedAt) + purge, err = s.deps.PermanentDeleteAgent(ctx, ag.ID, ag.UserID, ag.CreatedAt) } else if ag.DeletedAt != nil { return nil, NewError(http.StatusNotFound, "not_found", "agent not found") } else { @@ -240,11 +240,15 @@ func (s *Server) handleDeleteAgent(ctx context.Context, in *deleteAgentInput) (* return nil, NewError(http.StatusInternalServerError, "internal_error", "failed to delete agent") } // ag.ID is the agent's email (canonical form) — echo it as the identity key. - return &deleteAgentOutput{Body: DeleteAgentResult{ + res := DeleteAgentResult{ Deleted: true, Email: ag.ID, - MessagesDeleted: messagesDeleted, - }}, nil + MessagesDeleted: purge.MessagesDeleted, + } + if purge.EraseDeferred { + res.EraseDeferred, res.PurgeAfter, res.Message = true, purge.PurgeAfter, identity.AgentEraseDeferredMessage + } + return &deleteAgentOutput{Body: res}, nil } // handleRestoreAgent brings a trashed agent back (POST diff --git a/internal/httpapi/delete_results.go b/internal/httpapi/delete_results.go index 935da911b..b6db307bd 100644 --- a/internal/httpapi/delete_results.go +++ b/internal/httpapi/delete_results.go @@ -1,5 +1,7 @@ package httpapi +import "time" + // Uniform DELETE responses (GA review Tier-1 #54, Option B / Stripe-style). // // Every /v1 DELETE returns 200 OK with a small per-resource deletion object @@ -31,6 +33,11 @@ type DeleteAgentResult struct { Deleted bool `json:"deleted" doc:"Always true — the agent is no longer active. A failed delete is an error envelope, never deleted:false."` Email string `json:"email" doc:"Email address of the deleted agent."` MessagesDeleted int64 `json:"messages_deleted" doc:"Number of messages permanently removed by the cascade; zero when the agent is moved to trash."` + // Deferred permanent delete (additive): the agent emailed external + // recipients recently, so permanent=true moved it to the trash instead. + EraseDeferred bool `json:"erase_deferred,omitempty" doc:"True when permanent=true was requested but the agent emailed external recipients recently (within a deployment-configured window, 14 days by default), so it was moved to the trash instead of being deleted now: messages_deleted is 0, and the agent is purged at purge_after unless restored before then. Absent otherwise."` + PurgeAfter *time.Time `json:"purge_after,omitempty" doc:"When a deferred agent becomes eligible for permanent purge from the trash. Present only when erase_deferred is true."` + Message string `json:"message,omitempty" doc:"Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred."` } // @name DeleteAgentResult // Sending-identity teardown outcomes surfaced by deleteDomain. "confirmed" @@ -82,4 +89,9 @@ type DeleteWebhookResult struct { type DeleteMessageResult struct { Deleted bool `json:"deleted" doc:"Always true — the message is deleted (moved to trash or purged). A failed delete is an error envelope, never deleted:false."` ID string `json:"id" doc:"ID of the deleted message."` + // Deferred permanent delete (additive): the message was sent to external + // recipients recently, so permanent=true left it in the trash. + EraseDeferred bool `json:"erase_deferred,omitempty" doc:"True when permanent=true was requested but the message was sent to external recipients recently (within a deployment-configured window, 14 days by default), so it stays in the trash instead of being deleted now; it is purged at purge_after unless restored before then. Absent otherwise."` + PurgeAfter *time.Time `json:"purge_after,omitempty" doc:"When a deferred message becomes eligible for permanent purge from the trash. Present only when erase_deferred is true."` + Message string `json:"message,omitempty" doc:"Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred."` } // @name DeleteMessageResult diff --git a/internal/httpapi/domains.go b/internal/httpapi/domains.go index 9d6c5592b..d130a52f5 100644 --- a/internal/httpapi/domains.go +++ b/internal/httpapi/domains.go @@ -327,7 +327,7 @@ func (s *Server) registerDomains() { registerOp(s.API, huma.Operation{ OperationID: "deleteDomain", Method: http.MethodDelete, Path: "/v1/domains/{domain}", Summary: "Delete a domain", Tags: []string{"domains"}, - Description: "Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}).", + Description: "Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}).", Security: []map[string][]string{{"bearer": {}}}, Responses: map[string]*huma.Response{ "409": s.idempotencyInFlightResponse(), diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 50ee98c75..3d2362424 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -114,9 +114,11 @@ type AgentCreateEnforcer func(ctx context.Context, userID string) error // Agent mutation funcs mirror the like-named store methods. type ( - // AgentDeleter deletes an agent, returning the number of message rows - // removed by the cascade (surfaced in the DeleteAgentResult receipt). - AgentDeleter func(ctx context.Context, agentID, userID string, createdAt time.Time) (messagesDeleted int64, err error) + // AgentDeleter permanently deletes an agent, returning the number of + // message rows removed by the cascade — or, for an agent that emailed + // external recipients recently, the deferral to the agent trash + // (surfaced in the DeleteAgentResult receipt). + AgentDeleter func(ctx context.Context, agentID, userID string, createdAt time.Time) (identity.AgentPurgeResult, error) // AgentTrashOp moves an agent into or out of trash without deleting messages. AgentTrashOp func(ctx context.Context, agentID, userID string) error // AgentRestoreOp is AgentTrashOp's returning form: restore answers with the @@ -132,6 +134,11 @@ type ( // ErrMessageNotFound for the handler to map. type MessageTrashOp func(ctx context.Context, messageID, agentID string) error +// MessagePurger permanently deletes an already-trashed message, or reports +// that the purge was deferred (the message was sent to external recipients +// recently and stays in the trash until purge_after). +type MessagePurger func(ctx context.Context, messageID, agentID string) (identity.MessagePurgeResult, error) + // MessageRestoreOp is MessageTrashOp's returning form, used by RestoreMessage // for the same reason AgentRestoreOp exists: the restored view comes from // inside the restore transaction rather than a racy re-read afterwards. @@ -219,7 +226,7 @@ type Deps struct { // trash-only permanent purge. DeleteMessage MessageTrashOp RestoreMessage MessageRestoreOp - PurgeMessage MessageTrashOp + PurgeMessage MessagePurger // domains. ListDomains is keyset-paginated on (created_at, domain): the // handler passes limit+1 to detect a further page (limit<=0 = all), and the diff --git a/internal/httpapi/messages.go b/internal/httpapi/messages.go index 0b64fea46..2bf4d4502 100644 --- a/internal/httpapi/messages.go +++ b/internal/httpapi/messages.go @@ -431,7 +431,7 @@ func (s *Server) registerMessages() { Method: http.MethodDelete, Path: "/v1/agents/{email}/messages/{id}", Summary: "Delete a message (move to trash)", - Description: "Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes.", + Description: "Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes.", Tags: []string{"messages"}, Security: []map[string][]string{{"bearer": {}}}, }, s.handleDeleteMessage) @@ -617,10 +617,15 @@ func (s *Server) handleDeleteMessage(ctx context.Context, in *deleteMessageInput if s.deps.PurgeMessage == nil { return nil, NewError(http.StatusInternalServerError, "internal_error", "delete unavailable") } - if err := s.deps.PurgeMessage(ctx, in.MessageID, ag.ID); err != nil { + purge, err := s.deps.PurgeMessage(ctx, in.MessageID, ag.ID) + if err != nil { return nil, mapTrashErr(err, "message") } - return &deleteMessageOutput{Body: DeleteMessageResult{Deleted: true, ID: in.MessageID}}, nil + res := DeleteMessageResult{Deleted: true, ID: in.MessageID} + if purge.EraseDeferred { + res.EraseDeferred, res.PurgeAfter, res.Message = true, purge.PurgeAfter, identity.MessageEraseDeferredMessage + } + return &deleteMessageOutput{Body: res}, nil } if s.deps.DeleteMessage == nil { return nil, NewError(http.StatusInternalServerError, "internal_error", "delete unavailable") diff --git a/internal/httpapi/trash_endpoints_test.go b/internal/httpapi/trash_endpoints_test.go index 389f7c7f5..239dd7184 100644 --- a/internal/httpapi/trash_endpoints_test.go +++ b/internal/httpapi/trash_endpoints_test.go @@ -34,10 +34,10 @@ func withTrashDeps(c *trashCalls) func(*Deps) { c.lastMessageID, c.lastMessageAgent = messageID, agentID return nil } - d.PurgeMessage = func(ctx context.Context, messageID, agentID string) error { + d.PurgeMessage = func(ctx context.Context, messageID, agentID string) (identity.MessagePurgeResult, error) { c.purgeMsg++ c.lastMessageID, c.lastMessageAgent = messageID, agentID - return nil + return identity.MessagePurgeResult{}, nil } // Restore answers with the message view itself (the store builds it // inside the restore transaction), so the fake returns the row. @@ -55,10 +55,10 @@ func withTrashDeps(c *trashCalls) func(*Deps) { c.lastAgentID = agentID return nil } - d.PermanentDeleteAgent = func(ctx context.Context, agentID, userID string, createdAt time.Time) (int64, error) { + d.PermanentDeleteAgent = func(ctx context.Context, agentID, userID string, createdAt time.Time) (identity.AgentPurgeResult, error) { c.hardAgent++ c.lastAgentID = agentID - return 4, nil + return identity.AgentPurgeResult{MessagesDeleted: 4}, nil } // Restore answers with the LIVE agent the store read inside the // restore transaction — hence sampleAgent (no deleted_at). @@ -199,8 +199,8 @@ func TestDeleteMessagePermanent(t *testing.T) { func TestDeleteMessagePermanentNotInTrash(t *testing.T) { srv := testServer(t, func(d *Deps) { - d.PurgeMessage = func(ctx context.Context, messageID, agentID string) error { - return identity.ErrNotInTrash + d.PurgeMessage = func(ctx context.Context, messageID, agentID string) (identity.MessagePurgeResult, error) { + return identity.MessagePurgeResult{}, identity.ErrNotInTrash } }) code, body := sendJSON(t, "DELETE", srv.URL+"/v1/agents/support%40acme.com/messages/msg_live?permanent=true&confirm=DELETE", "good", nil) @@ -211,8 +211,8 @@ func TestDeleteMessagePermanentNotInTrash(t *testing.T) { func TestDeleteMessagePermanentSendInProgress(t *testing.T) { srv := testServer(t, func(d *Deps) { - d.PurgeMessage = func(ctx context.Context, messageID, agentID string) error { - return identity.ErrSendInProgress + d.PurgeMessage = func(ctx context.Context, messageID, agentID string) (identity.MessagePurgeResult, error) { + return identity.MessagePurgeResult{}, identity.ErrSendInProgress } }) code, body := sendJSON(t, "DELETE", srv.URL+"/v1/agents/support%40acme.com/messages/msg_sending?permanent=true&confirm=DELETE", "good", nil) @@ -444,8 +444,8 @@ func TestDeleteAgentPermanentFromTrash(t *testing.T) { func TestDeleteAgentPermanentSendInProgress(t *testing.T) { srv := testServer(t, func(d *Deps) { - d.PermanentDeleteAgent = func(ctx context.Context, agentID, userID string, createdAt time.Time) (int64, error) { - return 0, identity.ErrSendInProgress + d.PermanentDeleteAgent = func(ctx context.Context, agentID, userID string, createdAt time.Time) (identity.AgentPurgeResult, error) { + return identity.AgentPurgeResult{}, identity.ErrSendInProgress } }) code, body := sendJSON(t, "DELETE", srv.URL+"/v1/agents/support%40acme.com?confirm=DELETE&permanent=true", "good", nil) @@ -717,3 +717,49 @@ func TestCreateAgentTrashedByOtherUserIsAgentTaken(t *testing.T) { t.Fatalf("want 409 agent_taken (not address_in_trash), got %d %v", code, body) } } + +// A permanent agent delete deferred because the agent recently emailed +// external recipients is a 200 receipt with the additive erase_deferred, +// purge_after and message fields — never an error. +func TestDeleteAgentPermanentDeferredReceipt(t *testing.T) { + purgeAfter := time.Date(2026, 10, 28, 0, 0, 0, 0, time.UTC) + srv := testServer(t, func(d *Deps) { + d.PermanentDeleteAgent = func(context.Context, string, string, time.Time) (identity.AgentPurgeResult, error) { + return identity.AgentPurgeResult{EraseDeferred: true, PurgeAfter: &purgeAfter}, nil + } + }) + code, body := sendJSON(t, "DELETE", srv.URL+"/v1/agents/support%40acme.com?confirm=DELETE&permanent=true", "good", nil) + if code != 200 || body["deleted"] != true || body["erase_deferred"] != true || body["messages_deleted"] != float64(0) || + body["purge_after"] != "2026-10-28T00:00:00Z" || body["message"] != identity.AgentEraseDeferredMessage { + t.Fatalf("deferred agent receipt = %d %v", code, body) + } +} + +// A purged agent's receipt carries none of the deferral fields. +func TestDeleteAgentPermanentPurgedReceiptOmitsDeferral(t *testing.T) { + var c trashCalls + srv := testServer(t, withTrashDeps(&c)) + code, body := sendJSON(t, "DELETE", srv.URL+"/v1/agents/support%40acme.com?confirm=DELETE&permanent=true", "good", nil) + if code != 200 { + t.Fatalf("status %d %v", code, body) + } + for _, k := range []string{"erase_deferred", "purge_after", "message"} { + if _, ok := body[k]; ok { + t.Fatalf("purged receipt carries %s: %v", k, body) + } + } +} + +func TestDeleteMessagePermanentDeferredReceipt(t *testing.T) { + purgeAfter := time.Date(2026, 10, 28, 0, 0, 0, 0, time.UTC) + srv := testServer(t, func(d *Deps) { + d.PurgeMessage = func(context.Context, string, string) (identity.MessagePurgeResult, error) { + return identity.MessagePurgeResult{EraseDeferred: true, PurgeAfter: &purgeAfter}, nil + } + }) + code, body := sendJSON(t, "DELETE", srv.URL+"/v1/agents/support%40acme.com/messages/msg_sent?permanent=true&confirm=DELETE", "good", nil) + if code != 200 || body["deleted"] != true || body["id"] != "msg_sent" || body["erase_deferred"] != true || + body["purge_after"] != "2026-10-28T00:00:00Z" || body["message"] != identity.MessageEraseDeferredMessage { + t.Fatalf("deferred message receipt = %d %v", code, body) + } +} diff --git a/internal/identity/account_erase_defer.go b/internal/identity/account_erase_defer.go new file mode 100644 index 000000000..7c92b8baf --- /dev/null +++ b/internal/identity/account_erase_defer.go @@ -0,0 +1,252 @@ +package identity + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +// Deferred erase for recent external senders +// (docs/design/account-soft-deletion.md, "Deferred erase for recent senders"). +// +// Provider feedback — above all complaints — arrives hours to days after a +// send. An account that sends a burst and erases itself at once would take its +// sending control row and its bounce/complaint aggregates with it before that +// feedback lands, so the feedback would count against nothing. A permanent +// erase of an account that sent to an external recipient within the window is +// therefore held in the ordinary account trash instead: the account is +// deleted from the owner's point of view (inert, restorable until purge_after), +// and the janitor purges it at the normal end of the trash window while late +// feedback keeps updating its aggregates. + +// RecentSenderEraseDefer is the look-back window: a permanent erase of an +// account, a permanent agent delete, or a permanent message delete whose +// most recent send to an external recipient is younger than this is +// deferred to the matching trash (account trash, agent trash, message +// trash). Deferring the agent and message purges is what keeps the account +// check sound: the evidence it reads cannot be purged on demand inside the +// window. cmd/e2a assigns it at startup from +// trash.recent_sender_erase_defer_days (default 14). Zero disables the +// deferral everywhere. With account trash disabled the account-level +// deferral does not apply (there is no account trash window); agent and +// message deferral still do (their trash window is always at least a day). +var RecentSenderEraseDefer = 14 * 24 * time.Hour + +// EraseDeferredMessage is the human explanation carried on a deferred-erase +// receipt. +const EraseDeferredMessage = "This account emailed external recipients recently, so it is kept in the trash " + + "until purge_after before it is permanently erased (late delivery feedback such as spam complaints must " + + "still reach it). The account is already unusable; the owner can restore it by signing in before purge_after." + +// EraseDeferExemptDomains are recipient domains that never count as +// external: the deployment's shared agent domain(s) by name — matched even +// when a shared domain's domains row has been adopted by an account (e.g. the +// probe account) — and provider test domains such as the SES mailbox +// simulator, which the standing prober and the e2e harness send to on every +// run. cmd/e2a assigns it at startup from shared_domain plus +// trash.erase_defer_exempt_domains (default [simulator.amazonses.com]). +// Entries are lower-case domain names. +var EraseDeferExemptDomains = []string{"simulator.amazonses.com"} + +// eraseDeferExemptClasses are the account classes never deferred: synthetic +// probe traffic and internal dogfooding (the same classes sendingpolicy +// exempts from sending budgets). +const eraseDeferExemptClassesSQL = `('system', 'internal')` + +// EraseDeferRetryLag bounds how long after its anchor (created_at, or the +// scheduled/approval instant for a scheduled or review-held message) a +// message can still be submitted to the provider. The send worker's longest +// finite hold is outboundsend.PolicyBudgetHoldHorizon (7 days, measured from +// that anchor; every hold class promotes to it and nothing moves it later), +// after which the message fails terminally. The worker derives that deadline +// from the constant, never from the runtime policy's budget_hold_max_days, so +// the constant is the true bound. This lag is that bound plus a week of +// margin for in-flight retries; TestEraseDeferRetryLagCoversTheLongestHold +// (internal/outboundsend) fails if the hold horizon ever outgrows it. It is +// the lower bound that lets the recent-send lookup range-scan its indexes +// instead of every outbound message of the agent. +const EraseDeferRetryLag = 14 * 24 * time.Hour + +// sentExternallySinceSQL reports whether the account ($1) sent to an external +// recipient at or after $2 within the given scope (the whole account, one +// agent: agentScope, or one message: messageScope). $3 is the exempt domain +// list, $4 is $2 minus EraseDeferRetryLag, and $5 the scope id. +// +// Sends considered (per agent of the account, outbound only): +// - arm A: created at or after $4 (a bounded range on +// idx_messages_agent_created), whose send instant — the latest of +// created_at, provider_accepted_at, reviewed_at, scheduled_at and +// send_claimed_at — is at or after $2; +// - arm B: an older scheduled or review-held message, found through the +// partial expression index idx_messages_agent_delayed_outbound on +// (agent_id, GREATEST(scheduled_at, reviewed_at)) bounded below by $4 (a +// hold's TTL and a schedule's horizon are long, so created_at cannot bound +// it; the fire/approval instant can, give or take the same retry lag), +// whose send instant is at or after $2. +// +// Recipients: message_recipients rows (written when the provider or relay +// accepted the message, one per normalized envelope recipient) — or, for a +// message the provider accepted that has not settled yet (a provider message +// id with no recipient rows, or a send claimed inside the window), the +// message's own to/cc/bcc lists. +// +// "External" is the external-sending-access notion: anything other than +// - an agent of the same account (any state — by account erase time the +// account's agents are already trashed by the account trash), +// - the account's verified owner mailbox (valid proof for its CURRENT +// email, as sendingpolicy.ownerRecipientVerified), or +// - an address whose domain — the part after the LAST '@', so a quoted +// local part containing '@' cannot pose as an internal domain — is an +// exempt domain ($3) or a verified domains row with no owning account. +// +// System and internal account classes are never deferred. +func sentExternallySinceSQL(agentScope, messageScope string) string { + return ` +SELECT EXISTS ( + SELECT 1 + FROM users AS u + JOIN agent_identities AS a ON a.user_id = u.id + CROSS JOIN LATERAL ( + SELECT m.id, m.to_recipients, m.cc, m.bcc, m.recipient, + m.provider_message_id, m.delivery_status, m.send_claimed_at + FROM messages AS m + WHERE m.agent_id = a.id AND m.direction = 'outbound' + AND m.created_at >= $4` + messageScope + ` + AND GREATEST(m.created_at, m.provider_accepted_at, m.reviewed_at, + m.scheduled_at, m.send_claimed_at) >= $2 + UNION ALL + SELECT m.id, m.to_recipients, m.cc, m.bcc, m.recipient, + m.provider_message_id, m.delivery_status, m.send_claimed_at + FROM messages AS m + WHERE m.agent_id = a.id AND m.direction = 'outbound' + AND (m.scheduled_at IS NOT NULL OR m.reviewed_at IS NOT NULL) + AND GREATEST(m.scheduled_at, m.reviewed_at) >= $4 + AND m.created_at < $4` + messageScope + ` + AND GREATEST(m.created_at, m.provider_accepted_at, m.reviewed_at, + m.scheduled_at, m.send_claimed_at) >= $2 + ) AS m + CROSS JOIN LATERAL ( + SELECT r.address FROM message_recipients AS r WHERE r.message_id = m.id + UNION ALL + SELECT lower(btrim(x)) + FROM unnest(COALESCE(m.to_recipients, '{}') || COALESCE(m.cc, '{}') || + COALESCE(m.bcc, '{}') || + CASE WHEN m.to_recipients IS NULL THEN ARRAY[m.recipient] ELSE '{}' END) AS x + WHERE NOT EXISTS (SELECT 1 FROM message_recipients AS r2 WHERE r2.message_id = m.id) + AND (COALESCE(m.provider_message_id, '') <> '' + OR (m.delivery_status = 'sending' AND m.send_claimed_at >= $2)) + ) AS rcpt + WHERE u.id = $1` + agentScope + ` + AND u.account_class NOT IN ` + eraseDeferExemptClassesSQL + ` + AND rcpt.address <> '' + AND NOT EXISTS ( + SELECT 1 FROM agent_identities AS own + WHERE own.user_id = $1 AND lower(own.id) = rcpt.address) + AND NOT (u.owner_email_verified_at IS NOT NULL + AND u.owner_email_verified_address IS NOT NULL + AND u.owner_email_verified_address = lower(btrim(u.email)) + AND rcpt.address = u.owner_email_verified_address) + AND NOT (lower(COALESCE(substring(rcpt.address from '@([^@]+)$'), '')) = ANY($3::text[])) + AND NOT EXISTS ( + SELECT 1 FROM domains AS d + WHERE d.user_id IS NULL AND d.verified + AND d.domain = lower(substring(rcpt.address from '@([^@]+)$'))))` +} + +var ( + accountSentExternallySinceSQL = sentExternallySinceSQL("", "") + agentSentExternallySinceSQL = sentExternallySinceSQL("\n AND a.id = $5", "") + messageSentExternallySinceSQL = sentExternallySinceSQL("", " AND m.id = $5") +) + +func sentExternallySince(ctx context.Context, q rowQuerier, query, userID string, since time.Time, scope ...any) (bool, error) { + args := append([]any{userID, since, exemptDomains(), since.Add(-EraseDeferRetryLag)}, scope...) + var sent bool + if err := q.QueryRow(ctx, query, args...).Scan(&sent); err != nil { + return false, fmt.Errorf("erase: recent external send: %w", err) + } + return sent, nil +} + +// exemptDomains is EraseDeferExemptDomains normalized, never nil (a nil +// slice would bind as SQL NULL and make "= ANY" unknown). +func exemptDomains() []string { + out := make([]string, 0, len(EraseDeferExemptDomains)) + for _, d := range EraseDeferExemptDomains { + if d = strings.ToLower(strings.TrimSpace(d)); d != "" { + out = append(out, d) + } + } + return out +} + +// AccountSentExternallySince reports whether the account sent to at least one +// external recipient at or after since (see sentExternallySinceSQL). +func (s *Store) AccountSentExternallySince(ctx context.Context, userID string, since time.Time) (bool, error) { + return sentExternallySince(ctx, s.pool, accountSentExternallySinceSQL, userID, since) +} + +// eraseDeferralCutoff returns the start of the look-back window and whether +// the deferral is enabled at all. +func eraseDeferralCutoff() (time.Time, bool) { + if RecentSenderEraseDefer <= 0 { + return time.Time{}, false + } + return time.Now().Add(-RecentSenderEraseDefer), true +} + +// accountEraseDeferredTx reports whether a permanent erase of the account +// must be deferred to the account trash: the deferral is enabled, the +// deployment has account trash, and the account sent externally inside the +// window. purgeAccount runs it inside its claim transaction, under the user +// row lock. +func accountEraseDeferredTx(ctx context.Context, q rowQuerier, userID string) (bool, error) { + since, ok := eraseDeferralCutoff() + if !ok || !AccountTrashEnabled() { + return false, nil + } + return sentExternallySince(ctx, q, accountSentExternallySinceSQL, userID, since) +} + +// agentEraseDeferredTx reports whether a permanent delete of the agent must +// be deferred to the agent trash: the agent sent to an external recipient +// inside the window. Deferring here is what keeps the account-level check +// sound: the evidence it reads cannot be purged on demand inside the window. +func agentEraseDeferredTx(ctx context.Context, q rowQuerier, userID, agentID string) (bool, error) { + since, ok := eraseDeferralCutoff() + if !ok { + return false, nil + } + return sentExternallySince(ctx, q, agentSentExternallySinceSQL, userID, since, agentID) +} + +// messageEraseDeferredTx is agentEraseDeferredTx for one message. +func messageEraseDeferredTx(ctx context.Context, q rowQuerier, userID, messageID string) (bool, error) { + since, ok := eraseDeferralCutoff() + if !ok { + return false, nil + } + return sentExternallySince(ctx, q, messageSentExternallySinceSQL, userID, since, messageID) +} + +// Human explanations carried on deferred agent and message delete receipts. +const ( + AgentEraseDeferredMessage = "This agent emailed external recipients recently, so it was moved to the trash instead " + + "of being deleted permanently now (late delivery feedback such as spam complaints must still reach it). It is " + + "purged at purge_after and can be restored until then." + MessageEraseDeferredMessage = "This message was sent to external recipients recently, so it stays in the trash " + + "instead of being deleted permanently now (late delivery feedback such as spam complaints must still reach it). " + + "It is purged at purge_after and can be restored until then." +) + +// errAccountEraseDeferred is purgeAccount's in-transaction signal that an +// on-demand erase must stay in the account trash. It never leaves the store. +var errAccountEraseDeferred = errors.New("identity: account erase deferred") + +// ErrPurgeDeferred is returned by the int-returning purge wrappers +// (DeleteAgent, DeleteAgentIncarnation, PurgeMessage) when the purge was +// deferred to the trash instead: nothing was deleted. Callers that need the +// deferred receipt use PermanentDeleteAgentIncarnation / PurgeMessageOrDefer. +var ErrPurgeDeferred = errors.New("identity: permanent deletion deferred to the trash (recent external send)") diff --git a/internal/identity/account_erase_defer_test.go b/internal/identity/account_erase_defer_test.go new file mode 100644 index 000000000..5a1c9cbdf --- /dev/null +++ b/internal/identity/account_erase_defer_test.go @@ -0,0 +1,727 @@ +package identity_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/testutil" +) + +// Deferred erase for recent external senders +// (docs/design/account-soft-deletion.md, "Deferred erase for recent senders"). + +const deferSharedDomain = "agents.localhost" + +type deferFixture struct { + store *identity.Store + pool *pgxpool.Pool + userID string + agent string +} + +// newDeferFixture seeds an account with one shared-domain agent, a sending +// control row and a bounce/complaint aggregate row. +func newDeferFixture(t *testing.T, slug string) deferFixture { + t.Helper() + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + if err := store.EnsureSharedDomain(ctx, deferSharedDomain); err != nil { + t.Fatal(err) + } + user, err := store.CreateOrGetUser(ctx, slug+"@example.test", "Owner", "sub-"+slug) + if err != nil { + t.Fatal(err) + } + ag, err := store.CreateAgent(ctx, slug+"-bot@"+deferSharedDomain, deferSharedDomain, "Bot", "", "cloud", user.ID) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO account_sending_controls (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING`, user.ID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO account_sending_outcomes_daily (user_id, outcome_epoch, day, shared_reputation, delivered_count, complaint_count) + VALUES ($1, 1, current_date, true, 10, 0)`, user.ID); err != nil { + t.Fatal(err) + } + return deferFixture{store: store, pool: pool, userID: user.ID, agent: ag.ID} +} + +// sent records an outbound message from the fixture's agent that the +// provider accepted `ago` in the past, with one sent recipient row per +// address — the rows the send worker writes on provider acceptance. +func (f deferFixture) sent(t *testing.T, id string, ago time.Duration, recipients ...string) { + t.Helper() + ctx := context.Background() + at := time.Now().Add(-ago) + if _, err := f.pool.Exec(ctx, ` + INSERT INTO messages (id, agent_id, direction, sender, recipient, subject, delivery_status, created_at, provider_accepted_at) + VALUES ($1, $2, 'outbound', $2, $3, 'hello', 'sent', $4, $4)`, + id, f.agent, recipients[0], at); err != nil { + t.Fatalf("seed outbound message: %v", err) + } + for i, r := range recipients { + if _, err := f.pool.Exec(ctx, ` + INSERT INTO message_recipients (id, message_id, address, kind, status, updated_at) + VALUES ($1, $2, $3, 'to', 'sent', $4)`, + id+"_r"+string(rune('a'+i)), id, r, at); err != nil { + t.Fatalf("seed recipient: %v", err) + } + } +} + +func (f deferFixture) userExists(t *testing.T) (exists bool, deletedAt *time.Time) { + t.Helper() + err := f.pool.QueryRow(context.Background(), + `SELECT true, deleted_at FROM users WHERE id = $1`, f.userID).Scan(&exists, &deletedAt) + if err != nil { + return false, nil + } + return exists, deletedAt +} + +func TestEraseIsDeferredForARecentExternalSender(t *testing.T) { + f := newDeferFixture(t, "recent") + ctx := context.Background() + f.sent(t, "msg_defer_recent", 24*time.Hour, "someone@example.com") + + res, err := f.store.EraseAccount(ctx, f.userID, nil) + if err != nil { + t.Fatalf("EraseAccount: %v", err) + } + if res.Mode != identity.AccountDeleteModeTrash || !res.EraseDeferred || res.UserDeleted || res.Message == "" { + t.Fatalf("receipt = %+v, want mode trash, erase_deferred, a message, user not deleted", res) + } + exists, deletedAt := f.userExists(t) + if !exists || deletedAt == nil { + t.Fatalf("user exists=%v deleted_at=%v, want a trashed row", exists, deletedAt) + } + if res.PurgeAfter == nil || !res.PurgeAfter.Equal(deletedAt.Add(identity.AccountTrashRetention)) { + t.Fatalf("purge_after = %v, want deleted_at + account retention (%v)", res.PurgeAfter, deletedAt.Add(identity.AccountTrashRetention)) + } + if res.AgentsDeleted != 1 { + t.Fatalf("agents trashed = %d, want 1 (the trash receipt counts)", res.AgentsDeleted) + } + // The sending control row and the feedback aggregates survive, so late + // provider feedback still has something to count against. + var controls, aggregates, msgs int + if err := f.pool.QueryRow(ctx, ` + SELECT (SELECT count(*) FROM account_sending_controls WHERE user_id = $1), + (SELECT count(*) FROM account_sending_outcomes_daily WHERE user_id = $1), + (SELECT count(*) FROM messages WHERE id = 'msg_defer_recent')`, f.userID, + ).Scan(&controls, &aggregates, &msgs); err != nil { + t.Fatal(err) + } + if controls != 1 || aggregates != 1 || msgs != 1 { + t.Fatalf("controls=%d aggregates=%d messages=%d after a deferred erase, want 1/1/1", controls, aggregates, msgs) + } + + // A second "erase now" on the trashed account (the restore interstitial) + // is deferred again, with nothing new trashed. + again, err := f.store.EraseAccount(ctx, f.userID, nil) + if err != nil { + t.Fatalf("second EraseAccount: %v", err) + } + if !again.EraseDeferred || again.Mode != identity.AccountDeleteModeTrash || again.AgentsDeleted != 0 || + again.PurgeAfter == nil || !again.PurgeAfter.Equal(*res.PurgeAfter) { + t.Fatalf("second receipt = %+v, want deferred trash receipt with the same purge_after and zero counts", again) + } + + // Restore works exactly as for any trashed account. + tok, err := f.store.CreateRestrictedUserSession(ctx, f.userID) + if err != nil { + t.Fatal(err) + } + restored, err := f.store.RestoreAccount(ctx, f.userID, tok) + if err != nil { + t.Fatalf("RestoreAccount after a deferred erase: %v", err) + } + if restored.DeletedAt != nil { + t.Fatalf("restored deleted_at = %v, want nil", restored.DeletedAt) + } +} + +func TestEraseIsImmediateWhenTheLastExternalSendIsOutsideTheWindow(t *testing.T) { + f := newDeferFixture(t, "old") + f.sent(t, "msg_defer_old", 15*24*time.Hour, "someone@example.com") + + res, err := f.store.EraseAccount(context.Background(), f.userID, nil) + if err != nil { + t.Fatalf("EraseAccount: %v", err) + } + if res.Mode != identity.AccountDeleteModePermanent || res.EraseDeferred || !res.UserDeleted { + t.Fatalf("receipt = %+v, want an immediate permanent erase", res) + } + if exists, _ := f.userExists(t); exists { + t.Fatal("user row survived an erase whose last external send was 15 days ago") + } +} + +func TestEraseIsImmediateWhenEverySendWasInternal(t *testing.T) { + f := newDeferFixture(t, "internal") + ctx := context.Background() + // A second agent of the same account on a custom domain, a verified owner + // mailbox, and another account's agent on the shared domain. + if _, err := f.store.ClaimOrCreateDomain(ctx, "internal.example.test", f.userID); err != nil { + t.Fatal(err) + } + if _, err := f.store.CreateAgent(ctx, "desk@internal.example.test", "internal.example.test", "Desk", "", "cloud", f.userID); err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(ctx, ` + UPDATE users SET owner_email_verified_address = lower(email), owner_email_verified_at = now(), owner_email_verified_source = 'google_oauth' + WHERE id = $1`, f.userID); err != nil { + t.Fatal(err) + } + f.sent(t, "msg_defer_int", time.Hour, + "desk@internal.example.test", "internal@example.test", "stranger-bot@"+deferSharedDomain, f.agent) + + res, err := f.store.EraseAccount(ctx, f.userID, nil) + if err != nil { + t.Fatalf("EraseAccount: %v", err) + } + if res.EraseDeferred || !res.UserDeleted { + t.Fatalf("receipt = %+v, want an immediate erase (no external recipient)", res) + } +} + +func TestUnverifiedOwnerMailboxCountsAsExternal(t *testing.T) { + f := newDeferFixture(t, "unproven") + f.sent(t, "msg_defer_unproven", time.Hour, "unproven@example.test") + + res, err := f.store.EraseAccount(context.Background(), f.userID, nil) + if err != nil { + t.Fatalf("EraseAccount: %v", err) + } + if !res.EraseDeferred { + t.Fatalf("receipt = %+v, want deferred: an unproven owner mailbox is external, as for external sending access", res) + } +} + +func TestRecentlyApprovedOldMessageCountsAsARecentSend(t *testing.T) { + f := newDeferFixture(t, "held") + ctx := context.Background() + f.sent(t, "msg_defer_held", 20*24*time.Hour, "someone@example.com") + // Created 20 days ago but submitted to the provider an hour ago (a + // review hold or a schedule). + if _, err := f.pool.Exec(ctx, + `UPDATE messages SET provider_accepted_at = now() - interval '1 hour' WHERE id = 'msg_defer_held'`); err != nil { + t.Fatal(err) + } + res, err := f.store.EraseAccount(ctx, f.userID, nil) + if err != nil { + t.Fatalf("EraseAccount: %v", err) + } + if !res.EraseDeferred { + t.Fatalf("receipt = %+v, want deferred for a send accepted an hour ago", res) + } +} + +func TestEraseDeferralWindowZeroNeverDefers(t *testing.T) { + prev := identity.RecentSenderEraseDefer + identity.RecentSenderEraseDefer = 0 + t.Cleanup(func() { identity.RecentSenderEraseDefer = prev }) + + f := newDeferFixture(t, "zero") + f.sent(t, "msg_defer_zero", time.Hour, "someone@example.com") + res, err := f.store.EraseAccount(context.Background(), f.userID, nil) + if err != nil { + t.Fatalf("EraseAccount: %v", err) + } + if res.EraseDeferred || !res.UserDeleted { + t.Fatalf("receipt = %+v, want an immediate erase with the window disabled", res) + } +} + +func TestPausedRecentSenderIsStillEraseHeld(t *testing.T) { + f := newDeferFixture(t, "paused") + ctx := context.Background() + f.sent(t, "msg_defer_paused", time.Hour, "someone@example.com") + if _, err := f.pool.Exec(ctx, + `UPDATE account_sending_controls SET state = 'paused', reason = 'r', actor = 'op', pause_class = 'operator' WHERE user_id = $1`, + f.userID); err != nil { + t.Fatal(err) + } + if _, err := f.store.EraseAccount(ctx, f.userID, nil); !errors.Is(err, identity.ErrEraseHeld) { + t.Fatalf("EraseAccount of a paused recent sender err = %v, want ErrEraseHeld", err) + } + if exists, deletedAt := f.userExists(t); !exists || deletedAt != nil { + t.Fatalf("user exists=%v deleted_at=%v, want the account untouched by a held erase", exists, deletedAt) + } +} + +func TestOperatorForcePurgeStillPurgesADeferredAccount(t *testing.T) { + f := newDeferFixture(t, "force") + ctx := context.Background() + f.sent(t, "msg_defer_force", time.Hour, "someone@example.com") + if res, err := f.store.EraseAccount(ctx, f.userID, nil); err != nil || !res.EraseDeferred { + t.Fatalf("EraseAccount = %+v err=%v, want deferred", res, err) + } + // Runbook: backdate deleted_at past the window, then let the janitor run. + ageTrash(t, f.pool, f.userID) + purged, err := f.store.PurgeDeletedUsers(ctx, nil) + if err != nil { + t.Fatalf("PurgeDeletedUsers: %v", err) + } + if len(purged) != 1 || purged[0] != f.userID { + t.Fatalf("purged = %v, want the deferred account", purged) + } +} + +// ── Agent- and message-level deferral: the account check's evidence cannot +// be purged on demand inside the window. ── + +func (f deferFixture) agentCreatedAt(t *testing.T, agentID string) time.Time { + t.Helper() + var at time.Time + if err := f.pool.QueryRow(context.Background(), + `SELECT created_at FROM agent_identities WHERE id = $1`, agentID).Scan(&at); err != nil { + t.Fatal(err) + } + return at +} + +func (f deferFixture) agentDeletedAt(t *testing.T, agentID string) (exists bool, deletedAt *time.Time) { + t.Helper() + err := f.pool.QueryRow(context.Background(), + `SELECT true, deleted_at FROM agent_identities WHERE id = $1`, agentID).Scan(&exists, &deletedAt) + if err != nil { + return false, nil + } + return exists, deletedAt +} + +func TestPermanentAgentDeleteIsDeferredForARecentExternalSender(t *testing.T) { + f := newDeferFixture(t, "agentrecent") + ctx := context.Background() + f.sent(t, "msg_agent_recent", 24*time.Hour, "someone@example.com") + + res, err := f.store.PermanentDeleteAgentIncarnation(ctx, f.agent, f.userID, f.agentCreatedAt(t, f.agent)) + if err != nil { + t.Fatalf("PermanentDeleteAgentIncarnation: %v", err) + } + if !res.EraseDeferred || res.MessagesDeleted != 0 || res.PurgeAfter == nil { + t.Fatalf("result = %+v, want deferred with purge_after and nothing deleted", res) + } + exists, deletedAt := f.agentDeletedAt(t, f.agent) + if !exists || deletedAt == nil { + t.Fatalf("agent exists=%v deleted_at=%v, want a trashed row", exists, deletedAt) + } + if !res.PurgeAfter.Equal(deletedAt.Add(identity.TrashRetention)) { + t.Fatalf("purge_after = %v, want deleted_at + trash retention", res.PurgeAfter) + } + var msgs int + if err := f.pool.QueryRow(ctx, `SELECT count(*) FROM message_recipients WHERE message_id = 'msg_agent_recent'`).Scan(&msgs); err != nil { + t.Fatal(err) + } + if msgs != 1 { + t.Fatalf("recipient evidence rows = %d after a deferred agent delete, want 1", msgs) + } + // A second permanent delete of the now-trashed agent is deferred again, + // and the int-returning wrapper reports it as ErrPurgeDeferred. + if _, err := f.store.DeleteAgent(ctx, f.agent, f.userID); !errors.Is(err, identity.ErrPurgeDeferred) { + t.Fatalf("DeleteAgent of the trashed recent sender err = %v, want ErrPurgeDeferred", err) + } + // Restore works as for any trashed agent. + if _, err := f.store.RestoreAgent(ctx, f.agent, f.userID); err != nil { + t.Fatalf("RestoreAgent after a deferred delete: %v", err) + } + // And the account erase is still deferred. + acct, err := f.store.EraseAccount(ctx, f.userID, nil) + if err != nil || !acct.EraseDeferred { + t.Fatalf("EraseAccount after the agent delete = %+v err=%v, want deferred", acct, err) + } +} + +func TestPermanentAgentDeleteOfAnInternalOnlySenderPurges(t *testing.T) { + f := newDeferFixture(t, "agentinternal") + ctx := context.Background() + f.sent(t, "msg_agent_internal", time.Hour, "peer-bot@"+deferSharedDomain, f.agent) + + res, err := f.store.PermanentDeleteAgentIncarnation(ctx, f.agent, f.userID, f.agentCreatedAt(t, f.agent)) + if err != nil { + t.Fatalf("PermanentDeleteAgentIncarnation: %v", err) + } + if res.EraseDeferred || res.MessagesDeleted != 1 { + t.Fatalf("result = %+v, want an immediate purge of 1 message", res) + } + if exists, _ := f.agentDeletedAt(t, f.agent); exists { + t.Fatal("agent row survived a purge with only internal sends") + } +} + +func TestPermanentAgentDeleteOutsideTheWindowPurges(t *testing.T) { + f := newDeferFixture(t, "agentold") + f.sent(t, "msg_agent_old", 15*24*time.Hour, "someone@example.com") + if n, err := f.store.DeleteAgent(context.Background(), f.agent, f.userID); err != nil || n != 1 { + t.Fatalf("DeleteAgent = %d err=%v, want 1 message purged", n, err) + } +} + +func TestPausedAccountAgentDeleteStaysEraseHeld(t *testing.T) { + f := newDeferFixture(t, "agentpaused") + ctx := context.Background() + f.sent(t, "msg_agent_paused", time.Hour, "someone@example.com") + if _, err := f.pool.Exec(ctx, + `UPDATE account_sending_controls SET state = 'paused', reason = 'r', actor = 'op', pause_class = 'operator' WHERE user_id = $1`, + f.userID); err != nil { + t.Fatal(err) + } + if _, err := f.store.DeleteAgent(ctx, f.agent, f.userID); !errors.Is(err, identity.ErrEraseHeld) { + t.Fatalf("DeleteAgent on a paused account err = %v, want ErrEraseHeld", err) + } + if _, deletedAt := f.agentDeletedAt(t, f.agent); deletedAt != nil { + t.Fatal("a held permanent delete trashed the agent") + } +} + +func TestPermanentMessageDeleteIsDeferredForARecentExternalSend(t *testing.T) { + f := newDeferFixture(t, "msgrecent") + ctx := context.Background() + f.sent(t, "msg_purge_recent", time.Hour, "someone@example.com") + f.sent(t, "msg_purge_internal", time.Hour, "peer-bot@"+deferSharedDomain) + for _, id := range []string{"msg_purge_recent", "msg_purge_internal"} { + if err := f.store.SoftDeleteMessage(ctx, id, f.agent); err != nil { + t.Fatalf("SoftDeleteMessage %s: %v", id, err) + } + } + + res, err := f.store.PurgeMessageOrDefer(ctx, "msg_purge_recent", f.agent) + if err != nil { + t.Fatalf("PurgeMessageOrDefer: %v", err) + } + var deletedAt time.Time + if err := f.pool.QueryRow(ctx, `SELECT deleted_at FROM messages WHERE id = 'msg_purge_recent'`).Scan(&deletedAt); err != nil { + t.Fatalf("externally sent message was purged: %v", err) + } + if !res.EraseDeferred || res.PurgeAfter == nil || !res.PurgeAfter.Equal(deletedAt.Add(identity.TrashRetention)) { + t.Fatalf("result = %+v, want deferred with purge_after = deleted_at + trash retention", res) + } + if err := f.store.PurgeMessage(ctx, "msg_purge_recent", f.agent); !errors.Is(err, identity.ErrPurgeDeferred) { + t.Fatalf("PurgeMessage err = %v, want ErrPurgeDeferred", err) + } + + // A message sent only internally is purged at once. + if res, err := f.store.PurgeMessageOrDefer(ctx, "msg_purge_internal", f.agent); err != nil || res.EraseDeferred { + t.Fatalf("internal message purge = %+v err=%v, want purged", res, err) + } + var n int + if err := f.pool.QueryRow(ctx, `SELECT count(*) FROM messages WHERE id = 'msg_purge_internal'`).Scan(&n); err != nil || n != 0 { + t.Fatalf("internal message rows = %d err=%v, want 0", n, err) + } +} + +func TestWindowZeroDisablesAgentAndMessageDeferral(t *testing.T) { + prev := identity.RecentSenderEraseDefer + identity.RecentSenderEraseDefer = 0 + t.Cleanup(func() { identity.RecentSenderEraseDefer = prev }) + + f := newDeferFixture(t, "zeroagent") + ctx := context.Background() + f.sent(t, "msg_zero_a", time.Hour, "someone@example.com") + f.sent(t, "msg_zero_b", time.Hour, "someone@example.com") + if err := f.store.SoftDeleteMessage(ctx, "msg_zero_a", f.agent); err != nil { + t.Fatal(err) + } + if res, err := f.store.PurgeMessageOrDefer(ctx, "msg_zero_a", f.agent); err != nil || res.EraseDeferred { + t.Fatalf("message purge with window 0 = %+v err=%v, want purged", res, err) + } + if n, err := f.store.DeleteAgent(ctx, f.agent, f.userID); err != nil || n != 1 { + t.Fatalf("agent purge with window 0 = %d err=%v, want 1 message purged", n, err) + } +} + +// The original bypass, end to end: send externally, try to permanently +// delete the sending agent (and its sent message), then erase the account. +// The evidence survives and the account erase is deferred. +func TestBypassDeleteAgentsThenEraseIsStillDeferred(t *testing.T) { + f := newDeferFixture(t, "bypass") + ctx := context.Background() + f.sent(t, "msg_bypass", time.Hour, "someone@example.com") + + if err := f.store.SoftDeleteMessage(ctx, "msg_bypass", f.agent); err != nil { + t.Fatal(err) + } + if res, err := f.store.PurgeMessageOrDefer(ctx, "msg_bypass", f.agent); err != nil || !res.EraseDeferred { + t.Fatalf("message purge = %+v err=%v, want deferred", res, err) + } + if res, err := f.store.PermanentDeleteAgentIncarnation(ctx, f.agent, f.userID, f.agentCreatedAt(t, f.agent)); err != nil || !res.EraseDeferred { + t.Fatalf("agent purge = %+v err=%v, want deferred", res, err) + } + acct, err := f.store.EraseAccount(ctx, f.userID, nil) + if err != nil { + t.Fatalf("EraseAccount: %v", err) + } + if !acct.EraseDeferred || acct.UserDeleted { + t.Fatalf("account erase after the bypass sequence = %+v, want deferred", acct) + } + var recipients int + if err := f.pool.QueryRow(ctx, `SELECT count(*) FROM message_recipients WHERE message_id = 'msg_bypass'`).Scan(&recipients); err != nil || recipients != 1 { + t.Fatalf("recipient evidence rows = %d err=%v, want 1", recipients, err) + } +} + +// ── Review hardening: exemptions, parsing, unsettled sends, scheduled sends ── + +func TestProviderSimulatorRecipientsNeverDefer(t *testing.T) { + f := newDeferFixture(t, "simulator") + ctx := context.Background() + f.sent(t, "msg_sim", time.Hour, "success@simulator.amazonses.com", "bounce@SIMULATOR.amazonses.com") + if n, err := f.store.DeleteAgent(ctx, f.agent, f.userID); err != nil || n != 1 { + t.Fatalf("DeleteAgent of a simulator-only sender = %d err=%v, want purged", n, err) + } + res, err := f.store.EraseAccount(ctx, f.userID, nil) + if err != nil || res.EraseDeferred || !res.UserDeleted { + t.Fatalf("EraseAccount = %+v err=%v, want an immediate erase", res, err) + } +} + +func TestConfiguredExemptDomainsAreRespected(t *testing.T) { + prev := identity.EraseDeferExemptDomains + identity.EraseDeferExemptDomains = []string{"sink.example.test"} + t.Cleanup(func() { identity.EraseDeferExemptDomains = prev }) + + f := newDeferFixture(t, "exemptcfg") + f.sent(t, "msg_exempt_sink", time.Hour, "probe@sink.example.test") + f.sent(t, "msg_exempt_sim", time.Hour, "success@simulator.amazonses.com") + res, err := f.store.PermanentDeleteAgentIncarnation(context.Background(), f.agent, f.userID, f.agentCreatedAt(t, f.agent)) + if err != nil { + t.Fatal(err) + } + // The configured list replaces the default: the simulator is external now. + if !res.EraseDeferred { + t.Fatalf("result = %+v, want deferred (simulator no longer exempt)", res) + } +} + +// The shared agent domain is exempt BY NAME even when its domains row is +// owned by an account (the probe account adopts it on some deployments). +func TestSharedDomainByNameIsInternalEvenWhenOwned(t *testing.T) { + f := newDeferFixture(t, "shareowned") + ctx := context.Background() + if _, err := f.pool.Exec(ctx, `UPDATE domains SET user_id = $1 WHERE domain = $2`, f.userID, deferSharedDomain); err != nil { + t.Fatal(err) + } + f.sent(t, "msg_share_owned", time.Hour, "someone-elses-bot@"+deferSharedDomain) + + sent, err := f.store.AccountSentExternallySince(ctx, f.userID, time.Now().Add(-24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if !sent { + t.Fatal("without the name exemption an owned shared domain should count as external (test precondition)") + } + prev := identity.EraseDeferExemptDomains + identity.EraseDeferExemptDomains = append(append([]string(nil), prev...), deferSharedDomain) + t.Cleanup(func() { identity.EraseDeferExemptDomains = prev }) + if sent, err := f.store.AccountSentExternallySince(ctx, f.userID, time.Now().Add(-24*time.Hour)); err != nil || sent { + t.Fatalf("with the shared domain exempt by name: sent=%v err=%v, want false", sent, err) + } +} + +func TestSystemAndInternalAccountsNeverDefer(t *testing.T) { + for _, class := range []string{"system", "internal"} { + t.Run(class, func(t *testing.T) { + f := newDeferFixture(t, "class"+class) + ctx := context.Background() + if _, err := f.pool.Exec(ctx, `UPDATE users SET account_class = $2 WHERE id = $1`, f.userID, class); err != nil { + t.Fatal(err) + } + f.sent(t, "msg_class_"+class, time.Hour, "someone@example.com") + if n, err := f.store.DeleteAgent(ctx, f.agent, f.userID); err != nil || n != 1 { + t.Fatalf("DeleteAgent = %d err=%v, want purged", n, err) + } + if res, err := f.store.EraseAccount(ctx, f.userID, nil); err != nil || res.EraseDeferred { + t.Fatalf("EraseAccount = %+v err=%v, want an immediate erase", res, err) + } + }) + } +} + +// A quoted local part containing '@' must not pose as an internal domain: +// the domain is the part after the LAST '@'. +func TestQuotedLocalPartCannotPoseAsTheSharedDomain(t *testing.T) { + f := newDeferFixture(t, "quoted") + f.sent(t, "msg_quoted", time.Hour, `"x@`+deferSharedDomain+`@y"@victim.example.test`) + res, err := f.store.PermanentDeleteAgentIncarnation(context.Background(), f.agent, f.userID, f.agentCreatedAt(t, f.agent)) + if err != nil { + t.Fatal(err) + } + if !res.EraseDeferred { + t.Fatalf("result = %+v, want deferred: the recipient's domain is victim.example.test", res) + } +} + +// A message the provider accepted but that has not settled yet has no +// message_recipients rows; its own recipient lists decide. +func TestUnsettledProviderAcceptedSendCounts(t *testing.T) { + f := newDeferFixture(t, "unsettled") + ctx := context.Background() + if _, err := f.pool.Exec(ctx, ` + INSERT INTO messages (id, agent_id, direction, sender, recipient, subject, delivery_status, + provider_message_id, send_claimed_at, to_recipients, cc) + VALUES ('msg_unsettled_int', $1, 'outbound', $1, $2, 's', 'sending', 'ses-int-1', now(), ARRAY[$2], ARRAY['peer@`+deferSharedDomain+`']), + ('msg_unsettled_ext', $1, 'outbound', $1, 'x@example.com', 's', 'sending', 'ses-ext-1', now(), ARRAY[$2], ARRAY['Someone@Example.com'])`, + f.agent, f.agent); err != nil { + t.Fatal(err) + } + if sent, err := f.store.AccountSentExternallySince(ctx, f.userID, time.Now().Add(-time.Hour)); err != nil || !sent { + t.Fatalf("unsettled external send: sent=%v err=%v, want true", sent, err) + } + if _, err := f.pool.Exec(ctx, `DELETE FROM messages WHERE id = 'msg_unsettled_ext'`); err != nil { + t.Fatal(err) + } + if sent, err := f.store.AccountSentExternallySince(ctx, f.userID, time.Now().Add(-time.Hour)); err != nil || sent { + t.Fatalf("unsettled internal-only send: sent=%v err=%v, want false", sent, err) + } +} + +// Arm B: an old review-held message approved inside the window counts even +// though created_at is beyond the retry-lag lower bound. +func TestOldHeldMessageApprovedRecentlyCounts(t *testing.T) { + f := newDeferFixture(t, "oldheld") + ctx := context.Background() + f.sent(t, "msg_old_held", 60*24*time.Hour, "someone@example.com") + if _, err := f.pool.Exec(ctx, + `UPDATE messages SET reviewed_at = now() - interval '1 hour', provider_accepted_at = NULL WHERE id = 'msg_old_held'`); err != nil { + t.Fatal(err) + } + if sent, err := f.store.AccountSentExternallySince(ctx, f.userID, time.Now().Add(-24*time.Hour)); err != nil || !sent { + t.Fatalf("old held message approved an hour ago: sent=%v err=%v, want true", sent, err) + } +} + +type deferRecordingCanceller struct{ jobIDs []int64 } + +func (c *deferRecordingCanceller) CancelTx(_ context.Context, _ pgx.Tx, jobID int64) error { + c.jobIDs = append(c.jobIDs, jobID) + return nil +} + +// A deferred permanent agent delete cancels the agent's pending scheduled +// sends: a later restore must not re-arm them. +func TestDeferredAgentDeleteCancelsScheduledSends(t *testing.T) { + f := newDeferFixture(t, "schedcancel") + ctx := context.Background() + canceller := &deferRecordingCanceller{} + f.store.SetOutboundJobCanceller(canceller) + wireTrashScheduledFinalizer(f.store, f.pool) + f.sent(t, "msg_sched_evidence", time.Hour, "someone@example.com") + pending := trashOutbound(t, f.store, f.agent, "scheduled") + linkTrashTestSendJob(t, f.pool, pending.ID, 701) + if _, err := f.pool.Exec(ctx, + `UPDATE messages SET scheduled_at = now() + interval '1 day' WHERE id = $1`, pending.ID); err != nil { + t.Fatal(err) + } + + res, err := f.store.PermanentDeleteAgentIncarnation(ctx, f.agent, f.userID, f.agentCreatedAt(t, f.agent)) + if err != nil || !res.EraseDeferred { + t.Fatalf("result = %+v err=%v, want deferred", res, err) + } + if len(canceller.jobIDs) != 1 || canceller.jobIDs[0] != 701 { + t.Fatalf("cancelled jobs = %v, want [701]", canceller.jobIDs) + } + if _, err := f.store.RestoreAgent(ctx, f.agent, f.userID); err != nil { + t.Fatalf("RestoreAgent: %v", err) + } + var status, detail string + if err := f.pool.QueryRow(ctx, + `SELECT delivery_status, COALESCE(delivery_detail, '') FROM messages WHERE id = $1`, pending.ID, + ).Scan(&status, &detail); err != nil { + t.Fatal(err) + } + if status != "failed" || detail != identity.ScheduledCancelDeferredPurge { + t.Fatalf("scheduled message after deferred delete + restore = %q %q, want failed with the deferred-purge detail", status, detail) + } + if len(canceller.jobIDs) != 1 { + t.Fatalf("restore re-touched jobs: %v", canceller.jobIDs) + } +} + +// A send claimed inside the window (the provider call may have gone out) with +// no provider id yet also counts, classified by its own recipient lists. +func TestClaimedUnacceptedSendCounts(t *testing.T) { + f := newDeferFixture(t, "claimed") + ctx := context.Background() + if _, err := f.pool.Exec(ctx, ` + INSERT INTO messages (id, agent_id, direction, sender, recipient, subject, delivery_status, + send_claimed_at, to_recipients) + VALUES ('msg_claimed', $1, 'outbound', $1, 'x@example.com', 's', 'sending', now(), ARRAY['x@example.com'])`, + f.agent); err != nil { + t.Fatal(err) + } + if sent, err := f.store.AccountSentExternallySince(ctx, f.userID, time.Now().Add(-time.Hour)); err != nil || !sent { + t.Fatalf("claimed external send: sent=%v err=%v, want true", sent, err) + } +} + +// M4c: provider evidence alone defers, whatever the row's status and claim — +// e.g. a row locally inferred failed that the provider did accept. +func TestProviderAcceptedUnsettledSendCountsWithoutARecentClaim(t *testing.T) { + for name, claim := range map[string]string{"null claim": "NULL", "old claim": "now() - interval '40 days'"} { + t.Run(name, func(t *testing.T) { + f := newDeferFixture(t, "evidence") + ctx := context.Background() + if _, err := f.pool.Exec(ctx, ` + INSERT INTO messages (id, agent_id, direction, sender, recipient, subject, delivery_status, + provider_message_id, send_claimed_at, to_recipients) + VALUES ('msg_evidence', $1, 'outbound', $1, 'x@example.com', 's', 'failed', 'ses-evidence-1', `+claim+`, + ARRAY['x@example.com'])`, f.agent); err != nil { + t.Fatal(err) + } + res, err := f.store.PermanentDeleteAgentIncarnation(ctx, f.agent, f.userID, f.agentCreatedAt(t, f.agent)) + if err != nil || !res.EraseDeferred { + t.Fatalf("result = %+v err=%v, want deferred on provider evidence alone", res, err) + } + }) + } +} + +// M6b: owner-mailbox proof is for the CURRENT email only; after an email +// change the old verified mailbox is an external recipient. +func TestStaleOwnerMailboxProofCountsAsExternal(t *testing.T) { + f := newDeferFixture(t, "stalemailbox") + ctx := context.Background() + if _, err := f.pool.Exec(ctx, ` + UPDATE users SET owner_email_verified_address = 'old-owner@example.test', owner_email_verified_at = now(), + owner_email_verified_source = 'google_oauth', email = 'new-owner@example.test' + WHERE id = $1`, f.userID); err != nil { + t.Fatal(err) + } + f.sent(t, "msg_stale_owner", time.Hour, "old-owner@example.test") + if sent, err := f.store.AccountSentExternallySince(ctx, f.userID, time.Now().Add(-24*time.Hour)); err != nil || !sent { + t.Fatalf("send to the previously verified mailbox: sent=%v err=%v, want external", sent, err) + } + // Control: proof for the current email makes the same mailbox internal. + if _, err := f.pool.Exec(ctx, `UPDATE users SET email = 'old-owner@example.test' WHERE id = $1`, f.userID); err != nil { + t.Fatal(err) + } + if sent, err := f.store.AccountSentExternallySince(ctx, f.userID, time.Now().Add(-24*time.Hour)); err != nil || sent { + t.Fatalf("send to the current verified mailbox: sent=%v err=%v, want internal", sent, err) + } +} + +// S8: with account trash disabled there is no window to hold the account in, +// so a recent external sender's erase is immediate. +func TestAccountTrashDisabledErasesARecentSenderImmediately(t *testing.T) { + prev := identity.AccountTrashRetention + identity.AccountTrashRetention = 0 + t.Cleanup(func() { identity.AccountTrashRetention = prev }) + + f := newDeferFixture(t, "notrash") + f.sent(t, "msg_notrash", time.Hour, "someone@example.com") + res, err := f.store.EraseAccount(context.Background(), f.userID, nil) + if err != nil { + t.Fatalf("EraseAccount: %v", err) + } + if res.EraseDeferred || !res.UserDeleted { + t.Fatalf("receipt = %+v, want an immediate erase with account trash disabled", res) + } +} diff --git a/internal/identity/account_trash.go b/internal/identity/account_trash.go index 3c6cf2678..3bac947a8 100644 --- a/internal/identity/account_trash.go +++ b/internal/identity/account_trash.go @@ -434,6 +434,11 @@ func accountLoginIdentifiersTx(ctx context.Context, tx pgx.Tx, userID, email, su // with ErrEraseHeld so an operator can classify the pause before the content // goes (the account can still be trashed; the janitor purges it after the // window, writing abuse tombstones when the history says abuse). +// +// An account that sent to an external recipient within +// RecentSenderEraseDefer is not purged: it stays in the trash (trashed here +// if it was live) and the receipt is mode "trash" with erase_deferred — see +// account_erase_defer.go. The pause refusal above takes precedence. func (s *Store) EraseAccount(ctx context.Context, userID string, perDomainInTx func(ctx context.Context, tx pgx.Tx, domain string) error) (*DeleteUserDataResult, error) { res := &DeleteUserDataResult{Mode: AccountDeleteModePermanent} // Fast path with no side effects; the authoritative check runs under the @@ -454,12 +459,19 @@ func (s *Store) EraseAccount(ctx context.Context, userID string, perDomainInTx f if s.tombstones.Enabled && s.tombstones.Keyring == nil { return nil, ErrTombstoneKeyUnavailable } + var trashRes *DeleteUserDataResult if u.DeletedAt == nil { - if _, err := s.TrashAccount(ctx, userID, perDomainInTx); err != nil && !errors.Is(err, ErrAccountTrashed) { + if trashRes, err = s.TrashAccount(ctx, userID, perDomainInTx); err != nil && !errors.Is(err, ErrAccountTrashed) { return nil, err } } + // Deferred erase for recent external senders: decided inside the purge + // claim, under the user row lock, after the trash committed (a trashed + // account can no longer send). purged, err := s.purgeAccount(ctx, userID, true, perDomainInTx) + if errors.Is(err, errAccountEraseDeferred) { + return s.deferredEraseReceipt(ctx, userID, trashRes) + } if err != nil { return nil, err } @@ -467,6 +479,28 @@ func (s *Store) EraseAccount(ctx context.Context, userID string, perDomainInTx f return res, nil } +// deferredEraseReceipt is the receipt of a permanent erase deferred to the +// trash: mode "trash" with erase_deferred and purge_after. When this request +// trashed the account the trash counts are kept; for an account that was +// already in the trash (the restore interstitial's "erase now") nothing new +// was trashed and the counts are zero. +func (s *Store) deferredEraseReceipt(ctx context.Context, userID string, trashRes *DeleteUserDataResult) (*DeleteUserDataResult, error) { + res := trashRes + if res == nil { + res = &DeleteUserDataResult{Mode: AccountDeleteModeTrash} + } + if res.PurgeAfter == nil { + u, err := s.GetUserByIDAnyState(ctx, userID) + if err != nil { + return nil, err + } + res.PurgeAfter = u.PurgeAfter() + } + res.EraseDeferred = true + res.Message = EraseDeferredMessage + return res, nil +} + // ErrEraseHeld refuses an on-demand permanent erasure (of an account or one // of its agents) while the account's sending is paused. var ErrEraseHeld = errors.New("identity: permanent erasure is held while the account is paused") @@ -647,6 +681,23 @@ func (s *Store) purgeAccount(ctx context.Context, userID string, force bool, per if paused { return ErrEraseHeld } + // Deferred erase for recent external senders, under the same + // lock. A failed check (a transient database error, a statement + // timeout) defers rather than erases: erasure is irreversible + // while a deferral is not — the account is already trashed, the + // owner sees a successful delete either way, and the janitor + // purges it at the end of the window. Failing open would let an + // induced error destroy exactly the evidence this check protects; + // failing with an error would leave the caller retrying against + // the same fault. + deferred, err := accountEraseDeferredTx(ctx, tx, userID) + if err != nil { + log.Printf("[identity] erase deferral check failed; keeping the account in the trash: user=%s err=%v", userID, err) + deferred = true + } + if deferred { + return errAccountEraseDeferred + } } if purgeToken != nil { token = *purgeToken diff --git a/internal/identity/agent_purge.go b/internal/identity/agent_purge.go index 314a9707a..d6375aff1 100644 --- a/internal/identity/agent_purge.go +++ b/internal/identity/agent_purge.go @@ -20,7 +20,10 @@ const ( // agentPurgeDecisionTx locks the exact incarnation the request resolved and // either leaves it on the bounded atomic path or durably claims it for a -// resumable chunked purge. An existing claim is always adopted. +// resumable chunked purge. An existing claim is always adopted. It returns +// errAgentPurgeDeferred (with the agent lock still held) when the agent sent +// to an external recipient inside RecentSenderEraseDefer; the caller then +// trashes the agent in the same transaction instead of purging it. func (s *Store) agentPurgeDecisionTx( ctx context.Context, tx pgx.Tx, @@ -63,6 +66,14 @@ func (s *Store) agentPurgeDecisionTx( if err := ensureNoAgentSendInProgressTx(ctx, tx, agentID); err != nil { return "", false, err } + // Deferred erase for recent external senders: decided under the agent + // lock, after the send-lease check, so no send of this agent can settle + // between the check and the purge. The caller trashes instead. + if deferred, err := agentEraseDeferredTx(ctx, tx, userID, agentID); err != nil { + return "", false, err + } else if deferred { + return "", false, errAgentPurgeDeferred + } tooManyMessages, err := rowsOverLimitTx(ctx, tx, `SELECT 1 FROM messages WHERE agent_id = $1 LIMIT $2`, @@ -112,6 +123,58 @@ func (s *Store) agentPurgeDecisionTx( return token, true, err } +// errAgentPurgeDeferred is agentPurgeDecisionTx's in-transaction signal that +// the purge must be deferred to the agent trash. It never leaves the store. +var errAgentPurgeDeferred = errors.New("identity: agent purge deferred") + +// trashAgentForDeferredPurgeTx moves the (locked) agent to the trash if it +// is live, cancels its pending scheduled sends — an agent the owner asked to +// delete permanently must not have them re-armed by a later restore — and +// returns when the janitor will purge it. +func (s *Store) trashAgentForDeferredPurgeTx(ctx context.Context, tx pgx.Tx, agentID, userID string) (time.Time, error) { + var deletedAt time.Time + err := tx.QueryRow(ctx, + `UPDATE agent_identities SET deleted_at = COALESCE(deleted_at, now()) + WHERE id = $1 AND user_id = $2 + RETURNING deleted_at`, agentID, userID).Scan(&deletedAt) + if errors.Is(err, pgx.ErrNoRows) { + return time.Time{}, ErrAgentNotFound + } + if err != nil { + return time.Time{}, err + } + rows, err := tx.Query(ctx, + `SELECT id, send_job_id + FROM messages + WHERE agent_id = $1 + AND direction = 'outbound' + AND delivery_status = 'accepted' + AND scheduled_at IS NOT NULL + AND send_job_id IS NOT NULL + ORDER BY id + FOR UPDATE`, agentID) + if err != nil { + return time.Time{}, err + } + var scheduled []pastDueScheduledJob + for rows.Next() { + var j pastDueScheduledJob + if err := rows.Scan(&j.messageID, &j.jobID); err != nil { + rows.Close() + return time.Time{}, err + } + scheduled = append(scheduled, j) + } + rows.Close() + if err := rows.Err(); err != nil { + return time.Time{}, err + } + if err := s.cancelScheduledJobsTx(ctx, tx, scheduled, ScheduledCancelDeferredPurge); err != nil { + return time.Time{}, err + } + return deletedAt.Add(TrashRetention), nil +} + func lockAgentMessagesTx(ctx context.Context, tx pgx.Tx, agentID string) error { rows, err := tx.Query(ctx, `SELECT id FROM messages WHERE agent_id = $1 ORDER BY id FOR UPDATE`, agentID) diff --git a/internal/identity/store.go b/internal/identity/store.go index 9bdfef4d5..1d16a6383 100644 --- a/internal/identity/store.go +++ b/internal/identity/store.go @@ -619,7 +619,7 @@ type OutboundJobCanceller interface { // scheduled send restored after its cutoff. It is implemented by the outbound // adapter so identity does not duplicate provider-evidence and webhook logic. type ScheduledSendFinalizer interface { - FinalizeScheduledCancellationTx(ctx context.Context, tx pgx.Tx, messageID string, jobID int64, occurredAt time.Time) error + FinalizeScheduledCancellationTx(ctx context.Context, tx pgx.Tx, messageID string, jobID int64, occurredAt time.Time, detail string) error } func NewStore(pool *pgxpool.Pool) *Store { @@ -687,7 +687,20 @@ type pastDueScheduledJob struct { jobID int64 } +// Delivery details recorded when a scheduled send is canceled. +const ( + ScheduledCancelRestoredLate = "scheduled send canceled because it was restored after scheduled_at" + ScheduledCancelDeferredPurge = "scheduled send canceled because its agent was permanently deleted (deletion deferred to the trash)" +) + func (s *Store) cancelPastDueScheduledJobsTx(ctx context.Context, tx pgx.Tx, jobs []pastDueScheduledJob) error { + return s.cancelScheduledJobsTx(ctx, tx, jobs, ScheduledCancelRestoredLate) +} + +// cancelScheduledJobsTx cancels scheduled sends' River jobs and finalizes +// each message through the canonical guarded terminal transition (settled +// as sent on provider evidence, otherwise failed with detail). +func (s *Store) cancelScheduledJobsTx(ctx context.Context, tx pgx.Tx, jobs []pastDueScheduledJob, detail string) error { if len(jobs) == 0 { return nil } @@ -704,7 +717,7 @@ func (s *Store) cancelPastDueScheduledJobsTx(ctx context.Context, tx pgx.Tx, job now := time.Now().UTC() for _, job := range jobs { if err := s.scheduledSendFinalizer.FinalizeScheduledCancellationTx( - ctx, tx, job.messageID, job.jobID, now, + ctx, tx, job.messageID, job.jobID, now, detail, ); err != nil { return err } @@ -2668,31 +2681,75 @@ func (s *Store) DeleteAgent(ctx context.Context, agentID, userID string) (messag return s.DeleteAgentIncarnation(ctx, agentID, userID, createdAt) } +// AgentPurgeResult is the outcome of a permanent agent delete: either the +// agent and its messages were removed (MessagesDeleted), or — for an agent +// that sent to an external recipient inside RecentSenderEraseDefer — the +// purge was deferred and the agent is in the trash until PurgeAfter. +type AgentPurgeResult struct { + MessagesDeleted int64 + EraseDeferred bool + PurgeAfter *time.Time +} + // DeleteAgentIncarnation permanently deletes only the incarnation previously -// resolved by the caller. Carrying createdAt across the handler/store boundary -// prevents a delayed request from attaching to a same-owner recreation at the -// same address before any purge token has been claimed. +// resolved by the caller (see PermanentDeleteAgentIncarnation). A deferred +// purge is reported as ErrPurgeDeferred: nothing was deleted. +func (s *Store) DeleteAgentIncarnation(ctx context.Context, agentID, userID string, createdAt time.Time) (messagesDeleted int64, err error) { + res, err := s.PermanentDeleteAgentIncarnation(ctx, agentID, userID, createdAt) + if err != nil { + // A chunked purge that fails part-way still reports what it committed. + return res.MessagesDeleted, err + } + if res.EraseDeferred { + return 0, ErrPurgeDeferred + } + return res.MessagesDeleted, nil +} + +// PermanentDeleteAgentIncarnation permanently deletes only the incarnation +// previously resolved by the caller. Carrying createdAt across the +// handler/store boundary prevents a delayed request from attaching to a +// same-owner recreation at the same address before any purge token has been +// claimed. // // While the owning account's sending is paused (any class) it refuses with // ErrEraseHeld: an account under a pause may trash its agents but may not // erase their content before an operator has classified the pause. -func (s *Store) DeleteAgentIncarnation(ctx context.Context, agentID, userID string, createdAt time.Time) (messagesDeleted int64, err error) { - var token string - var chunked bool - err = s.WithTx(ctx, func(tx pgx.Tx) error { +// +// An agent that sent to an external recipient within RecentSenderEraseDefer +// is not purged: it is moved to the trash (if live) in the same transaction +// and the result reports EraseDeferred with the trash purge time, so late +// provider feedback can still be attributed and the account-level erase +// deferral keeps its evidence (account_erase_defer.go). +func (s *Store) PermanentDeleteAgentIncarnation(ctx context.Context, agentID, userID string, createdAt time.Time) (AgentPurgeResult, error) { + var ( + res AgentPurgeResult + token string + chunked bool + ) + err := s.WithTx(ctx, func(tx pgx.Tx) error { var decisionErr error token, chunked, decisionErr = s.agentPurgeDecisionTx(ctx, tx, agentID, userID, createdAt) + if errors.Is(decisionErr, errAgentPurgeDeferred) { + purgeAfter, err := s.trashAgentForDeferredPurgeTx(ctx, tx, agentID, userID) + if err != nil { + return err + } + res.EraseDeferred, res.PurgeAfter = true, &purgeAfter + return nil + } if decisionErr != nil || chunked { return decisionErr } var deleteErr error - messagesDeleted, deleteErr = s.deleteAgentAtomicTx(ctx, tx, agentID, userID) + res.MessagesDeleted, deleteErr = s.deleteAgentAtomicTx(ctx, tx, agentID, userID) return deleteErr }) if err != nil || !chunked { - return messagesDeleted, err + return res, err } - return s.purgeAgentChunked(ctx, agentID, userID, token) + res.MessagesDeleted, err = s.purgeAgentChunked(ctx, agentID, userID, token) + return res, err } // SoftDeleteAgent moves a live agent to the trash (docs/design/ @@ -5561,9 +5618,32 @@ func (s *Store) RestoreMessage(ctx context.Context, messageID, agentID string) ( // PurgeMessage permanently deletes a message that is already in the trash // ("delete forever" — the Gmail journey is delete → trash → delete forever, // so a live message must be trashed first). Returns ErrNotInTrash for a -// live message, ErrMessageNotFound otherwise. +// live message, ErrMessageNotFound otherwise, and ErrPurgeDeferred when the +// message was sent externally inside RecentSenderEraseDefer (see +// PurgeMessageOrDefer; nothing is deleted). func (s *Store) PurgeMessage(ctx context.Context, messageID, agentID string) error { - return s.WithTx(ctx, func(tx pgx.Tx) error { + res, err := s.PurgeMessageOrDefer(ctx, messageID, agentID) + if err == nil && res.EraseDeferred { + return ErrPurgeDeferred + } + return err +} + +// MessagePurgeResult is the outcome of a permanent message delete: purged, +// or — for a message sent to an external recipient inside +// RecentSenderEraseDefer — left in the message trash until PurgeAfter. +type MessagePurgeResult struct { + EraseDeferred bool + PurgeAfter *time.Time +} + +// PurgeMessageOrDefer is PurgeMessage returning the deferral: an +// already-trashed message that was sent to an external recipient within +// RecentSenderEraseDefer stays in the trash (the janitor purges it +// TrashRetention after deleted_at) instead of being deleted now. +func (s *Store) PurgeMessageOrDefer(ctx context.Context, messageID, agentID string) (MessagePurgeResult, error) { + var res MessagePurgeResult + err := s.WithTx(ctx, func(tx pgx.Tx) error { var deletedAt *time.Time var deliveryStatus string var activeSend bool @@ -5588,6 +5668,19 @@ func (s *Store) PurgeMessage(ctx context.Context, messageID, agentID string) err if deletedAt == nil { return ErrNotInTrash } + var userID string + if err := tx.QueryRow(ctx, + `SELECT user_id FROM agent_identities WHERE id = $1`, agentID, + ).Scan(&userID); err != nil { + return err + } + if deferred, err := messageEraseDeferredTx(ctx, tx, userID, messageID); err != nil { + return err + } else if deferred { + purgeAfter := deletedAt.Add(TrashRetention) + res.EraseDeferred, res.PurgeAfter = true, &purgeAfter + return nil + } if sendJobID != nil && (deliveryStatus == "accepted" || deliveryStatus == "sending") { if err := s.cancelOutboundJobIDsTx(ctx, tx, []int64{*sendJobID}); err != nil { return err @@ -5607,6 +5700,7 @@ func (s *Store) PurgeMessage(ctx context.Context, messageID, agentID string) err _, err = tx.Exec(ctx, `DELETE FROM messages WHERE id = $1`, messageID) return err }) + return res, err } // classifyTrashMiss turns a zero-row trash mutation into the precise error: diff --git a/internal/identity/user_data_rights.go b/internal/identity/user_data_rights.go index 4aa2a6b6e..f2a5a6f39 100644 --- a/internal/identity/user_data_rights.go +++ b/internal/identity/user_data_rights.go @@ -273,6 +273,11 @@ type DeleteUserDataResult struct { OAuthAccessTokensDeleted int64 `json:"oauth_access_tokens_deleted,omitempty"` OAuthRefreshTokensDeleted int64 `json:"oauth_refresh_tokens_deleted,omitempty"` UserDeleted bool `json:"user_deleted" doc:"True only when the account row itself was erased (mode permanent)."` + // EraseDeferred marks a permanent erase that was deferred to the trash + // because the account recently emailed external recipients + // (identity.RecentSenderEraseDefer); Message explains it. + EraseDeferred bool `json:"erase_deferred,omitempty" doc:"True when permanent=true was requested but the account emailed external recipients recently (within a deployment-configured window, 14 days by default), so it was moved to the trash instead of being erased now: mode is trash and the account is purged at purge_after. The account is already unusable; the owner can restore it by signing in before purge_after. Absent otherwise."` + Message string `json:"message,omitempty" doc:"Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred."` } // @name DeleteUserDataResult // DeleteUserData wipes everything tied to a user in a single transaction. diff --git a/internal/outboundsend/erase_defer_lag_test.go b/internal/outboundsend/erase_defer_lag_test.go new file mode 100644 index 000000000..4b92fdffc --- /dev/null +++ b/internal/outboundsend/erase_defer_lag_test.go @@ -0,0 +1,24 @@ +package outboundsend + +import ( + "testing" + "time" + + "github.com/tokencanopy/e2a/internal/identity" +) + +// The deferred-erase lookup bounds ordinary sends by created_at >= window +// start - identity.EraseDeferRetryLag. That is only sound while no message can +// reach the provider later than the lag after its anchor; the worker's longest +// finite hold is PolicyBudgetHoldHorizon (and SendRetryHorizon for the other +// classes). Keep at least a day of margin for in-flight retries. +func TestEraseDeferRetryLagCoversTheLongestHold(t *testing.T) { + longest := PolicyBudgetHoldHorizon + if SendRetryHorizon > longest { + longest = SendRetryHorizon + } + const margin = 24 * time.Hour + if identity.EraseDeferRetryLag < longest+margin { + t.Fatalf("identity.EraseDeferRetryLag = %v, must be at least the longest send hold (%v) plus a day", identity.EraseDeferRetryLag, longest) + } +} diff --git a/internal/selftest/cleanup.go b/internal/selftest/cleanup.go index ade9de088..7dddd4c9b 100644 --- a/internal/selftest/cleanup.go +++ b/internal/selftest/cleanup.go @@ -42,6 +42,11 @@ const ( type SweepResult struct { Trashed int `json:"trashed"` Purged int `json:"purged"` + // Deferred counts purge attempts the server answered with + // erase_deferred: the message was sent to an external recipient + // recently, so it stays in the trash until purge_after. It is NOT + // purged; the janitor removes it at the end of the trash window. + Deferred int `json:"deferred,omitempty"` } // SweepMessages trashes live probe messages and then purges the trash. @@ -66,14 +71,22 @@ func (p *Probe) SweepMessages() SweepResult { // so the defaults would walk straight past every outbound copy and every // inbound message a scenario had already read. for _, id := range p.listMessageIDs(ctx, "direction=all&read_status=all") { - if p.deleteMessage(ctx, id, false) { + if ok, _ := p.deleteMessage(ctx, id, false); ok { res.Trashed++ } } // Trash → gone. Picks up what was just trashed plus anything an earlier - // run trashed and left sitting for the 30-day janitor. + // run trashed and left sitting for the 30-day janitor. A deferred purge + // (erase_deferred) left the message in the trash, so it is counted apart + // and never booked as purged. The probe's own recipients are exempt from + // the deferral server-side (its account class, the shared domain and the + // provider simulator), so deferrals here indicate a misconfiguration. for _, id := range p.listMessageIDs(ctx, "deleted=true&direction=all&read_status=all") { - if p.deleteMessage(ctx, id, true) { + ok, deferred := p.deleteMessage(ctx, id, true) + switch { + case deferred: + res.Deferred++ + case ok: res.Purged++ } } @@ -112,15 +125,28 @@ func (p *Probe) listMessageIDs(ctx context.Context, query string) []string { } // deleteMessage trashes (permanent=false) or purges (permanent=true) one -// message, reporting whether the row actually moved. A 409 — a message held for -// review, or one whose provider submission is still in flight — counts as -// skipped and is retried next tick, rather than being booked as done. -func (p *Probe) deleteMessage(ctx context.Context, id string, permanent bool) bool { +// message, reporting whether the row actually moved and whether a purge was +// deferred (200 with erase_deferred: the message stays in the trash). A 409 — +// a message held for review, or one whose provider submission is still in +// flight — counts as skipped and is retried next tick, rather than being +// booked as done. +func (p *Probe) deleteMessage(ctx context.Context, id string, permanent bool) (moved, deferred bool) { u := p.HTTPBaseURL + "/v1/agents/" + url.PathEscape(p.AgentEmail) + "/messages/" + url.PathEscape(id) if permanent { u += "?permanent=true&confirm=DELETE" } - st, _, err := p.do(ctx, http.MethodDelete, u, nil) - return err == nil && st == http.StatusOK + st, body, err := p.do(ctx, http.MethodDelete, u, nil) + if err != nil || st != http.StatusOK { + return false, false + } + if permanent { + var receipt struct { + EraseDeferred bool `json:"erase_deferred"` + } + if json.Unmarshal(body, &receipt) == nil && receipt.EraseDeferred { + return false, true + } + } + return true, false } diff --git a/internal/selftest/cleanup_test.go b/internal/selftest/cleanup_test.go index ecf11763d..c59834e2e 100644 --- a/internal/selftest/cleanup_test.go +++ b/internal/selftest/cleanup_test.go @@ -153,3 +153,30 @@ func TestSweepMessagesDoesNotCountConflicts(t *testing.T) { t.Fatalf("Trashed = %d, want 0 — a 409 is skipped, not booked as done", got.Trashed) } } + +// A purge the server deferred (erase_deferred: the message stays in the +// trash) is counted apart and never booked as purged. +func TestSweepMessagesCountsDeferredPurgesApart(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + items := []map[string]string{} + if r.URL.Query().Get("deleted") == "true" { + items = []map[string]string{{"id": "msg_deferred"}, {"id": "msg_gone"}} + } + _ = json.NewEncoder(w).Encode(map[string]any{"items": items, "next_cursor": nil}) + return + } + w.WriteHeader(http.StatusOK) + if strings.Contains(r.URL.Path, "msg_deferred") { + _, _ = w.Write([]byte(`{"deleted":true,"id":"msg_deferred","erase_deferred":true,"purge_after":"2026-10-28T00:00:00Z"}`)) + return + } + _, _ = w.Write([]byte(`{"deleted":true,"id":"msg_gone"}`)) + })) + defer srv.Close() + + got := failProbe(srv.URL, "", nil).SweepMessages() + if got.Purged != 1 || got.Deferred != 1 { + t.Fatalf("SweepMessages() = %+v, want {Purged:1 Deferred:1}", got) + } +} diff --git a/internal/testutil/contract_server.go b/internal/testutil/contract_server.go index 4aa17dfa3..1a84f5cf2 100644 --- a/internal/testutil/contract_server.go +++ b/internal/testutil/contract_server.go @@ -101,6 +101,17 @@ type ContractServer struct { // which is one contract run; no other scenario may use them. DisposableTrashAPIKey string DisposableEraseAPIKey string + // DisposableDeferredEraseAPIKey authenticates a third throwaway account + // seeded with a provider-accepted send to an external recipient an hour + // ago, so its permanent erase is deferred to the trash + // (identity.RecentSenderEraseDefer). Deleted exactly once per server; + // no other scenario may use it. + DisposableDeferredEraseAPIKey string + // DeferredPurgeAPIKey authenticates an account whose one agent has a + // provider-accepted send to an external recipient an hour ago, so a + // permanent delete of that agent or that message is deferred to the + // trash. Its scenario restores both, so it is re-runnable. + DeferredPurgeAPIKey string // ReadOnlyAPIKey authenticates an account paused for abuse // (pause_class abuse), which makes it read-only: every write is refused // with 403 account_read_only. The read-only scenario ends by moving it to @@ -433,28 +444,50 @@ func StartContractServer(ctx context.Context, dbURL string) (*ContractServer, er } } + deferredEraseKey, err := seedRecentExternalSenderAccount(ctx, pool, store) + if err != nil { + _ = smtpServer.Close() + _ = httpServer.Shutdown(context.Background()) + _ = httpLn.Close() + wsHub.Close() + pool.Close() + return nil, err + } + + deferredPurgeKey, err := seedDeferredPurgeAccount(ctx, pool, store) + if err != nil { + _ = smtpServer.Close() + _ = httpServer.Shutdown(context.Background()) + _ = httpLn.Close() + wsHub.Close() + pool.Close() + return nil, err + } + return &ContractServer{ - DisposableTrashAPIKey: disposable[0], - DisposableEraseAPIKey: disposable[1], - ReadOnlyAPIKey: readOnlyKey, - ReadOnlyUserID: readOnlyUser, - RestrictedAPIKey: restrictedKey, - RestrictedUserID: restrictedUser, - RestrictedSDKAPIKey: restrictedSDKKey, - BaseURL: "http://" + httpLn.Addr().String(), - APIKey: key.PlaintextKey, - UserID: user.ID, - CappedAPIKey: cappedKey.PlaintextKey, - CappedUserID: cappedUser.ID, - OverCapAPIKey: overCapKey.PlaintextKey, - OverCapUserID: overCapUser.ID, - DBPool: pool, - Store: store, - WSHub: wsHub, - SMTPAddr: smtpAddr, - httpServer: httpServer, - httpLn: httpLn, - smtpServer: smtpServer, + DeferredPurgeAPIKey: deferredPurgeKey, + DisposableDeferredEraseAPIKey: deferredEraseKey, + DisposableTrashAPIKey: disposable[0], + DisposableEraseAPIKey: disposable[1], + ReadOnlyAPIKey: readOnlyKey, + ReadOnlyUserID: readOnlyUser, + RestrictedAPIKey: restrictedKey, + RestrictedUserID: restrictedUser, + RestrictedSDKAPIKey: restrictedSDKKey, + BaseURL: "http://" + httpLn.Addr().String(), + APIKey: key.PlaintextKey, + UserID: user.ID, + CappedAPIKey: cappedKey.PlaintextKey, + CappedUserID: cappedUser.ID, + OverCapAPIKey: overCapKey.PlaintextKey, + OverCapUserID: overCapUser.ID, + DBPool: pool, + Store: store, + WSHub: wsHub, + SMTPAddr: smtpAddr, + httpServer: httpServer, + httpLn: httpLn, + smtpServer: smtpServer, }, nil } @@ -546,6 +579,75 @@ const ( ContractReadOnlyAgent = "readonly-bot@agents.localhost" ) +// ContractDeferredEraseAgent is the sending agent of the recent-external- +// sender fixture account. +const ContractDeferredEraseAgent = "deferred-erase-bot@agents.localhost" + +// seedRecentExternalSenderAccount seeds the disposable account whose +// permanent erase is deferred: one shared-domain agent with an outbound +// message the provider accepted an hour ago, addressed to an external +// recipient — the message_recipients row the send worker writes on provider +// acceptance. Seeded directly because the contract server has no provider. +func seedRecentExternalSenderAccount(ctx context.Context, pool *pgxpool.Pool, store *identity.Store) (string, error) { + user, err := store.CreateOrGetUser(ctx, "disposable-deferred-erase@example.test", "Contract Disposable", "google-contract-disposable-deferred-erase") + if err != nil { + return "", err + } + if _, err := store.CreateAgentWithLimit(ctx, ContractDeferredEraseAgent, "agents.localhost", "Deferred Erase Bot", user.ID, 0); err != nil { + return "", err + } + if _, err := pool.Exec(ctx, ` + INSERT INTO messages (id, agent_id, direction, sender, recipient, subject, delivery_status, created_at, provider_accepted_at) + VALUES ('msg_contract_deferred_erase', $1, 'outbound', $1, 'someone@example.com', 'contract fixture', 'sent', + now() - interval '1 hour', now() - interval '1 hour')`, ContractDeferredEraseAgent); err != nil { + return "", err + } + if _, err := pool.Exec(ctx, ` + INSERT INTO message_recipients (id, message_id, address, kind, status) + VALUES ('rcpt_contract_deferred_erase', 'msg_contract_deferred_erase', 'someone@example.com', 'to', 'sent')`); err != nil { + return "", err + } + key, err := store.CreateAPIKey(ctx, user.ID, "contract-disposable-deferred-erase-key", nil) + if err != nil { + return "", err + } + return key.PlaintextKey, nil +} + +// Deferred-purge fixture: the agent and its externally sent message. +const ( + ContractDeferredPurgeAgent = "deferred-purge-bot@agents.localhost" + ContractDeferredPurgeMessage = "msg_contract_deferred_purge" +) + +// seedDeferredPurgeAccount seeds the account whose agent and message +// permanent deletes are deferred (see ContractServer.DeferredPurgeAPIKey). +func seedDeferredPurgeAccount(ctx context.Context, pool *pgxpool.Pool, store *identity.Store) (string, error) { + user, err := store.CreateOrGetUser(ctx, "deferred-purge@example.test", "Contract Deferred Purge", "google-contract-deferred-purge") + if err != nil { + return "", err + } + if _, err := store.CreateAgentWithLimit(ctx, ContractDeferredPurgeAgent, "agents.localhost", "Deferred Purge Bot", user.ID, 0); err != nil { + return "", err + } + if _, err := pool.Exec(ctx, ` + INSERT INTO messages (id, agent_id, direction, sender, recipient, subject, delivery_status, created_at, provider_accepted_at) + VALUES ($1, $2, 'outbound', $2, 'someone@example.com', 'contract fixture', 'sent', + now() - interval '1 hour', now() - interval '1 hour')`, ContractDeferredPurgeMessage, ContractDeferredPurgeAgent); err != nil { + return "", err + } + if _, err := pool.Exec(ctx, ` + INSERT INTO message_recipients (id, message_id, address, kind, status) + VALUES ('rcpt_contract_deferred_purge', $1, 'someone@example.com', 'to', 'sent')`, ContractDeferredPurgeMessage); err != nil { + return "", err + } + key, err := store.CreateAPIKey(ctx, user.ID, "contract-deferred-purge-key", nil) + if err != nil { + return "", err + } + return key.PlaintextKey, nil +} + func seedReadOnlyAccount(ctx context.Context, pool *pgxpool.Pool, store *identity.Store) (string, string, error) { user, err := store.CreateOrGetUser(ctx, ContractReadOnlyOwner, "Contract Read-Only", "google-contract-readonly") if err != nil { diff --git a/mcp/src/tools/agents.ts b/mcp/src/tools/agents.ts index ad3f60a58..ca7b07c2f 100644 --- a/mcp/src/tools/agents.ts +++ b/mcp/src/tools/agents.ts @@ -251,7 +251,7 @@ export function registerAgentTools(server: McpServer, client: McpClient): void { title: "Delete an agent inbox (DESTRUCTIVE)", annotations: { destructiveHint: true, idempotentHint: true }, description: - "Move the agent inbox to trash for about 30 days by default. The agent stops receiving mail and disappears from normal lists, but its messages and configuration are retained so it can be restored before automatic purge. Pass `permanent: true` to skip the trash and delete the inbox and every message in it irreversibly right away instead (accepts live and trashed agents); `delete_domain` succeeds only when the domain has no agents, so permanently delete every live or trashed agent on it first. Requires `confirm: true` — set it explicitly to acknowledge the destructive action.", + "Move the agent inbox to trash for about 30 days by default. The agent stops receiving mail and disappears from normal lists, but its messages and configuration are retained so it can be restored before automatic purge. Pass `permanent: true` to skip the trash and delete the inbox and every message in it irreversibly right away instead (accepts live and trashed agents) — except that an agent which emailed external recipients recently (within a deployment-configured window, 14 days by default) is moved to the trash instead and purged later: the result then has `erase_deferred: true`, `purge_after` and a `message`, and the agent can still be restored until `purge_after`; `delete_domain` succeeds only when the domain has no agents, so permanently delete every live or trashed agent on it first — an agent whose permanent delete returned `erase_deferred: true` stays in the trash until `purge_after`, and `delete_domain` keeps failing with `domain_has_agents` until then. Requires `confirm: true` — set it explicitly to acknowledge the destructive action.", inputSchema: strictInputSchema({ email: z .string() @@ -269,7 +269,7 @@ export function registerAgentTools(server: McpServer, client: McpClient): void { .boolean() .optional() .describe( - "Skip the trash and delete irreversibly right away, for a live or already-trashed agent. Purges every message in the inbox with no restore path. Defaults to false (moves to trash, restorable for about 30 days).", + "Skip the trash and delete irreversibly right away, for a live or already-trashed agent. Purges every message in the inbox with no restore path — unless the agent emailed external recipients recently, in which case it is moved to the trash instead (result erase_deferred: true, purge_after). Defaults to false (moves to trash, restorable for about 30 days).", ), }), }, @@ -281,7 +281,8 @@ export function registerAgentTools(server: McpServer, client: McpClient): void { ); } // Return the server's deletion receipt verbatim: - // {deleted:true, email, messages_deleted}. + // {deleted:true, email, messages_deleted} (+ erase_deferred, + // purge_after, message when a permanent delete was deferred). return client.deleteAgent(args.email, args.permanent); }), ); diff --git a/mcp/src/tools/domains.ts b/mcp/src/tools/domains.ts index 546bbe531..76ffb5328 100644 --- a/mcp/src/tools/domains.ts +++ b/mcp/src/tools/domains.ts @@ -91,7 +91,7 @@ export function registerDomainTools(server: McpServer, client: McpClient): void title: "Delete a custom mail domain (DESTRUCTIVE)", annotations: { destructiveHint: true, idempotentHint: true }, description: - "Permanently remove a domain registration and deprovision its sending identity. The operation succeeds only when the domain has no agents: permanently delete every live or trashed agent on the domain first. Moving an agent to trash is not sufficient because trashed agents still belong to the domain. The returned `sending_teardown` receipt is the DNS-release contract: only `confirmed` proves the provider identity is absent. Keep DNS published for `pending`, `manual_review`, missing, or unknown values. A unique `idempotency_key` is REQUIRED for this logical deletion; reuse it after an ambiguous network failure so, within the published retention window (at least 24 hours), the server follows the original incarnation-bound receipt without deleting a later registration of the same domain. After retention, the same key is a new operation. Use a new key only to delete a replacement registration. `manual_review` requires operator support. Irreversible. Requires `confirm: true` — set it explicitly to acknowledge the destructive scope.", + "Permanently remove a domain registration and deprovision its sending identity. The operation succeeds only when the domain has no agents: permanently delete every live or trashed agent on the domain first. Moving an agent to trash is not sufficient because trashed agents still belong to the domain, and an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends (its delete returns `erase_deferred: true`), so the domain stays blocked until that agent's `purge_after`. The returned `sending_teardown` receipt is the DNS-release contract: only `confirmed` proves the provider identity is absent. Keep DNS published for `pending`, `manual_review`, missing, or unknown values. A unique `idempotency_key` is REQUIRED for this logical deletion; reuse it after an ambiguous network failure so, within the published retention window (at least 24 hours), the server follows the original incarnation-bound receipt without deleting a later registration of the same domain. After retention, the same key is a new operation. Use a new key only to delete a replacement registration. `manual_review` requires operator support. Irreversible. Requires `confirm: true` — set it explicitly to acknowledge the destructive scope.", inputSchema: strictInputSchema({ domain: z.string().min(1).describe("Domain to delete."), idempotency_key: z diff --git a/migrations/125_messages_agent_delayed_outbound_idx.sql b/migrations/125_messages_agent_delayed_outbound_idx.sql new file mode 100644 index 000000000..4fcd699f2 --- /dev/null +++ b/migrations/125_messages_agent_delayed_outbound_idx.sql @@ -0,0 +1,26 @@ +-- 125_messages_agent_delayed_outbound_idx.sql +-- e2a:no-transaction +-- +-- Supports the deferred-erase recent-external-send lookup +-- (internal/identity/account_erase_defer.go, arm B): an OLD outbound message +-- that was scheduled or held for review can be submitted long after its +-- created_at (a hold's TTL is unbounded), so that arm cannot bound +-- created_at and would otherwise walk every message of the agent through +-- idx_messages_agent_created while the agent row is locked. It is keyed by +-- the fire/approval instant, GREATEST(scheduled_at, reviewed_at), so a lookup +-- for "anything that went out in the last N days" range-scans only those. +-- Scheduled and review-held sends are a small fraction of outbound mail, so +-- the partial index is small; the query repeats its predicate verbatim so the +-- planner can prove the implication. +-- +-- CREATE INDEX CONCURRENTLY + e2a:no-transaction for the same reasons as +-- 106/107: messages is the hottest table and a plain CREATE INDEX would block +-- writes for the whole build. +-- +-- OPS NOTE — invalid-index recovery: an interrupted CONCURRENTLY build leaves +-- an INVALID index that IF NOT EXISTS then skips. To recover: +-- DROP INDEX CONCURRENTLY IF EXISTS idx_messages_agent_delayed_outbound; +-- then re-run this statement. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_messages_agent_delayed_outbound + ON messages (agent_id, (GREATEST(scheduled_at, reviewed_at))) + WHERE direction = 'outbound' AND (scheduled_at IS NOT NULL OR reviewed_at IS NOT NULL); diff --git a/sdks/python/src/e2a/v1/client.py b/sdks/python/src/e2a/v1/client.py index a92f42af5..d930377af 100644 --- a/sdks/python/src/e2a/v1/client.py +++ b/sdks/python/src/e2a/v1/client.py @@ -487,11 +487,27 @@ async def replace_protection(self, email: str, config: Body) -> ProtectionConfig lambda h: self._api.put_agent_protection(email, req, _headers=h) ) - async def delete(self, email: str) -> DeleteAgentResult: - # The typed .delete() call is the confirmation; the SDK supplies the - # ?confirm=DELETE guard the raw API requires (AG-6). Returns the deletion - # receipt ({deleted, email, messages_deleted}). - return await self._c._write_idempotent(lambda h: self._api.delete_agent(email, confirm="DELETE", _headers=h)) + async def delete(self, email: str, *, permanent: bool = False) -> DeleteAgentResult: + """Move an agent to the trash (restorable via ``restore()`` within the + 30-day window by default). + + Pass ``permanent=True`` to delete irreversibly right away instead — + accepts live and trashed agents. An agent that emailed external + recipients recently (within a deployment-configured window, 14 days + by default) is not deleted at once: it is moved to the trash (or + stays there) so late delivery feedback still reaches it, and the + receipt has ``erase_deferred=True``, ``purge_after`` and a + human-readable ``message``; it can be restored until ``purge_after``. + + The typed .delete() call is the confirmation; the SDK supplies the + ?confirm=DELETE guard the raw API requires (AG-6). Returns the + deletion receipt ({deleted, email, messages_deleted}). + """ + return await self._c._write_idempotent( + lambda h: self._api.delete_agent( + email, confirm="DELETE", permanent=permanent or None, _headers=h + ) + ) async def restore(self, email: str) -> AgentView: """Restore an agent from the 30-day trash. Scheduled messages restored @@ -661,7 +677,11 @@ async def delete( in the trash ("delete forever") — irreversible, account scope only. The typed .delete() call is the confirmation; the SDK supplies the ?confirm=DELETE guard the raw API requires on that path (it is ignored - when permanent is unset). + when permanent is unset). A message sent to external recipients + recently (within a deployment-configured window, 14 days by default) + is not deleted at once: it stays in the trash, and the receipt has + ``erase_deferred=True``, ``purge_after`` and a human-readable + ``message``. A message held for review cannot be deleted (409 message_held) — resolve it on the review queue first. Returns the deletion receipt @@ -1456,6 +1476,15 @@ async def delete(self, *, permanent: bool = False) -> DeleteUserDataResult: other counts describe rows trashed/revoked/unverified, not deleted); ``user_deleted`` is true only for ``mode="permanent"``. + ``permanent=True`` on an account that emailed external recipients + recently (within a deployment-configured window, 14 days by default) + is not erased at once: it is moved to the trash like a default + delete so late delivery feedback (spam complaints, bounces) still + reaches it. The call still succeeds — the receipt has + ``mode="trash"``, ``erase_deferred=True``, ``purge_after`` (when it + will be erased) and a human-readable ``message``; the owner can + restore it before ``purge_after``. + Deliberately NOT retried (unlike the other DELETEs): even the default trash mode revokes every key/grant/session at once, and a transient failure should surface loudly to the caller rather than diff --git a/sdks/python/src/e2a/v1/generated/api/account_api.py b/sdks/python/src/e2a/v1/generated/api/account_api.py index 793bb2c2d..d5618d2b7 100644 --- a/sdks/python/src/e2a/v1/generated/api/account_api.py +++ b/sdks/python/src/e2a/v1/generated/api/account_api.py @@ -627,7 +627,7 @@ def _create_sending_access_request_serialize( async def delete_account( self, confirm: Annotated[StrictStr, Field(description="Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible.")], - permanent: Annotated[Optional[StrictBool], Field(description="Erase the account and all its data immediately instead of moving it to the trash. Irreversible.")] = None, + permanent: Annotated[Optional[StrictBool], Field(description="Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -643,11 +643,11 @@ async def delete_account( ) -> DeleteUserDataResult: """Delete your account (trash by default; permanent=true erases now) - Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account's sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account's sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. + Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account's sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account's sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. :param confirm: Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible. (required) :type confirm: str - :param permanent: Erase the account and all its data immediately instead of moving it to the trash. Irreversible. + :param permanent: Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. :type permanent: bool :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -699,7 +699,7 @@ async def delete_account( async def delete_account_with_http_info( self, confirm: Annotated[StrictStr, Field(description="Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible.")], - permanent: Annotated[Optional[StrictBool], Field(description="Erase the account and all its data immediately instead of moving it to the trash. Irreversible.")] = None, + permanent: Annotated[Optional[StrictBool], Field(description="Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -715,11 +715,11 @@ async def delete_account_with_http_info( ) -> ApiResponse[DeleteUserDataResult]: """Delete your account (trash by default; permanent=true erases now) - Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account's sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account's sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. + Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account's sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account's sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. :param confirm: Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible. (required) :type confirm: str - :param permanent: Erase the account and all its data immediately instead of moving it to the trash. Irreversible. + :param permanent: Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. :type permanent: bool :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -771,7 +771,7 @@ async def delete_account_with_http_info( async def delete_account_without_preload_content( self, confirm: Annotated[StrictStr, Field(description="Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible.")], - permanent: Annotated[Optional[StrictBool], Field(description="Erase the account and all its data immediately instead of moving it to the trash. Irreversible.")] = None, + permanent: Annotated[Optional[StrictBool], Field(description="Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -787,11 +787,11 @@ async def delete_account_without_preload_content( ) -> RESTResponseType: """Delete your account (trash by default; permanent=true erases now) - Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account's sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account's sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. + Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account's sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account's sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. :param confirm: Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible. (required) :type confirm: str - :param permanent: Erase the account and all its data immediately instead of moving it to the trash. Irreversible. + :param permanent: Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. :type permanent: bool :param _request_timeout: timeout setting for this request. If one number provided, it will be total request diff --git a/sdks/python/src/e2a/v1/generated/api/agents_api.py b/sdks/python/src/e2a/v1/generated/api/agents_api.py index 310b16da6..f0f8fdbf0 100644 --- a/sdks/python/src/e2a/v1/generated/api/agents_api.py +++ b/sdks/python/src/e2a/v1/generated/api/agents_api.py @@ -624,7 +624,7 @@ async def delete_agent( self, email: StrictStr, confirm: Annotated[StrictStr, Field(description="Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible.")], - permanent: Annotated[Optional[StrictBool], Field(description="Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents.")] = None, + permanent: Annotated[Optional[StrictBool], Field(description="Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -640,13 +640,13 @@ async def delete_agent( ) -> DeleteAgentResult: """Delete an agent - Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account's sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. + Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account's sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. :param email: (required) :type email: str :param confirm: Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible. (required) :type confirm: str - :param permanent: Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. + :param permanent: Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. :type permanent: bool :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -699,7 +699,7 @@ async def delete_agent_with_http_info( self, email: StrictStr, confirm: Annotated[StrictStr, Field(description="Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible.")], - permanent: Annotated[Optional[StrictBool], Field(description="Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents.")] = None, + permanent: Annotated[Optional[StrictBool], Field(description="Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -715,13 +715,13 @@ async def delete_agent_with_http_info( ) -> ApiResponse[DeleteAgentResult]: """Delete an agent - Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account's sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. + Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account's sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. :param email: (required) :type email: str :param confirm: Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible. (required) :type confirm: str - :param permanent: Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. + :param permanent: Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. :type permanent: bool :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -774,7 +774,7 @@ async def delete_agent_without_preload_content( self, email: StrictStr, confirm: Annotated[StrictStr, Field(description="Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible.")], - permanent: Annotated[Optional[StrictBool], Field(description="Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents.")] = None, + permanent: Annotated[Optional[StrictBool], Field(description="Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -790,13 +790,13 @@ async def delete_agent_without_preload_content( ) -> RESTResponseType: """Delete an agent - Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account's sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. + Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account's sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. :param email: (required) :type email: str :param confirm: Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible. (required) :type confirm: str - :param permanent: Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. + :param permanent: Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. :type permanent: bool :param _request_timeout: timeout setting for this request. If one number provided, it will be total request diff --git a/sdks/python/src/e2a/v1/generated/api/domains_api.py b/sdks/python/src/e2a/v1/generated/api/domains_api.py index 7c3117af4..d96725441 100644 --- a/sdks/python/src/e2a/v1/generated/api/domains_api.py +++ b/sdks/python/src/e2a/v1/generated/api/domains_api.py @@ -63,7 +63,7 @@ async def delete_domain( ) -> DeleteDomainResult: """Delete a domain - Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). + Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). :param domain: (required) :type domain: str @@ -140,7 +140,7 @@ async def delete_domain_with_http_info( ) -> ApiResponse[DeleteDomainResult]: """Delete a domain - Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). + Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). :param domain: (required) :type domain: str @@ -217,7 +217,7 @@ async def delete_domain_without_preload_content( ) -> RESTResponseType: """Delete a domain - Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). + Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). :param domain: (required) :type domain: str diff --git a/sdks/python/src/e2a/v1/generated/api/messages_api.py b/sdks/python/src/e2a/v1/generated/api/messages_api.py index a93cf4e42..ccc15d1fe 100644 --- a/sdks/python/src/e2a/v1/generated/api/messages_api.py +++ b/sdks/python/src/e2a/v1/generated/api/messages_api.py @@ -73,7 +73,7 @@ async def delete_message( ) -> DeleteMessageResult: """Delete a message (move to trash) - Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. + Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. :param email: The agent's full email address. (required) :type email: str @@ -152,7 +152,7 @@ async def delete_message_with_http_info( ) -> ApiResponse[DeleteMessageResult]: """Delete a message (move to trash) - Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. + Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. :param email: The agent's full email address. (required) :type email: str @@ -231,7 +231,7 @@ async def delete_message_without_preload_content( ) -> RESTResponseType: """Delete a message (move to trash) - Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. + Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. :param email: The agent's full email address. (required) :type email: str diff --git a/sdks/python/src/e2a/v1/generated/models/delete_agent_result.py b/sdks/python/src/e2a/v1/generated/models/delete_agent_result.py index 44f47fa98..81264b090 100644 --- a/sdks/python/src/e2a/v1/generated/models/delete_agent_result.py +++ b/sdks/python/src/e2a/v1/generated/models/delete_agent_result.py @@ -17,8 +17,9 @@ import re # noqa: F401 import json +from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List +from typing import Any, ClassVar, Dict, List, Optional from typing import Optional, Set from typing_extensions import Self @@ -28,9 +29,12 @@ class DeleteAgentResult(BaseModel): """ # noqa: E501 deleted: StrictBool = Field(description="Always true — the agent is no longer active. A failed delete is an error envelope, never deleted:false.") email: StrictStr = Field(description="Email address of the deleted agent.") + erase_deferred: Optional[StrictBool] = Field(default=None, description="True when permanent=true was requested but the agent emailed external recipients recently (within a deployment-configured window, 14 days by default), so it was moved to the trash instead of being deleted now: messages_deleted is 0, and the agent is purged at purge_after unless restored before then. Absent otherwise.") + message: Optional[StrictStr] = Field(default=None, description="Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred.") messages_deleted: StrictInt = Field(description="Number of messages permanently removed by the cascade; zero when the agent is moved to trash.") + purge_after: Optional[datetime] = Field(default=None, description="When a deferred agent becomes eligible for permanent purge from the trash. Present only when erase_deferred is true.") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["deleted", "email", "messages_deleted"] + __properties: ClassVar[List[str]] = ["deleted", "email", "erase_deferred", "message", "messages_deleted", "purge_after"] model_config = ConfigDict( populate_by_name=True, @@ -92,7 +96,10 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "deleted": obj.get("deleted"), "email": obj.get("email"), - "messages_deleted": obj.get("messages_deleted") + "erase_deferred": obj.get("erase_deferred"), + "message": obj.get("message"), + "messages_deleted": obj.get("messages_deleted"), + "purge_after": obj.get("purge_after") }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdks/python/src/e2a/v1/generated/models/delete_message_result.py b/sdks/python/src/e2a/v1/generated/models/delete_message_result.py index 81f742e7c..d2b6136cf 100644 --- a/sdks/python/src/e2a/v1/generated/models/delete_message_result.py +++ b/sdks/python/src/e2a/v1/generated/models/delete_message_result.py @@ -17,8 +17,9 @@ import re # noqa: F401 import json +from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr -from typing import Any, ClassVar, Dict, List +from typing import Any, ClassVar, Dict, List, Optional from typing import Optional, Set from typing_extensions import Self @@ -27,9 +28,12 @@ class DeleteMessageResult(BaseModel): DeleteMessageResult """ # noqa: E501 deleted: StrictBool = Field(description="Always true — the message is deleted (moved to trash or purged). A failed delete is an error envelope, never deleted:false.") + erase_deferred: Optional[StrictBool] = Field(default=None, description="True when permanent=true was requested but the message was sent to external recipients recently (within a deployment-configured window, 14 days by default), so it stays in the trash instead of being deleted now; it is purged at purge_after unless restored before then. Absent otherwise.") id: StrictStr = Field(description="ID of the deleted message.") + message: Optional[StrictStr] = Field(default=None, description="Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred.") + purge_after: Optional[datetime] = Field(default=None, description="When a deferred message becomes eligible for permanent purge from the trash. Present only when erase_deferred is true.") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["deleted", "id"] + __properties: ClassVar[List[str]] = ["deleted", "erase_deferred", "id", "message", "purge_after"] model_config = ConfigDict( populate_by_name=True, @@ -90,7 +94,10 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "deleted": obj.get("deleted"), - "id": obj.get("id") + "erase_deferred": obj.get("erase_deferred"), + "id": obj.get("id"), + "message": obj.get("message"), + "purge_after": obj.get("purge_after") }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdks/python/src/e2a/v1/generated/models/delete_user_data_result.py b/sdks/python/src/e2a/v1/generated/models/delete_user_data_result.py index e5a922db5..a3f0c1bdb 100644 --- a/sdks/python/src/e2a/v1/generated/models/delete_user_data_result.py +++ b/sdks/python/src/e2a/v1/generated/models/delete_user_data_result.py @@ -33,6 +33,8 @@ class DeleteUserDataResult(BaseModel): api_keys_deleted: StrictInt deleted: StrictBool = Field(description="Always true — the account is no longer usable. A failed delete is an error envelope, never deleted:false.") domains_deleted: StrictInt + erase_deferred: Optional[StrictBool] = Field(default=None, description="True when permanent=true was requested but the account emailed external recipients recently (within a deployment-configured window, 14 days by default), so it was moved to the trash instead of being erased now: mode is trash and the account is purged at purge_after. The account is already unusable; the owner can restore it by signing in before purge_after. Absent otherwise.") + message: Optional[StrictStr] = Field(default=None, description="Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred.") messages_deleted: StrictInt mode: Optional[StrictStr] = Field(default=None, description="How the account was deleted. trash: the account is inert and restorable by signing in to the dashboard until purge_after, after which it is purged; messages_deleted is 0 and the other counts describe rows trashed, revoked or unverified. permanent: the content was erased now (?permanent=true, or a deployment with account trash disabled) and the counts are the rows removed. Open set: tolerate unknown values.") oauth_access_tokens_deleted: Optional[StrictInt] = None @@ -44,7 +46,7 @@ class DeleteUserDataResult(BaseModel): usage_summaries_deleted: StrictInt user_deleted: StrictBool = Field(description="True only when the account row itself was erased (mode permanent).") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["agent_suppressions_deleted", "agent_unsubscribe_tokens_deleted", "agents_deleted", "api_keys_deleted", "deleted", "domains_deleted", "messages_deleted", "mode", "oauth_access_tokens_deleted", "oauth_auth_codes_deleted", "oauth_refresh_tokens_deleted", "purge_after", "sessions_deleted", "usage_events_deleted", "usage_summaries_deleted", "user_deleted"] + __properties: ClassVar[List[str]] = ["agent_suppressions_deleted", "agent_unsubscribe_tokens_deleted", "agents_deleted", "api_keys_deleted", "deleted", "domains_deleted", "erase_deferred", "message", "messages_deleted", "mode", "oauth_access_tokens_deleted", "oauth_auth_codes_deleted", "oauth_refresh_tokens_deleted", "purge_after", "sessions_deleted", "usage_events_deleted", "usage_summaries_deleted", "user_deleted"] model_config = ConfigDict( populate_by_name=True, @@ -110,6 +112,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "api_keys_deleted": obj.get("api_keys_deleted"), "deleted": obj.get("deleted"), "domains_deleted": obj.get("domains_deleted"), + "erase_deferred": obj.get("erase_deferred"), + "message": obj.get("message"), "messages_deleted": obj.get("messages_deleted"), "mode": obj.get("mode"), "oauth_access_tokens_deleted": obj.get("oauth_access_tokens_deleted"), diff --git a/sdks/python/tests/test_contract.py b/sdks/python/tests/test_contract.py index b237fdff8..c839b0fe8 100644 --- a/sdks/python/tests/test_contract.py +++ b/sdks/python/tests/test_contract.py @@ -69,6 +69,12 @@ # deployed server — those scenarios then skip. DISPOSABLE_TRASH_API_KEY = os.environ.get("E2A_TEST_DISPOSABLE_TRASH_API_KEY", "") DISPOSABLE_ERASE_API_KEY = os.environ.get("E2A_TEST_DISPOSABLE_ERASE_API_KEY", "") +# The contract server's throwaway account seeded with a recent external send, +# whose permanent erase is deferred to the trash (once per server). +DISPOSABLE_DEFERRED_ERASE_API_KEY = os.environ.get("E2A_TEST_DISPOSABLE_DEFERRED_ERASE_API_KEY", "") +# The contract server's account whose agent/message permanent deletes are +# deferred (the scenario restores both, so it is re-runnable). +DEFERRED_PURGE_API_KEY = os.environ.get("E2A_TEST_DEFERRED_PURGE_API_KEY", "") # The contract server's abuse-paused (read-only) account; its scenario trashes # it at the end (once per server). Absent against a deployed server — the # scenario then skips. @@ -166,6 +172,8 @@ def values_equal(json_val: Any, yaml_val: Any) -> bool: RESTRICTED_KEY_PLACEHOLDER = "{restricted_api_key}" DISPOSABLE_TRASH_KEY_PLACEHOLDER = "{disposable_trash_api_key}" DISPOSABLE_ERASE_KEY_PLACEHOLDER = "{disposable_erase_api_key}" +DISPOSABLE_DEFERRED_ERASE_KEY_PLACEHOLDER = "{disposable_deferred_erase_api_key}" +DEFERRED_PURGE_KEY_PLACEHOLDER = "{deferred_purge_api_key}" READONLY_KEY_PLACEHOLDER = "{readonly_api_key}" @@ -255,6 +263,10 @@ def __init__(self, base_url: str, api_key: str, scenario: dict[str, Any]): self.vars["disposable_trash_api_key"] = DISPOSABLE_TRASH_API_KEY if DISPOSABLE_ERASE_API_KEY: self.vars["disposable_erase_api_key"] = DISPOSABLE_ERASE_API_KEY + if DISPOSABLE_DEFERRED_ERASE_API_KEY: + self.vars["disposable_deferred_erase_api_key"] = DISPOSABLE_DEFERRED_ERASE_API_KEY + if DEFERRED_PURGE_API_KEY: + self.vars["deferred_purge_api_key"] = DEFERRED_PURGE_API_KEY if READONLY_API_KEY: self.vars["readonly_api_key"] = READONLY_API_KEY self._http = httpx.Client(base_url=base_url, timeout=30) @@ -1306,6 +1318,13 @@ def test_contract_scenario(scenario): pytest.skip(f"scenario {scenario['name']}: needs E2A_TEST_DISPOSABLE_TRASH_API_KEY") if _scenario_uses_placeholder(scenario, DISPOSABLE_ERASE_KEY_PLACEHOLDER) and not DISPOSABLE_ERASE_API_KEY: pytest.skip(f"scenario {scenario['name']}: needs E2A_TEST_DISPOSABLE_ERASE_API_KEY") + if ( + _scenario_uses_placeholder(scenario, DISPOSABLE_DEFERRED_ERASE_KEY_PLACEHOLDER) + and not DISPOSABLE_DEFERRED_ERASE_API_KEY + ): + pytest.skip(f"scenario {scenario['name']}: needs E2A_TEST_DISPOSABLE_DEFERRED_ERASE_API_KEY") + if _scenario_uses_placeholder(scenario, DEFERRED_PURGE_KEY_PLACEHOLDER) and not DEFERRED_PURGE_API_KEY: + pytest.skip(f"scenario {scenario['name']}: needs E2A_TEST_DEFERRED_PURGE_API_KEY") # The read-only scenario runs only against the contract server's seeded # abuse-paused account (and trashes it). if _scenario_uses_placeholder(scenario, READONLY_KEY_PLACEHOLDER) and not READONLY_API_KEY: diff --git a/sdks/python/tests/test_v1_client.py b/sdks/python/tests/test_v1_client.py index eca70937c..0eac54d61 100644 --- a/sdks/python/tests/test_v1_client.py +++ b/sdks/python/tests/test_v1_client.py @@ -2086,3 +2086,81 @@ async def test_request_sending_access_rate_limited_maps_to_rate_limit_error(http assert ei.value.code == "rate_limited" assert ei.value.retryable is True assert ei.value.retry_after_seconds == 60 + + +@pytest.mark.anyio +async def test_account_delete_permanent_deferred_receipt(httpx_mock): + httpx_mock.add_response( + json={ + "deleted": True, + "mode": "trash", + "erase_deferred": True, + "purge_after": "2026-10-26T00:00:00Z", + "message": "kept in the trash", + "messages_deleted": 0, + "usage_events_deleted": 0, + "usage_summaries_deleted": 0, + "agents_deleted": 1, + "domains_deleted": 0, + "api_keys_deleted": 1, + "sessions_deleted": 0, + "agent_suppressions_deleted": 0, + "agent_unsubscribe_tokens_deleted": 0, + "user_deleted": False, + } + ) + async with _client() as c: + res = await c.account.delete(permanent=True) + assert res.mode == "trash" + assert res.erase_deferred is True + assert res.purge_after is not None + assert res.user_deleted is False + + +@pytest.mark.anyio +async def test_agent_delete_trashes_by_default_and_omits_permanent(httpx_mock): + httpx_mock.add_response(json={"deleted": True, "email": "bot@agents.localhost", "messages_deleted": 0}) + async with _client() as c: + res = await c.agents.delete("bot@agents.localhost") + req = httpx_mock.get_requests()[-1] + assert req.method == "DELETE" + assert "confirm=DELETE" in str(req.url) + assert "permanent" not in req.url.params + assert res.erase_deferred is None + + +@pytest.mark.anyio +async def test_agent_delete_permanent_reports_a_deferral(httpx_mock): + httpx_mock.add_response( + json={ + "deleted": True, + "email": "bot@agents.localhost", + "messages_deleted": 0, + "erase_deferred": True, + "purge_after": "2026-10-26T00:00:00Z", + "message": "moved to the trash", + } + ) + async with _client() as c: + res = await c.agents.delete("bot@agents.localhost", permanent=True) + req = httpx_mock.get_requests()[-1] + assert req.url.params["permanent"] == "true" + assert res.erase_deferred is True + assert res.purge_after is not None + + +@pytest.mark.anyio +async def test_message_delete_permanent_reports_a_deferral(httpx_mock): + httpx_mock.add_response( + json={ + "deleted": True, + "id": "msg_sent", + "erase_deferred": True, + "purge_after": "2026-10-26T00:00:00Z", + "message": "stays in the trash", + } + ) + async with _client() as c: + res = await c.messages.delete("bot@agents.localhost", "msg_sent", permanent=True) + assert res.erase_deferred is True + assert res.purge_after is not None diff --git a/sdks/typescript/src/v1/client.ts b/sdks/typescript/src/v1/client.ts index 9eb4e71a3..230d04e02 100644 --- a/sdks/typescript/src/v1/client.ts +++ b/sdks/typescript/src/v1/client.ts @@ -317,6 +317,13 @@ class AgentsResource { * call is itself the confirmation; the ?confirm=DELETE guard exists to * protect raw/curl callers (AG-6). Returns the deletion receipt * ({deleted:true, email, messages_deleted}). + * + * `{ permanent: true }` on an agent that emailed external recipients + * recently (within a deployment-configured window, 14 days by default) is + * not deleted at once: it is moved to the trash (or stays there) so late + * delivery feedback still reaches it. The call still succeeds — the receipt + * has `eraseDeferred: true`, `purgeAfter` and a human-readable `message`, + * and the agent can be restored until `purgeAfter`. */ delete(email: string, opts: { permanent?: boolean } = {}): Promise { return call(() => this.api.deleteAgent(email, "DELETE", opts.permanent)); @@ -438,7 +445,10 @@ class MessagesResource { * only. The typed .delete() call is itself the confirmation; the SDK supplies * the ?confirm=DELETE guard the raw API requires on that path (the query * guard exists to protect raw/curl callers). It is ignored when permanent is - * unset. + * unset. A message sent to external recipients recently (within a + * deployment-configured window, 14 days by default) is not deleted at once: + * it stays in the trash, and the receipt has `eraseDeferred: true`, + * `purgeAfter` and a human-readable `message`. * * A message held for review cannot be deleted (409 message_held) — resolve it * on the review queue first. Returns the deletion receipt ({deleted:true, id}). @@ -953,6 +963,14 @@ class AccountResource { * (`mode` is `"trash"` or `"permanent"`; `messagesDeleted` is 0 on the trash * path, with the other counts describing rows trashed/revoked/unverified * rather than deleted; `userDeleted` is true only for `mode: "permanent"`). + * + * `{ permanent: true }` on an account that emailed external recipients + * recently (within a deployment-configured window, 14 days by default) is + * not erased at once: it is moved to the trash like a default delete so + * late delivery feedback (spam complaints, bounces) still reaches it. The + * call still succeeds — the receipt has `mode: "trash"`, + * `eraseDeferred: true`, `purgeAfter` (when it will be erased) and a + * human-readable `message`; the owner can restore it before `purgeAfter`. */ delete(opts: { permanent?: boolean } = {}): Promise { return call(() => this.api.deleteAccount("DELETE", opts.permanent || undefined)); diff --git a/sdks/typescript/src/v1/generated/apis/AccountApi.ts b/sdks/typescript/src/v1/generated/apis/AccountApi.ts index 5c77fe4aa..002a11a49 100644 --- a/sdks/typescript/src/v1/generated/apis/AccountApi.ts +++ b/sdks/typescript/src/v1/generated/apis/AccountApi.ts @@ -129,10 +129,10 @@ export class AccountApiRequestFactory extends BaseAPIRequestFactory { } /** - * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. + * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. * Delete your account (trash by default; permanent=true erases now) * @param confirm Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible. - * @param permanent Erase the account and all its data immediately instead of moving it to the trash. Irreversible. + * @param permanent Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. */ public async deleteAccount(confirm: 'DELETE', permanent?: boolean, _options?: Configuration): Promise { let _config = _options || this.configuration; diff --git a/sdks/typescript/src/v1/generated/apis/AgentsApi.ts b/sdks/typescript/src/v1/generated/apis/AgentsApi.ts index 85f0b729c..fc1faf4c3 100644 --- a/sdks/typescript/src/v1/generated/apis/AgentsApi.ts +++ b/sdks/typescript/src/v1/generated/apis/AgentsApi.ts @@ -134,11 +134,11 @@ export class AgentsApiRequestFactory extends BaseAPIRequestFactory { } /** - * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. + * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. * Delete an agent * @param email * @param confirm Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible. - * @param permanent Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. + * @param permanent Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. */ public async deleteAgent(email: string, confirm: 'DELETE', permanent?: boolean, _options?: Configuration): Promise { let _config = _options || this.configuration; diff --git a/sdks/typescript/src/v1/generated/apis/DomainsApi.ts b/sdks/typescript/src/v1/generated/apis/DomainsApi.ts index 7372fbf17..531276e75 100644 --- a/sdks/typescript/src/v1/generated/apis/DomainsApi.ts +++ b/sdks/typescript/src/v1/generated/apis/DomainsApi.ts @@ -22,7 +22,7 @@ import { VerifyDomainView } from '../models/VerifyDomainView.js'; export class DomainsApiRequestFactory extends BaseAPIRequestFactory { /** - * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). + * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). * Delete a domain * @param domain * @param confirm Must be the literal DELETE — this action is irreversible. diff --git a/sdks/typescript/src/v1/generated/apis/MessagesApi.ts b/sdks/typescript/src/v1/generated/apis/MessagesApi.ts index 969b7d135..5ec697890 100644 --- a/sdks/typescript/src/v1/generated/apis/MessagesApi.ts +++ b/sdks/typescript/src/v1/generated/apis/MessagesApi.ts @@ -30,7 +30,7 @@ import { UpdateMessageResultView } from '../models/UpdateMessageResultView.js'; export class MessagesApiRequestFactory extends BaseAPIRequestFactory { /** - * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. + * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. * Delete a message (move to trash) * @param email The agent\'s full email address. * @param id The message id, e.g. msg_abc123. diff --git a/sdks/typescript/src/v1/generated/models/DeleteAgentResult.ts b/sdks/typescript/src/v1/generated/models/DeleteAgentResult.ts index 8bacb6177..5af642a5a 100644 --- a/sdks/typescript/src/v1/generated/models/DeleteAgentResult.ts +++ b/sdks/typescript/src/v1/generated/models/DeleteAgentResult.ts @@ -22,9 +22,21 @@ export class DeleteAgentResult { */ 'email': string; /** + * True when permanent=true was requested but the agent emailed external recipients recently (within a deployment-configured window, 14 days by default), so it was moved to the trash instead of being deleted now: messages_deleted is 0, and the agent is purged at purge_after unless restored before then. Absent otherwise. + */ + 'eraseDeferred'?: boolean; + /** + * Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred. + */ + 'message'?: string; + /** * Number of messages permanently removed by the cascade; zero when the agent is moved to trash. */ 'messagesDeleted': number; + /** + * When a deferred agent becomes eligible for permanent purge from the trash. Present only when erase_deferred is true. + */ + 'purgeAfter'?: Date; static readonly discriminator: string | undefined = undefined; @@ -43,11 +55,29 @@ export class DeleteAgentResult { "type": "string", "format": "" }, + { + "name": "eraseDeferred", + "baseName": "erase_deferred", + "type": "boolean", + "format": "" + }, + { + "name": "message", + "baseName": "message", + "type": "string", + "format": "" + }, { "name": "messagesDeleted", "baseName": "messages_deleted", "type": "number", "format": "int64" + }, + { + "name": "purgeAfter", + "baseName": "purge_after", + "type": "Date", + "format": "date-time" } ]; static getAttributeTypeMap() { diff --git a/sdks/typescript/src/v1/generated/models/DeleteMessageResult.ts b/sdks/typescript/src/v1/generated/models/DeleteMessageResult.ts index 913213f7c..40389ef7d 100644 --- a/sdks/typescript/src/v1/generated/models/DeleteMessageResult.ts +++ b/sdks/typescript/src/v1/generated/models/DeleteMessageResult.ts @@ -18,9 +18,21 @@ export class DeleteMessageResult { */ 'deleted': boolean; /** + * True when permanent=true was requested but the message was sent to external recipients recently (within a deployment-configured window, 14 days by default), so it stays in the trash instead of being deleted now; it is purged at purge_after unless restored before then. Absent otherwise. + */ + 'eraseDeferred'?: boolean; + /** * ID of the deleted message. */ 'id': string; + /** + * Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred. + */ + 'message'?: string; + /** + * When a deferred message becomes eligible for permanent purge from the trash. Present only when erase_deferred is true. + */ + 'purgeAfter'?: Date; static readonly discriminator: string | undefined = undefined; @@ -33,11 +45,29 @@ export class DeleteMessageResult { "type": "boolean", "format": "" }, + { + "name": "eraseDeferred", + "baseName": "erase_deferred", + "type": "boolean", + "format": "" + }, { "name": "id", "baseName": "id", "type": "string", "format": "" + }, + { + "name": "message", + "baseName": "message", + "type": "string", + "format": "" + }, + { + "name": "purgeAfter", + "baseName": "purge_after", + "type": "Date", + "format": "date-time" } ]; static getAttributeTypeMap() { diff --git a/sdks/typescript/src/v1/generated/models/DeleteUserDataResult.ts b/sdks/typescript/src/v1/generated/models/DeleteUserDataResult.ts index 333f3776a..3e3f19a7b 100644 --- a/sdks/typescript/src/v1/generated/models/DeleteUserDataResult.ts +++ b/sdks/typescript/src/v1/generated/models/DeleteUserDataResult.ts @@ -22,6 +22,14 @@ export class DeleteUserDataResult { */ 'deleted': boolean; 'domainsDeleted': number; + /** + * True when permanent=true was requested but the account emailed external recipients recently (within a deployment-configured window, 14 days by default), so it was moved to the trash instead of being erased now: mode is trash and the account is purged at purge_after. The account is already unusable; the owner can restore it by signing in before purge_after. Absent otherwise. + */ + 'eraseDeferred'?: boolean; + /** + * Human-readable explanation, present when erase_deferred is true. Do not parse it; branch on erase_deferred. + */ + 'message'?: string; 'messagesDeleted': number; /** * How the account was deleted. trash: the account is inert and restorable by signing in to the dashboard until purge_after, after which it is purged; messages_deleted is 0 and the other counts describe rows trashed, revoked or unverified. permanent: the content was erased now (?permanent=true, or a deployment with account trash disabled) and the counts are the rows removed. Open set: tolerate unknown values. @@ -83,6 +91,18 @@ export class DeleteUserDataResult { "type": "number", "format": "int64" }, + { + "name": "eraseDeferred", + "baseName": "erase_deferred", + "type": "boolean", + "format": "" + }, + { + "name": "message", + "baseName": "message", + "type": "string", + "format": "" + }, { "name": "messagesDeleted", "baseName": "messages_deleted", diff --git a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts index 37907ca82..a01f327d7 100644 --- a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts @@ -207,7 +207,7 @@ export interface AccountApiDeleteAccountRequest { */ confirm: 'DELETE' /** - * Erase the account and all its data immediately instead of moving it to the trash. Irreversible. + * Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. * Defaults to: undefined * @type boolean * @memberof AccountApideleteAccount @@ -371,7 +371,7 @@ export class ObjectAccountApi { } /** - * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. + * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. * Delete your account (trash by default; permanent=true erases now) * @param param the request object */ @@ -380,7 +380,7 @@ export class ObjectAccountApi { } /** - * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. + * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. * Delete your account (trash by default; permanent=true erases now) * @param param the request object */ @@ -578,7 +578,7 @@ export interface AgentsApiDeleteAgentRequest { */ confirm: 'DELETE' /** - * Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. + * Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. * Defaults to: undefined * @type boolean * @memberof AgentsApideleteAgent @@ -778,7 +778,7 @@ export class ObjectAgentsApi { } /** - * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. + * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. * Delete an agent * @param param the request object */ @@ -787,7 +787,7 @@ export class ObjectAgentsApi { } /** - * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. + * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. * Delete an agent * @param param the request object */ @@ -1635,7 +1635,7 @@ export class ObjectDomainsApi { } /** - * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). + * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). * Delete a domain * @param param the request object */ @@ -1644,7 +1644,7 @@ export class ObjectDomainsApi { } /** - * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). + * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). * Delete a domain * @param param the request object */ @@ -2270,7 +2270,7 @@ export class ObjectMessagesApi { } /** - * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. + * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. * Delete a message (move to trash) * @param param the request object */ @@ -2279,7 +2279,7 @@ export class ObjectMessagesApi { } /** - * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. + * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. * Delete a message (move to trash) * @param param the request object */ diff --git a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts index ee88e80d3..bf741fe5d 100644 --- a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts @@ -258,10 +258,10 @@ export class ObservableAccountApi { } /** - * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. + * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. * Delete your account (trash by default; permanent=true erases now) * @param confirm Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible. - * @param [permanent] Erase the account and all its data immediately instead of moving it to the trash. Irreversible. + * @param [permanent] Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. */ public deleteAccountWithHttpInfo(confirm: 'DELETE', permanent?: boolean, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); @@ -284,10 +284,10 @@ export class ObservableAccountApi { } /** - * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. + * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. * Delete your account (trash by default; permanent=true erases now) * @param confirm Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible. - * @param [permanent] Erase the account and all its data immediately instead of moving it to the trash. Irreversible. + * @param [permanent] Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. */ public deleteAccount(confirm: 'DELETE', permanent?: boolean, _options?: ConfigurationOptions): Observable { return this.deleteAccountWithHttpInfo(confirm, permanent, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); @@ -662,11 +662,11 @@ export class ObservableAgentsApi { } /** - * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. + * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. * Delete an agent * @param email * @param confirm Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible. - * @param [permanent] Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. + * @param [permanent] Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. */ public deleteAgentWithHttpInfo(email: string, confirm: 'DELETE', permanent?: boolean, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); @@ -689,11 +689,11 @@ export class ObservableAgentsApi { } /** - * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. + * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. * Delete an agent * @param email * @param confirm Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible. - * @param [permanent] Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. + * @param [permanent] Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. */ public deleteAgent(email: string, confirm: 'DELETE', permanent?: boolean, _options?: ConfigurationOptions): Observable { return this.deleteAgentWithHttpInfo(email, confirm, permanent, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); @@ -1576,7 +1576,7 @@ export class ObservableDomainsApi { } /** - * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). + * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). * Delete a domain * @param domain * @param confirm Must be the literal DELETE — this action is irreversible. @@ -1603,7 +1603,7 @@ export class ObservableDomainsApi { } /** - * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). + * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). * Delete a domain * @param domain * @param confirm Must be the literal DELETE — this action is irreversible. @@ -1902,7 +1902,7 @@ export class ObservableMessagesApi { } /** - * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. + * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. * Delete a message (move to trash) * @param email The agent\'s full email address. * @param id The message id, e.g. msg_abc123. @@ -1930,7 +1930,7 @@ export class ObservableMessagesApi { } /** - * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. + * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. * Delete a message (move to trash) * @param email The agent\'s full email address. * @param id The message id, e.g. msg_abc123. diff --git a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts index fd4237584..cced3e17e 100644 --- a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts +++ b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts @@ -229,10 +229,10 @@ export class PromiseAccountApi { } /** - * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. + * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. * Delete your account (trash by default; permanent=true erases now) * @param confirm Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible. - * @param [permanent] Erase the account and all its data immediately instead of moving it to the trash. Irreversible. + * @param [permanent] Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. */ public deleteAccountWithHttpInfo(confirm: 'DELETE', permanent?: boolean, _options?: PromiseConfigurationOptions): Promise> { const observableOptions = wrapOptions(_options); @@ -241,10 +241,10 @@ export class PromiseAccountApi { } /** - * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. + * Moves the account to the trash. Requires ?confirm=DELETE. The account becomes unusable at once: every API key, OAuth grant and dashboard session is revoked, every agent is trashed (inbound mail is refused), sending stops, and every custom domain loses its verification. Signing in to the dashboard before purge_after offers a restore — keys stay revoked and domains must be re-verified — after which the account and all its data are purged permanently (the trash window is deployment-configurable; 30 days by default). Pass permanent=true to erase the account and all its data immediately instead (refused with 409 erase_held while the account\'s sending is paused). An account that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not erased at once even with permanent=true: it is moved to the trash like a default delete and purged at purge_after, so delivery feedback such as spam complaints that arrives after a send still reaches it; the receipt then has mode trash, erase_deferred:true, purge_after and a message, and the owner can still restore it before purge_after. On deployments that disable account trash, every deletion is permanent. Either way the account\'s sign-in identity may be held for a period after deletion and cannot immediately register a new account. Returns 409 send_in_progress while an outbound provider call has a fresh lease; retry after it finishes. Returns 200 with a deletion receipt (deleted:true, mode, and per-table counts) — like every delete op, which all return 200 + a deletion object. * Delete your account (trash by default; permanent=true erases now) * @param confirm Must be the literal DELETE. The default action moves the account to the trash; permanent=true is irreversible. - * @param [permanent] Erase the account and all its data immediately instead of moving it to the trash. Irreversible. + * @param [permanent] Erase the account and all its data immediately instead of moving it to the trash. Irreversible. An account that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. */ public deleteAccount(confirm: 'DELETE', permanent?: boolean, _options?: PromiseConfigurationOptions): Promise { const observableOptions = wrapOptions(_options); @@ -502,11 +502,11 @@ export class PromiseAgentsApi { } /** - * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. + * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. * Delete an agent * @param email * @param confirm Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible. - * @param [permanent] Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. + * @param [permanent] Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. */ public deleteAgentWithHttpInfo(email: string, confirm: 'DELETE', permanent?: boolean, _options?: PromiseConfigurationOptions): Promise> { const observableOptions = wrapOptions(_options); @@ -515,11 +515,11 @@ export class PromiseAgentsApi { } /** - * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. + * Move an agent the caller owns to the trash. Requires ?confirm=DELETE. A trashed agent stops receiving mail, disappears from lists, and its held messages leave the review queue; restore it via POST /v1/agents/{email}/restore within the trash retention window — 30 days by default (deployment-configurable) — after which it is purged permanently (messages included). Live message data is otherwise retained indefinitely. Pass permanent=true to skip the trash and delete irreversibly right away (accepts live and trashed agents; refused with 409 erase_held while the account\'s sending is paused). An agent that emailed external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once even with permanent=true: it is moved to the trash (or stays there) and purged at purge_after, so delivery feedback such as spam complaints still reaches it; the receipt then has erase_deferred:true, purge_after and a message. Returns 200 with a deletion receipt; messages_deleted is zero when the agent is moved to trash. * Delete an agent * @param email * @param confirm Must be the literal DELETE. The default action moves the agent to trash; permanent=true is irreversible. - * @param [permanent] Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. + * @param [permanent] Delete irreversibly right away instead of moving to the trash. Accepts live and trashed agents. An agent that emailed external recipients recently is moved to the trash instead (receipt erase_deferred:true) and purged at purge_after. */ public deleteAgent(email: string, confirm: 'DELETE', permanent?: boolean, _options?: PromiseConfigurationOptions): Promise { const observableOptions = wrapOptions(_options); @@ -1143,7 +1143,7 @@ export class PromiseDomainsApi { } /** - * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). + * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). * Delete a domain * @param domain * @param confirm Must be the literal DELETE — this action is irreversible. @@ -1156,7 +1156,7 @@ export class PromiseDomainsApi { } /** - * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). + * Deletes the domain (refused with 400 domain_has_agents while any live or trashed agent exists on it; an agent that emailed external recipients recently cannot be permanently deleted until its trash window ends — its permanent delete answers erase_deferred — so the domain stays blocked until that agent is purged) and commits durable teardown of its sending identity. The provider-side identity is normally removed before the response returns; otherwise sending_teardown is pending (durable retries, including provider-disabled managed identities) or manual_review (an identity exists but ownership cannot be established). Send a unique Idempotency-Key for each logical deletion and reuse that key after an ambiguous network failure: within the published key-retention window, the key is committed with an incarnation-bound receipt, follows pending to confirmed, and cannot delete a later registration of the same domain. A retry after that window is a new operation. Use a new key to delete a replacement registration. Without a key, repeating DELETE only polls while the domain remains absent and is unsafe across re-registration. Keep DNS published unless sending_teardown is confirmed; treat missing or unknown values as not confirmed. Requires ?confirm=DELETE (irreversible). Returns 200 with a deletion object ({deleted:true, domain, sending_teardown}). * Delete a domain * @param domain * @param confirm Must be the literal DELETE — this action is irreversible. @@ -1375,7 +1375,7 @@ export class PromiseMessagesApi { } /** - * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. + * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. * Delete a message (move to trash) * @param email The agent\'s full email address. * @param id The message id, e.g. msg_abc123. @@ -1389,7 +1389,7 @@ export class PromiseMessagesApi { } /** - * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"). A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. + * Move a message to the trash. Trashed messages disappear from lists, threads, and reply targets, but can be restored via POST …/messages/{id}/restore until they are purged — 30 days after deletion by default (the trash retention window is deployment-configurable). Live message data is otherwise retained indefinitely. No confirmation is required because the default delete is reversible. Pass permanent=true with confirm=DELETE to permanently delete a message that is ALREADY in the trash (\"delete forever\"); a message sent to external recipients recently (within a deployment-configured window, 14 days by default) is not deleted at once — it stays in the trash and is purged at purge_after, and the receipt has erase_deferred:true, purge_after and a message. A message held for review (review_status=pending_review) cannot be deleted — resolve it in the review queue first (409 message_held). Returns 409 send_in_progress if provider submission has already started; retry after it finishes. * Delete a message (move to trash) * @param email The agent\'s full email address. * @param id The message id, e.g. msg_abc123. diff --git a/sdks/typescript/test/v1/contract.test.ts b/sdks/typescript/test/v1/contract.test.ts index 8f9a9e92c..692436ddd 100644 --- a/sdks/typescript/test/v1/contract.test.ts +++ b/sdks/typescript/test/v1/contract.test.ts @@ -63,6 +63,12 @@ const RESTRICTED_API_KEY = process.env.E2A_TEST_RESTRICTED_API_KEY; // deployed server — those scenarios then skip. const DISPOSABLE_TRASH_API_KEY = process.env.E2A_TEST_DISPOSABLE_TRASH_API_KEY; const DISPOSABLE_ERASE_API_KEY = process.env.E2A_TEST_DISPOSABLE_ERASE_API_KEY; +// The contract server's throwaway account seeded with a recent external send, +// whose permanent erase is deferred to the trash (once per server). +const DISPOSABLE_DEFERRED_ERASE_API_KEY = process.env.E2A_TEST_DISPOSABLE_DEFERRED_ERASE_API_KEY; +// The contract server's account whose agent/message permanent deletes are +// deferred (the scenario restores both, so it is re-runnable). +const DEFERRED_PURGE_API_KEY = process.env.E2A_TEST_DEFERRED_PURGE_API_KEY; // The contract server's abuse-paused (read-only) account; its scenario trashes // it at the end (once per server). Absent against a deployed server — the // scenario then skips. @@ -106,6 +112,14 @@ function scenarioNeedsDisposableEraseAccount(sc: Scenario): boolean { return scenarioUsesPlaceholder(sc, "{disposable_erase_api_key}"); } +function scenarioNeedsDisposableDeferredEraseAccount(sc: Scenario): boolean { + return scenarioUsesPlaceholder(sc, "{disposable_deferred_erase_api_key}"); +} + +function scenarioNeedsDeferredPurgeAccount(sc: Scenario): boolean { + return scenarioUsesPlaceholder(sc, "{deferred_purge_api_key}"); +} + function scenarioNeedsReadOnlyAccount(sc: Scenario): boolean { return scenarioUsesPlaceholder(sc, "{readonly_api_key}"); } @@ -933,6 +947,10 @@ class Runner { if (RESTRICTED_API_KEY) this.vars.restricted_api_key = RESTRICTED_API_KEY; if (DISPOSABLE_TRASH_API_KEY) this.vars.disposable_trash_api_key = DISPOSABLE_TRASH_API_KEY; if (DISPOSABLE_ERASE_API_KEY) this.vars.disposable_erase_api_key = DISPOSABLE_ERASE_API_KEY; + if (DISPOSABLE_DEFERRED_ERASE_API_KEY) { + this.vars.disposable_deferred_erase_api_key = DISPOSABLE_DEFERRED_ERASE_API_KEY; + } + if (DEFERRED_PURGE_API_KEY) this.vars.deferred_purge_api_key = DEFERRED_PURGE_API_KEY; if (READONLY_API_KEY) this.vars.readonly_api_key = READONLY_API_KEY; this.api = new RawApi(apiKey, baseUrl); this.seeder = SEED ? new Seeder(baseUrl, apiKey) : null; @@ -1369,6 +1387,8 @@ describe.skipIf(!baseUrl || !apiKey)("Contract scenarios", () => { // against the contract server's seeded disposable accounts. (scenarioNeedsDisposableTrashAccount(sc) && !DISPOSABLE_TRASH_API_KEY) || (scenarioNeedsDisposableEraseAccount(sc) && !DISPOSABLE_ERASE_API_KEY) || + (scenarioNeedsDisposableDeferredEraseAccount(sc) && !DISPOSABLE_DEFERRED_ERASE_API_KEY) || + (scenarioNeedsDeferredPurgeAccount(sc) && !DEFERRED_PURGE_API_KEY) || // The read-only scenario runs only against the contract server's seeded // abuse-paused account (and trashes it). (scenarioNeedsReadOnlyAccount(sc) && !READONLY_API_KEY); diff --git a/tests/contract/contract_test.go b/tests/contract/contract_test.go index 1f9cfb1c1..a86cbda6e 100644 --- a/tests/contract/contract_test.go +++ b/tests/contract/contract_test.go @@ -149,6 +149,12 @@ type testEnv struct { // throwaway accounts the account-deletion scenarios delete. disposableTrashAPIKey string disposableEraseAPIKey string + // disposableDeferredEraseAPIKey authenticates the throwaway account with + // a recent external send, whose permanent erase is deferred. + disposableDeferredEraseAPIKey string + // deferredPurgeAPIKey authenticates the account whose agent and message + // permanent deletes are deferred. + deferredPurgeAPIKey string // readOnlyAPIKey authenticates the abuse-paused (read-only) account. readOnlyAPIKey string } @@ -183,6 +189,9 @@ func setupEnv(t *testing.T) *testEnv { disposableTrashAPIKey: cs.DisposableTrashAPIKey, disposableEraseAPIKey: cs.DisposableEraseAPIKey, + disposableDeferredEraseAPIKey: cs.DisposableDeferredEraseAPIKey, + deferredPurgeAPIKey: cs.DeferredPurgeAPIKey, + readOnlyAPIKey: cs.ReadOnlyAPIKey, } } @@ -382,6 +391,8 @@ func (r *runner) resolve(s string) string { s = strings.ReplaceAll(s, restrictedKeyPlaceholder, r.env.restrictedAPIKey) s = strings.ReplaceAll(s, "{disposable_trash_api_key}", r.env.disposableTrashAPIKey) s = strings.ReplaceAll(s, "{disposable_erase_api_key}", r.env.disposableEraseAPIKey) + s = strings.ReplaceAll(s, "{disposable_deferred_erase_api_key}", r.env.disposableDeferredEraseAPIKey) + s = strings.ReplaceAll(s, "{deferred_purge_api_key}", r.env.deferredPurgeAPIKey) s = strings.ReplaceAll(s, readOnlyKeyPlaceholder, r.env.readOnlyAPIKey) for k, v := range r.vars { s = strings.ReplaceAll(s, "{"+k+"}", v) diff --git a/tests/contract/scenarios.yaml b/tests/contract/scenarios.yaml index f043c5ec1..1ddeff1f3 100644 --- a/tests/contract/scenarios.yaml +++ b/tests/contract/scenarios.yaml @@ -3548,6 +3548,105 @@ scenarios: body_match: "error.code": unauthorized + # Runs as {disposable_deferred_erase_api_key}: a throwaway account the + # contract server seeds with a provider-accepted send to an external + # recipient an hour ago. Deleted exactly once per run. + - name: account_delete_permanent_deferred_for_recent_sender + description: > + DELETE /v1/account?permanent=true on an account that emailed an external + recipient recently is deferred (docs/design/account-soft-deletion.md, + "Deferred erase for recent senders"): a 200 trash receipt with + erase_deferred=true, a purge_after and a message, user_deleted=false — + and the key stops authenticating at once, as for any trash. + auth_override: "Bearer {disposable_deferred_erase_api_key}" + steps: + - id: erase_is_deferred + action: request + method: DELETE + path: /v1/account?confirm=DELETE&permanent=true + expect: + status: 200 + body_contains: [purge_after, message] + body_match: + deleted: true + mode: trash + erase_deferred: true + user_deleted: false + messages_deleted: 0 + + - id: deferred_key_is_401 + action: request + method: GET + path: /v1/account + expect: + status: 401 + body_match: + "error.code": unauthorized + + # Runs as {deferred_purge_api_key}: an account whose one agent has a + # provider-accepted send to an external recipient an hour ago. The + # scenario restores what it trashes, so it is re-runnable. + - name: permanent_agent_and_message_delete_deferred_for_recent_sender + description: > + Permanent deletion of a message sent to an external recipient recently, + and of the agent that sent it, is deferred to the trash + (docs/design/account-soft-deletion.md, "Deferred erase for recent + senders"): each answers 200 with erase_deferred=true, a purge_after and + a message, nothing is deleted, and both can be restored. + auth_override: "Bearer {deferred_purge_api_key}" + steps: + - id: trash_message + action: request + method: DELETE + path: /v1/agents/deferred-purge-bot@agents.localhost/messages/msg_contract_deferred_purge + expect: + status: 200 + body_match: + deleted: true + id: msg_contract_deferred_purge + + - id: message_purge_is_deferred + action: request + method: DELETE + path: /v1/agents/deferred-purge-bot@agents.localhost/messages/msg_contract_deferred_purge?permanent=true&confirm=DELETE + expect: + status: 200 + body_contains: [purge_after, message] + body_match: + deleted: true + erase_deferred: true + + - id: restore_message + action: request + method: POST + path: /v1/agents/deferred-purge-bot@agents.localhost/messages/msg_contract_deferred_purge/restore + expect: + status: 200 + body_match: + id: msg_contract_deferred_purge + + - id: agent_purge_is_deferred + action: request + method: DELETE + path: /v1/agents/deferred-purge-bot@agents.localhost?confirm=DELETE&permanent=true + expect: + status: 200 + body_contains: [purge_after, message] + body_match: + deleted: true + email: deferred-purge-bot@agents.localhost + erase_deferred: true + messages_deleted: 0 + + - id: restore_agent + action: request + method: POST + path: /v1/agents/deferred-purge-bot@agents.localhost/restore + expect: + status: 200 + body_match: + email: deferred-purge-bot@agents.localhost + # ── Read-only accounts (docs/design/account-read-only.md) ── # Runs as the contract server's abuse-paused account ({readonly_api_key}), # which it seeds read-only. The scenario ends by moving that account to the diff --git a/tests/e2e-prod/harness/cleanup.test.ts b/tests/e2e-prod/harness/cleanup.test.ts index 04cc94cf2..da1f5a6c5 100644 --- a/tests/e2e-prod/harness/cleanup.test.ts +++ b/tests/e2e-prod/harness/cleanup.test.ts @@ -406,7 +406,7 @@ test("a Retry-After shorter than the backoff floor does not shorten the wait", a test("cleanup on an empty registry is a no-op", async () => { const { client, calls } = fakeClient(() => 204); const r = await cleanup(client, { sleep: noSleep }); - assert.deepEqual(r, { attempted: 0, succeeded: 0, failed: [], completed: [] }); + assert.deepEqual(r, { attempted: 0, succeeded: 0, failed: [], completed: [], deferred: [] }); assert.equal(calls.length, 0); }); @@ -506,3 +506,34 @@ test("a second cleanup pass retries what the first one failed", async () => { assert.equal(r2.succeeded, 1); assert.equal(getTracked().length, 0); }); + +test("a deferred permanent delete is reported apart, not as succeeded, and not retried", async () => { + track("agent", "sent-externally@x.test"); + track("agent", "quiet@x.test"); + const { client, calls } = fakeClient((path) => + path.includes("sent-externally") + ? { + status: 200, + headers: {}, + raw: JSON.stringify({ + deleted: true, + email: "sent-externally@x.test", + messages_deleted: 0, + erase_deferred: true, + purge_after: "2026-10-28T00:00:00Z", + }), + } + : { status: 200, headers: {}, raw: JSON.stringify({ deleted: true, email: "quiet@x.test", messages_deleted: 2 }) }, + ); + + const r = await cleanup(client, { sleep: noSleep }); + + assert.equal(r.attempted, 2); + assert.equal(r.succeeded, 1); + assert.deepEqual(r.failed, []); + assert.deepEqual(r.deferred, [ + { kind: "agent", id: "sent-externally@x.test", purgeAfter: "2026-10-28T00:00:00Z" }, + ]); + assert.equal(calls.filter((c) => c.includes("sent-externally")).length, 1, "a deferral must not be retried"); + assert.equal(getTracked().length, 0); +}); diff --git a/tests/e2e-prod/harness/cleanup.ts b/tests/e2e-prod/harness/cleanup.ts index b0a3fbbe4..7270bb1a6 100644 --- a/tests/e2e-prod/harness/cleanup.ts +++ b/tests/e2e-prod/harness/cleanup.ts @@ -20,6 +20,14 @@ export interface CleanupResult { * domain-fixture flow gates DNS removal on sending_teardown. */ completed: Array<{ kind: Kind; id: string; raw: string }>; + /** + * Permanent deletes the server deferred (200 with erase_deferred: the + * fixture emailed an external recipient recently, so it stays in the trash + * until purge_after). NOT purged and not counted in `succeeded`; untracked + * all the same — retrying would be deferred again, and the janitor removes + * it at the end of the trash window. + */ + deferred: Array<{ kind: Kind; id: string; purgeAfter: string | null }>; } export interface CleanupOpts { @@ -116,6 +124,7 @@ export async function cleanupFixtures( const failed: CleanupResult["failed"] = []; const completed: CleanupResult["completed"] = []; + const deferred: CleanupResult["deferred"] = []; let succeeded = 0; // Snapshot up front: the loop mutates `tracked` via untrack(). const batch = [...fixtures].reverse(); @@ -124,7 +133,11 @@ export async function cleanupFixtures( // budget — so neither a hard failure nor an exhausted retry on one fixture // can stop the remaining ones from being deleted. const outcome = await deleteWithRetry(client, t, attempts, conflictAttempts, backoffMs, sleep); - if (outcome.reason === null) { + const deferral = outcome.reason === null ? eraseDeferral(outcome.raw) : null; + if (deferral !== null) { + deferred.push({ ...t, purgeAfter: deferral.purgeAfter }); + untrack(t.kind, t.id); + } else if (outcome.reason === null) { succeeded++; completed.push({ ...t, raw: outcome.raw }); if ( @@ -139,7 +152,18 @@ export async function cleanupFixtures( failed.push({ ...t, reason: outcome.reason }); } } - return { attempted: batch.length, succeeded, failed, completed }; + return { attempted: batch.length, succeeded, failed, completed, deferred }; +} + +/** The deferral of a 200 permanent-delete receipt, or null when it purged. */ +function eraseDeferral(raw: string): { purgeAfter: string | null } | null { + try { + const body = JSON.parse(raw) as { erase_deferred?: unknown; purge_after?: unknown }; + if (body.erase_deferred !== true) return null; + return { purgeAfter: typeof body.purge_after === "string" ? body.purge_after : null }; + } catch { + return null; + } } /** Finish one domain registration's cleanup after its DNS records are gone. */ diff --git a/web/src/app/(app)/inboxes/(view)/trash/page.test.tsx b/web/src/app/(app)/inboxes/(view)/trash/page.test.tsx index e9074bd88..44fb4e87f 100644 --- a/web/src/app/(app)/inboxes/(view)/trash/page.test.tsx +++ b/web/src/app/(app)/inboxes/(view)/trash/page.test.tsx @@ -169,4 +169,39 @@ describe("AgentTrashPage", () => { ); }); }); + it("explains a deferred Delete forever and keeps the message in the trash", async () => { + mockFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url === LIST_URL && !init?.method) { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ items: [trashedMessage] }), + }); + } + if (init?.method === "DELETE") { + return Promise.resolve({ + ok: true, + status: 200, + text: () => + Promise.resolve( + JSON.stringify({ + deleted: true, + id: "msg_1", + erase_deferred: true, + purge_after: "2026-10-28T12:00:00Z", + }), + ), + }); + } + return Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve("not found") }); + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Delete forever/ })); + fireEvent.click(await screen.findByRole("button", { name: /Click again to confirm/ })); + const notice = await screen.findByText(/went to people outside e2a recently/i); + expect(notice).toHaveTextContent(/stays in the trash until .*2026/i); + expect(notice).toHaveTextContent(/still restore it/i); + expect(screen.getByRole("button", { name: /^Restore$/ })).toBeInTheDocument(); + }); }); diff --git a/web/src/app/(app)/inboxes/(view)/trash/page.tsx b/web/src/app/(app)/inboxes/(view)/trash/page.tsx index 4f5b6e210..6eb5ae4de 100644 --- a/web/src/app/(app)/inboxes/(view)/trash/page.tsx +++ b/web/src/app/(app)/inboxes/(view)/trash/page.tsx @@ -20,7 +20,7 @@ import { invalidateAgentMessages, invalidateAgentUnread, } from "../../../../../lib/swrKeys"; -import { TRASH_RETENTION_DAYS, daysLeft } from "../../../../../lib/trash"; +import { TRASH_RETENTION_DAYS, daysLeft, deferredCopy, isDeferred } from "../../../../../lib/trash"; import type { MessageSummary } from "../../../../components/types"; export default function AgentTrashPage() { @@ -45,14 +45,21 @@ function AgentTrashContent() { // Per-row in-flight + error state, keyed by message id. const [busy, setBusy] = useState(null); const [rowError, setRowError] = useState<{ id: string; msg: string } | null>(null); + // A "Delete forever" the server deferred (the message went to people + // outside e2a recently): it stays in the trash until purge_after. + const [rowNotice, setRowNotice] = useState<{ id: string; msg: string } | null>(null); // Two-click "Delete forever": first click arms, second click fires. const [armed, setArmed] = useState(null); - const run = async (id: string, op: () => Promise) => { + const run = async (id: string, op: () => Promise) => { setBusy(id); setRowError(null); + setRowNotice(null); try { - await op(); + const receipt = await op(); + if (isDeferred(receipt)) { + setRowNotice({ id, msg: deferredCopy("This message went to people outside e2a recently", receipt.purge_after) }); + } await mutate(); // refresh the trash list // The live inbox views are stale after a restore. void invalidateAgentMessages(email); @@ -146,6 +153,11 @@ function AgentTrashContent() { )} + {rowNotice?.id === m.id && ( +
+ {rowNotice.msg} +
+ )} {rowError?.id === m.id && (
{ ); }); + it("tells the user a deferred erase left the account in the trash until purge_after", async () => { + const purgeAfter = "2026-10-26T12:00:00Z"; + global.fetch = jest.fn(async () => ({ + ok: true, + status: 200, + text: async () => "", + json: async () => ({ deleted: true, mode: "trash", erase_deferred: true, purge_after: purgeAfter }), + })) as unknown as typeof fetch; + + render(); + openDeleteFlow(); + fireEvent.click(screen.getByRole("radio", { name: /erase permanently now/i })); + fireEvent.change(screen.getByPlaceholderText("DELETE"), { target: { value: "DELETE" } }); + fireEvent.click(screen.getByRole("checkbox", { name: /can.t be recovered/i })); + fireEvent.click(screen.getByRole("button", { name: /erase my account permanently/i })); + + const heading = await screen.findByText(/was deleted and moved to the trash/i); + const status = heading.closest('[role="status"]') as HTMLElement; + expect(status).toHaveTextContent(/emailed people outside e2a recently/i); + expect(status).toHaveTextContent(/stays in the trash/i); + expect(status).toHaveTextContent(/2026/); + expect(status).toHaveTextContent(/sign in again/i); + expect(mockHardNavigate).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: /continue/i })); + expect(mockHardNavigate).toHaveBeenCalledWith("/?account_deleted=1"); + }); + it("switching back to trash clears the permanent acknowledgement", () => { render(); openDeleteFlow(); diff --git a/web/src/app/(app)/settings/page.tsx b/web/src/app/(app)/settings/page.tsx index 64b7abf12..83c654271 100644 --- a/web/src/app/(app)/settings/page.tsx +++ b/web/src/app/(app)/settings/page.tsx @@ -10,7 +10,7 @@ import { getSendingAccessRequest, type SendingAccessRequest, } from "../../components/onboarding/api"; -import { readApiError } from "../../../lib/accountDeletion"; +import { formatLongDate, readApiError } from "../../../lib/accountDeletion"; import { hardNavigate } from "../../../lib/navigation"; import { sendingAccessRequestKey } from "../../../lib/swrKeys"; import { sendingAccessSettingsSummary } from "../../../lib/sendingAccess"; @@ -287,13 +287,16 @@ function ExportSection() { ); } -type DeleteState = "idle" | "deleting" | "error"; +type DeleteState = "idle" | "deleting" | "error" | "deferred"; type DeleteMode = "trash" | "permanent"; // Delete account. DELETE /v1/account?confirm=DELETE moves the account to the // trash (restorable by signing in again for the trash window); adding // permanent=true erases it immediately. Erasing is a separate choice with its -// own acknowledgement, never the default. +// own acknowledgement, never the default. A permanent erase of an account that +// emailed external recipients recently comes back as a trash receipt with +// erase_deferred: the account is deleted but kept in the trash until +// purge_after, so we say so (and when) before leaving the page. function DangerZone() { const [open, setOpen] = useState(false); const [confirmText, setConfirmText] = useState(""); @@ -301,6 +304,7 @@ function DangerZone() { const [acknowledged, setAcknowledged] = useState(false); const [state, setState] = useState("idle"); const [errorMessage, setErrorMessage] = useState(""); + const [deferredUntil, setDeferredUntil] = useState(""); const permanent = mode === "permanent"; const ready = confirmText === "DELETE" && (!permanent || acknowledged); @@ -340,6 +344,15 @@ function DangerZone() { } return; } + const receipt = (await res.json().catch(() => null)) as { + erase_deferred?: boolean; + purge_after?: string; + } | null; + if (receipt?.erase_deferred) { + setDeferredUntil(formatLongDate(receipt.purge_after)); + setState("deferred"); + return; + } // Every session is revoked server-side; a full navigation makes the // site re-read that and land signed out. hardNavigate("/?account_deleted=1"); @@ -396,7 +409,39 @@ function DangerZone() { can't register a new account until it's released. - {!open ? ( + {state === "deferred" ? ( +
+

Your account was deleted and moved to the trash.

+

+ It emailed people outside e2a recently, so it isn't erased + right away: it stays in the trash + {deferredUntil ? <> until {deferredUntil} : <> until the trash window ends} + , then it's erased permanently. To restore it, sign in again + before then. +

+ +
+ ) : !open ? (
+ {rowNotice?.email === a.email && ( +
+ {rowNotice.msg} +
+ )} {rowError?.email === a.email && (
{rowError.msg} diff --git a/web/src/app/account/restore/page.test.tsx b/web/src/app/account/restore/page.test.tsx index dd2a069ff..0d5d428fa 100644 --- a/web/src/app/account/restore/page.test.tsx +++ b/web/src/app/account/restore/page.test.tsx @@ -238,6 +238,29 @@ describe("/account/restore", () => { expect(mockHardNavigate).not.toHaveBeenCalled(); }); + it("keeps the account restorable when the erase is deferred for a recent external sender", async () => { + const purgeAfter = new Date(Date.now() + 24 * DAY).toISOString(); + installFetch({ + erase: jsonResponse(200, { + deleted: true, + mode: "trash", + erase_deferred: true, + purge_after: purgeAfter, + user_deleted: false, + }), + }); + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: /erase now/i })); + fireEvent.click(screen.getByRole("button", { name: /erase permanently/i })); + const status = await screen.findByText(/emailed people outside e2a recently/i); + expect(status).toHaveTextContent(/stays in the trash and is erased permanently after/i); + expect(status).toHaveTextContent(/can still\s+restore it/i); + // Still on the trashed-account screen, with the restore offer intact. + expect(screen.queryByRole("heading", { name: /were erased/i })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /restore account/i })).toBeEnabled(); + expect(screen.queryByRole("region", { name: /erase this account permanently/i })).not.toBeInTheDocument(); + }); + it("disables the confirm while erasing", async () => { installFetch({ erase: new Promise(() => {}) }); await renderReady(); diff --git a/web/src/app/account/restore/page.tsx b/web/src/app/account/restore/page.tsx index 14e9f0eac..89db8a501 100644 --- a/web/src/app/account/restore/page.tsx +++ b/web/src/app/account/restore/page.tsx @@ -7,6 +7,9 @@ // GET /api/account/deletion → {email, deleted_at, purge_after, purge_in_progress} // POST /api/account/restore → the same cookie becomes an ordinary session // POST /api/account/erase → permanent deletion; the server clears the cookie +// (or, for an account that emailed external +// recipients recently, erase_deferred: the +// account stays in the trash and restorable) // // Lives outside the (app) route group on purpose: the app shell would see no // ordinary session and bounce to the sign-in wall. @@ -215,6 +218,9 @@ function TrashedAccount({ const [error, setError] = useState(""); const [restoreRefused, setRestoreRefused] = useState(false); const [restored, setRestored] = useState(false); + // Set when "erase now" was deferred: the account emailed external + // recipients recently, so it stays in the trash until purge_after. + const [eraseDeferred, setEraseDeferred] = useState(null); const confirmHeadingRef = useRef(null); const eraseTriggerRef = useRef(null); @@ -277,7 +283,21 @@ function TrashedAccount({ method: "POST", credentials: "include", }); - if (res.ok) return onPhase({ kind: "erased" }); + if (res.ok) { + const receipt = (await res.json().catch(() => null)) as { + erase_deferred?: boolean; + purge_after?: string; + } | null; + if (receipt?.erase_deferred) { + // Still in the trash and still restorable: stay on this screen. + setEraseDeferred(formatLongDate(receipt.purge_after ?? view.purge_after) || "the trash window ends"); + returnFocusToTrigger.current = true; + setConfirmingErase(false); + setBusy(null); + return; + } + return onPhase({ kind: "erased" }); + } const err = await readApiError(res); if (res.status === 401) return onPhase({ kind: "signed-out" }); if (err.code === "purge_in_progress") return onPhase({ kind: "purging" }); @@ -354,6 +374,22 @@ function TrashedAccount({

)} + {eraseDeferred && ( +

+ This account emailed people outside e2a recently, so it can't be erased right away. + It stays in the trash and is erased permanently after {eraseDeferred}. You can still + restore it until then. +

+ )} + {restored && (

Restored. Opening your dashboard… @@ -377,6 +413,7 @@ function TrashedAccount({ type="button" onClick={() => { setError(""); + setEraseDeferred(null); setConfirmingErase(true); }} disabled={busy !== null || restored} diff --git a/web/src/app/components/onboarding/api.ts b/web/src/app/components/onboarding/api.ts index 33b0a8a24..2e644f201 100644 --- a/web/src/app/components/onboarding/api.ts +++ b/web/src/app/components/onboarding/api.ts @@ -173,10 +173,21 @@ export async function restoreAgent(email: string): Promise { ); } +// The deferral fields of a permanent agent/message delete receipt. A +// recipient of external mail within the deployment's window is not purged +// on demand: it stays in the trash until purge_after (erase_deferred). +export interface PermanentDeleteReceipt { + deleted: boolean; + erase_deferred?: boolean; + purge_after?: string; + message?: string; +} + // DELETE /v1/agents/{email}?permanent=true — irreversible ("delete -// forever" from the trash view). -export async function permanentDeleteAgent(email: string): Promise { - return request( +// forever" from the trash view), unless the agent emailed external +// recipients recently (erase_deferred: it stays in the trash). +export async function permanentDeleteAgent(email: string): Promise { + return request( "/v1/agents/" + encodeURIComponent(email) + "?confirm=DELETE&permanent=true", @@ -326,9 +337,10 @@ export async function restoreMessage(email: string, id: string): Promise { } // DELETE …?permanent=true&confirm=DELETE — permanently delete a message -// that is already in the trash ("delete forever"). -export async function purgeMessage(email: string, id: string): Promise { - return request( +// that is already in the trash ("delete forever"), unless it was sent to +// external recipients recently (erase_deferred: it stays in the trash). +export async function purgeMessage(email: string, id: string): Promise { + return request( "/v1/agents/" + encodeURIComponent(email) + "/messages/" + diff --git a/web/src/lib/trash.ts b/web/src/lib/trash.ts index 02b5e7448..8aa5fbfca 100644 --- a/web/src/lib/trash.ts +++ b/web/src/lib/trash.ts @@ -5,6 +5,8 @@ // default 30 days). Display-only — the janitor owns the real clock; if the // backend window is ever tuned, update this constant (or, better, switch the // API to emit a server-computed purge_at and delete this file). +import { formatLongDate } from "./accountDeletion"; + export const TRASH_RETENTION_DAYS = 30; // daysLeft returns the whole days remaining until a trashed resource is @@ -14,3 +16,26 @@ export function daysLeft(deletedAt: string): number { new Date(deletedAt).getTime() + TRASH_RETENTION_DAYS * 24 * 3600 * 1000; return Math.max(0, Math.ceil((purgeAt - Date.now()) / (24 * 3600 * 1000))); } + +// A permanent ("Delete forever") delete the server deferred: the resource +// emailed recipients outside e2a recently, so it stays in the trash until +// purge_after instead of being purged now (receipt erase_deferred). +export function isDeferred( + receipt: unknown, +): receipt is { erase_deferred: true; purge_after?: string } { + return ( + typeof receipt === "object" && + receipt !== null && + (receipt as { erase_deferred?: unknown }).erase_deferred === true + ); +} + +// deferredCopy is the notice shown for a deferred "Delete forever"; lead is +// the opening clause ("This inbox emailed people outside e2a recently"). +export function deferredCopy(lead: string, purgeAfter?: string): string { + const when = formatLongDate(purgeAfter); + return ( + `${lead}, so it can't be deleted forever yet. ` + + `It stays in the trash${when ? ` until ${when}` : ""}, then it's deleted automatically. You can still restore it until then.` + ); +}