From 6fd8aa0af1e611529f64daff8145ffe7a8418e88 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:48:11 +0800 Subject: [PATCH 01/12] feat(account): defer immediate erase for recent external senders A permanent account erase (DELETE /v1/account?permanent=true, or the restore interstitial's erase now) of an account that sent to an external recipient within trash.recent_sender_erase_defer_days (default 14, 0 disables) now leaves the account in the trash and returns a trash receipt with erase_deferred, purge_after and a message. Late provider feedback keeps landing on the sending controls and aggregates until the janitor purges at the end of the window. The paused-account erase_held refusal is unchanged and checked first. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- cmd/e2a/main.go | 3 + config.example.yaml | 12 + internal/agent/billing_hook_test.go | 54 ++++ internal/agent/user_data_rights_api.go | 13 +- internal/config/config.go | 22 +- internal/config/trash_account_test.go | 36 +++ internal/httpapi/account.go | 4 +- internal/httpapi/account_restore.go | 5 +- internal/httpapi/account_trash_test.go | 57 ++++ internal/identity/account_erase_defer.go | 97 ++++++ internal/identity/account_erase_defer_test.go | 275 ++++++++++++++++++ internal/identity/account_trash.go | 43 ++- internal/identity/user_data_rights.go | 5 + 13 files changed, 619 insertions(+), 7 deletions(-) create mode 100644 internal/identity/account_erase_defer.go create mode 100644 internal/identity/account_erase_defer_test.go diff --git a/cmd/e2a/main.go b/cmd/e2a/main.go index e762b3cf4..88f84d0e0 100644 --- a/cmd/e2a/main.go +++ b/cmd/e2a/main.go @@ -184,6 +184,9 @@ 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.RecentSenderEraseDeferDays) * 24 * time.Hour // 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..f4c25aa5c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -474,9 +474,21 @@ 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): an on-demand +# permanent erase (DELETE /v1/account?permanent=true, or "erase now" on the +# restore screen) of an account that emailed an external recipient — anyone +# other than its own agents, its verified owner mailbox, or an address on the +# shared agent domain — within this many days is deferred: the account goes +# to the trash instead (receipt mode "trash", erase_deferred: true) and is +# purged at the end of account_retention_days, so complaints and bounces that +# arrive days after a send still land on its sending controls. No effect when +# account trash is disabled. E2A_TRASH_RECENT_SENDER_ERASE_DEFER_DAYS +# overrides it. trash: retention_days: 30 # account_retention_days: 30 + recent_sender_erase_defer_days: 14 identity_tombstones: false # — Metrics (Prometheus) —————————————————————————————————————————————————————— 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/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/config/config.go b/internal/config/config.go index b197cfdea..7ce8f4f59 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -672,6 +672,16 @@ 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 account + // erase (DELETE /v1/account?permanent=true, or the restore + // interstitial's "erase now") for an account that emailed an external + // recipient within this many days: the account is moved to the trash + // instead and purged at the end of the normal account trash window, so + // late provider feedback (complaints, bounces) still lands on its sending + // controls and aggregates. Default 14; 0 disables the deferral. It has no + // effect when account trash is disabled (account_retention_days: 0). + // Override with E2A_TRASH_RECENT_SENDER_ERASE_DEFER_DAYS. + RecentSenderEraseDeferDays int `yaml:"recent_sender_erase_defer_days"` // 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 @@ -758,7 +768,7 @@ 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, RecentSenderEraseDeferDays: 14}, // 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 +1003,13 @@ 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 := os.Getenv("E2A_TRASH_IDENTITY_TOMBSTONES"); v != "" { b, err := strconv.ParseBool(strings.TrimSpace(v)) if err != nil { @@ -1064,6 +1081,9 @@ 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 c.Trash.RecentSenderEraseDeferDays < 0 { + return fmt.Errorf("config: trash.recent_sender_erase_defer_days must be 0 (never defer) or a positive number of days (got %d)", c.Trash.RecentSenderEraseDeferDays) + } 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..fa23baa24 100644 --- a/internal/config/trash_account_test.go +++ b/internal/config/trash_account_test.go @@ -59,3 +59,39 @@ 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.RecentSenderEraseDeferDays != 14 { + t.Fatalf("recent_sender_erase_defer_days default = %d, want 14", cfg.Trash.RecentSenderEraseDeferDays) + } + 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.RecentSenderEraseDeferDays != 0 { + t.Fatalf("explicit 0 = %d, want 0 (disabled)", cfg.Trash.RecentSenderEraseDeferDays) + } + 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.RecentSenderEraseDeferDays != 3 { + t.Fatalf("env override = %d, want 3", cfg.Trash.RecentSenderEraseDeferDays) + } +} 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..dc9947782 100644 --- a/internal/httpapi/account_trash_test.go +++ b/internal/httpapi/account_trash_test.go @@ -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/identity/account_erase_defer.go b/internal/identity/account_erase_defer.go new file mode 100644 index 000000000..8209b9b1f --- /dev/null +++ b/internal/identity/account_erase_defer.go @@ -0,0 +1,97 @@ +package identity + +import ( + "context" + "fmt" + "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 whose most recent send to an external recipient is younger than +// this is deferred to the trash. cmd/e2a assigns it at startup from +// trash.recent_sender_erase_defer_days (default 14). Zero disables the +// deferral. It has no effect on a deployment with account trash disabled +// (there is no trash window to hold the account in). +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." + +// accountSentExternallySinceSQL reports whether the account ($1) had an +// outbound message settled as sent to an external recipient at or after $2. +// +// Source: message_recipients. A row there is written exactly when the +// provider (or relay) accepted the message — one per envelope recipient +// (to/cc/bcc), normalized — so it records real sends, never drafts, holds, +// refusals or queued mail. It is reached through the account's agents and +// idx_messages_agent_created, and the EXISTS stops at the first hit. +// +// The send instant is the latest of created_at, provider_accepted_at, +// reviewed_at and scheduled_at (GREATEST ignores NULLs), so a scheduled or +// review-held message that was submitted recently counts even when it was +// created long ago. +// +// "External" is the external-sending-access notion: anything other than +// - an agent of the same account (any state — by 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 on one of the deployment's shared agent domains (the +// verified domains rows with no owning account). +const accountSentExternallySinceSQL = ` +SELECT EXISTS ( + SELECT 1 + FROM agent_identities AS a + JOIN messages AS m ON m.agent_id = a.id + JOIN message_recipients AS r ON r.message_id = m.id + JOIN users AS u ON u.id = a.user_id + WHERE a.user_id = $1 + AND m.direction = 'outbound' + AND GREATEST(m.created_at, m.provider_accepted_at, m.reviewed_at, m.scheduled_at) >= $2 + AND NOT EXISTS ( + SELECT 1 FROM agent_identities AS own + WHERE own.user_id = $1 AND lower(own.id) = r.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 r.address = u.owner_email_verified_address) + AND NOT EXISTS ( + SELECT 1 FROM domains AS d + WHERE d.user_id IS NULL AND d.verified + AND d.domain = split_part(r.address, '@', 2)))` + +// AccountSentExternallySince reports whether the account sent to at least one +// external recipient at or after since (see accountSentExternallySinceSQL). +func (s *Store) AccountSentExternallySince(ctx context.Context, userID string, since time.Time) (bool, error) { + var sent bool + if err := s.pool.QueryRow(ctx, accountSentExternallySinceSQL, userID, since).Scan(&sent); err != nil { + return false, fmt.Errorf("erase: recent external send: %w", err) + } + return sent, nil +} + +// eraseDeferralApplies reports whether a permanent erase of the account must +// be deferred to the trash: the deferral is enabled, the deployment has +// account trash, and the account sent externally inside the window. +func (s *Store) eraseDeferralApplies(ctx context.Context, userID string) (bool, error) { + if RecentSenderEraseDefer <= 0 || !AccountTrashEnabled() { + return false, nil + } + return s.AccountSentExternallySince(ctx, userID, time.Now().Add(-RecentSenderEraseDefer)) +} diff --git a/internal/identity/account_erase_defer_test.go b/internal/identity/account_erase_defer_test.go new file mode 100644 index 000000000..532bcde10 --- /dev/null +++ b/internal/identity/account_erase_defer_test.go @@ -0,0 +1,275 @@ +package identity_test + +import ( + "context" + "errors" + "testing" + "time" + + "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) + } +} diff --git a/internal/identity/account_trash.go b/internal/identity/account_trash.go index d30997994..0218a9d38 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,11 +459,25 @@ 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 AFTER the trash + // commits, so no send can settle between the check and the purge: the + // trashed account can no longer send. + deferred, err := s.eraseDeferralApplies(ctx, userID) + if err != nil { + // Fail toward keeping the evidence: the account is already trashed + // and the janitor purges it at the end of the window. + log.Printf("[identity] erase deferral check failed; keeping the account in the trash: user=%s err=%v", userID, err) + deferred = true + } + if deferred { + return s.deferredEraseReceipt(ctx, userID, trashRes) + } purged, err := s.purgeAccount(ctx, userID, true, perDomainInTx) if err != nil { return nil, err @@ -467,6 +486,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") diff --git a/internal/identity/user_data_rights.go b/internal/identity/user_data_rights.go index 2620428d1..4d50ac463 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. From 4b34bdd1059dc35afb2a8df1213b400a849b4982 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:48:12 +0800 Subject: [PATCH 02/12] feat(sdk): expose erase_deferred on the account delete receipt Regenerated the TS and Python bases, documented the deferred erase on both client wrappers, and added a shared contract scenario run by the Go, TS and Python runners against a new seeded disposable account. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- api/openapi.yaml | 12 ++- cmd/e2a-contract-server/main.go | 6 +- internal/testutil/contract_server.go | 94 ++++++++++++++----- sdks/python/src/e2a/v1/client.py | 9 ++ .../src/e2a/v1/generated/api/account_api.py | 18 ++-- .../models/delete_user_data_result.py | 6 +- sdks/python/tests/test_contract.py | 11 +++ sdks/typescript/src/v1/client.ts | 8 ++ .../src/v1/generated/apis/AccountApi.ts | 4 +- .../generated/models/DeleteUserDataResult.ts | 20 ++++ .../src/v1/generated/types/ObjectParamAPI.ts | 6 +- .../src/v1/generated/types/ObservableAPI.ts | 8 +- .../src/v1/generated/types/PromiseAPI.ts | 8 +- sdks/typescript/test/v1/contract.test.ts | 11 +++ tests/contract/contract_test.go | 6 ++ tests/contract/scenarios.yaml | 35 +++++++ 16 files changed, 214 insertions(+), 48 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 28416a121..11ff35304 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1462,6 +1462,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 +5369,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 +5382,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": diff --git a/cmd/e2a-contract-server/main.go b/cmd/e2a-contract-server/main.go index 575146a71..84f7581db 100644 --- a/cmd/e2a-contract-server/main.go +++ b/cmd/e2a-contract-server/main.go @@ -40,12 +40,16 @@ 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_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\n", srv.BaseURL, srv.APIKey, srv.CappedAPIKey, srv.OverCapAPIKey, srv.RestrictedAPIKey, srv.DisposableTrashAPIKey, srv.DisposableEraseAPIKey, srv.ReadOnlyAPIKey, srv.RestrictedSDKAPIKey, + srv.DisposableDeferredEraseAPIKey, ) if envFile != "" { if err := os.WriteFile(envFile, []byte(envContent), 0o600); err != nil { diff --git a/internal/testutil/contract_server.go b/internal/testutil/contract_server.go index 4aa17dfa3..feebbd9d7 100644 --- a/internal/testutil/contract_server.go +++ b/internal/testutil/contract_server.go @@ -101,6 +101,12 @@ 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 // 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 +439,39 @@ 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 + } + 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, + 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 +563,41 @@ 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 +} + 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/sdks/python/src/e2a/v1/client.py b/sdks/python/src/e2a/v1/client.py index a92f42af5..6aca3df36 100644 --- a/sdks/python/src/e2a/v1/client.py +++ b/sdks/python/src/e2a/v1/client.py @@ -1456,6 +1456,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/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..3c20231dc 100644 --- a/sdks/python/tests/test_contract.py +++ b/sdks/python/tests/test_contract.py @@ -69,6 +69,9 @@ # 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 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 +169,7 @@ 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}" READONLY_KEY_PLACEHOLDER = "{readonly_api_key}" @@ -255,6 +259,8 @@ 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 READONLY_API_KEY: self.vars["readonly_api_key"] = READONLY_API_KEY self._http = httpx.Client(base_url=base_url, timeout=30) @@ -1306,6 +1312,11 @@ 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") # 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/typescript/src/v1/client.ts b/sdks/typescript/src/v1/client.ts index 9eb4e71a3..cff032a7e 100644 --- a/sdks/typescript/src/v1/client.ts +++ b/sdks/typescript/src/v1/client.ts @@ -953,6 +953,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/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..9dfaa59a2 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 */ diff --git a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts index ee88e80d3..a698b03ee 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)); diff --git a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts index fd4237584..b71325320 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); diff --git a/sdks/typescript/test/v1/contract.test.ts b/sdks/typescript/test/v1/contract.test.ts index 8f9a9e92c..9f3025451 100644 --- a/sdks/typescript/test/v1/contract.test.ts +++ b/sdks/typescript/test/v1/contract.test.ts @@ -63,6 +63,9 @@ 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 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 +109,10 @@ 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 scenarioNeedsReadOnlyAccount(sc: Scenario): boolean { return scenarioUsesPlaceholder(sc, "{readonly_api_key}"); } @@ -933,6 +940,9 @@ 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 (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 +1379,7 @@ 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) || // 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..bc165957b 100644 --- a/tests/contract/contract_test.go +++ b/tests/contract/contract_test.go @@ -149,6 +149,9 @@ 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 // readOnlyAPIKey authenticates the abuse-paused (read-only) account. readOnlyAPIKey string } @@ -183,6 +186,8 @@ func setupEnv(t *testing.T) *testEnv { disposableTrashAPIKey: cs.DisposableTrashAPIKey, disposableEraseAPIKey: cs.DisposableEraseAPIKey, + disposableDeferredEraseAPIKey: cs.DisposableDeferredEraseAPIKey, + readOnlyAPIKey: cs.ReadOnlyAPIKey, } } @@ -382,6 +387,7 @@ 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, 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..0825f5234 100644 --- a/tests/contract/scenarios.yaml +++ b/tests/contract/scenarios.yaml @@ -3548,6 +3548,41 @@ 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 + # ── 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 From 2a79cd640de1df6aa126a774a5c611f520b83c43 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:48:12 +0800 Subject: [PATCH 03/12] feat(cli,web): explain a deferred account erase The CLI prints that permanent erasure was deferred and when purge happens. The settings page and restore interstitial tell the user the account stays in the trash until purge_after and can still be restored. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- cli/src/__tests__/account.test.ts | 19 ++++++++ cli/src/commands/account.ts | 8 ++++ web/src/app/(app)/settings/page.test.tsx | 27 +++++++++++ web/src/app/(app)/settings/page.tsx | 57 +++++++++++++++++++++-- web/src/app/account/restore/page.test.tsx | 23 +++++++++ web/src/app/account/restore/page.tsx | 39 +++++++++++++++- 6 files changed, 167 insertions(+), 6 deletions(-) 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/web/src/app/(app)/settings/page.test.tsx b/web/src/app/(app)/settings/page.test.tsx index abbac95eb..ab5c19a5c 100644 --- a/web/src/app/(app)/settings/page.test.tsx +++ b/web/src/app/(app)/settings/page.test.tsx @@ -303,6 +303,33 @@ describe("Settings — Danger zone (delete account)", () => { ); }); + 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 ? (