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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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":
Expand Down Expand Up @@ -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
Expand All @@ -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":
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions cli/src/__tests__/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
8 changes: 8 additions & 0 deletions cli/src/commands/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ export async function accountDelete(opts: AccountDeleteOptions): Promise<void> {
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(
Expand Down
8 changes: 7 additions & 1 deletion cmd/e2a-contract-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions cmd/e2a-prober/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 7 additions & 0 deletions cmd/e2a/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading