From cae233239a391712beb01e21d44dacf852fceecd Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:12:23 +0800 Subject: [PATCH 01/13] feat(sending): configurable external-sending unlocks and decision notices Add sending_protection.external_sending_access.unlocks, a closed set of operator_approval, verified_domain and paid_entitlement. Omitted means all three (unchanged behavior and policy hash); an empty set or one without operator_approval fails at startup and on stored-policy reads. The one decision function every stage calls consults the set from the same RuntimePolicy. GET /v1/account sending_access gains additive available_unlocks; the 403 message names only honored routes. Operator approve/decline of a request emails the account owner a neutral decision notice through the gate (customer_notification keyed by the request); a failed notice warns and never fails the command. New-request operator mail is skipped for exempt (system/internal) account classes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- api/openapi.yaml | 9 +- cmd/e2a/sending_policy.go | 80 ++++- cmd/e2a/sending_policy_test.go | 88 +++++- config.example.yaml | 21 +- docs/api.md | 41 ++- docs/design/async-message-pipeline.md | 35 ++- internal/agent/external_access.go | 25 +- internal/agent/external_access_test.go | 40 +++ internal/config/config.go | 11 +- internal/httpapi/sending_access.go | 23 +- internal/httpapi/sending_access_test.go | 78 +++++ internal/sendingaccessnotice/notice.go | 284 ++++++++++++++++++ internal/sendingaccessnotice/notice_test.go | 199 ++++++++++++ internal/sendingpolicy/external_access.go | 29 +- .../sendingpolicy/external_access_admin.go | 20 +- .../external_access_integration_test.go | 8 +- .../sendingpolicy/external_policy_test.go | 115 +++++++ .../external_unlocks_integration_test.go | 214 +++++++++++++ internal/sendingpolicy/fromconfig.go | 10 + internal/sendingpolicy/fromconfig_test.go | 50 +++ internal/sendingpolicy/operations.go | 21 +- internal/sendingpolicy/policy.go | 123 ++++++++ internal/sendingpolicy/secrets.go | 8 +- internal/sendingpolicy/secrets_test.go | 4 +- internal/sendingpolicy/types.go | 26 +- .../generated/models/sending_access_view.py | 15 +- .../v1/generated/models/SendingAccessView.ts | 12 +- 27 files changed, 1544 insertions(+), 45 deletions(-) create mode 100644 internal/sendingaccessnotice/notice.go create mode 100644 internal/sendingaccessnotice/notice_test.go create mode 100644 internal/sendingpolicy/external_unlocks_integration_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index d20cfa880..a290013c3 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -4467,6 +4467,13 @@ components: SendingAccessView: additionalProperties: true properties: + available_unlocks: + description: "The routes this deployment accepts for lifting the restriction. Open set: treat entries as strings and ignore unknown values. Known values: operator_approval (file a request with POST /v1/account/sending-access/request; an operator reviews it and the account owner is emailed the decision — always present), verified_domain (sending as the account's own verified custom domain reaches external recipients), paid_entitlement (a paid base plan lifts the restriction). Absent only from servers that predate the field, which accept all three." + items: + type: string + type: + - array + - "null" enforcement_applies: description: True when the deployment enforces external sending access for this account (enforce mode, account inside the rollout cohort, not a platform account). Stays true after approval. False when the control is disabled or in shadow mode, or the account is outside the cohort. type: boolean @@ -4474,7 +4481,7 @@ components: description: True when the account's current sign-in email was verified by a trusted login, so it is an allowed destination while external sending is restricted. type: boolean paid_external_sending_entitled: - description: True when an active paid base subscription grants external sending (hosted service). Independent of shared_external_approved. + description: True when the account holds the billing-issued paid base entitlement (hosted service). It lifts the restriction only when available_unlocks contains paid_entitlement; otherwise it is informational. Independent of shared_external_approved. type: boolean shared_external_approved: description: True when an operator granted this account external sending through the shared sending identity. Reports the grant only, not whether enforcement is on. diff --git a/cmd/e2a/sending_policy.go b/cmd/e2a/sending_policy.go index b0e8b864a..6638dcdfa 100644 --- a/cmd/e2a/sending_policy.go +++ b/cmd/e2a/sending_policy.go @@ -6,11 +6,16 @@ import ( "errors" "fmt" "io" + "log" "os/user" "strings" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/tokencanopy/e2a/internal/config" + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/outbound" + "github.com/tokencanopy/e2a/internal/sendingaccessnotice" "github.com/tokencanopy/e2a/internal/sendingpolicy" ) @@ -129,7 +134,8 @@ func runSendingProtectionCommand(ctx context.Context, cfg *config.Config, pool * case f.reconcile: return runReconcileLegacySendingJobs(ctx, pool, sendingpolicy.NewGate(pool, secrets, source, policy), stdout) case f.inspectExternal, f.approveExternal, f.revokeExternal, f.declineExternal: - return runExternalSendingCommand(ctx, sendingpolicy.NewPolicyModule(pool, secrets, source, policy), f, stdout) + module := sendingpolicy.NewPolicyModule(pool, secrets, source, policy) + return runExternalSendingCommand(ctx, module, newDecisionNotifier(cfg, pool, module), f, stdout) case f.pauseAccount, f.resumeAccount, f.inspectPause: return runAccountPauseCommand(ctx, sendingpolicy.NewPolicyModule(pool, secrets, source, policy), f, stdout) } @@ -313,7 +319,7 @@ func runPrintCapabilities(source sendingpolicy.PolicySource, secrets sendingpoli // operator inspected, and a nonblank reason; a stale revision writes nothing, // and the same state at the current revision is a no-op. After a lost // response, inspect before retrying. -func runExternalSendingCommand(ctx context.Context, module *sendingpolicy.Module, f *sendingProtectionFlags, stdout io.Writer) error { +func runExternalSendingCommand(ctx context.Context, module *sendingpolicy.Module, notifier decisionNotifier, f *sendingProtectionFlags, stdout io.Writer) error { if strings.TrimSpace(f.accountID) == "" { return errors.New("external sending commands require -account-id") } @@ -333,6 +339,7 @@ func runExternalSendingCommand(ctx context.Context, module *sendingpolicy.Module return err } fmt.Fprintf(stdout, "request: %s declined (grant unchanged)\n", f.requestID) + sendDecisionNotice(ctx, notifier, f.requestID, stdout) return nil } if strings.TrimSpace(f.reason) == "" { @@ -356,7 +363,13 @@ func runExternalSendingCommand(ctx context.Context, module *sendingpolicy.Module fmt.Fprintf(stdout, "status: no-op; the grant already had this state at revision %d\n", res.Record.Revision) } printExternalAccess(stdout, res.Record) - if !res.Record.Approved && res.Record.PaidEntitled { + if f.approveExternal && strings.TrimSpace(f.requestID) != "" { + // Only an approval that decides a customer request is announced: a + // direct grant (for example pre-granting existing accounts before a + // rollout) has no request to answer. + sendDecisionNotice(ctx, notifier, f.requestID, stdout) + } + if !res.Record.Approved && res.Record.PaidEntitled && unlockAvailable(res.Record.AvailableUnlocks, sendingpolicy.UnlockPaidEntitlement) { fmt.Fprintf(stdout, "warning: the account still holds the paid-base entitlement, which independently allows external sending; pause the account to stop all sending\n") } return nil @@ -375,11 +388,29 @@ func printExternalAccess(stdout io.Writer, rec sendingpolicy.ExternalAccessRecor fmt.Fprintf(stdout, "owner_recipient_verified: %v\n", rec.OwnerVerified) fmt.Fprintf(stdout, "enforcement_applies: %v\n", rec.EnforcementApplies) fmt.Fprintf(stdout, "sending_paused: %v\n", rec.Paused) + if rec.AvailableUnlocks != nil { + names := make([]string, len(rec.AvailableUnlocks)) + for i, u := range rec.AvailableUnlocks { + names[i] = string(u) + } + fmt.Fprintf(stdout, "available_unlocks: %s\n", strings.Join(names, ",")) + } if rec.PendingRequestID != "" { fmt.Fprintf(stdout, "pending_request_id: %s\n", rec.PendingRequestID) } } +// unlockAvailable reports whether the governing policy lets u lift the +// restriction. +func unlockAvailable(set []sendingpolicy.ExternalUnlock, u sendingpolicy.ExternalUnlock) bool { + for _, have := range set { + if have == u { + return true + } + } + return false +} + // runAccountPauseCommand pauses, resumes or inspects an account's sending. // Like the external-sending commands, this is operator-only: there is no // HTTP, SDK or MCP route. A pause requires -pause-class and -reason, and may @@ -435,3 +466,46 @@ func printAccountPause(stdout io.Writer, rec sendingpolicy.AccountPauseRecord) { fmt.Fprintf(stdout, "evidence_ref: %s\n", rec.EvidenceRef) } } + +// decisionNotifier emails the account owner the decision on a request. +type decisionNotifier interface { + NotifyDecision(ctx context.Context, requestID string) error +} + +// newDecisionNotifier builds the decision notice sender over the command's +// own policy module (the same gate the server authorizes through) and the +// configured outbound relay. Nil when no relay is configured: the decision +// still commits, and the command prints why no notice went out. +func newDecisionNotifier(cfg *config.Config, pool *pgxpool.Pool, module *sendingpolicy.Module) decisionNotifier { + relay := outbound.NewSMTPRelay(&cfg.OutboundSMTP) + if !relay.Configured() || strings.TrimSpace(cfg.OutboundSMTP.FromDomain) == "" { + return nil + } + submitter := outbound.NewProviderSubmitter(relay, module) + submitter.SetSESConfigurationSet(cfg.DeliveryFeedback.SESConfigurationSet) + return sendingaccessnotice.New(pool, module, submitter, cfg.OutboundSMTP.FromDomain, + cfg.Notifications.FromAddress, cfg.Notifications.ReplyTo, cfg.HTTP.PublicURL). + WithDKIM(identity.NewStore(pool)) +} + +// decisionNoticeTimeout bounds the notice so a hung relay cannot hold the +// operator's terminal. +const decisionNoticeTimeout = 30 * time.Second + +// sendDecisionNotice emails the decision after it has committed. It never +// fails the command: the decision is durable either way, so a failed notice +// is a warning line the operator can follow up on by hand. +func sendDecisionNotice(ctx context.Context, notifier decisionNotifier, requestID string, stdout io.Writer) { + if notifier == nil { + fmt.Fprintf(stdout, "warning: decision notice not sent: no outbound SMTP relay is configured\n") + return + } + noticeCtx, cancel := context.WithTimeout(ctx, decisionNoticeTimeout) + defer cancel() + if err := notifier.NotifyDecision(noticeCtx, requestID); err != nil { + log.Printf("[sending-protection] decision notice for request %s failed: %v", requestID, err) + fmt.Fprintf(stdout, "warning: decision notice not sent (the decision stands): %v\n", err) + return + } + fmt.Fprintf(stdout, "decision_notice: sent to the account owner\n") +} diff --git a/cmd/e2a/sending_policy_test.go b/cmd/e2a/sending_policy_test.go index 9dfe39c52..f32ef92bf 100644 --- a/cmd/e2a/sending_policy_test.go +++ b/cmd/e2a/sending_policy_test.go @@ -330,7 +330,7 @@ func TestSendingProtectionCommands(t *testing.T) { if err != nil { t.Fatalf("capabilities: %v", err) } - for _, want := range []string{`"sending_protection_contract":0`, `"runtime_policy_source":"config"`, `"operator_notice_recipient_commitments":{}`, `"runtime_policy_features":["external_sending_access"]`} { + for _, want := range []string{`"sending_protection_contract":0`, `"runtime_policy_source":"config"`, `"operator_notice_recipient_commitments":{}`, `"runtime_policy_features":["external_sending_access","external_sending_unlocks"]`} { if !strings.Contains(out, want) { t.Errorf("capabilities missing %s in %s", want, out) } @@ -410,3 +410,89 @@ func TestExternalSendingOperatorCommands(t *testing.T) { t.Fatalf("revoke = %q err=%v", out, err) } } + +type fakeDecisionNotifier struct { + calls []string + err error +} + +func (f *fakeDecisionNotifier) NotifyDecision(_ context.Context, requestID string) error { + f.calls = append(f.calls, requestID) + return f.err +} + +// The decision notice goes out only for a decided request, after the +// decision committed; a failed notice is a warning line and never fails the +// command (the decision stands). +func TestExternalSendingDecisionNotice(t *testing.T) { + ctx := context.Background() + pool := testutil.TestDB(t) + clearEnvForTest(t) + policy := sendingpolicy.DisabledPolicy() + policy.ExternalSendingAccess = &sendingpolicy.ExternalSendingAccessPolicy{Mode: sendingpolicy.ModeEnforce, AccountsCreatedAtOrAfter: "1970-01-01T00:00:00Z", + Unlocks: []sendingpolicy.ExternalUnlock{sendingpolicy.UnlockOperatorApproval}} + module := sendingpolicy.NewPolicyModule(pool, sendingpolicy.Secrets{}, sendingpolicy.PolicySourceConfig, policy) + newRequest := func(user string) string { + t.Helper() + if _, err := pool.Exec(ctx, `INSERT INTO users (id, email, google_subject) VALUES ($1, $1 || '@notice-cmd.example.test', 'sub-' || $1)`, user); err != nil { + t.Fatal(err) + } + req, created, err := module.SubmitAccessRequest(ctx, user, sendingpolicy.AccessRequestInput{UseCase: "synthetic", Recipients: "synthetic", ExpectedDailyVolume: 1}) + if err != nil || !created { + t.Fatalf("submit: %v", err) + } + return req.ID + } + + // Decline, notice fails: the command succeeds and prints a warning. + reqA := newRequest("usr_notice_cmd_a") + failing := &fakeDecisionNotifier{err: errors.New("relay refused")} + var out bytes.Buffer + if err := runExternalSendingCommand(ctx, module, failing, &sendingProtectionFlags{declineExternal: true, accountID: "usr_notice_cmd_a", requestID: reqA}, &out); err != nil { + t.Fatalf("decline with a failing notice must still succeed: %v", err) + } + if len(failing.calls) != 1 || failing.calls[0] != reqA || !strings.Contains(out.String(), "warning:") || !strings.Contains(out.String(), "decision stands") { + t.Fatalf("calls=%v out=%q", failing.calls, out.String()) + } + if latest, err := module.LatestAccessRequest(ctx, "usr_notice_cmd_a"); err != nil || latest.State != "declined" { + t.Fatalf("the decline must have committed: %+v %v", latest, err) + } + + // Approve deciding a request: notice sent. + reqB := newRequest("usr_notice_cmd_b") + ok := &fakeDecisionNotifier{} + out.Reset() + if err := runExternalSendingCommand(ctx, module, ok, &sendingProtectionFlags{approveExternal: true, accountID: "usr_notice_cmd_b", expectedExternal: 0, reason: "reviewed", requestID: reqB}, &out); err != nil { + t.Fatal(err) + } + if len(ok.calls) != 1 || ok.calls[0] != reqB || !strings.Contains(out.String(), "decision_notice: sent") { + t.Fatalf("calls=%v out=%q", ok.calls, out.String()) + } + if !strings.Contains(out.String(), "available_unlocks: operator_approval") { + t.Fatalf("readback must show the unlock set: %q", out.String()) + } + + // A direct grant with no request (pre-granting before a rollout) sends + // nothing. + if _, err := pool.Exec(ctx, `INSERT INTO users (id, email, google_subject) VALUES ('usr_notice_cmd_c', 'c@notice-cmd.example.test', 'sub-c')`); err != nil { + t.Fatal(err) + } + direct := &fakeDecisionNotifier{} + out.Reset() + if err := runExternalSendingCommand(ctx, module, direct, &sendingProtectionFlags{approveExternal: true, accountID: "usr_notice_cmd_c", expectedExternal: 0, reason: "pre-grant"}, &out); err != nil { + t.Fatal(err) + } + if len(direct.calls) != 0 { + t.Fatalf("a direct grant must not send a decision notice: %v", direct.calls) + } + + // No relay configured: warning, success. + reqD := newRequest("usr_notice_cmd_d") + out.Reset() + if err := runExternalSendingCommand(ctx, module, nil, &sendingProtectionFlags{declineExternal: true, accountID: "usr_notice_cmd_d", requestID: reqD}, &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "no outbound SMTP relay is configured") { + t.Fatalf("out=%q", out.String()) + } +} diff --git a/config.example.yaml b/config.example.yaml index 0fb4de543..530a5ba39 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -349,15 +349,28 @@ sending_protection: operator_notice_recipient_version: 1 # External sending access (optional, absent = disabled). When set, accounts # created at/after the cutoff may reach only their verified owner mailbox - # and their own live agents through the shared sending identity until an - # operator approves them (`e2a -approve-external-sending`), a billing writer - # sets account_limits.external_sending_entitled (active paid base plan), or - # they send from their own verified domain. + # and their own live agents through the shared sending identity until one + # of the configured unlocks applies: + # operator_approval an operator approves them (`e2a -approve-external-sending`); + # always available — customers file a request, and the + # owner is emailed the decision + # verified_domain they send from their own verified domain + # paid_entitlement a billing writer sets + # account_limits.external_sending_entitled (paid base plan) + # `unlocks` omitted = all three (the behavior before the key existed, and the + # same policy hash). An empty list, or one without operator_approval, is a + # startup error. The hosted e2a service runs `unlocks: [operator_approval]` + # with the cutoff at the epoch: every account is restricted until an operator + # approves it, and a verified domain or paid plan is a signal the operator + # weighs rather than an automatic unlock (a stolen-card paid signup must not + # unlock bulk sending on its own). Pre-grant legitimate accounts before + # narrowing the set or moving the cutoff. # The cutoff is an immutable RFC3339 UTC instant; leave the block out to keep # existing behavior (and the existing policy hash). # external_sending_access: # mode: shadow # disabled | shadow | enforce # accounts_created_at_or_after: "2026-10-01T00:00:00Z" + # unlocks: [operator_approval, verified_domain, paid_entitlement] # Outbound delivery feedback (decision 9 / Slice 4b). When ses_configuration_set # is set, outbound mail is tagged with X-SES-CONFIGURATION-SET so SES publishes diff --git a/docs/api.md b/docs/api.md index b63b73d98..8a33f4a1f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -80,7 +80,8 @@ stable field are beta, `x-experimental-values` on that field): - **External sending access** — the `sending_access` object on `GET /v1/account` and the `ExternalSendingNotEnabledDetails` shape of the experimental `external_sending_not_enabled` error. The control ships - disabled; see the error-code table. + disabled; see the error-code table and + [External sending access](#external-sending-access-beta) below. - **Account export interior schemas** — `GET /v1/account/export` is a GA operation, but its interior record shapes are versioned by the export's `schema_version` envelope field rather than the v1 freeze, and are @@ -566,6 +567,9 @@ Workspace identity, plan limits, keys, suppressions, and data rights. of sent/inbound messages are inside the exported `raw_message`. A held draft's (`pending_review`) staged attachment bytes are internal transient storage and are not inlined. +- `GET /v1/account/sending-access/request`, `POST /v1/account/sending-access/request` + (beta) — the account's latest external sending access request, and filing + one. See [External sending access](#external-sending-access-beta). - `GET/POST /v1/account/api-keys`, `DELETE /v1/account/api-keys/{id}?confirm=DELETE` — mint (plaintext shown once), list (metadata only), and revoke API keys. Account scope only. @@ -594,6 +598,41 @@ Cascading deletes may additionally carry receipt counts (all additive): of `deleted: true`. Domain deletion adds the durable, open-set `sending_teardown` state described below. +#### External sending access (beta) + +A deployment may restrict which recipients an account can reach through the +shared sending identity. When it does, `GET /v1/account` carries an additive +`sending_access` object (absent when the control is disabled or its state is +unreadable): + +| Field | Meaning | +| --- | --- | +| `enforcement_applies` | The deployment enforces the restriction for this account (enforce mode, inside the cohort, not a platform account). Stays `true` after approval. | +| `shared_external_approved` | An operator approved this account. | +| `paid_external_sending_entitled` | The account holds the billing-issued paid base entitlement. It lifts the restriction only when `available_unlocks` contains `paid_entitlement`; otherwise it is informational. | +| `owner_recipient_verified` | The account's sign-in email was verified by a trusted login, so it is an allowed destination while restricted. | +| `available_unlocks` | The routes this deployment accepts for lifting the restriction, as an open set of strings: `operator_approval` (always present — file a request, an operator reviews it, and the account owner is emailed the decision), `verified_domain` (sending as the account's own verified custom domain), `paid_entitlement` (a paid base plan). Servers that predate the field omit it and accept all three. | + +A restricted account can always send to agent inboxes in the same account and +to its verified account email. Any other To/Cc/Bcc recipient refuses the whole +send with `403 external_sending_not_enabled`. To lift the restriction: + +```json +POST /v1/account/sending-access/request +{"use_case": "Order confirmations for customers who signed up on example.com", + "recipients": "Our own signed-up customers", + "expected_daily_volume": 200} +``` + +File **one** request. While it is pending, resubmitting returns the same +request (`200`); after a decline a new request may be filed, up to 3 per 30 +days (`429 rate_limited` beyond that — do not retry). The decision is emailed +to the account owner; `GET /v1/account/sending-access/request` shows its +`state` (`pending`, `approved`, `declined`; open set). The self-host default +accepts all three unlocks; the hosted e2a service accepts only +`operator_approval`. The MCP server exposes the same two operations as the +`request_sending_access` and `get_sending_access_request` tools. + ### Domains (`/v1/domains`) Custom sending/receiving domains and their DNS verification. diff --git a/docs/design/async-message-pipeline.md b/docs/design/async-message-pipeline.md index af49ca696..5c5300ea7 100644 --- a/docs/design/async-message-pipeline.md +++ b/docs/design/async-message-pipeline.md @@ -385,9 +385,40 @@ lock is taken on the accept path: acceptance reads the runtime policy without the singleton share lock because it already holds source locks. The control is an optional `external_sending_access` runtime-policy object (absent = disabled, legacy hashes unchanged): `mode`, the immutable cohort cutoff -and `accounts_created_at_or_after`. The paid-base entitlement is the +`accounts_created_at_or_after`, and the optional `unlocks` set drawn from the +closed vocabulary `operator_approval`, `verified_domain`, `paid_entitlement`. +Omitted `unlocks` means all three (the original rule, same hash); a present set +must be non-empty and must contain `operator_approval` (validated at startup +and on every stored-policy read — an unreachable policy fails loudly instead of +silently denying everyone). The set is consulted by the one decision function +every seam above calls, from the same `RuntimePolicy`, so the stages cannot +disagree: without `verified_domain` step 3 (own verified identity) no longer +applies, and without `paid_entitlement` step 4 ignores the entitlement column. +The metrics route vocabulary is unchanged. `GET /v1/account` reports the +effective set as `sending_access.available_unlocks` so clients render only the +recovery routes the deployment honors; a binary that understands the key +advertises the `external_sending_unlocks` runtime-policy feature marker. +The paid-base entitlement is the billing-written `account_limits.external_sending_entitled` boolean (the -server only reads it; `plan_code` is not authorization). Operator grants are +server only reads it; `plan_code` is not authorization). + +Hosted e2a runs `unlocks: [operator_approval]` with the cutoff at the epoch: +every account is restricted by default and explicit operator approval is the +only unlock. A paid plan used to unlock shared-identity sending instantly; in a +2026-09-26 incident a paid signup made with a stolen card used that instant +unlock to send a phishing burst within hours. A verified domain or a paid plan +is now evidence the operator weighs when reviewing a request, not an automatic +unlock. Self-hosters keep the default (all three) or choose their own set. +When an operator decides a request (`-approve-external-sending +-external-sending-request-id …` or `-decline-external-sending-request`), the +command emails the account owner a neutral decision notice from the +deployment's notification identity (`notifications.from_address` / +`reply_to`), authorized through the gate as a `customer_notification` +operation keyed by the request (`op_esad_`); operator-authored copy +only, never the customer's free text. A failed notice prints a warning and +never fails the command. The operator notification of a NEW request is skipped +for system/internal (`account_class`-exempt) accounts, which the rule never +binds. Operator grants are local server commands (`-approve-external-sending` / `-revoke-external-sending` with a revision CAS and append-only `external_sending_access_events`); no API credential can grant access. Owner-mailbox proof diff --git a/internal/agent/external_access.go b/internal/agent/external_access.go index 0ef5331f9..43eacd7bd 100644 --- a/internal/agent/external_access.go +++ b/internal/agent/external_access.go @@ -18,8 +18,9 @@ import ( const ExternalSendingNotEnabledCode = "external_sending_not_enabled" // ExternalSendingRecoveryPath is the authenticated dashboard page that -// explains the restriction and offers the recovery routes (verify a domain, -// request approval, and — on the hosted service — a paid base plan). +// explains the restriction and offers the recovery routes the deployment's +// unlock set allows (always a request for approval; a verified domain and a +// paid base plan only where configured). const ExternalSendingRecoveryPath = "/sending-access" // Allowed-destination tokens carried in the 403 details. Open set. @@ -84,9 +85,18 @@ func bareRecipient(raw string) string { func (a *API) externalSendingNotEnabledError(ctx context.Context, userID string) *OutboundError { allowed := []string{AllowedSameAccountAgents} ownerVerified := false + // Unknown unlock set (status unreadable) falls back to naming only the + // route that is always available: approval. Never a route the + // deployment may not honor. + domainUnlock := false if a.externalAccess != nil { - if st, err := a.externalAccess.ExternalAccessStatus(ctx, userID); err == nil && st.OwnerRecipientVerified { - ownerVerified = true + if st, err := a.externalAccess.ExternalAccessStatus(ctx, userID); err == nil { + ownerVerified = st.OwnerRecipientVerified + for _, u := range st.AvailableUnlocks { + if u == sendingpolicy.UnlockVerifiedDomain { + domainUnlock = true + } + } } } msg := "External sending is not enabled for this account. You can send to agent inboxes in this account" @@ -94,7 +104,12 @@ func (a *API) externalSendingNotEnabledError(ctx context.Context, userID string) allowed = []string{AllowedVerifiedOwnerEmail, AllowedSameAccountAgents} msg += " and to your verified account email" } - msg += ". To email other recipients, send from your own verified domain or request approval in the dashboard. Retrying this request will not change the result." + if domainUnlock { + msg += ". To email other recipients, send from your own verified domain or request approval in the dashboard." + } else { + msg += ". To email other recipients, request approval in the dashboard (an operator reviews each request)." + } + msg += " Retrying this request will not change the result." details := map[string]any{"allowed_recipients": allowed} if base := strings.TrimRight(a.publicURL, "/"); base != "" { details["recovery_url"] = base + ExternalSendingRecoveryPath diff --git a/internal/agent/external_access_test.go b/internal/agent/external_access_test.go index 64af1b0c8..bddfd226d 100644 --- a/internal/agent/external_access_test.go +++ b/internal/agent/external_access_test.go @@ -3,6 +3,7 @@ package agent_test import ( "context" "net/http" + "strings" "testing" "github.com/jackc/pgx/v5" @@ -146,3 +147,42 @@ func TestDeliverOutboundPausedLoopbackUnaffectedOutsideEnforcement(t *testing.T) }) } } + +// The 403 names only recovery routes the deployment's unlock set honors: a +// hosted deployment running [operator_approval] must not tell a customer that +// verifying a domain will lift the restriction. +func TestDeliverOutboundExternalAccessMessageFollowsUnlocks(t *testing.T) { + for name, tc := range map[string]struct { + unlocks []sendingpolicy.ExternalUnlock + wantDomain bool + }{ + "absent (all)": {nil, true}, + "approval only": {[]sendingpolicy.ExternalUnlock{sendingpolicy.UnlockOperatorApproval}, false}, + "approval+paid": {[]sendingpolicy.ExternalUnlock{sendingpolicy.UnlockOperatorApproval, sendingpolicy.UnlockPaidEntitlement}, false}, + "approval+domain": { + []sendingpolicy.ExternalUnlock{sendingpolicy.UnlockOperatorApproval, sendingpolicy.UnlockVerifiedDomain}, true, + }, + } { + tc := tc + t.Run(name, func(t *testing.T) { + api, store, _, _, pool := setupAsyncAPIWithPool(t) + policy := esaEnforcePolicy() + policy.ExternalSendingAccess.Unlocks = tc.unlocks + api.SetExternalAccess(sendingpolicy.NewPolicyModule(pool, sendingpolicy.Secrets{}, sendingpolicy.PolicySourceConfig, policy)) + label := map[string]string{"absent (all)": "esaula", "approval only": "esaulo", "approval+paid": "esaulp", "approval+domain": "esauld"}[name] + user, ag := selfAgent(t, store, label) + _, oerr := api.DeliverOutbound(context.Background(), user, ag, outbound.SendRequest{ + To: []string{"customer@outside.example"}, Subject: "hi", Body: "body", + }, "send", "", nil, nil) + if oerr == nil || oerr.Code != "external_sending_not_enabled" { + t.Fatalf("external send = %+v", oerr) + } + if got := strings.Contains(oerr.Msg, "verified domain"); got != tc.wantDomain { + t.Fatalf("message mentions verified domain = %v, want %v: %q", got, tc.wantDomain, oerr.Msg) + } + if !strings.Contains(oerr.Msg, "request approval") { + t.Fatalf("approval is always offered: %q", oerr.Msg) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index e5961d4fe..9ddeed6cd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -575,9 +575,16 @@ type SendingProtectionConfig struct { // Mode is disabled|shadow|enforce; AccountsCreatedAtOrAfter is the immutable // RFC3339 UTC cohort cutoff (for example 2026-10-01T00:00:00Z). Both are // validated by internal/sendingpolicy. +// +// Unlocks optionally narrows which routes may lift the restriction, from the +// closed vocabulary operator_approval, verified_domain, paid_entitlement. +// Absent (nil) means all three — the behavior before the key existed. An +// explicit empty list, or a list without operator_approval, is a startup +// error. Hosted e2a runs [operator_approval]: explicit approval only. type ExternalSendingAccessConfig struct { - Mode string `yaml:"mode"` - AccountsCreatedAtOrAfter string `yaml:"accounts_created_at_or_after"` + Mode string `yaml:"mode"` + AccountsCreatedAtOrAfter string `yaml:"accounts_created_at_or_after"` + Unlocks []string `yaml:"unlocks"` } // LimitsConfig is the operator-configured fallback applied to any user diff --git a/internal/httpapi/sending_access.go b/internal/httpapi/sending_access.go index 67bdc45ad..ee0bbdd24 100644 --- a/internal/httpapi/sending_access.go +++ b/internal/httpapi/sending_access.go @@ -25,8 +25,12 @@ const sendingAccessBetaDoc = "Beta: external sending access is a platform contro type SendingAccessView struct { EnforcementApplies bool `json:"enforcement_applies" doc:"True when the deployment enforces external sending access for this account (enforce mode, account inside the rollout cohort, not a platform account). Stays true after approval. False when the control is disabled or in shadow mode, or the account is outside the cohort."` SharedExternalApproved bool `json:"shared_external_approved" doc:"True when an operator granted this account external sending through the shared sending identity. Reports the grant only, not whether enforcement is on."` - PaidExternalSendingEntitled bool `json:"paid_external_sending_entitled" doc:"True when an active paid base subscription grants external sending (hosted service). Independent of shared_external_approved."` + PaidExternalSendingEntitled bool `json:"paid_external_sending_entitled" doc:"True when the account holds the billing-issued paid base entitlement (hosted service). It lifts the restriction only when available_unlocks contains paid_entitlement; otherwise it is informational. Independent of shared_external_approved."` OwnerRecipientVerified bool `json:"owner_recipient_verified" doc:"True when the account's current sign-in email was verified by a trusted login, so it is an allowed destination while external sending is restricted."` + // AvailableUnlocks is optional on the wire only so an SDK talking to an + // older server (which never sends it) still parses the object; a server + // that emits sending_access always emits a non-empty list. + AvailableUnlocks []string `json:"available_unlocks,omitempty" doc:"The routes this deployment accepts for lifting the restriction. Open set: treat entries as strings and ignore unknown values. Known values: operator_approval (file a request with POST /v1/account/sending-access/request; an operator reviews it and the account owner is emailed the decision — always present), verified_domain (sending as the account's own verified custom domain reaches external recipients), paid_entitlement (a paid base plan lifts the restriction). Absent only from servers that predate the field, which accept all three."` } func sendingAccessView(st sendingpolicy.ExternalAccessStatus) *SendingAccessView { @@ -35,9 +39,18 @@ func sendingAccessView(st sendingpolicy.ExternalAccessStatus) *SendingAccessView SharedExternalApproved: st.SharedExternalApproved, PaidExternalSendingEntitled: st.PaidExternalSendingEntitled, OwnerRecipientVerified: st.OwnerRecipientVerified, + AvailableUnlocks: unlockNames(st.AvailableUnlocks), } } +func unlockNames(unlocks []sendingpolicy.ExternalUnlock) []string { + out := make([]string, len(unlocks)) + for i, u := range unlocks { + out[i] = string(u) + } + return out +} + // accountSendingAccess resolves the optional object for GET /v1/account. A // read failure omits the object rather than failing whoami or inventing // false booleans; the gate still enforces the real state on every send. @@ -182,7 +195,13 @@ func (s *Server) handleCreateSendingAccessRequest(ctx context.Context, in *creat status := http.StatusOK if created { status = http.StatusCreated - if s.deps.NotifySendingAccessRequest != nil { + if req.FromExemptAccount { + // A system/internal account is outside the rule, so there is + // nothing for an operator to decide — and the scheduled + // conformance suite, which runs as an internal account, would + // otherwise email the operator on every run. + log.Printf("[httpapi] sending access request %s filed by an exempt-class account; operator notification skipped", req.ID) + } else if s.deps.NotifySendingAccessRequest != nil { // Best effort, bounded, after commit: a failed operator email must // not lose the request, which is durably queued either way. notifyCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) diff --git a/internal/httpapi/sending_access_test.go b/internal/httpapi/sending_access_test.go index badeda3ca..4bbf70dad 100644 --- a/internal/httpapi/sending_access_test.go +++ b/internal/httpapi/sending_access_test.go @@ -195,3 +195,81 @@ func TestSendingAccessDisabledSurfaces(t *testing.T) { t.Fatalf("post: %d %v", code, body) } } + +func TestAccountSendingAccessAvailableUnlocks(t *testing.T) { + for name, tc := range map[string]struct { + unlocks []sendingpolicy.ExternalUnlock + want []any + }{ + "hosted: approval only": {[]sendingpolicy.ExternalUnlock{sendingpolicy.UnlockOperatorApproval}, []any{"operator_approval"}}, + "default: all three": { + []sendingpolicy.ExternalUnlock{sendingpolicy.UnlockOperatorApproval, sendingpolicy.UnlockVerifiedDomain, sendingpolicy.UnlockPaidEntitlement}, + []any{"operator_approval", "verified_domain", "paid_entitlement"}, + }, + } { + tc := tc + t.Run(name, func(t *testing.T) { + srv := testServer(t, func(d *Deps) { + d.SendingAccessStatus = func(context.Context, string) (sendingpolicy.ExternalAccessStatus, error) { + return sendingpolicy.ExternalAccessStatus{EnforcementApplies: true, PaidExternalSendingEntitled: true, AvailableUnlocks: tc.unlocks}, nil + } + }) + code, body := getJSON(t, srv.URL+"/v1/account", "good") + if code != 200 { + t.Fatalf("status %d", code) + } + sa, _ := body["sending_access"].(map[string]any) + got, _ := sa["available_unlocks"].([]any) + if len(got) != len(tc.want) { + t.Fatalf("available_unlocks = %v, want %v", sa["available_unlocks"], tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("available_unlocks = %v, want %v", got, tc.want) + } + } + // Additive: every existing field is still present. + for _, k := range []string{"enforcement_applies", "shared_external_approved", "paid_external_sending_entitled", "owner_recipient_verified"} { + if _, ok := sa[k]; !ok { + t.Fatalf("existing field %s missing: %v", k, sa) + } + } + }) + } +} + +// A request filed by a system/internal account (the scheduled conformance +// suite runs as one) is outside the rule: nothing for an operator to decide, +// so no operator email. Standard accounts keep their notification. +func TestSendingAccessRequestSkipsOperatorNotificationForExemptClass(t *testing.T) { + for name, tc := range map[string]struct { + exempt bool + wantNotify int + }{ + "exempt class": {exempt: true, wantNotify: 0}, + "standard class": {exempt: false, wantNotify: 1}, + } { + tc := tc + t.Run(name, func(t *testing.T) { + notified := 0 + srv := testServer(t, func(d *Deps) { + d.SubmitSendingAccessRequest = func(_ context.Context, _ string, in sendingpolicy.AccessRequestInput) (sendingpolicy.AccessRequest, bool, error) { + return sendingpolicy.AccessRequest{ID: "esar_x", State: "pending", UseCase: in.UseCase, Recipients: in.Recipients, + ExpectedDailyVolume: in.ExpectedDailyVolume, CreatedAt: time.Now().UTC(), FromExemptAccount: tc.exempt}, true, nil + } + d.NotifySendingAccessRequest = func(context.Context, string, sendingpolicy.AccessRequest) { notified++ } + }) + form := map[string]any{"use_case": "x", "recipients": "y", "expected_daily_volume": 1} + code, body := sendJSON(t, http.MethodPost, srv.URL+"/v1/account/sending-access/request", "good", form) + if code != 201 { + t.Fatalf("create: %d %v", code, body) + } + if _, leaked := body["from_exempt_account"]; leaked { + t.Fatal("the exemption flag is server-internal and must not reach the wire") + } + if notified != tc.wantNotify { + t.Fatalf("notified %d, want %d", notified, tc.wantNotify) + } + }) + } +} diff --git a/internal/sendingaccessnotice/notice.go b/internal/sendingaccessnotice/notice.go new file mode 100644 index 000000000..826f6047f --- /dev/null +++ b/internal/sendingaccessnotice/notice.go @@ -0,0 +1,284 @@ +// Package sendingaccessnotice emails an account owner the operator's decision +// on an external sending access request. +// +// The local operator commands (-approve-external-sending with +// -external-sending-request-id, and -decline-external-sending-request) decide +// the request in their own committed transaction, then call NotifyDecision. +// The notice is platform mail about the account, so it leaves through the +// same authorized seam as every other notification: a customer_notification +// operation prepared from the durable request row, Reserve, ConsumeAttempt, +// and one ProviderSubmitter call per charged attempt. The envelope is the +// gate's (the account owner's current address), never a caller's. +// +// Content is operator-authored only. The customer's free-text request fields +// are never echoed back: the notice states the decision and where to go next. +package sendingaccessnotice + +import ( + "context" + "errors" + "fmt" + "html" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/tokencanopy/e2a/internal/outbound" + "github.com/tokencanopy/e2a/internal/sendingpolicy" +) + +// notifyLocalPart is the fallback sender local part when +// notifications.from_address is unset, on outbound_smtp.from_domain — the +// same zero-config pattern the HITL and webhook-health notices use. +const notifyLocalPart = "notifications" + +// SendingAccessPath is the dashboard page a declined notice links to. +const SendingAccessPath = "/sending-access" + +// Decision states the notice reports, mirroring the request row. +const ( + DecisionApproved = "approved" + DecisionDeclined = "declined" +) + +// sendAttempts bounds the physical submissions one notice may make; each is a +// distinct charged ordinal on the one operation. +const sendAttempts = 3 + +var retryBackoff = []time.Duration{time.Second, 2 * time.Second} + +// TxBeginner is the transaction surface the notice needs (*pgxpool.Pool). +type TxBeginner interface { + Begin(ctx context.Context) (pgx.Tx, error) +} + +// Submitter is the one provider seam (*outbound.ProviderSubmitter). +type Submitter interface { + SubmitOnce(ctx context.Context, auth sendingpolicy.ProviderAuthorization, env outbound.Envelope) (outbound.ProviderResult, error) +} + +// Notifier composes and sends decision notices. +type Notifier struct { + pool TxBeginner + gate sendingpolicy.Gate + submitter Submitter + dkim outbound.DKIMKeyLookup + fromAddress string + fromDomain string + replyTo string + publicURL string + replyable bool +} + +// New returns a Notifier. fromDomain is outbound_smtp.from_domain; +// fromAddress and replyTo are the optional notifications.from_address and +// notifications.reply_to values (the deployment's notification identity); +// publicURL builds the dashboard links (empty degrades to generic copy). +func New(pool TxBeginner, gate sendingpolicy.Gate, s Submitter, fromDomain, fromAddress, replyTo, publicURL string) *Notifier { + addr := strings.TrimSpace(fromAddress) + if addr == "" && strings.TrimSpace(fromDomain) != "" { + addr = fmt.Sprintf("%s@%s", notifyLocalPart, strings.TrimSpace(fromDomain)) + } + msgIDDomain := strings.TrimSpace(fromDomain) + if i := strings.LastIndex(addr, "@"); i >= 0 && i+1 < len(addr) { + msgIDDomain = addr[i+1:] + } + return &Notifier{ + pool: pool, + gate: gate, + submitter: s, + fromAddress: addr, + fromDomain: msgIDDomain, + replyTo: strings.TrimSpace(replyTo), + publicURL: strings.TrimRight(strings.TrimSpace(publicURL), "/"), + replyable: strings.TrimSpace(fromAddress) != "" || strings.TrimSpace(replyTo) != "", + } +} + +// WithDKIM wires per-domain DKIM signing for the From domain (fail-open, as +// for the other notices). +func (n *Notifier) WithDKIM(lookup outbound.DKIMKeyLookup) *Notifier { + n.dkim = lookup + return n +} + +// NotifyDecision emails the owner of the request's account the decision the +// request row records. It returns an error when the notice was not sent; the +// caller (an operator command whose decision has already committed) reports +// it as a warning and does not fail. +func (n *Notifier) NotifyDecision(ctx context.Context, requestID string) error { + if n == nil || n.pool == nil || n.gate == nil || n.submitter == nil { + return errors.New("sending access notice: notifier is not wired") + } + if n.fromAddress == "" { + return errors.New("sending access notice: no sender identity (outbound_smtp.from_domain or notifications.from_address) is configured") + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return errors.New("sending access notice: request id is empty") + } + + ref, decision, err := n.prepare(ctx, requestID) + if err != nil { + return err + } + + var last error + for attempt := 0; attempt < sendAttempts; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return errors.Join(ctx.Err(), last) + case <-time.After(retryBackoff[attempt-1]): + } + } + early, attemptRef, err := n.gate.Reserve(ctx, ref) + if err != nil { + return fmt.Errorf("sending access notice: reserve: %w", err) + } + if !early.Allow { + return fmt.Errorf("sending access notice: held by sending policy: %s", early.Reason) + } + d, auth, err := n.gate.ConsumeAttempt(ctx, attemptRef) + if err != nil { + releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + _ = n.gate.CancelAttempt(releaseCtx, attemptRef) + cancel() + return fmt.Errorf("sending access notice: authorize: %w", err) + } + if !d.Allow || auth == nil { + return fmt.Errorf("sending access notice: held by sending policy: %s", d.Reason) + } + // Composed from the gate's resolved envelope, so the To header and + // RCPT TO name exactly the mailbox that was authorized. + recipients := auth.AuthorizedRecipients() + message, err := n.compose(requestID, decision, recipients) + if err != nil { + return err + } + _, err = n.submitter.SubmitOnce(ctx, *auth, outbound.Envelope{From: n.fromAddress, Recipients: recipients, Message: message}) + if err == nil { + return nil + } + last = err + if outbound.IsPermanentSMTPError(err) || errors.Is(err, outbound.ErrProviderAcceptanceUnknown) { + // A definite rejection resends nothing; an unknown acceptance + // may already be in the owner's inbox. + break + } + } + return fmt.Errorf("sending access notice: smtp send: %w", last) +} + +// prepare derives the notice operation from the decided request row and reads +// the decision it reports, in one committed transaction. +func (n *Notifier) prepare(ctx context.Context, requestID string) (sendingpolicy.OperationRef, string, error) { + tx, err := n.pool.Begin(ctx) + if err != nil { + return sendingpolicy.OperationRef{}, "", fmt.Errorf("sending access notice: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + ref, err := n.gate.PrepareNotificationTx(ctx, tx, sendingpolicy.NewSendingAccessDecisionNotificationRef(requestID)) + if err != nil { + return sendingpolicy.OperationRef{}, "", fmt.Errorf("sending access notice: prepare: %w", err) + } + var state string + if err := tx.QueryRow(ctx, `SELECT state FROM external_sending_access_requests WHERE id = $1`, requestID).Scan(&state); err != nil { + return sendingpolicy.OperationRef{}, "", fmt.Errorf("sending access notice: read decision: %w", err) + } + if state != DecisionApproved && state != DecisionDeclined { + return sendingpolicy.OperationRef{}, "", fmt.Errorf("sending access notice: request is %q, not decided", state) + } + if err := tx.Commit(ctx); err != nil { + return sendingpolicy.OperationRef{}, "", fmt.Errorf("sending access notice: commit: %w", err) + } + return ref, state, nil +} + +func (n *Notifier) compose(requestID, decision string, recipients []string) ([]byte, error) { + subject, text, htmlBody := Render(decision, requestID, n.publicURL, n.replyable) + message, err := outbound.ComposeMultipartMessage( + fmt.Sprintf("e2a <%s>", n.fromAddress), recipients, nil, + subject, text, htmlBody, + "", nil, n.fromDomain, n.replyTo, "", + ) + if err != nil { + return nil, fmt.Errorf("sending access notice: compose: %w", err) + } + // Deterministic per request: a request is decided once, and a re-drive + // after an ambiguous send collapses at Message-ID-deduping clients. + msgID := fmt.Sprintf("", decision, requestID, n.fromDomain) + if !strings.ContainsAny(msgID, "\r\n") { + message = append([]byte("Message-ID: "+msgID+"\r\n"), message...) + } + if signed, ok := outbound.SignWithDKIM(n.dkim, message, n.fromDomain); ok { + message = signed + } + return message, nil +} + +// Render returns the subject, plain-text and HTML bodies of a decision +// notice. Everything in it is operator-authored copy plus the request id and +// the deployment's dashboard URL; no customer-supplied text is included. +func Render(decision, requestID, publicURL string, replyable bool) (subject, text, htmlBody string) { + base := strings.TrimRight(publicURL, "/") + var link, linkLabel string + var lines []string + switch decision { + case DecisionApproved: + subject = "[e2a] External sending is enabled for your account" + lines = []string{ + fmt.Sprintf("Your external sending access request (%s) was reviewed and approved.", requestID), + "Your account can now send email to external recipients. Sending limits, pause controls and content checks still apply.", + } + if base != "" { + link, linkLabel = base+"/", "Open the dashboard" + } + default: + subject = "[e2a] Your external sending access request was declined" + lines = []string{ + fmt.Sprintf("Your external sending access request (%s) was reviewed and declined.", requestID), + "Your account can still send to agent inboxes in the account and to your verified account email.", + "You may file a new request with more detail about your use case; each account can file up to 3 requests per 30 days.", + } + if base != "" { + link, linkLabel = base+SendingAccessPath, "Review sending access" + } + } + + var b strings.Builder + for _, l := range lines { + b.WriteString(l) + b.WriteString("\n\n") + } + if link != "" { + fmt.Fprintf(&b, "%s:\n %s\n", linkLabel, link) + } else if decision != DecisionApproved { + b.WriteString("You can file a new request from the Sending access page of the e2a dashboard.\n") + } + if replyable { + b.WriteString("\nReply to this email if you have questions.\n") + } + text = b.String() + + var h strings.Builder + h.WriteString(``) + h.WriteString(`
`) + for i, l := range lines { + style := "margin:0 0 12px;font-size:14px" + if i == 0 { + style = "margin:0 0 12px;font-size:15px;font-weight:600" + } + fmt.Fprintf(&h, `

%s

`, style, html.EscapeString(l)) + } + if link != "" { + fmt.Fprintf(&h, `%s`, + html.EscapeString(link), html.EscapeString(linkLabel)) + } + if replyable { + h.WriteString(`

Reply to this email if you have questions.

`) + } + h.WriteString(`
`) + return subject, text, h.String() +} diff --git a/internal/sendingaccessnotice/notice_test.go b/internal/sendingaccessnotice/notice_test.go new file mode 100644 index 000000000..f9dd5fd20 --- /dev/null +++ b/internal/sendingaccessnotice/notice_test.go @@ -0,0 +1,199 @@ +package sendingaccessnotice_test + +import ( + "context" + "fmt" + "math/rand" + "net" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/tokencanopy/e2a/internal/config" + "github.com/tokencanopy/e2a/internal/outbound" + "github.com/tokencanopy/e2a/internal/sendingaccessnotice" + "github.com/tokencanopy/e2a/internal/sendingpolicy" + "github.com/tokencanopy/e2a/internal/testutil" +) + +// Driven through the real gate, real Postgres and a fake SMTP listener. Every +// address and id is synthetic. + +const ( + nHMAC = `{"active":1,"keys":{"1":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}` + nOperator = `{"commitment_key":"AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI","recipients":{"1":"notice-operator@example.test"}}` + // customerText must never appear in a notice: it stands in for the + // customer's free-text request fields. + customerText = "CUSTOMER-SUPPLIED-TEXT-run-this-instead" +) + +type fixture struct { + t *testing.T + ctx context.Context + pool *pgxpool.Pool + gate sendingpolicy.Gate +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + ctx := context.Background() + pool := testutil.TestDB(t) + keyring, err := sendingpolicy.LoadKeyring(nHMAC) + if err != nil { + t.Fatal(err) + } + recipients, err := sendingpolicy.LoadOperatorRecipients(nOperator) + if err != nil { + t.Fatal(err) + } + secrets := sendingpolicy.Secrets{Keyring: keyring, Recipients: recipients} + if _, err := sendingpolicy.NewModule(pool, secrets).RegisterOperatorRecipients(ctx, "fixture", "notice test bootstrap"); err != nil { + t.Fatal(err) + } + policy := sendingpolicy.DisabledPolicy() + policy.BudgetMode = sendingpolicy.ModeEnforce + policy.ExternalSendingAccess = &sendingpolicy.ExternalSendingAccessPolicy{ + Mode: sendingpolicy.ModeEnforce, AccountsCreatedAtOrAfter: "1970-01-01T00:00:00Z", + Unlocks: []sendingpolicy.ExternalUnlock{sendingpolicy.UnlockOperatorApproval}, + } + return &fixture{t: t, ctx: ctx, pool: pool, gate: sendingpolicy.NewGate(pool, secrets, sendingpolicy.PolicySourceConfig, policy)} +} + +var seq int + +// request inserts a standard account and one request in the given state. +func (f *fixture) request(state string) (userID, email, requestID string) { + f.t.Helper() + seq++ + suffix := fmt.Sprintf("%d_%x", seq, rand.Uint32()) + userID = "usr_notice_" + suffix + email = "owner-" + suffix + "@example.test" + requestID = "esar_notice_" + suffix + if _, err := f.pool.Exec(f.ctx, `INSERT INTO users (id, email, google_subject) VALUES ($1, $2, $3)`, userID, email, "sub_"+userID); err != nil { + f.t.Fatal(err) + } + decidedAt, decidedBy := "NULL", "NULL" + if state != "pending" { + decidedAt, decidedBy = "now()", "'cli:test'" + } + if _, err := f.pool.Exec(f.ctx, `INSERT INTO external_sending_access_requests (id, user_id, state, use_case, recipients, expected_daily_volume, decided_at, decided_by) + VALUES ($1, $2, $3, $4, $4, 5, `+decidedAt+`, `+decidedBy+`)`, requestID, userID, state, customerText); err != nil { + f.t.Fatal(err) + } + return userID, email, requestID +} + +func (f *fixture) notifier(relay *outbound.SMTPRelay) *sendingaccessnotice.Notifier { + return sendingaccessnotice.New(f.pool, f.gate, outbound.NewProviderSubmitter(relay, f.gate), + "notify.example.test", "", "support@example.test", "https://dash.example.test") +} + +func acceptingRelay(t *testing.T) (*outbound.SMTPRelay, func() []testutil.SMTPMessage) { + t.Helper() + addr, messages := testutil.FakeSMTPServer(t) + return outbound.NewSMTPRelay(&config.OutboundSMTPConfig{Host: addr.Host, Port: addr.Port}), messages +} + +func TestNotifyDecisionSendsToOwner(t *testing.T) { + for _, tc := range []struct { + state string + subject string + link string + }{ + {"approved", "External sending is enabled", "https://dash.example.test/"}, + {"declined", "was declined", "https://dash.example.test/sending-access"}, + } { + t.Run(tc.state, func(t *testing.T) { + f := newFixture(t) + relay, messages := acceptingRelay(t) + _, email, requestID := f.request(tc.state) + if err := f.notifier(relay).NotifyDecision(f.ctx, requestID); err != nil { + t.Fatalf("notify: %v", err) + } + got := messages() + if len(got) != 1 { + t.Fatalf("messages = %d, want 1", len(got)) + } + m := got[0] + if len(m.Recipients) != 1 || !strings.EqualFold(m.Recipients[0], email) { + t.Fatalf("recipients = %v, want the account owner", m.Recipients) + } + if m.From != "notifications@notify.example.test" { + t.Fatalf("envelope from = %q", m.From) + } + if !strings.Contains(m.Data, tc.subject) || !strings.Contains(m.Data, tc.link) || !strings.Contains(m.Data, requestID) { + t.Fatalf("body missing subject/link/request id:\n%s", m.Data) + } + if !strings.Contains(m.Data, "Reply-To: support@example.test") { + t.Fatalf("notification reply-to missing:\n%s", m.Data) + } + if strings.Contains(m.Data, customerText) { + t.Fatal("a decision notice must never echo customer-supplied text") + } + // One logical notice per request: the operation is keyed by it. + var n int + if err := f.pool.QueryRow(f.ctx, `SELECT count(*) FROM sending_provider_operations WHERE operation_id = $1 AND purpose = 'customer_notification'`, + sendingpolicy.SendingAccessDecisionOperationID(requestID)).Scan(&n); err != nil || n != 1 { + t.Fatalf("operation rows = %d err=%v", n, err) + } + }) + } +} + +func TestNotifyDecisionRefusesPendingRequest(t *testing.T) { + f := newFixture(t) + relay, messages := acceptingRelay(t) + _, _, requestID := f.request("pending") + if err := f.notifier(relay).NotifyDecision(f.ctx, requestID); err == nil { + t.Fatal("an undecided request has no decision to report") + } + if len(messages()) != 0 { + t.Fatal("nothing may be sent for a pending request") + } +} + +func TestNotifyDecisionHeldForPausedAccount(t *testing.T) { + f := newFixture(t) + relay, messages := acceptingRelay(t) + userID, _, requestID := f.request("declined") + if _, err := f.pool.Exec(f.ctx, `INSERT INTO account_sending_controls (user_id, state, reason, actor) VALUES ($1, 'paused', 'test', 'test')`, userID); err != nil { + t.Fatal(err) + } + err := f.notifier(relay).NotifyDecision(f.ctx, requestID) + if err == nil || !strings.Contains(err.Error(), "held") { + t.Fatalf("paused account notice err = %v, want a policy hold", err) + } + if len(messages()) != 0 { + t.Fatal("a held notice must not reach the relay") + } +} + +func TestNotifyDecisionReportsRelayFailure(t *testing.T) { + f := newFixture(t) + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := l.Addr().(*net.TCPAddr) + _ = l.Close() // nothing listens: every dial is refused + relay := outbound.NewSMTPRelay(&config.OutboundSMTPConfig{Host: "127.0.0.1", Port: addr.Port}) + _, _, requestID := f.request("approved") + if err := f.notifier(relay).NotifyDecision(f.ctx, requestID); err == nil { + t.Fatal("a refused relay must be reported as an error") + } +} + +func TestRenderNeverClaimsMoreThanTheDecision(t *testing.T) { + subj, text, htmlBody := sendingaccessnotice.Render("declined", "esar_x", "", false) + if !strings.Contains(subj, "declined") || !strings.Contains(text, "3 requests per 30 days") { + t.Fatalf("declined copy = %q / %q", subj, text) + } + if strings.Contains(text, "Reply to this email") || strings.Contains(htmlBody, "` — is a pre-derivation reference: its source is @@ -421,6 +434,15 @@ func NewWebhookHealthNotificationRef(webhookID, kind string) NotificationRef { return NotificationRef{source: NotificationWebhookHealth, id: webhookID, kind: kind} } +// NewSendingAccessDecisionNotificationRef references a DECIDED external +// sending access request whose outcome is being reported to the account +// owner. PrepareNotificationTx reads the request row and refuses a request +// that is still pending (ErrSourceUnavailable): there is no decision to +// report, so nothing to authorize. +func NewSendingAccessDecisionNotificationRef(requestID string) NotificationRef { + return NotificationRef{source: NotificationSendingAccessDecision, id: requestID} +} + // ProtectionNoticeRef names one already-committed notice event and audience. // The event row must exist: notices are enqueued by the transaction that // detects the violation, and the drain worker only ever resumes them. diff --git a/sdks/python/src/e2a/v1/generated/models/sending_access_view.py b/sdks/python/src/e2a/v1/generated/models/sending_access_view.py index e303260c6..2453fee79 100644 --- a/sdks/python/src/e2a/v1/generated/models/sending_access_view.py +++ b/sdks/python/src/e2a/v1/generated/models/sending_access_view.py @@ -17,8 +17,8 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictBool -from typing import Any, ClassVar, Dict, List +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr +from typing import Any, ClassVar, Dict, List, Optional from typing import Optional, Set from typing_extensions import Self @@ -26,12 +26,13 @@ class SendingAccessView(BaseModel): """ SendingAccessView """ # noqa: E501 + available_unlocks: Optional[List[StrictStr]] = Field(default=None, description="The routes this deployment accepts for lifting the restriction. Open set: treat entries as strings and ignore unknown values. Known values: operator_approval (file a request with POST /v1/account/sending-access/request; an operator reviews it and the account owner is emailed the decision — always present), verified_domain (sending as the account's own verified custom domain reaches external recipients), paid_entitlement (a paid base plan lifts the restriction). Absent only from servers that predate the field, which accept all three.") enforcement_applies: StrictBool = Field(description="True when the deployment enforces external sending access for this account (enforce mode, account inside the rollout cohort, not a platform account). Stays true after approval. False when the control is disabled or in shadow mode, or the account is outside the cohort.") owner_recipient_verified: StrictBool = Field(description="True when the account's current sign-in email was verified by a trusted login, so it is an allowed destination while external sending is restricted.") - paid_external_sending_entitled: StrictBool = Field(description="True when an active paid base subscription grants external sending (hosted service). Independent of shared_external_approved.") + paid_external_sending_entitled: StrictBool = Field(description="True when the account holds the billing-issued paid base entitlement (hosted service). It lifts the restriction only when available_unlocks contains paid_entitlement; otherwise it is informational. Independent of shared_external_approved.") shared_external_approved: StrictBool = Field(description="True when an operator granted this account external sending through the shared sending identity. Reports the grant only, not whether enforcement is on.") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["enforcement_applies", "owner_recipient_verified", "paid_external_sending_entitled", "shared_external_approved"] + __properties: ClassVar[List[str]] = ["available_unlocks", "enforcement_applies", "owner_recipient_verified", "paid_external_sending_entitled", "shared_external_approved"] model_config = ConfigDict( populate_by_name=True, @@ -79,6 +80,11 @@ def to_dict(self) -> Dict[str, Any]: for _key, _value in self.additional_properties.items(): _dict[_key] = _value + # set to None if available_unlocks (nullable) is None + # and model_fields_set contains the field + if self.available_unlocks is None and "available_unlocks" in self.model_fields_set: + _dict['available_unlocks'] = None + return _dict @classmethod @@ -91,6 +97,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ + "available_unlocks": obj.get("available_unlocks"), "enforcement_applies": obj.get("enforcement_applies"), "owner_recipient_verified": obj.get("owner_recipient_verified"), "paid_external_sending_entitled": obj.get("paid_external_sending_entitled"), diff --git a/sdks/typescript/src/v1/generated/models/SendingAccessView.ts b/sdks/typescript/src/v1/generated/models/SendingAccessView.ts index bec1b949e..36b529a26 100644 --- a/sdks/typescript/src/v1/generated/models/SendingAccessView.ts +++ b/sdks/typescript/src/v1/generated/models/SendingAccessView.ts @@ -13,6 +13,10 @@ import { HttpFile } from '../http/http.js'; export class SendingAccessView { + /** + * The routes this deployment accepts for lifting the restriction. Open set: treat entries as strings and ignore unknown values. Known values: operator_approval (file a request with POST /v1/account/sending-access/request; an operator reviews it and the account owner is emailed the decision — always present), verified_domain (sending as the account\'s own verified custom domain reaches external recipients), paid_entitlement (a paid base plan lifts the restriction). Absent only from servers that predate the field, which accept all three. + */ + 'availableUnlocks'?: Array | null; /** * True when the deployment enforces external sending access for this account (enforce mode, account inside the rollout cohort, not a platform account). Stays true after approval. False when the control is disabled or in shadow mode, or the account is outside the cohort. */ @@ -22,7 +26,7 @@ export class SendingAccessView { */ 'ownerRecipientVerified': boolean; /** - * True when an active paid base subscription grants external sending (hosted service). Independent of shared_external_approved. + * True when the account holds the billing-issued paid base entitlement (hosted service). It lifts the restriction only when available_unlocks contains paid_entitlement; otherwise it is informational. Independent of shared_external_approved. */ 'paidExternalSendingEntitled': boolean; /** @@ -35,6 +39,12 @@ export class SendingAccessView { static readonly mapping: {[index: string]: string} | undefined = undefined; static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "availableUnlocks", + "baseName": "available_unlocks", + "type": "Array", + "format": "" + }, { "name": "enforcementApplies", "baseName": "enforcement_applies", From 91b7021e8d75a82dc0cde02deef9462f176433bc Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:17:16 +0800 Subject: [PATCH 02/13] feat(sdk): surface sending_access.available_unlocks Regenerated TS and Python bases pick up the additive optional field; the ergonomic clients document it, tests cover decoding from current and pre-field servers, and the shared contract scenario asserts the default set. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- sdks/python/CHANGELOG.md | 6 ++++++ sdks/python/src/e2a/v1/client.py | 10 +++++++++ sdks/python/tests/test_v1_client.py | 25 +++++++++++++++++++++ sdks/typescript/CHANGELOG.md | 6 ++++++ sdks/typescript/src/v1/client.ts | 11 +++++++++- sdks/typescript/test/v1/client.test.ts | 30 ++++++++++++++++++++++++++ tests/contract/scenarios.yaml | 9 +++++++- 7 files changed, 95 insertions(+), 2 deletions(-) diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index f5628b209..11a93877b 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -19,6 +19,12 @@ gain the keyword-only ``permanent`` argument. ### Added +- **``SendingAccessView.available_unlocks``** (beta, optional + ``list[str]``): the routes the deployment accepts for lifting the + external-sending restriction — ``operator_approval`` (always present), + ``verified_domain``, ``paid_entitlement``; open set. ``None`` from servers + that predate the field, which accept all three. ``paid_external_sending_entitled`` + now lifts the restriction only when ``paid_entitlement`` is listed. - **``AccountView`` (``account.get()``)** gains optional ``deleted_at``, ``purge_after``, and ``restored_at`` (``datetime``), reflecting the authenticated account's trash state. diff --git a/sdks/python/src/e2a/v1/client.py b/sdks/python/src/e2a/v1/client.py index 32577189f..380ab60aa 100644 --- a/sdks/python/src/e2a/v1/client.py +++ b/sdks/python/src/e2a/v1/client.py @@ -1387,6 +1387,15 @@ def __init__(self, api: AccountApi, client: AsyncE2AClient) -> None: self.api_keys = APIKeysResource(api, client) async def get(self) -> AccountView: + """The authenticated account (whoami). + + On a deployment that restricts external sending, ``sending_access`` + (beta) reports the account's state; its ``available_unlocks`` lists + the routes that deployment accepts for lifting the restriction + (``operator_approval`` — see :meth:`request_sending_access` — is + always among them; ``verified_domain`` and ``paid_entitlement`` only + where configured). ``None`` from servers that predate the field. + """ return await self._c._read(lambda h: self._api.get_account(_headers=h)) async def export(self) -> UserExport: @@ -1473,6 +1482,7 @@ async def request_sending_access( ) -> SendingAccessRequestView: """Beta: file a request for support to review external sending access. + File ONE request; the decision is emailed to the account owner. Idempotent while a request is pending — calling this again returns the existing pending request instead of creating another. After a decline, a new request may be filed as an appeal (``rate_limited`` beyond 3 per diff --git a/sdks/python/tests/test_v1_client.py b/sdks/python/tests/test_v1_client.py index 05a525b5e..eca70937c 100644 --- a/sdks/python/tests/test_v1_client.py +++ b/sdks/python/tests/test_v1_client.py @@ -1952,6 +1952,31 @@ async def test_outreach_exposes_etag_and_sends_if_match(httpx_mock): # ── account: sending access (beta) ──────────────────────────────────────── +def test_sending_access_view_decodes_available_unlocks(): + from e2a.v1.generated.models.sending_access_view import SendingAccessView + + view = SendingAccessView.from_dict( + { + "enforcement_applies": True, + "shared_external_approved": False, + "paid_external_sending_entitled": True, + "owner_recipient_verified": True, + "available_unlocks": ["operator_approval"], + } + ) + assert view.available_unlocks == ["operator_approval"] + # A server that predates the field omits it; the object still decodes. + legacy = SendingAccessView.from_dict( + { + "enforcement_applies": True, + "shared_external_approved": False, + "paid_external_sending_entitled": False, + "owner_recipient_verified": False, + } + ) + assert legacy.available_unlocks is None + + @pytest.mark.anyio async def test_get_sending_access_request_reads_the_endpoint(httpx_mock): httpx_mock.add_response( diff --git a/sdks/typescript/CHANGELOG.md b/sdks/typescript/CHANGELOG.md index 4ee954f90..b349adcc1 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -17,6 +17,12 @@ `userDeleted` is `true` only for `mode: "permanent"`. ### Added +- **`SendingAccessView.availableUnlocks`** (beta, optional `string[]`): the + routes the deployment accepts for lifting the external-sending restriction — + `"operator_approval"` (always present), `"verified_domain"`, + `"paid_entitlement"`; open set. Undefined from servers that predate the + field, which accept all three. `paidExternalSendingEntitled` now lifts the + restriction only when `"paid_entitlement"` is listed. - **`AccountView` (`account.get()`)** gains optional `deletedAt`, `purgeAfter`, and `restoredAt` (`Date`), reflecting the authenticated account's trash state. diff --git a/sdks/typescript/src/v1/client.ts b/sdks/typescript/src/v1/client.ts index 4028e73f5..41e1bc4ae 100644 --- a/sdks/typescript/src/v1/client.ts +++ b/sdks/typescript/src/v1/client.ts @@ -906,6 +906,14 @@ class AccountResource { this.suppressions = new SuppressionsResource(api); this.apiKeys = new APIKeysResource(api); } + /** + * The authenticated account (whoami). On a deployment that restricts + * external sending, `sendingAccess` (beta) reports the account's state; its + * `availableUnlocks` lists the routes that deployment accepts for lifting + * the restriction (`"operator_approval"` — see {@link requestSendingAccess} + * — is always among them; `"verified_domain"` and `"paid_entitlement"` only + * where configured). Undefined from servers that predate the field. + */ get(): Promise { return call(() => this.api.getAccount()); } @@ -962,7 +970,8 @@ class AccountResource { /** * Beta: files a request for support to review this account's external * sending access — the recovery path named by an - * `external_sending_not_enabled` error's `details.recovery_url (raw wire key)`. Idempotent + * `external_sending_not_enabled` error's `details.recovery_url (raw wire key)`. + * File ONE request; the decision is emailed to the account owner. Idempotent * while a request is pending: submitting again returns the SAME pending * request instead of creating a second one. Capped at 3 requests per 30 * days (`E2ARateLimitError` beyond that). Filing never grants access by diff --git a/sdks/typescript/test/v1/client.test.ts b/sdks/typescript/test/v1/client.test.ts index 7e442de8f..fbb8018ae 100644 --- a/sdks/typescript/test/v1/client.test.ts +++ b/sdks/typescript/test/v1/client.test.ts @@ -1289,6 +1289,36 @@ describe("E2AClient", () => { expect(lastCall().url).toContain("/v1/account/suppressions"); }); + it("account.get decodes sending_access.available_unlocks (beta, additive)", async () => { + globalThis.fetch = mockFetch(200, { + plan: "free", + sending_access: { + enforcement_applies: true, + shared_external_approved: false, + paid_external_sending_entitled: true, + owner_recipient_verified: true, + available_unlocks: ["operator_approval"], + }, + }); + const account = await client.account.get(); + expect(account.sendingAccess?.availableUnlocks).toEqual(["operator_approval"]); + expect(account.sendingAccess?.paidExternalSendingEntitled).toBe(true); + + // A server that predates the field omits it; the object still decodes. + globalThis.fetch = mockFetch(200, { + plan: "free", + sending_access: { + enforcement_applies: true, + shared_external_approved: false, + paid_external_sending_entitled: false, + owner_recipient_verified: false, + }, + }); + const legacy = await client.account.get(); + expect(legacy.sendingAccess?.enforcementApplies).toBe(true); + expect(legacy.sendingAccess?.availableUnlocks).toBeUndefined(); + }); + it("account.requestSendingAccess POSTs the snake_case body and decodes the camelCase view", async () => { globalThis.fetch = mockFetch(201, { id: "sar_1", diff --git a/tests/contract/scenarios.yaml b/tests/contract/scenarios.yaml index 4c93469c2..f043c5ec1 100644 --- a/tests/contract/scenarios.yaml +++ b/tests/contract/scenarios.yaml @@ -3277,7 +3277,8 @@ scenarios: - name: external_sending_access_restricted_account description: > External sending access over the wire. The contract server enforces the - control with a far-future cohort cutoff, so ONLY the restricted account + control with a far-future cohort cutoff and the default unlock set (all + three, reported as available_unlocks), so ONLY the restricted account ({restricted_api_key}) is in the cohort. It may send to its verified owner email and its own live agents; any external To, Cc or Bcc refuses the WHOLE send with 403 external_sending_not_enabled and nothing is @@ -3297,6 +3298,12 @@ scenarios: "sending_access.shared_external_approved": false "sending_access.paid_external_sending_entitled": false "sending_access.owner_recipient_verified": true + # The contract server leaves `unlocks` unset: the default is + # every unlock, reported in canonical order. + "sending_access.available_unlocks.length": 3 + "sending_access.available_unlocks[0]": operator_approval + "sending_access.available_unlocks[1]": verified_domain + "sending_access.available_unlocks[2]": paid_entitlement - id: primary_account_is_outside_the_cohort action: request From 5a0ae925fa553a3df0f93ac9a5afa60622a94fc5 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:19:24 +0800 Subject: [PATCH 03/13] feat(cli): follow sending_access.available_unlocks status prints the deployment's unlock set; status and whoami offer only honored recovery routes and treat a paid plan as a grant only where paid_entitlement is listed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- cli/CHANGELOG.md | 7 +++ cli/README.md | 14 +++--- cli/src/__tests__/sending-access.test.ts | 41 +++++++++++++++++ cli/src/__tests__/whoami.test.ts | 23 ++++++++++ cli/src/bin/e2a.ts | 2 +- cli/src/commands/sending-access.ts | 57 ++++++++++++++++++------ cli/src/commands/whoami.ts | 11 +++-- 7 files changed, 129 insertions(+), 26 deletions(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 0028e46dc..b275e437b 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +**Changed:** `e2a sending-access status` prints an `available unlocks: ...` +line from the deployment's `sending_access.available_unlocks`, and both it and +`e2a whoami` offer only the recovery routes the deployment honors. A paid plan +counts as a grant only where `paid_entitlement` is listed (a server that omits +the field is read as accepting all three, as before). `e2a sending-access +request` now says the decision is emailed to the account owner. + **Added:** `e2a account delete [--permanent] [--yes] [--json]`. By default the account is moved to the trash — every API key, OAuth grant, and dashboard session is revoked and sending stops immediately, but the account is diff --git a/cli/README.md b/cli/README.md index 06830b83b..aca04c567 100644 --- a/cli/README.md +++ b/cli/README.md @@ -66,7 +66,8 @@ e2a keys create --agent bot@acme.com Show the key identity: user, scope, bound agent, plan. When the deployment reports `sending_access` (beta — see `e2a sending-access` below) and this account is currently restricted to the narrow shared-identity allowlist, an -extra `External sending: restricted (...)` line points at how to recover. If +extra `External sending: restricted (...)` line points at how to recover — +naming only the routes the deployment's `available_unlocks` honors. If the account was restored from the trash (`e2a account delete` below), an extra `restored: (from trash)` line appears. `--json` always includes the raw `sending_access` object when present. @@ -383,10 +384,13 @@ External sending access is a platform control on the shared sending identity: while an account is restricted, it may only send to its verified account email and to agent inboxes in the same account — any other To/Cc/Bcc refuses the whole send with `external_sending_not_enabled` (see `e2a send`/`e2a -reply` below). `status` shows the current restriction and the account's -latest access request, if any; `request` files a new one for support to -review. Filing never grants access by itself, and is capped at 3 requests per -30 days. +reply` below). `status` shows the current restriction, the deployment's +`available unlocks` (`operator_approval` always; `verified_domain` and +`paid_entitlement` only where the deployment accepts them — the hosted service +accepts approval only), and the account's latest access request, if any; +`request` files a new one for an operator to review. File one request: the +decision is emailed to the account owner. Filing never grants access by +itself, and is capped at 3 requests per 30 days. ```bash e2a sending-access status diff --git a/cli/src/__tests__/sending-access.test.ts b/cli/src/__tests__/sending-access.test.ts index b593cee29..d5f582c5d 100644 --- a/cli/src/__tests__/sending-access.test.ts +++ b/cli/src/__tests__/sending-access.test.ts @@ -130,6 +130,47 @@ describe("sending-access commands", () => { expect(out).toContain("allowed (paid plan entitlement)"); }); + it("status lists available unlocks and offers only honored routes", async () => { + for (const [unlocks, wantDomain, wantPaid] of [ + [["operator_approval"], false, false], + [["operator_approval", "verified_domain"], true, false], + [["operator_approval", "paid_entitlement"], false, true], + [undefined, true, true], + ] as const) { + stdout.mockClear(); + mockAccountGet.mockResolvedValue( + makeAccount({ ...RESTRICTED, ...(unlocks ? { availableUnlocks: [...unlocks] } : {}) }), + ); + mockGetRequest.mockRejectedValue( + new E2ANotFoundError({ code: "not_found", message: "none", status: 404, retryable: false }), + ); + const { sendingAccessStatus } = await import("../commands/sending-access.js"); + await sendingAccessStatus({}); + const out = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(out).toContain("External sending: restricted"); + expect(out).toContain("request approval"); + expect(out.includes("verified domain")).toBe(wantDomain); + expect(out.includes("paid plan")).toBe(wantPaid); + expect(out).toContain( + `available unlocks: ${(unlocks ?? ["operator_approval", "verified_domain", "paid_entitlement"]).join(", ")}`, + ); + } + }); + + it("a paid entitlement is not reported as a grant under approval-only unlocks", async () => { + mockAccountGet.mockResolvedValue( + makeAccount({ ...RESTRICTED, paidExternalSendingEntitled: true, availableUnlocks: ["operator_approval"] }), + ); + mockGetRequest.mockRejectedValue( + new E2ANotFoundError({ code: "not_found", message: "none", status: 404, retryable: false }), + ); + const { sendingAccessStatus } = await import("../commands/sending-access.js"); + await sendingAccessStatus({}); + const out = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(out).toContain("External sending: restricted"); + expect(out).not.toContain("allowed (paid plan entitlement)"); + }); + it("reports the operator-approved grant", async () => { mockAccountGet.mockResolvedValue( makeAccount({ ...RESTRICTED, sharedExternalApproved: true }), diff --git a/cli/src/__tests__/whoami.test.ts b/cli/src/__tests__/whoami.test.ts index 3a170dde1..2401a37d8 100644 --- a/cli/src/__tests__/whoami.test.ts +++ b/cli/src/__tests__/whoami.test.ts @@ -190,6 +190,29 @@ describe("whoami command", () => { expect(output).not.toContain("External sending:"); }); + it("a paid plan is not a grant where available_unlocks omits paid_entitlement", async () => { + mockAccountGet.mockResolvedValue( + makeAccount({ + sendingAccess: { + enforcementApplies: true, + sharedExternalApproved: false, + paidExternalSendingEntitled: true, + ownerRecipientVerified: true, + availableUnlocks: ["operator_approval"], + }, + }), + ); + const { whoami } = await import("../commands/whoami.js"); + await whoami({}); + + const output = mockStdout.mock.calls.map((c: unknown[]) => c[0]).join(""); + expect(output).toContain("External sending: restricted"); + expect(output).toContain("request approval"); + // Only routes the deployment honors are offered. + expect(output).not.toContain("verified domain"); + expect(output).not.toContain("paid plan"); + }); + it("says nothing new when sending_access is omitted (older/self-host deployment)", async () => { mockAccountGet.mockResolvedValue(makeAccount()); const { whoami } = await import("../commands/whoami.js"); diff --git a/cli/src/bin/e2a.ts b/cli/src/bin/e2a.ts index a5ed9515c..3428571bb 100644 --- a/cli/src/bin/e2a.ts +++ b/cli/src/bin/e2a.ts @@ -124,7 +124,7 @@ Usage: e2a sending-access status Beta: show this account's external-sending restriction and its latest access request, if any --json Raw { sendingAccess, latestRequest } objects - e2a sending-access request Beta: ask support to review external sending access + e2a sending-access request Beta: ask an operator to review external sending access --use-case What you're building and why (1-2000 chars) --recipients Who you'll email (1-1000 chars) --volume Expected recipients per day (1-1000000) diff --git a/cli/src/commands/sending-access.ts b/cli/src/commands/sending-access.ts index fac0fbfac..c529acec1 100644 --- a/cli/src/commands/sending-access.ts +++ b/cli/src/commands/sending-access.ts @@ -7,7 +7,8 @@ import { EXIT, fail } from "../exit.js"; // identity. `status` reads GET /v1/account's additive `sending_access` object // plus the account's latest request (if any); `request` files a new one via // POST /v1/account/sending-access/request. Filing never grants access by -// itself — only support (or a paid plan) does that. +// itself — an operator's approval does (or, where the deployment's +// `available_unlocks` lists them, a verified domain or a paid plan). export interface SendingAccessStatusOptions { json?: boolean; @@ -23,11 +24,40 @@ export interface SendingAccessRequestOptions { export const SENDING_ACCESS_REQUEST_USAGE = "usage: e2a sending-access request --use-case --recipients --volume [--json]"; +/** The deployment's unlock set. A server that predates `available_unlocks` + * omits it and accepts all three, so absence reads as all three. */ +export function availableUnlocks(access: SendingAccessView): string[] { + return access.availableUnlocks ?? ["operator_approval", "verified_domain", "paid_entitlement"]; +} + +/** True when the paid entitlement actually lifts the restriction here. */ +function paidUnlocks(access: SendingAccessView): boolean { + return access.paidExternalSendingEntitled && availableUnlocks(access).includes("paid_entitlement"); +} + /** True exactly when the account is restricted to the narrow allowed-recipient - * set right now — enforcement applies and neither grant (operator approval or - * a paid plan) is in effect. Mirrors the server's own decision at send time. */ -function isRestricted(access: SendingAccessView): boolean { - return access.enforcementApplies && !access.sharedExternalApproved && !access.paidExternalSendingEntitled; + * set right now — enforcement applies and no account-level grant in effect + * (operator approval, or a paid plan where the deployment accepts one). + * Mirrors the server's own decision at send time. */ +export function isRestricted(access: SendingAccessView): boolean { + return access.enforcementApplies && !access.sharedExternalApproved && !paidUnlocks(access); +} + +const REQUEST_HINT = + "e2a sending-access request --use-case --recipients --volume "; + +/** The one-line restricted summary shared by `whoami` and `status`: what the + * account can still reach, and only the recovery routes the deployment + * honors. */ +export function restrictedLine(access: SendingAccessView): string { + const unlocks = availableUnlocks(access); + const routes = [`request approval with: ${REQUEST_HINT}`]; + if (unlocks.includes("verified_domain")) routes.push("or send from your own verified domain"); + if (unlocks.includes("paid_entitlement")) routes.push("or choose a paid plan"); + return ( + "External sending: restricted (send to your verified account email and agent inboxes in " + + `this account; ${routes.join(" ")})` + ); } function describeAccess(access: SendingAccessView | undefined): string { @@ -39,17 +69,13 @@ function describeAccess(access: SendingAccessView | undefined): string { if (!access.enforcementApplies) { return "External sending: unrestricted (this control does not apply to this account)."; } - if (access.paidExternalSendingEntitled) { - return "External sending: allowed (paid plan entitlement)."; - } if (access.sharedExternalApproved) { return "External sending: allowed (approved by an operator)."; } - return ( - "External sending: restricted (send to your verified account email and agent inboxes in " + - "this account; request approval with: e2a sending-access request --use-case " + - "--recipients --volume )" - ); + if (paidUnlocks(access)) { + return "External sending: allowed (paid plan entitlement)."; + } + return restrictedLine(access); } function describeRequest(req: SendingAccessRequestView): string { @@ -81,6 +107,9 @@ export async function sendingAccessStatus(opts: SendingAccessStatusOptions): Pro } process.stdout.write(describeAccess(account.sendingAccess) + "\n"); + if (account.sendingAccess) { + process.stdout.write(`available unlocks: ${availableUnlocks(account.sendingAccess).join(", ")}\n`); + } if (latest) process.stdout.write(describeRequest(latest) + "\n"); } @@ -105,7 +134,7 @@ export async function sendingAccessRequest(opts: SendingAccessRequestOptions): P process.stdout.write(`${result.id}\t${result.state}\n`); process.stderr.write( result.state === "pending" - ? "Filed (or already pending) — support will review it. Check back with: e2a sending-access status\n" + ? "Filed (or already pending) — an operator will review it and the account owner will get an email with the decision. Do not re-file. Check back with: e2a sending-access status\n" : `Note: the account's latest request is already ${result.state}; this filing may be a new appeal.\n`, ); } diff --git a/cli/src/commands/whoami.ts b/cli/src/commands/whoami.ts index 4a56c040e..a1c11d478 100644 --- a/cli/src/commands/whoami.ts +++ b/cli/src/commands/whoami.ts @@ -1,5 +1,6 @@ import { createClient } from "../sdk.js"; import { loadConfig } from "../config.js"; +import { isRestricted, restrictedLine } from "./sending-access.js"; export interface WhoamiOptions { json?: boolean; @@ -56,12 +57,10 @@ export async function whoami(opts: WhoamiOptions): Promise { // misleading "unrestricted". Only surface a line when this account is // ACTUALLY restricted right now (enforced, and neither grant applies) — // an unrestricted or already-approved account gets no new noise here. + // A paid plan counts as a grant only where the deployment's + // `available_unlocks` lists paid_entitlement. const access = account.sendingAccess; - if (access && access.enforcementApplies && !access.sharedExternalApproved && !access.paidExternalSendingEntitled) { - process.stdout.write( - "External sending: restricted (send to your verified account email and agent inboxes in " + - "this account; request approval with: e2a sending-access request --use-case " + - "--recipients --volume )\n", - ); + if (access && isRestricted(access)) { + process.stdout.write(restrictedLine(access) + "\n"); } } From f66b893c9ae852bb18c917214700da3c9655e73e Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:29:19 +0800 Subject: [PATCH 04/13] feat(mcp): request_sending_access and get_sending_access_request tools Two admin-tier tools wrap POST/GET /v1/account/sending-access/request. request_sending_access is mutating (refused with account_read_only on a frozen account). Descriptions tell an agent to file once, not retry on a pending request or rate limit, that the decision is emailed to the account owner, and that whoami's sending_access.available_unlocks explains what can lift the restriction. The send/reply/forward descriptions point at the new tool and stop promising an unconditional domain or paid unlock. Tool catalog 78 -> 80 (plugin manifests and version bumped); e2e-prod suite 40 covers both tools for the MCP coverage gate. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- .claude-plugin/marketplace.json | 4 +- .cursor-plugin/marketplace.json | 2 +- docs/runbooks/mcp-server.md | 2 +- mcp/README.md | 32 ++-- mcp/examples/README.md | 2 +- mcp/src/client.ts | 12 ++ mcp/src/server.ts | 2 + mcp/src/tools/agents.ts | 2 +- mcp/src/tools/legacy.ts | 2 +- mcp/src/tools/messages.ts | 6 +- mcp/src/tools/mutating.ts | 6 + mcp/src/tools/sendingaccess.ts | 72 +++++++++ mcp/src/tools/tiers.ts | 4 + mcp/tests/events.test.ts | 7 +- mcp/tests/sending-access.test.ts | 153 ++++++++++++++++++ mcp/tests/tools.test.ts | 15 +- mcp/tool-names.v1.json | 2 + plugins/e2a/.claude-plugin/plugin.json | 4 +- plugins/e2a/.codex-plugin/plugin.json | 4 +- plugins/e2a/.cursor-plugin/plugin.json | 4 +- plugins/e2a/plugin.json | 4 +- plugins/e2a/plugin.meta.json | 6 +- plugins/e2a/skills/e2a/SKILL.md | 2 +- scripts/plugin-email-evals.test.mjs | 2 +- scripts/plugin-packaging.test.mjs | 8 +- .../suites/40-mcp-sending-access.test.ts | 76 +++++++++ 26 files changed, 390 insertions(+), 45 deletions(-) create mode 100644 mcp/src/tools/sendingaccess.ts create mode 100644 mcp/tests/sending-access.test.ts create mode 100644 tests/e2e-prod/suites/40-mcp-sending-access.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b1d4f14c0..3e9644411 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,12 +7,12 @@ }, "metadata": { "description": "e2a plugins for Claude Code — open-source email API for applications and AI agents", - "version": "0.9.5" + "version": "0.9.6" }, "plugins": [ { "name": "e2a", - "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, HITL approval, structured SPF/DKIM/DMARC evidence, and a queryable event log. 78 MCP tools over hosted streamable HTTP with OAuth.", + "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, HITL approval, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.", "category": "productivity", "source": "./plugins/e2a", "homepage": "https://e2a.dev" diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 4e8e52141..13a26916d 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "e2a — open-source email API for applications and AI agents (MCP configuration and canonical docs).", - "version": "0.9.5" + "version": "0.9.6" }, "plugins": [ { diff --git a/docs/runbooks/mcp-server.md b/docs/runbooks/mcp-server.md index 8303d5cb5..db2e54696 100644 --- a/docs/runbooks/mcp-server.md +++ b/docs/runbooks/mcp-server.md @@ -116,7 +116,7 @@ design. - **Symptoms**: requests succeed, but the credential is served at least-privilege **agent scope**: account-scoped callers see the tool list - shrink from 78 tools to the 21 runtime tools, and the per-request default + shrink from 80 tools to the 21 runtime tools, and the per-request default agent is unset (explicit `email` required). - **Detection**: `auth_resolution` WARNING `whoami probe failed; serving least-privilege fallback`; `mcp_auth_resolutions_total{result="fallback"}`. diff --git a/mcp/README.md b/mcp/README.md index c25b4f31c..e244164f0 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -121,15 +121,17 @@ Hosts that support OAuth connectors can instead add `https://api.e2a.dev/mcp` as ## Tools -The server exposes up to **78** tools spanning agents, messages, human-in-the-loop +The server exposes up to **80** tools spanning agents, messages, human-in-the-loop approval, attachments, domains, events, webhooks, API keys, contacts/outreach, -email templates (beta), and suppressions. +email templates (beta), suppressions, and external sending access requests +(beta). **The visible set depends on your credential's scope:** an **agent**-scoped credential sees the 21 runtime/inbox tools (read, send, reply, restore messages, per-agent outreach); an **account**-scoped credential also sees the -57 admin/setup tools (agent/domain/webhook/event/template/API-key/contact/ -suppression management — **and HITL review discovery plus approve/reject, which -is an account-owner action, never agent self-approval**) — all 78. +59 admin/setup tools (agent/domain/webhook/event/template/API-key/contact/ +suppression/sending-access management — **and HITL review discovery plus +approve/reject, which is an account-owner action, never agent +self-approval**) — all 80. Every tool carries MCP annotations (`readOnlyHint`/`destructiveHint`/ `idempotentHint`) so hosts can auto-approve reads and flag destructive actions. The tables below highlight the most commonly used ones — your MCP host's tool list @@ -167,10 +169,22 @@ before it is declared stable**. > send with `external_sending_not_enabled` (403) — nothing is queued, and the > identical call will not succeed on retry. `whoami`'s `sending_access` object > reports the current state (`enforcement_applies`, `shared_external_approved`, -> `paid_external_sending_entitled`, `owner_recipient_verified`); recovery -> (verifying a sending domain, or filing a request for review) happens in the -> dashboard, named by the error's `recovery_url` — there is no MCP tool that -> files a request or changes approval. +> `paid_external_sending_entitled`, `owner_recipient_verified`) and +> `available_unlocks` — what can lift the restriction on this deployment: +> `operator_approval` always; `verified_domain` (send from your own verified +> domain) and `paid_entitlement` (a paid plan) only where listed. The hosted +> service lists only `operator_approval`. To ask for approval, call +> `request_sending_access` **once** (account scope; also available in the +> dashboard at the error's `recovery_url`): resubmitting while a request is +> pending returns the same request, and `rate_limited` (3 requests per 30 days) +> must not be retried. An operator decides and the decision is **emailed to the +> account owner**; `get_sending_access_request` shows the current state. No MCP +> tool can grant approval. +> +> | Tool | Description | +> | --- | --- | +> | `request_sending_access` | Beta. File one request for an operator to review external sending access (`use_case`, `recipients`, `expected_daily_volume`). Mutating: refused with `account_read_only` on a frozen account. (Admin/account-scoped.) | +> | `get_sending_access_request` | Beta. The account's latest request and its `state` (`pending` / `approved` / `declined`). Read-only. (Admin/account-scoped.) | | Tool | Description | | --- | --- | diff --git a/mcp/examples/README.md b/mcp/examples/README.md index 2f55e2d88..681e404cb 100644 --- a/mcp/examples/README.md +++ b/mcp/examples/README.md @@ -12,7 +12,7 @@ End-to-end demos showing how to wire the e2a MCP surface into popular agent fram ## One hosted endpoint, same tool surface -Each example exercises the same e2a tool surface (78 tools; the visible set depends on your key's scope) against the hosted MCP server. The Python framework examples ship an `agent.py` script; the Codex example ships a TOML block (Codex is itself the agent, so you configure it instead of writing a script). +Each example exercises the same e2a tool surface (80 tools; the visible set depends on your key's scope) against the hosted MCP server. The Python framework examples ship an `agent.py` script; the Codex example ships a TOML block (Codex is itself the agent, so you configure it instead of writing a script). Every example connects to `https://api.e2a.dev/mcp` over Streamable HTTP with an agent-scoped API key in the `Authorization` header. Set `E2A_MCP_URL` to point diff --git a/mcp/src/client.ts b/mcp/src/client.ts index 7013f056a..a7804b0c9 100644 --- a/mcp/src/client.ts +++ b/mcp/src/client.ts @@ -63,6 +63,8 @@ import type { AgentSuppressionView, CreateAgentSuppressionRequest, DeleteSuppressionResult, + SendingAccessRequestInput, + SendingAccessRequestView, } from "@e2a/sdk/v1"; import type { McpConfig } from "./config.js"; import type { Scope } from "./tools/tiers.js"; @@ -135,6 +137,16 @@ export class McpClient { return this.sdk.account.get(); } + // External sending access (beta) — GET/POST /v1/account/sending-access/request. + // Account scope only; filing never grants access by itself. + getSendingAccessRequest(): Promise { + return this.sdk.account.getSendingAccessRequest(); + } + + requestSendingAccess(body: SendingAccessRequestInput): Promise { + return this.sdk.account.requestSendingAccess(body); + } + // ── Agents ────────────────────────────────────────────────────── // Cursor-paginated (GET /v1/agents). One page in `agents` + a next_cursor diff --git a/mcp/src/server.ts b/mcp/src/server.ts index 598f4743e..ecc54c948 100644 --- a/mcp/src/server.ts +++ b/mcp/src/server.ts @@ -12,6 +12,7 @@ import { registerLegacyTools } from "./tools/legacy.js"; import { registerContactTools } from "./tools/contacts.js"; import { registerSuppressionTools } from "./tools/suppressions.js"; import { registerMetricsTools } from "./tools/metrics.js"; +import { registerSendingAccessTools } from "./tools/sendingaccess.js"; import { toolNamesForScope } from "./tools/tiers.js"; import { isMutatingTool, MUTATING_META_KEY } from "./tools/mutating.js"; import { resolveServerVersion } from "./version.js"; @@ -140,6 +141,7 @@ export function buildServer({ registerContactTools(server, client); registerSuppressionTools(server, client); registerMetricsTools(server, client); + registerSendingAccessTools(server, client); registerLegacyTools(server, client); return server; } diff --git a/mcp/src/tools/agents.ts b/mcp/src/tools/agents.ts index 532588031..ad3f60a58 100644 --- a/mcp/src/tools/agents.ts +++ b/mcp/src/tools/agents.ts @@ -51,7 +51,7 @@ export function registerAgentTools(server: McpServer, client: McpClient): void { title: "Get the authenticated account's identity", annotations: { readOnlyHint: true }, description: - "Use first when starting work on e2a to learn WHO you are: the authenticated user (email), the credential's scope (`account` or `agent`), and your plan + usage limits. For an agent-scoped credential it also returns `agent_email` — the single agent that credential IS. Account-scoped credentials own many agents; discover them with `list_agents`. This is identity, not an agent — it never guesses a 'default' agent. When the deployment restricts external sending, the optional `sending_access` object reports whether this account may email external recipients (`enforcement_applies`, `shared_external_approved`, `paid_external_sending_entitled`, `owner_recipient_verified`). `read_only: true` means the account is frozen while its sending is paused for an abuse review: read tools keep working, and every tool that changes anything (send, create, update, delete, approve, …) fails with `account_read_only` — do not retry those; the account owner must contact support.", + "Use first when starting work on e2a to learn WHO you are: the authenticated user (email), the credential's scope (`account` or `agent`), and your plan + usage limits. For an agent-scoped credential it also returns `agent_email` — the single agent that credential IS. Account-scoped credentials own many agents; discover them with `list_agents`. This is identity, not an agent — it never guesses a 'default' agent. When the deployment restricts external sending, the optional `sending_access` object reports whether this account may email external recipients (`enforcement_applies`, `shared_external_approved`, `paid_external_sending_entitled`, `owner_recipient_verified`) and `available_unlocks` — what can lift the restriction on this deployment: `operator_approval` (always; file one request with `request_sending_access`), and only where listed `verified_domain` (send from your own verified domain) or `paid_entitlement` (a paid plan). A paid plan that is not listed there does NOT lift it. `read_only: true` means the account is frozen while its sending is paused for an abuse review: read tools keep working, and every tool that changes anything (send, create, update, delete, approve, …) fails with `account_read_only` — do not retry those; the account owner must contact support.", inputSchema: strictInputSchema({}), }, async () => runTool(() => client.whoami()), diff --git a/mcp/src/tools/legacy.ts b/mcp/src/tools/legacy.ts index 6c9e1afa4..cc399597b 100644 --- a/mcp/src/tools/legacy.ts +++ b/mcp/src/tools/legacy.ts @@ -52,7 +52,7 @@ export function registerLegacyTools(server: McpServer, client: McpClient): void title: "Deprecated alias: send_message", annotations: { destructiveHint: false }, description: - "DEPRECATED — use `send_message` instead. Sends a new email from the agent's inbox to one or more recipients, identical to `send_message` except that it keeps the historical field names (`body`/`html_body`/`agent_email` rather than `text`/`html`/`email`). It exists only for MCP clients pinned to the old schema and gains no new features: templates and scheduled sending are unavailable here. Like `send_message`, it starts a NEW thread — use `reply_to_message` to respond to a message you can see. **`accepted` and `pending_review` are both success, not failure — do NOT re-send.** `accepted` means the send was durably persisted and queued; `pending_review` means a human review hold caught it first. The terminal outcome arrives later via webhook events or by polling `get_message`, not by retrying. **Beta: external sending access.** On a deployment that restricts it, a To/Cc/Bcc recipient outside the account's shared sending identity refuses the WHOLE send with `external_sending_not_enabled` (403) — nothing is queued. The account may currently send only to its verified account email and to live agent inboxes in the same account; recover by verifying a sending domain or filing a request in the dashboard (the error's `recovery_url`). Do not retry the identical call.", + "DEPRECATED — use `send_message` instead. Sends a new email from the agent's inbox to one or more recipients, identical to `send_message` except that it keeps the historical field names (`body`/`html_body`/`agent_email` rather than `text`/`html`/`email`). It exists only for MCP clients pinned to the old schema and gains no new features: templates and scheduled sending are unavailable here. Like `send_message`, it starts a NEW thread — use `reply_to_message` to respond to a message you can see. **`accepted` and `pending_review` are both success, not failure — do NOT re-send.** `accepted` means the send was durably persisted and queued; `pending_review` means a human review hold caught it first. The terminal outcome arrives later via webhook events or by polling `get_message`, not by retrying. **Beta: external sending access.** On a deployment that restricts it, a To/Cc/Bcc recipient outside the account's shared sending identity refuses the WHOLE send with `external_sending_not_enabled` (403) — nothing is queued. The account may currently send only to its verified account email and to live agent inboxes in the same account; to lift the restriction, call `request_sending_access` ONCE (or use the dashboard page named by the error's `recovery_url`); depending on the deployment's `available_unlocks` (see `whoami`'s `sending_access`), sending from a verified domain or a paid plan may also lift it. Do not retry the identical call.", inputSchema: strictInputSchema({ to: z.array(z.string()).describe("Recipient email addresses (one or more)."), subject: z.string().describe("Subject line of the new message."), diff --git a/mcp/src/tools/messages.ts b/mcp/src/tools/messages.ts index 86942a34b..1de969f2a 100644 --- a/mcp/src/tools/messages.ts +++ b/mcp/src/tools/messages.ts @@ -132,7 +132,7 @@ export function registerMessageTools(server: McpServer, client: McpClient): void title: "Send email", annotations: { destructiveHint: false }, description: - "Use when starting a NEW email thread to a fresh recipient. To respond to a message you can see in `list_messages`, use `reply_to_message` instead — it preserves the In-Reply-To / References headers so the reply lands in the same thread, which this tool deliberately does not do. Attach files via `attachments`; pass base64 strings produced by other tools (e.g. `get_attachment`) verbatim — don't hand-encode raw text. **`accepted`, `scheduled`, and `pending_review` are all success, not failure — do NOT re-send.** `{ status: \"accepted\", message_id: ... }` means the send was durably persisted and queued for immediate submission. `{ status: \"scheduled\", message_id: ..., scheduled_at: ... }` means it was durably queued for future submission at `scheduled_at`; `wait=sent` does not wait until then. `{ status: \"pending_review\", message_id: ... }` means a human review hold caught it first. In every case, re-calling this tool (especially without reusing the same `idempotency_key`) risks a real second send — the terminal outcome (delivered or failed) arrives later via webhook events (`email.sent` / `email.failed`) or by polling `get_message`/`list_messages`, not by retrying. **Templates (beta):** instead of literal subject/text, reference a stored template with `template_id` XOR `template_alias` plus `template_data` — a template reference is mutually exclusive with subject/text/html (pass neither literal field). The server renders before any review hold, so a reviewer sees final content. Missing variables render as empty strings (no error) — validate data against the template's variables first. Only send supports templates; reply/forward do not. **Beta: external sending access.** On a deployment that restricts it, a To/Cc/Bcc recipient outside the account's shared sending identity refuses the WHOLE send with `external_sending_not_enabled` (403) — nothing is queued. The account may currently send only to its verified account email and to live agent inboxes in the same account; recover by verifying a sending domain or filing a request in the dashboard (the error's `recovery_url`). Do not retry the identical call.", + "Use when starting a NEW email thread to a fresh recipient. To respond to a message you can see in `list_messages`, use `reply_to_message` instead — it preserves the In-Reply-To / References headers so the reply lands in the same thread, which this tool deliberately does not do. Attach files via `attachments`; pass base64 strings produced by other tools (e.g. `get_attachment`) verbatim — don't hand-encode raw text. **`accepted`, `scheduled`, and `pending_review` are all success, not failure — do NOT re-send.** `{ status: \"accepted\", message_id: ... }` means the send was durably persisted and queued for immediate submission. `{ status: \"scheduled\", message_id: ..., scheduled_at: ... }` means it was durably queued for future submission at `scheduled_at`; `wait=sent` does not wait until then. `{ status: \"pending_review\", message_id: ... }` means a human review hold caught it first. In every case, re-calling this tool (especially without reusing the same `idempotency_key`) risks a real second send — the terminal outcome (delivered or failed) arrives later via webhook events (`email.sent` / `email.failed`) or by polling `get_message`/`list_messages`, not by retrying. **Templates (beta):** instead of literal subject/text, reference a stored template with `template_id` XOR `template_alias` plus `template_data` — a template reference is mutually exclusive with subject/text/html (pass neither literal field). The server renders before any review hold, so a reviewer sees final content. Missing variables render as empty strings (no error) — validate data against the template's variables first. Only send supports templates; reply/forward do not. **Beta: external sending access.** On a deployment that restricts it, a To/Cc/Bcc recipient outside the account's shared sending identity refuses the WHOLE send with `external_sending_not_enabled` (403) — nothing is queued. The account may currently send only to its verified account email and to live agent inboxes in the same account; to lift the restriction, call `request_sending_access` ONCE (or use the dashboard page named by the error's `recovery_url`); depending on the deployment's `available_unlocks` (see `whoami`'s `sending_access`), sending from a verified domain or a paid plan may also lift it. Do not retry the identical call.", inputSchema: strictInputSchema({ to: z.array(z.string()).describe("Recipient email addresses (one or more)."), subject: z.string().optional().describe("Literal subject. Required unless a template reference is used (then it must be omitted)."), @@ -227,7 +227,7 @@ export function registerMessageTools(server: McpServer, client: McpClient): void title: "Reply to a message", annotations: { destructiveHint: false }, description: - "Use whenever you're responding to a message you can see — preserves the In-Reply-To and References headers so the reply joins the original email thread instead of starting a new one. Works on both a message the agent RECEIVED (replies to its sender) and a message the agent SENT (continues the thread to its original recipients, i.e. a Gmail-style follow-up on your own message). Prefer this over `send_message` for any in-thread response; thread fragmentation (broken conversation view in the recipient's mail client) is the most visible symptom of using `send_message` by mistake. Pass `reply_all: true` to copy the original Cc list; subject is auto-derived as `Re: …` by the server. Same review caveat as `send_message`: **`accepted`, `scheduled`, and `pending_review` are all success, not failure — do NOT re-send.** `accepted` means the reply was durably persisted and queued for immediate submission; `scheduled` means it was durably queued for future submission at `scheduled_at`; `pending_review` means a human review hold caught it first. The terminal outcome arrives later via webhook events (`email.sent` / `email.failed`) or by polling `get_message`, not by retrying. **Beta: external sending access.** On a deployment that restricts it, a To/Cc/Bcc recipient outside the account's shared sending identity refuses the WHOLE reply with `external_sending_not_enabled` (403) — nothing is queued. The account may currently send only to its verified account email and to live agent inboxes in the same account; recover by verifying a sending domain or filing a request in the dashboard (the error's `recovery_url`). Do not retry the identical call.", + "Use whenever you're responding to a message you can see — preserves the In-Reply-To and References headers so the reply joins the original email thread instead of starting a new one. Works on both a message the agent RECEIVED (replies to its sender) and a message the agent SENT (continues the thread to its original recipients, i.e. a Gmail-style follow-up on your own message). Prefer this over `send_message` for any in-thread response; thread fragmentation (broken conversation view in the recipient's mail client) is the most visible symptom of using `send_message` by mistake. Pass `reply_all: true` to copy the original Cc list; subject is auto-derived as `Re: …` by the server. Same review caveat as `send_message`: **`accepted`, `scheduled`, and `pending_review` are all success, not failure — do NOT re-send.** `accepted` means the reply was durably persisted and queued for immediate submission; `scheduled` means it was durably queued for future submission at `scheduled_at`; `pending_review` means a human review hold caught it first. The terminal outcome arrives later via webhook events (`email.sent` / `email.failed`) or by polling `get_message`, not by retrying. **Beta: external sending access.** On a deployment that restricts it, a To/Cc/Bcc recipient outside the account's shared sending identity refuses the WHOLE reply with `external_sending_not_enabled` (403) — nothing is queued. The account may currently send only to its verified account email and to live agent inboxes in the same account; to lift the restriction, call `request_sending_access` ONCE (or use the dashboard page named by the error's `recovery_url`); depending on the deployment's `available_unlocks` (see `whoami`'s `sending_access`), sending from a verified domain or a paid plan may also lift it. Do not retry the identical call.", inputSchema: strictInputSchema({ message_id: z.string().describe("ID of the message to reply to — inbound or one the agent sent (e.g. msg_…)."), text: z.string().describe("Plain-text reply body."), @@ -308,7 +308,7 @@ export function registerMessageTools(server: McpServer, client: McpClient): void title: "Forward a message", annotations: { destructiveHint: false }, description: - "Forward a message the agent has received OR one it sent to one or more new recipients. The server auto-prepends a Gmail-style header block (From/Date/Subject/To/Cc) and the original body to whatever optional comment you pass in `text`/`html`, **and carries over the original message's attachments by default** — you do NOT need to re-fetch them via `get_attachment`. Anything you pass in `attachments[]` is added on top of the originals. **Unlike `reply_to_message`, a forward is a NEW thread** — no In-Reply-To / References headers are emitted, so the recipient sees a fresh conversation. Use this when the user asks to share an email with someone else; use `reply_to_message` when continuing the existing conversation. Same review behavior as send/reply: **`accepted`, `scheduled`, and `pending_review` are all success, not failure — do NOT re-send.** `accepted` means the forward was durably persisted and queued for immediate submission; `scheduled` means it was durably queued for future submission at `scheduled_at`; `pending_review` means a human review hold caught it first. The terminal outcome arrives later via webhook events (`email.sent` / `email.failed`) or by polling `get_message`, not by retrying. **Beta: external sending access.** On a deployment that restricts it, a To/Cc/Bcc recipient outside the account's shared sending identity refuses the WHOLE forward with `external_sending_not_enabled` (403) — nothing is queued. The account may currently send only to its verified account email and to live agent inboxes in the same account; recover by verifying a sending domain or filing a request in the dashboard (the error's `recovery_url`). Do not retry the identical call.", + "Forward a message the agent has received OR one it sent to one or more new recipients. The server auto-prepends a Gmail-style header block (From/Date/Subject/To/Cc) and the original body to whatever optional comment you pass in `text`/`html`, **and carries over the original message's attachments by default** — you do NOT need to re-fetch them via `get_attachment`. Anything you pass in `attachments[]` is added on top of the originals. **Unlike `reply_to_message`, a forward is a NEW thread** — no In-Reply-To / References headers are emitted, so the recipient sees a fresh conversation. Use this when the user asks to share an email with someone else; use `reply_to_message` when continuing the existing conversation. Same review behavior as send/reply: **`accepted`, `scheduled`, and `pending_review` are all success, not failure — do NOT re-send.** `accepted` means the forward was durably persisted and queued for immediate submission; `scheduled` means it was durably queued for future submission at `scheduled_at`; `pending_review` means a human review hold caught it first. The terminal outcome arrives later via webhook events (`email.sent` / `email.failed`) or by polling `get_message`, not by retrying. **Beta: external sending access.** On a deployment that restricts it, a To/Cc/Bcc recipient outside the account's shared sending identity refuses the WHOLE forward with `external_sending_not_enabled` (403) — nothing is queued. The account may currently send only to its verified account email and to live agent inboxes in the same account; to lift the restriction, call `request_sending_access` ONCE (or use the dashboard page named by the error's `recovery_url`); depending on the deployment's `available_unlocks` (see `whoami`'s `sending_access`), sending from a verified domain or a paid plan may also lift it. Do not retry the identical call.", inputSchema: strictInputSchema({ message_id: z.string().describe("ID of the message to forward — inbound or one the agent sent (e.g. msg_…)."), to: z.array(z.string()).describe("Forward target addresses (one or more)."), diff --git a/mcp/src/tools/mutating.ts b/mcp/src/tools/mutating.ts index b92706369..13271ba9e 100644 --- a/mcp/src/tools/mutating.ts +++ b/mcp/src/tools/mutating.ts @@ -74,6 +74,8 @@ export const MUTATING_TOOLS: ReadonlySet = new Set([ "delete_suppression", "create_agent_suppression", "delete_agent_suppression", + // external sending access (beta) + "request_sending_access", ]); /** Tools that only read — they keep working for a read-only account. */ @@ -117,6 +119,7 @@ export const NON_MUTATING_TOOLS: ReadonlySet = new Set([ "list_agent_suppressions", "get_agent_metrics", "get_account_metrics", + "get_sending_access_request", ]); /** @@ -216,6 +219,9 @@ export const TOOL_OPERATIONS: Readonly> = { // metrics get_agent_metrics: ["getAgentMetrics"], get_account_metrics: ["getAccountMetrics"], + // external sending access (beta) + get_sending_access_request: ["getSendingAccessRequest"], + request_sending_access: ["createSendingAccessRequest"], }; /** The `_meta` key under which every tool advertises its mutating flag. */ diff --git a/mcp/src/tools/sendingaccess.ts b/mcp/src/tools/sendingaccess.ts new file mode 100644 index 000000000..713dacfd3 --- /dev/null +++ b/mcp/src/tools/sendingaccess.ts @@ -0,0 +1,72 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpClient } from "../client.js"; +import { z } from "zod"; +import { runTool, strictInputSchema } from "./util.js"; + +// External sending access (beta): the request/decision path that lifts the +// restriction on emailing external recipients. Both tools wrap +// /v1/account/sending-access/request and are ADMIN tier — the server requires +// an account-scoped credential. request_sending_access is a write (POST), so +// it is MUTATING: a read-only (abuse-paused) account is refused with +// account_read_only like every other write. Neither tool can grant access: +// approval is a local operator decision on the server, and the decision is +// emailed to the account owner. + +const DECISION_NOTE = + "The decision is made by an operator and emailed to the account owner — it does not arrive through this tool or any webhook; check back later with `get_sending_access_request`."; + +export function registerSendingAccessTools(server: McpServer, client: McpClient): void { + server.registerTool( + "get_sending_access_request", + { + title: "Get the account's external sending access request (beta)", + annotations: { readOnlyHint: true }, + description: + "Read this account's most recent request for external sending access and its review `state` (`pending`, `approved`, `declined`; open set — treat unknown values as not approved). Fails with `not_found` (404) when the account has never filed one, and `not_implemented` (501) when the deployment does not restrict external sending at all. " + + DECISION_NOTE + + " `whoami`'s `sending_access.available_unlocks` explains what can lift the restriction on this deployment. BETA. Account scope only. Read-only.", + inputSchema: strictInputSchema({}), + }, + async () => runTool(() => client.getSendingAccessRequest()), + ); + + server.registerTool( + "request_sending_access", + { + title: "Request external sending access (beta)", + annotations: { destructiveHint: false }, + description: + "Use when a send failed with `external_sending_not_enabled` (or `whoami`'s `sending_access` shows the account is restricted) and the account legitimately needs to email external recipients. Files ONE request for an operator to review; filing never grants access by itself. Call it ONCE: while a request is pending, calling again just returns the same pending request — do not retry or re-file. Do NOT retry on `rate_limited` (429; at most 3 requests per 30 days per account) or any other error; tell the user instead. " + + DECISION_NOTE + + " Describe the real use case and recipients truthfully and specifically — vague or misleading requests are declined. `whoami`'s `sending_access.available_unlocks` lists every route this deployment accepts (`operator_approval` always; `verified_domain` / `paid_entitlement` only where listed). BETA. Account scope only.", + inputSchema: strictInputSchema({ + use_case: z + .string() + .trim() + .min(1) + .max(2000) + .describe("What you are building and why it needs to email external recipients (1-2000 chars)."), + recipients: z + .string() + .trim() + .min(1) + .max(1000) + .describe("Who you will email, e.g. 'customers who signed up on our site' (1-1000 chars)."), + expected_daily_volume: z + .number() + .int() + .min(1) + .max(1_000_000) + .describe("Expected recipients per day (1-1000000)."), + }), + }, + async (args) => + runTool(() => + client.requestSendingAccess({ + useCase: args.use_case, + recipients: args.recipients, + expectedDailyVolume: args.expected_daily_volume, + }), + ), + ); +} diff --git a/mcp/src/tools/tiers.ts b/mcp/src/tools/tiers.ts index c9c8e35a5..d859093e1 100644 --- a/mcp/src/tools/tiers.ts +++ b/mcp/src/tools/tiers.ts @@ -145,6 +145,10 @@ export const ADMIN_TOOLS: ReadonlySet = new Set([ // handler enforces it with requireAccountUser. An agent-scoped session sees // get_agent_metrics instead, which covers its own inbox. "get_account_metrics", + // External sending access requests are account-scoped on the server + // (requireAccountUser): an agent-scoped credential cannot file or read one. + "get_sending_access_request", + "request_sending_access", ]); export type Scope = "account" | "agent"; diff --git a/mcp/tests/events.test.ts b/mcp/tests/events.test.ts index e9344af41..60c1ae280 100644 --- a/mcp/tests/events.test.ts +++ b/mcp/tests/events.test.ts @@ -192,7 +192,7 @@ describe("MCP events tools", () => { }); describe("tool catalog", () => { - it("includes the 3 events tools in the additive v1 tool set — total 78", async () => { + it("includes the 3 events tools in the additive v1 tool set — total 80", async () => { const client = await buildClient(stub); const { tools } = await client.listTools(); const names = new Set(tools.map((t) => t.name)); @@ -200,8 +200,9 @@ describe("MCP events tools", () => { // full registered set (incl. the 8 beta template tools and the 3 // api-key tools, the 3 trash-lifecycle tools, the beta message-lifecycle // diagnostic tool, contact/outreach and suppression tools, the two - // delivery-metrics tools, and compatibility aliases) is 78 tools. - expect(tools).toHaveLength(78); + // delivery-metrics tools, the two sending-access tools, and + // compatibility aliases) is 80 tools. + expect(tools).toHaveLength(80); expect(names.has("list_events")).toBe(true); expect(names.has("get_event")).toBe(true); expect(names.has("redeliver_event")).toBe(true); diff --git a/mcp/tests/sending-access.test.ts b/mcp/tests/sending-access.test.ts new file mode 100644 index 000000000..be46d0b28 --- /dev/null +++ b/mcp/tests/sending-access.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { E2AError } from "@e2a/sdk/v1"; +import type { McpClient } from "../src/client.js"; +import { buildServer } from "../src/server.js"; +import { ADMIN_TOOLS, RUNTIME_TOOLS } from "../src/tools/tiers.js"; +import { MUTATING_META_KEY, MUTATING_TOOLS, NON_MUTATING_TOOLS, TOOL_OPERATIONS } from "../src/tools/mutating.js"; + +// External sending access (beta) over MCP: get_sending_access_request (read) +// and request_sending_access (write → mutating, refused for a read-only +// account). Every value is synthetic. + +const pending = { + id: "esar_test", + state: "pending", + useCase: "order confirmations for customers who signed up on example.com", + recipients: "our own signed-up customers", + expectedDailyVolume: 50, + createdAt: new Date("2026-01-01T00:00:00Z"), +}; + +function stubClient(overrides: Partial> = {}, scope: "account" | "agent" = "account") { + return { + agentEmail: undefined, + scope, + getSendingAccessRequest: vi.fn(async () => pending), + requestSendingAccess: vi.fn(async () => pending), + ...overrides, + } as unknown as McpClient & { + getSendingAccessRequest: ReturnType; + requestSendingAccess: ReturnType; + }; +} + +async function connect(stub: McpClient): Promise { + const server = buildServer({ client: stub, version: "0.0.0-test" }); + const [a, b] = InMemoryTransport.createLinkedPair(); + await server.connect(a); + const client = new Client({ name: "test", version: "0.0.0" }); + await client.connect(b); + return client; +} + +describe("sending access MCP tools", () => { + it("are classified: request mutating, get read-only, both admin tier, mapped to their operations", () => { + expect(MUTATING_TOOLS.has("request_sending_access")).toBe(true); + expect(NON_MUTATING_TOOLS.has("request_sending_access")).toBe(false); + expect(NON_MUTATING_TOOLS.has("get_sending_access_request")).toBe(true); + expect(TOOL_OPERATIONS.request_sending_access).toEqual(["createSendingAccessRequest"]); + expect(TOOL_OPERATIONS.get_sending_access_request).toEqual(["getSendingAccessRequest"]); + for (const name of ["request_sending_access", "get_sending_access_request"]) { + expect(ADMIN_TOOLS.has(name)).toBe(true); + expect(RUNTIME_TOOLS.has(name)).toBe(false); + } + }); + + it("advertise the mutating flag and annotations over tools/list; agent scope sees neither", async () => { + const acct = await connect(stubClient()); + const { tools } = await acct.listTools(); + const req = tools.find((t) => t.name === "request_sending_access"); + const get = tools.find((t) => t.name === "get_sending_access_request"); + expect(req?._meta?.[MUTATING_META_KEY]).toBe(true); + expect(get?._meta?.[MUTATING_META_KEY]).toBe(false); + expect(get?.annotations?.readOnlyHint).toBe(true); + expect(req?.annotations?.readOnlyHint).not.toBe(true); + expect(req?.inputSchema.required?.sort()).toEqual(["expected_daily_volume", "recipients", "use_case"]); + + const agent = await connect(stubClient({}, "agent")); + const agentNames = new Set((await agent.listTools()).tools.map((t) => t.name)); + expect(agentNames.has("request_sending_access")).toBe(false); + expect(agentNames.has("get_sending_access_request")).toBe(false); + }); + + it("tell an agent to file once, not retry, and that the decision arrives by email", async () => { + const { tools } = await (await connect(stubClient())).listTools(); + const req = tools.find((t) => t.name === "request_sending_access")!.description ?? ""; + expect(req).toMatch(/ONCE/); + expect(req).toMatch(/do not retry/i); + expect(req).toContain("rate_limited"); + expect(req).toMatch(/emailed to the account owner/); + expect(req).toContain("available_unlocks"); + const get = tools.find((t) => t.name === "get_sending_access_request")!.description ?? ""; + expect(get).toMatch(/emailed to the account owner/); + expect(get).toContain("available_unlocks"); + // The send tools point at the request tool and no longer promise an + // unconditional unlock. + for (const name of ["send_message", "reply_to_message", "forward_message", "send_email"]) { + const d = tools.find((t) => t.name === name)!.description ?? ""; + expect(d, name).toContain("request_sending_access"); + expect(d, name).toContain("available_unlocks"); + expect(d, name).not.toMatch(/recover by verifying a sending domain/); + } + const whoami = tools.find((t) => t.name === "whoami")!.description ?? ""; + expect(whoami).toContain("available_unlocks"); + }); + + it("request_sending_access forwards the snake_case form and returns the request in wire shape", async () => { + const stub = stubClient(); + const client = await connect(stub); + const res = await client.callTool({ + name: "request_sending_access", + arguments: { use_case: pending.useCase, recipients: pending.recipients, expected_daily_volume: 50 }, + }); + expect(res.isError).toBeFalsy(); + expect(stub.requestSendingAccess).toHaveBeenCalledWith({ + useCase: pending.useCase, + recipients: pending.recipients, + expectedDailyVolume: 50, + }); + const body = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(body).toMatchObject({ id: "esar_test", state: "pending", expected_daily_volume: 50 }); + }); + + it("request_sending_access rejects out-of-range input before calling the API", async () => { + const stub = stubClient(); + const client = await connect(stub); + for (const args of [ + { use_case: "", recipients: "x", expected_daily_volume: 1 }, + { use_case: "x", recipients: "x", expected_daily_volume: 0 }, + { use_case: "x", recipients: "x", expected_daily_volume: 1.5 }, + { use_case: "x", recipients: "x", expected_daily_volume: 1, account_id: "usr_other" }, + ]) { + const res = await client.callTool({ name: "request_sending_access", arguments: args }); + expect(res.isError, JSON.stringify(args)).toBe(true); + } + expect(stub.requestSendingAccess).not.toHaveBeenCalled(); + }); + + it("a read-only account's request surfaces account_read_only (not retryable)", async () => { + const stub = stubClient({ + requestSendingAccess: vi.fn(async () => { + throw new E2AError({ code: "account_read_only", message: "account is read-only", status: 403, retryable: false }); + }), + }); + const client = await connect(stub); + const res = await client.callTool({ + name: "request_sending_access", + arguments: { use_case: "x", recipients: "y", expected_daily_volume: 1 }, + }); + expect(res.isError).toBe(true); + expect(res.structuredContent).toMatchObject({ code: "account_read_only", retryable: false, status: 403 }); + }); + + it("get_sending_access_request returns the latest request", async () => { + const stub = stubClient(); + const client = await connect(stub); + const res = await client.callTool({ name: "get_sending_access_request", arguments: {} }); + expect(res.isError).toBeFalsy(); + expect(stub.getSendingAccessRequest).toHaveBeenCalledTimes(1); + expect(JSON.parse((res.content as Array<{ text: string }>)[0].text)).toMatchObject({ id: "esar_test", state: "pending" }); + }); +}); diff --git a/mcp/tests/tools.test.ts b/mcp/tests/tools.test.ts index b60abbe9f..46f180cda 100644 --- a/mcp/tests/tools.test.ts +++ b/mcp/tests/tools.test.ts @@ -31,6 +31,7 @@ import { registerLegacyTools } from "../src/tools/legacy.js"; import { registerContactTools } from "../src/tools/contacts.js"; import { registerSuppressionTools } from "../src/tools/suppressions.js"; import { registerMetricsTools } from "../src/tools/metrics.js"; +import { registerSendingAccessTools } from "../src/tools/sendingaccess.js"; import { CodedError, runTool, toMcpOutput } from "../src/tools/util.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -591,7 +592,7 @@ describe("e2a MCP server", () => { // account scope sees the full surface; agent scope sees only the runtime tier. it("keeps the frozen v1 tool-name baseline sorted, unique, and callable", async () => { - expect(frozenToolNames).toHaveLength(78); + expect(frozenToolNames).toHaveLength(80); expect(frozenToolNames).toEqual([...new Set(frozenToolNames)].sort()); const accountNames = new Set((await client.listTools()).tools.map((tool) => tool.name)); for (const name of frozenToolNames) { @@ -622,9 +623,10 @@ describe("e2a MCP server", () => { registerContactTools(recorder, stub); registerSuppressionTools(recorder, stub); registerMetricsTools(recorder, stub); + registerSendingAccessTools(recorder, stub); registerLegacyTools(recorder, stub); - expect(names).toHaveLength(78); + expect(names).toHaveLength(80); // Throws if any registered tool is untiered / double-tiered / phantom. expect(() => assertToolTiersComplete(names)).not.toThrow(); }); @@ -652,6 +654,7 @@ describe("e2a MCP server", () => { registerContactTools(recorder, stub); registerSuppressionTools(recorder, stub); registerMetricsTools(recorder, stub); + registerSendingAccessTools(recorder, stub); registerLegacyTools(recorder, stub); expect(() => assertMutatingClassificationComplete(names)).not.toThrow(); expect(() => assertMutatingClassificationComplete([...names, "brand_new_tool"])).toThrow(/unclassified: brand_new_tool/); @@ -662,14 +665,14 @@ describe("e2a MCP server", () => { expect(toolNamesForScope("")).toBe(RUNTIME_TOOLS); expect(toolNamesForScope("agent")).toBe(RUNTIME_TOOLS); expect(RUNTIME_TOOLS.size).toBe(21); - expect(ADMIN_TOOLS.size).toBe(57); - expect(toolNamesForScope("account").size).toBe(78); + expect(ADMIN_TOOLS.size).toBe(59); + expect(toolNamesForScope("account").size).toBe(80); }); - it("account scope exposes all 78 canonical and compatibility tools", async () => { + it("account scope exposes all 80 canonical and compatibility tools", async () => { const acct = await connect(makeStubClient({ scope: "account" })); const { tools } = await acct.listTools(); - expect(tools).toHaveLength(78); + expect(tools).toHaveLength(80); const names = new Set(tools.map((tool) => tool.name)); for (const name of ["list_reviews", "get_review", "approve_review", "reject_review"]) { expect(names.has(name), `account review tool ${name} should be visible`).toBe(true); diff --git a/mcp/tool-names.v1.json b/mcp/tool-names.v1.json index 121c7510b..920fc806e 100644 --- a/mcp/tool-names.v1.json +++ b/mcp/tool-names.v1.json @@ -35,6 +35,7 @@ "get_pending_message", "get_protection", "get_review", + "get_sending_access_request", "get_starter_template", "get_template", "get_webhook", @@ -61,6 +62,7 @@ "reject_pending_message", "reject_review", "reply_to_message", + "request_sending_access", "restore_agent", "restore_message", "rotate_webhook_secret", diff --git a/plugins/e2a/.claude-plugin/plugin.json b/plugins/e2a/.claude-plugin/plugin.json index c7df419e9..8020049ed 100644 --- a/plugins/e2a/.claude-plugin/plugin.json +++ b/plugins/e2a/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "e2a", "displayName": "e2a", - "version": "0.9.5", - "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 78 MCP tools over hosted streamable HTTP with OAuth.", + "version": "0.9.6", + "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.", "author": { "name": "TokenCanopy", "url": "https://e2a.dev" diff --git a/plugins/e2a/.codex-plugin/plugin.json b/plugins/e2a/.codex-plugin/plugin.json index 41a864a8c..69927f049 100644 --- a/plugins/e2a/.codex-plugin/plugin.json +++ b/plugins/e2a/.codex-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "e2a", "displayName": "e2a", - "version": "0.9.5", - "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 78 MCP tools over hosted streamable HTTP with OAuth.", + "version": "0.9.6", + "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.", "author": { "name": "TokenCanopy" }, diff --git a/plugins/e2a/.cursor-plugin/plugin.json b/plugins/e2a/.cursor-plugin/plugin.json index 678508905..b4a5f2901 100644 --- a/plugins/e2a/.cursor-plugin/plugin.json +++ b/plugins/e2a/.cursor-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "e2a", "displayName": "e2a", - "version": "0.9.5", - "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 78 MCP tools over hosted streamable HTTP with OAuth.", + "version": "0.9.6", + "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.", "author": { "name": "TokenCanopy", "url": "https://e2a.dev" diff --git a/plugins/e2a/plugin.json b/plugins/e2a/plugin.json index e75645049..b3994917d 100644 --- a/plugins/e2a/plugin.json +++ b/plugins/e2a/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "e2a", - "version": "0.9.5", - "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 78 MCP tools over hosted streamable HTTP with OAuth.", + "version": "0.9.6", + "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.", "author": { "name": "TokenCanopy", "url": "https://e2a.dev" diff --git a/plugins/e2a/plugin.meta.json b/plugins/e2a/plugin.meta.json index 49b1fd9c7..b86342927 100644 --- a/plugins/e2a/plugin.meta.json +++ b/plugins/e2a/plugin.meta.json @@ -3,7 +3,7 @@ "name": "e2a", "displayName": "e2a", - "version": "0.9.5", + "version": "0.9.6", "license": "Apache-2.0", "homepage": "https://e2a.dev", "repository": "https://github.com/tokencanopy/e2a", @@ -19,8 +19,8 @@ "descriptions": { "$comment": "Three variants ship today. `manifest` omits HITL; both marketplace variants mention it. See docs/design/2026-08-09-agent-plugins-conformance.md — unifying them is a copy decision, deliberately not made by this migration. The MCP tool count must match mcp/tool-names.v1.json; the validator checks numeric claims.", - "manifest": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 78 MCP tools over hosted streamable HTTP with OAuth.", - "marketplaceLong": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, HITL approval, structured SPF/DKIM/DMARC evidence, and a queryable event log. 78 MCP tools over hosted streamable HTTP with OAuth.", + "manifest": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.", + "marketplaceLong": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, HITL approval, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.", "marketplaceShort": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, HITL approval, structured SPF/DKIM/DMARC evidence, and a queryable event log." }, diff --git a/plugins/e2a/skills/e2a/SKILL.md b/plugins/e2a/skills/e2a/SKILL.md index 9e2f4d998..7b4d85f22 100644 --- a/plugins/e2a/skills/e2a/SKILL.md +++ b/plugins/e2a/skills/e2a/SKILL.md @@ -229,5 +229,5 @@ Templates are beta: shapes may change before they're declared stable. Only `send - Application SDK, REST, and webhook integration: `e2a-integrate`. - Connection, inbox, domain, webhook, and delivery diagnosis: `e2a-doctor`. - Exact tool signatures: call `tools/list` (authoritative). -- The MCP surface is **78 tools** (21 runtime/inbox + 57 admin/setup) spanning agents, messages, attachments, delivery metrics, contacts and outreach, suppressions, domains, events, webhooks, API keys, and templates (beta). The set you see depends on your credential's scope: an agent-scoped credential sees the 21 runtime tools; an account-scoped credential sees all 78. Tool descriptions teach behavior; this skill teaches the mental model. (`create_api_key` mints **agent-scoped** keys only — account-scoped keys come from the dashboard or raw API.) +- The MCP surface is **80 tools** (21 runtime/inbox + 59 admin/setup) spanning agents, messages, attachments, delivery metrics, contacts and outreach, suppressions, external sending access requests (beta), domains, events, webhooks, API keys, and templates (beta). The set you see depends on your credential's scope: an agent-scoped credential sees the 21 runtime tools; an account-scoped credential sees all 80. If a send fails with `external_sending_not_enabled`, call `request_sending_access` once (account scope); the operator's decision is emailed to the account owner — never retry the send or re-file. Tool descriptions teach behavior; this skill teaches the mental model. (`create_api_key` mints **agent-scoped** keys only — account-scoped keys come from the dashboard or raw API.) - Plugin homepage / docs index: https://e2a.dev (machine-readable index: https://e2a.dev/llms.txt) diff --git a/scripts/plugin-email-evals.test.mjs b/scripts/plugin-email-evals.test.mjs index a8eab5ad0..58c11ec86 100644 --- a/scripts/plugin-email-evals.test.mjs +++ b/scripts/plugin-email-evals.test.mjs @@ -603,7 +603,7 @@ test("templates and skill contain only synthetic email identities", async () => test("all plugin manifests release email-evals together without changing discovery conventions", async () => { for (const file of manifestFiles) { const manifest = JSON.parse(await readFile(file, "utf8")); - assert.equal(manifest.version ?? manifest.metadata?.version, "0.9.5", file); + assert.equal(manifest.version ?? manifest.metadata?.version, "0.9.6", file); } const claude = JSON.parse(await readFile(manifestFiles[0], "utf8")); diff --git a/scripts/plugin-packaging.test.mjs b/scripts/plugin-packaging.test.mjs index 000bca856..295d08f27 100644 --- a/scripts/plugin-packaging.test.mjs +++ b/scripts/plugin-packaging.test.mjs @@ -55,12 +55,12 @@ test("marketplaces expose the supported plugin set and release versions", async assert.deepEqual(claudeMarket.plugins.map((plugin) => plugin.name).sort(), ["e2a", "e2a-labs"]); assert.deepEqual(codexMarket.plugins.map((plugin) => plugin.name).sort(), ["e2a", "e2a-labs"]); assert.deepEqual(cursorMarket.plugins.map((plugin) => plugin.name), ["e2a"]); - assert.equal(claudeMarket.metadata.version, "0.9.5"); - assert.equal(cursorMarket.metadata.version, "0.9.5"); + assert.equal(claudeMarket.metadata.version, "0.9.6"); + assert.equal(cursorMarket.metadata.version, "0.9.6"); for (const client of [".claude-plugin", ".codex-plugin", ".cursor-plugin"]) { const core = JSON.parse(await readFile(`plugins/e2a/${client}/plugin.json`, "utf8")); - assert.equal(core.version, "0.9.5"); + assert.equal(core.version, "0.9.6"); } for (const client of [".claude-plugin", ".codex-plugin"]) { const labs = JSON.parse(await readFile(`plugins/e2a-labs/${client}/plugin.json`, "utf8")); @@ -80,7 +80,7 @@ test("client manifests retain only their supported visual fields", async () => { test("core Codex description advertises every stable capability", async () => { const codex = JSON.parse(await readFile("plugins/e2a/.codex-plugin/plugin.json", "utf8")); - assert.match(codex.description, /\b78 MCP tools\b/); + assert.match(codex.description, /\b80 MCP tools\b/); assert.match(codex.interface.longDescription, /setup/i); assert.match(codex.interface.longDescription, /application integration/i); assert.match(codex.interface.longDescription, /inbox operation/i); diff --git a/tests/e2e-prod/suites/40-mcp-sending-access.test.ts b/tests/e2e-prod/suites/40-mcp-sending-access.test.ts new file mode 100644 index 000000000..5915f0129 --- /dev/null +++ b/tests/e2e-prod/suites/40-mcp-sending-access.test.ts @@ -0,0 +1,76 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { ApiClient } from "../harness/client.ts"; +import { HttpMcpClient, callTool, type McpToolResult } from "../harness/mcp.ts"; +import { info, writeReport } from "../harness/report.ts"; + +// Black-box MCP conformance for the external-sending-access request tools +// (beta) against the DEPLOYED streamable-HTTP /mcp server — the MCP analogue +// of suite 39 (REST), so mcp_coverage_gate.py credits both tools. +// +// SAFETY / IDEMPOTENCE: the conformance account is an internal-class account, +// which the rule never binds, so the server sends NO operator notification for +// its requests. Filing never grants access, and while a request is pending a +// resubmit returns the same request — reruns converge on one pending request +// and never approach the 3-per-30-days cap. +// +// A deployment that does not enable the control answers 501 not_implemented +// (isError); that is recorded as info and the checks are skipped — the tools +// then stay uncovered and the coverage gate reports it, which is correct. +const SUITE = "40-mcp-sending-access"; +const apiClient = new ApiClient(); +const mcp = new HttpMcpClient(apiClient.env.mcpUrl, apiClient.env.apiKey); + +interface SendingAccessRequestView { + id: string; + state: string; + use_case: string; + recipients: string; + expected_daily_volume: number; + created_at: string; +} + +const form = { + use_case: "conformance suite probe: verifies the request intake contract", + recipients: "no recipients; this request exists only for automated conformance", + expected_daily_volume: 1, +}; + +function text(r: McpToolResult): string { + return r.content?.find((c) => c.type === "text")?.text ?? ""; +} + +after(async () => { + await mcp.stop(); + writeReport(`./reports/${SUITE}.json`); +}); + +test("mcp-sending-access: tools/list advertises both tools, request is marked mutating", async () => { + const list = await mcp.call<{ tools: Array<{ name: string; _meta?: Record; annotations?: { readOnlyHint?: boolean } }> }>("tools/list"); + const req = list.tools.find((t) => t.name === "request_sending_access"); + const get = list.tools.find((t) => t.name === "get_sending_access_request"); + assert.ok(req, "request_sending_access is advertised"); + assert.ok(get, "get_sending_access_request is advertised"); + assert.equal(req!._meta?.["e2a/mutating"], true, "request_sending_access is mutating"); + assert.equal(get!._meta?.["e2a/mutating"], false, "get_sending_access_request is a read"); + assert.equal(get!.annotations?.readOnlyHint, true, "get_sending_access_request is readOnlyHint"); +}); + +test("mcp-sending-access: request_sending_access → get_sending_access_request", async (t) => { + const filed = await callTool(mcp, "request_sending_access", form); + if (filed.isError && /not_implemented/.test(text(filed))) { + info(SUITE, "request_sending_access", "501 not_implemented: external sending access is not enabled on this deployment"); + t.skip("external sending access disabled on this deployment"); + return; + } + assert.equal(filed.isError, undefined, `request_sending_access isError: ${text(filed).slice(0, 300)}`); + const view = JSON.parse(text(filed)) as SendingAccessRequestView; + assert.ok(view.id, "request id present"); + assert.equal(typeof view.state, "string", "state is a string (open set)"); + assert.equal(typeof view.expected_daily_volume, "number", "expected_daily_volume is snake_case on the MCP wire"); + + const latest = await callTool(mcp, "get_sending_access_request", {}); + assert.equal(latest.isError, undefined, `get_sending_access_request isError: ${text(latest).slice(0, 300)}`); + const got = JSON.parse(text(latest)) as SendingAccessRequestView; + assert.equal(got.id, view.id, "latest request is the one request_sending_access returned"); +}); From a4053f612b601745538fd7d2a03044586d0ae38c Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:33:29 +0800 Subject: [PATCH 05/13] feat(web): derive sending-access guidance from available_unlocks The restriction notice and /sending-access offer Verify a domain only where verified_domain is listed and Choose a paid plan only where paid_entitlement is listed and billing is enabled; approval always leads. The headline no longer assumes an inbox exists, the recovery sentence is a proper list, an approved account sees one 'External sending is enabled' card naming the route, the declined card states the 3-per-30-days refile rule, and the form notes that decisions arrive by email. A paid entitlement that is not an unlock no longer reads as a grant. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- .../app/(app)/sending-access/page.test.tsx | 86 ++++++++++++++- web/src/app/(app)/sending-access/page.tsx | 84 ++++++++------ .../SendingAccessNotice.hosted.test.tsx | 30 ++++- .../components/SendingAccessNotice.test.tsx | 42 ++++++- .../app/components/SendingAccessNotice.tsx | 30 +++-- web/src/lib/sendingAccess.test.ts | 48 +++++++- web/src/lib/sendingAccess.ts | 104 ++++++++++++++---- 7 files changed, 349 insertions(+), 75 deletions(-) diff --git a/web/src/app/(app)/sending-access/page.test.tsx b/web/src/app/(app)/sending-access/page.test.tsx index 68ac8c2a8..c76acd57a 100644 --- a/web/src/app/(app)/sending-access/page.test.tsx +++ b/web/src/app/(app)/sending-access/page.test.tsx @@ -88,7 +88,62 @@ describe("/sending-access", () => { expect(await screen.findByRole("button", { name: "Submit request" })).toBeInTheDocument(); expect(screen.queryByText(/under review/)).not.toBeInTheDocument(); - expect(screen.queryByText("No approval needed")).not.toBeInTheDocument(); + expect(screen.queryByText("External sending is enabled")).not.toBeInTheDocument(); + expect(screen.getByText("External sending is restricted for this account.")).toBeInTheDocument(); + expect(screen.getByTestId("sending-access-review-note")).toHaveTextContent( + "Requests are reviewed by an operator; you'll get an email when a decision is made.", + ); + }); + + it("approval-only deployment: leads with the form, no domain or plan links", async () => { + stage({ + account: { + ...restrictedAccount, + sending_access: { ...restrictedAccount.sending_access, available_unlocks: ["operator_approval"] }, + }, + requestGet: notFoundRequest, + }); + render(); + + expect(await screen.findByRole("button", { name: "Submit request" })).toBeInTheDocument(); + expect(screen.getByText(/To email other recipients, request approval below\./)).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Verify a domain" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Choose a paid plan" })).not.toBeInTheDocument(); + }); + + it("verified_domain listed: offers Verify a domain above the form", async () => { + stage({ + account: { + ...restrictedAccount, + sending_access: { + ...restrictedAccount.sending_access, + available_unlocks: ["operator_approval", "verified_domain"], + }, + }, + requestGet: notFoundRequest, + }); + render(); + + expect(await screen.findByRole("link", { name: "Verify a domain" })).toHaveAttribute("href", "/domains"); + expect(screen.queryByRole("link", { name: "Choose a paid plan" })).not.toBeInTheDocument(); + }); + + it("a paid entitlement that is not an unlock leaves the account restricted", async () => { + stage({ + account: { + ...restrictedAccount, + sending_access: { + ...restrictedAccount.sending_access, + paid_external_sending_entitled: true, + available_unlocks: ["operator_approval"], + }, + }, + requestGet: notFoundRequest, + }); + render(); + + expect(await screen.findByRole("button", { name: "Submit request" })).toBeInTheDocument(); + expect(screen.queryByText("External sending is enabled")).not.toBeInTheDocument(); }); it("shows the under-review state and hides the form for a pending request", async () => { @@ -96,25 +151,44 @@ describe("/sending-access", () => { render(); expect(await screen.findByText("Your request is under review")).toBeInTheDocument(); + // The decision email makes this promise true. + expect(screen.getByText("We'll follow up by email once an operator decides.")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Submit request" })).not.toBeInTheDocument(); }); - it("shows eligibility and the approved request, keeping history visible with no form", async () => { + it("approved: renders ONE enabled card naming the route, no duplicate request card, no form", async () => { stage({ account: eligibleAccount, requestGet: requestView("approved") }); render(); - expect(await screen.findByText("No approval needed")).toBeInTheDocument(); - expect(screen.getByText("Operator-approved external sending")).toBeInTheDocument(); - expect(screen.getByText("Request approved")).toBeInTheDocument(); + expect(await screen.findByText("External sending is enabled")).toBeInTheDocument(); + expect(screen.getByText("An operator approved external sending for this account.")).toBeInTheDocument(); + expect(screen.queryByText("No approval needed")).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText("Loading your request…")).not.toBeInTheDocument()); + expect(screen.queryByText("Request approved")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Submit request" })).not.toBeInTheDocument(); }); + it("paid-plan grant (where accepted): the enabled card names the paid plan", async () => { + stage({ + account: { + ...restrictedAccount, + sending_access: { ...restrictedAccount.sending_access, paid_external_sending_entitled: true }, + }, + requestGet: notFoundRequest, + }); + render(); + + expect(await screen.findByText("External sending is enabled")).toBeInTheDocument(); + expect(screen.getByText("Your paid base plan includes external sending for this account.")).toBeInTheDocument(); + }); + it("offers a support appeal and still allows filing a new request when declined", async () => { stage({ account: restrictedAccount, requestGet: requestView("declined") }); render(); expect(await screen.findByText("Request declined")).toBeInTheDocument(); - const appeal = screen.getByRole("link", { name: "Contact support to appeal" }); + expect(screen.getByText(/up to 3 requests per 30 days/)).toBeInTheDocument(); + const appeal = screen.getByRole("link", { name: "contact support" }); expect(appeal).toHaveAttribute("href", "/feedback"); expect(screen.getByRole("button", { name: "Submit request" })).toBeInTheDocument(); }); diff --git a/web/src/app/(app)/sending-access/page.tsx b/web/src/app/(app)/sending-access/page.tsx index c4a2efd8c..a5e7ec07f 100644 --- a/web/src/app/(app)/sending-access/page.tsx +++ b/web/src/app/(app)/sending-access/page.tsx @@ -1,8 +1,9 @@ "use client"; -// Explains the external sending access restriction (beta) and hosts its two -// recovery routes: verifying a sending domain (handled on /domains) and -// filing a request for operator review (handled here). Reachable from the +// Explains the external sending access restriction (beta) and hosts its +// recovery routes: filing a request for operator review (handled here, always +// available) and — only where the deployment's `available_unlocks` lists +// them — verifying a sending domain (/domains) or a paid plan (/billing). Reachable from the // dashboard notice, the onboarding success panel, the review-queue // composer's warning, and a failed send's error message. @@ -19,8 +20,9 @@ import { import { sendingAccessRequestKey } from "../../../lib/swrKeys"; import { isSendingRestricted, + offeredUnlocks, parseErrorEnvelope, - sendingAccessEligibilityLabel, + sendingAccessEnabledRoute, sendingAccessNoticeCopy, } from "../../../lib/sendingAccess"; @@ -80,10 +82,12 @@ function RequestStatusCard({ request }: { request: SendingAccessRequest }) { Request declined

+ An operator declined this request. You can file a new one below with + more detail about your use case (up to 3 requests per 30 days), or{" "} - Contact support to appeal + contact support - , or file a new request below. + .

); @@ -112,9 +116,13 @@ export default function SendingAccessPage() { const [rateLimited, setRateLimited] = useState(false); const restricted = isSendingRestricted(status); - const eligibilityLabel = sendingAccessEligibilityLabel(status); + const enabledRoute = sendingAccessEnabledRoute(status); + const billingEnabled = Boolean(BILLING_API); const canFileRequest = !request || request.state === "declined"; const showForm = restricted && canFileRequest; + // Once external sending is enabled, the single "enabled" card already says + // an operator approved it; an "approved" request card would only repeat it. + const showRequestCard = Boolean(request) && !(request?.state === "approved" && !restricted && status?.enforcement_applies); const submit = async (e: React.FormEvent) => { e.preventDefault(); @@ -165,34 +173,42 @@ export default function SendingAccessPage() { External sending is not restricted for this account. ) : restricted ? ( -
-

- {sendingAccessNoticeCopy(status, { billingEnabled: Boolean(BILLING_API) }).headline} -

-

- {sendingAccessNoticeCopy(status, { billingEnabled: Boolean(BILLING_API) }).body} -

-

- - Verify a domain - - {BILLING_API && ( - <> - {" · "} - - Choose a paid plan - - - )} -

-
+ (() => { + const copy = sendingAccessNoticeCopy(status, { billingEnabled }); + const offered = offeredUnlocks(status, { billingEnabled }); + return ( +
+

+ {copy.headline} +

+

+ {copy.body} +

+ {(offered.domain || offered.paid) && ( +

+ {offered.domain && ( + + Verify a domain + + )} + {offered.domain && offered.paid && " · "} + {offered.paid && ( + + Choose a paid plan + + )} +

+ )} +
+ ); + })() ) : ( -
+

- No approval needed + External sending is enabled

- {eligibilityLabel ?? "External sending is enabled for this account."} + {enabledRoute ?? "External sending is enabled for this account."}

)} @@ -205,7 +221,7 @@ export default function SendingAccessPage() {

Couldn't load your request. {requestError.message}

- ) : request ? ( + ) : request && showRequestCard ? ( ) : null} @@ -281,6 +297,10 @@ export default function SendingAccessPage() { {submitting ? "Submitting…" : "Submit request"} +

+ Requests are reviewed by an operator; you'll get an email when a decision is made. +

+ {rateLimited && (

You've reached the limit of 3 requests per 30 days. Try again later, or{" "} diff --git a/web/src/app/components/SendingAccessNotice.hosted.test.tsx b/web/src/app/components/SendingAccessNotice.hosted.test.tsx index e134a3c8c..055f52152 100644 --- a/web/src/app/components/SendingAccessNotice.hosted.test.tsx +++ b/web/src/app/components/SendingAccessNotice.hosted.test.tsx @@ -32,8 +32,36 @@ describe("SendingAccessNotice (hosted, billing gate enabled)", () => { render(); expect( screen.getByText( - "Receive emails from anyone. Send to your verified account email or agent inboxes in this account. To email other recipients, send from your own verified domain or request approval, activate a paid base plan.", + "Receive emails from anyone. Send to your verified account email or agent inboxes in this account. To email other recipients, verify your own domain, request approval, or activate a paid base plan.", ), ).toBeInTheDocument(); }); + + it("all three unlocks: Request approval, Verify a domain, Choose a paid plan", () => { + render(); + expect(screen.getAllByRole("link").map((l) => l.textContent)).toEqual([ + "Request approval", + "Verify a domain", + "Choose a paid plan", + ]); + }); + + it("hosted approval-only policy: no plan link even with billing enabled", () => { + render(); + expect(screen.getAllByRole("link").map((l) => l.textContent)).toEqual(["Request approval"]); + expect(screen.queryByText(/paid base plan/)).not.toBeInTheDocument(); + }); + + it("approval + paid: plan link and clause, no domain link", () => { + render( + , + ); + expect(screen.getAllByRole("link").map((l) => l.textContent)).toEqual([ + "Request approval", + "Choose a paid plan", + ]); + expect(screen.getByText(/request approval or activate a paid base plan\./)).toBeInTheDocument(); + }); }); diff --git a/web/src/app/components/SendingAccessNotice.test.tsx b/web/src/app/components/SendingAccessNotice.test.tsx index ca2f826a4..92caf1e66 100644 --- a/web/src/app/components/SendingAccessNotice.test.tsx +++ b/web/src/app/components/SendingAccessNotice.test.tsx @@ -31,8 +31,10 @@ describe("SendingAccessNotice", () => { it("shows the full banner with recovery actions when restricted", () => { render(); expect(screen.getByTestId("sending-access-restricted-notice")).toHaveTextContent( - "Your inbox is ready. External sending is restricted.", + "External sending is restricted for this account.", ); + // The headline never assumes an inbox exists (an account may have none). + expect(screen.getByTestId("sending-access-restricted-notice")).not.toHaveTextContent(/inbox is ready/i); expect(screen.getByRole("link", { name: "Verify a domain" })).toHaveAttribute( "href", "/domains", @@ -74,4 +76,42 @@ describe("SendingAccessNotice", () => { "Paid plan: external sending enabled", ); }); + + it("approval-only deployment: leads with the request, no domain or plan links", () => { + render(); + const links = screen.getAllByRole("link").map((l) => l.textContent); + expect(links).toEqual(["Request approval"]); + expect(screen.getByText(/To email other recipients, request approval below\./)).toBeInTheDocument(); + }); + + it("verified_domain listed: offers Verify a domain after Request approval", () => { + render( + , + ); + const links = screen.getAllByRole("link").map((l) => l.textContent); + expect(links).toEqual(["Request approval", "Verify a domain"]); + expect(screen.getByText(/verify your own domain or request approval\./)).toBeInTheDocument(); + }); + + it("paid_entitlement listed but billing disabled (self-host): no plan link or clause", () => { + render( + , + ); + expect(screen.queryByRole("link", { name: "Choose a paid plan" })).not.toBeInTheDocument(); + expect(screen.queryByText(/paid base plan/)).not.toBeInTheDocument(); + }); + + it("a paid entitlement does not lift the banner where paid_entitlement is not an unlock", () => { + render( + , + ); + expect(screen.getByTestId("sending-access-restricted-notice")).toBeInTheDocument(); + expect(screen.queryByTestId("sending-access-eligible")).not.toBeInTheDocument(); + }); }); diff --git a/web/src/app/components/SendingAccessNotice.tsx b/web/src/app/components/SendingAccessNotice.tsx index 348af2ecc..c0980a0f1 100644 --- a/web/src/app/components/SendingAccessNotice.tsx +++ b/web/src/app/components/SendingAccessNotice.tsx @@ -17,6 +17,7 @@ import Link from "next/link"; import { isSendingRestricted, + offeredUnlocks, sendingAccessEligibilityLabel, sendingAccessNoticeCopy, type SendingAccessStatus, @@ -24,7 +25,10 @@ import { // Hosted-only billing gate (AGENTS.md: billing UI stays inert on self-host). // A self-host build never has this set, so "Choose a paid plan" and the -// paid-plan clause in the body copy only ever appear on the hosted service. +// paid-plan clause in the body copy only ever appear on the hosted service — +// and only where the deployment's `available_unlocks` lists paid_entitlement. +// Likewise "Verify a domain" appears only where verified_domain is listed. +// Approval is always offered, and leads. const BILLING_API = (process.env.NEXT_PUBLIC_BILLING_API ?? "").replace(/\/$/, ""); export function SendingAccessNotice({ @@ -48,9 +52,9 @@ export function SendingAccessNotice({ ); } - const { headline, body } = sendingAccessNoticeCopy(status, { - billingEnabled: Boolean(BILLING_API), - }); + const billingEnabled = Boolean(BILLING_API); + const { headline, body } = sendingAccessNoticeCopy(status, { billingEnabled }); + const offered = offeredUnlocks(status, { billingEnabled }); return (

- - Verify a domain - Request approval - {BILLING_API && ( + {offered.domain && ( + + Verify a domain + + )} + {offered.paid && ( { describe("sendingAccessNoticeCopy", () => { it("offers the verified-email destination when owner proof exists", () => { const copy = sendingAccessNoticeCopy(base, { billingEnabled: false }); - expect(copy.headline).toBe("Your inbox is ready. External sending is restricted."); + expect(copy.headline).toBe("External sending is restricted for this account."); expect(copy.body).toBe( - "Receive emails from anyone. Send to your verified account email or agent inboxes in this account. To email other recipients, send from your own verified domain or request approval.", + "Receive emails from anyone. Send to your verified account email or agent inboxes in this account. To email other recipients, verify your own domain or request approval.", ); }); @@ -89,12 +91,50 @@ describe("sendingAccessNoticeCopy", () => { expect(copy.body).toMatch(/testing/); }); - it("appends the paid-plan option only when the billing gate is enabled", () => { + it("lists all three routes as a proper list when billing is enabled and all unlocks apply", () => { const copy = sendingAccessNoticeCopy(base, { billingEnabled: true }); expect(copy.body).toBe( - "Receive emails from anyone. Send to your verified account email or agent inboxes in this account. To email other recipients, send from your own verified domain or request approval, activate a paid base plan.", + "Receive emails from anyone. Send to your verified account email or agent inboxes in this account. To email other recipients, verify your own domain, request approval, or activate a paid base plan.", ); }); + + it.each([ + [["operator_approval"], true, "To email other recipients, request approval below."], + [["operator_approval", "verified_domain"], true, "To email other recipients, verify your own domain or request approval."], + [["operator_approval", "paid_entitlement"], true, "To email other recipients, request approval or activate a paid base plan."], + [["operator_approval", "paid_entitlement"], false, "To email other recipients, request approval below."], + ])("unlocks %j (billing %s) → %s", (unlocks, billingEnabled, sentence) => { + const copy = sendingAccessNoticeCopy({ ...base, available_unlocks: unlocks }, { billingEnabled }); + expect(copy.body.endsWith(sentence)).toBe(true); + expect(copy.headline).toBe("External sending is restricted for this account."); + }); +}); + +describe("unlock-aware grants", () => { + it("a paid entitlement is not a grant where paid_entitlement is not an unlock", () => { + const hosted = { ...base, paid_external_sending_entitled: true, available_unlocks: ["operator_approval"] }; + expect(isSendingRestricted(hosted)).toBe(true); + expect(sendingAccessEligibilityLabel(hosted)).toBeNull(); + expect(sendingAccessEnabledRoute(hosted)).toBeNull(); + }); + + it("an absent list (older server) keeps the paid grant", () => { + expect(isSendingRestricted({ ...base, paid_external_sending_entitled: true })).toBe(false); + }); + + it("operator approval always lifts it and names the route", () => { + const approved = { ...base, shared_external_approved: true, available_unlocks: ["operator_approval"] }; + expect(isSendingRestricted(approved)).toBe(false); + expect(sendingAccessEnabledRoute(approved)).toBe("An operator approved external sending for this account."); + }); + + it("offeredUnlocks follows the list and the billing gate", () => { + expect(offeredUnlocks({ ...base, available_unlocks: ["operator_approval"] }, { billingEnabled: true })).toEqual({ + domain: false, approval: true, paid: false, + }); + expect(offeredUnlocks(base, { billingEnabled: false })).toEqual({ domain: true, approval: true, paid: false }); + expect(offeredUnlocks(base, { billingEnabled: true })).toEqual({ domain: true, approval: true, paid: true }); + }); }); describe("parseRecipientList", () => { diff --git a/web/src/lib/sendingAccess.ts b/web/src/lib/sendingAccess.ts index 7a5742475..d23193989 100644 --- a/web/src/lib/sendingAccess.ts +++ b/web/src/lib/sendingAccess.ts @@ -7,29 +7,54 @@ // module owns the logic, callers own the fetch + render. /** Mirrors SendingAccessView (GET /v1/account → sending_access, beta). - * Booleans only — describes what the account may do, never a promise that - * a given send also passes pause/quota/content/domain checks. The field is - * entirely omitted by the server when unavailable; callers must treat - * `undefined` as "no restriction info" (render nothing), never as - * "restricted". */ + * Describes what the account may do, never a promise that a given send + * also passes pause/quota/content/domain checks. The field is entirely + * omitted by the server when unavailable; callers must treat `undefined` + * as "no restriction info" (render nothing), never as "restricted". */ export type SendingAccessStatus = { enforcement_applies: boolean; shared_external_approved: boolean; paid_external_sending_entitled: boolean; owner_recipient_verified: boolean; + /** The routes this deployment accepts for lifting the restriction. Open + * set; absent only from servers that predate the field, which accept all + * three. Hosted e2a lists only "operator_approval". */ + available_unlocks?: string[]; }; +export type SendingAccessUnlock = "operator_approval" | "verified_domain" | "paid_entitlement"; + +const ALL_UNLOCKS: SendingAccessUnlock[] = ["operator_approval", "verified_domain", "paid_entitlement"]; + +/** True when the deployment accepts `unlock`. A missing list (older server) + * means every unlock, exactly as the server behaved before the field. */ +export function unlockAvailable( + status: SendingAccessStatus | null | undefined, + unlock: SendingAccessUnlock, +): boolean { + const list = status?.available_unlocks ?? ALL_UNLOCKS; + return list.includes(unlock); +} + +/** True when the paid entitlement actually lifts the restriction on this + * deployment — holding it is only a signal where paid_entitlement is not + * an available unlock. */ +function paidUnlocks(status: SendingAccessStatus): boolean { + return status.paid_external_sending_entitled && unlockAvailable(status, "paid_entitlement"); +} + /** True only when the deployment enforces the control for this account AND - * neither grant (operator approval or a paid base plan) already lifts it. - * A missing/undefined status — disabled, shadow mode, or an account - * outside the rollout cohort — is never "restricted". */ + * no account-level grant lifts it (operator approval, or a paid base plan + * where the deployment accepts one). A missing/undefined status — + * disabled, shadow mode, or an account outside the rollout cohort — is + * never "restricted". */ export function isSendingRestricted( status: SendingAccessStatus | null | undefined, ): boolean { return Boolean( status?.enforcement_applies && !status.shared_external_approved && - !status.paid_external_sending_entitled, + !paidUnlocks(status), ); } @@ -43,7 +68,7 @@ export function sendingAccessEligibilityLabel( status: SendingAccessStatus | null | undefined, ): string | null { if (!status?.enforcement_applies) return null; - if (status.paid_external_sending_entitled) { + if (paidUnlocks(status)) { return "Paid plan: external sending enabled"; } if (status.shared_external_approved) { @@ -52,21 +77,62 @@ export function sendingAccessEligibilityLabel( return null; } +/** The body for the single "External sending is enabled" card: which route + * lifted the restriction. Null while still restricted. */ +export function sendingAccessEnabledRoute( + status: SendingAccessStatus | null | undefined, +): string | null { + if (!status?.enforcement_applies || isSendingRestricted(status)) return null; + if (status.shared_external_approved) { + return "An operator approved external sending for this account."; + } + return "Your paid base plan includes external sending for this account."; +} + +/** Which recovery routes to offer a restricted account: approval always; + * a verified domain only where the deployment accepts it; a paid plan only + * where the deployment accepts it AND billing is enabled (hosted-only UI). */ +export function offeredUnlocks( + status: SendingAccessStatus, + opts: { billingEnabled: boolean }, +): { domain: boolean; approval: true; paid: boolean } { + return { + domain: unlockAvailable(status, "verified_domain"), + approval: true, + paid: opts.billingEnabled && unlockAvailable(status, "paid_entitlement"), + }; +} + export type SendingAccessNoticeCopy = { headline: string; body: string }; -/** Disclosure copy for the restriction banner (dashboard + onboarding). - * Callers must already have confirmed `isSendingRestricted(status)` — - * this always returns the "restricted" copy, never the eligible one. */ +/** Disclosure copy for the restriction banner (dashboard + onboarding + + * /sending-access). Callers must already have confirmed + * `isSendingRestricted(status)` — this always returns the "restricted" + * copy. The headline says nothing about inboxes (an account may have none + * yet); the recovery sentence lists only the routes this deployment + * honors. */ export function sendingAccessNoticeCopy( status: SendingAccessStatus, opts: { billingEnabled: boolean }, ): SendingAccessNoticeCopy { - const headline = "Your inbox is ready. External sending is restricted."; - const planClause = opts.billingEnabled ? ", activate a paid base plan" : ""; - const body = status.owner_recipient_verified - ? `Receive emails from anyone. Send to your verified account email or agent inboxes in this account. To email other recipients, send from your own verified domain or request approval${planClause}.` - : `Receive emails from anyone. Agent inboxes in this account are available for testing. To email other recipients, send from your own verified domain or request approval${planClause}.`; - return { headline, body }; + const headline = "External sending is restricted for this account."; + const offered = offeredUnlocks(status, opts); + const routes: string[] = []; + if (offered.domain) routes.push("verify your own domain"); + routes.push("request approval"); + if (offered.paid) routes.push("activate a paid base plan"); + let recovery: string; + if (routes.length === 1) { + recovery = "To email other recipients, request approval below."; + } else if (routes.length === 2) { + recovery = `To email other recipients, ${routes[0]} or ${routes[1]}.`; + } else { + recovery = `To email other recipients, ${routes.slice(0, -1).join(", ")}, or ${routes[routes.length - 1]}.`; + } + const reach = status.owner_recipient_verified + ? "Receive emails from anyone. Send to your verified account email or agent inboxes in this account." + : "Receive emails from anyone. Agent inboxes in this account are available for testing."; + return { headline, body: `${reach} ${recovery}` }; } // ── Composer preflight ────────────────────────────────────────────────── From 1e95b6091e14b0ec57a9b69cb306494ea19302a0 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:09:05 +0800 Subject: [PATCH 06/13] fix(config): strict sending_protection decode and null unlock rejection Re-decode only the sending_protection subtree with unknown keys rejected, so a misspelled or mis-cased unlocks key (or a mis-indented one) fails startup instead of meaning every unlock. An explicit null/blank/~ unlocks value is rejected like []; yaml.v3 never calls a custom unmarshaler for a null node, so presence is checked on the YAML node. An explicit full unlock set now canonicalizes to the omitted form so one policy has one hash. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- internal/config/config.go | 65 ++++++++++++++ .../config/sending_protection_strict_test.go | 85 +++++++++++++++++++ .../sendingpolicy/external_policy_test.go | 29 +++++++ internal/sendingpolicy/fromconfig_test.go | 11 ++- internal/sendingpolicy/policy.go | 14 +++ 5 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 internal/config/sending_protection_strict_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 9ddeed6cd..1cea77ea9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "errors" "fmt" "log" @@ -784,6 +785,9 @@ func Load(path string) (*Config, error) { if err := yaml.Unmarshal(data, cfg); err != nil { return nil, err } + if err := checkSendingProtectionStrict(data); err != nil { + return nil, err + } // E2A_ENV overrides `env:` in config.yaml. Every other config knob // already has an env-var override; env: previously had none — the only @@ -1290,3 +1294,64 @@ func absoluteHTTPURL(raw string) (*url.URL, error) { } return parsed, nil } + +// checkSendingProtectionStrict re-decodes ONLY the `sending_protection` +// subtree with unknown keys rejected. The rest of the file stays lenient +// (self-hosters carry keys from older docs), but this block is a security +// policy: a misspelled `unlock:` or `Unlocks:` under external_sending_access, +// or a key indented one level off, would otherwise silently mean "every +// unlock". It also rejects an explicitly null or blank `unlocks:` — yaml.v3 +// decodes `unlocks:`, `unlocks: null` and `unlocks: ~` to a nil slice without +// ever invoking a custom unmarshaler, which would read as "absent = all +// three" and bypass the empty-list rejection. Presence is therefore detected +// on the YAML node itself. +func checkSendingProtectionStrict(data []byte) error { + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return err + } + if len(root.Content) == 0 { + return nil + } + sp := mappingValue(root.Content[0], "sending_protection") + if sp == nil || sp.ShortTag() == "!!null" { + return nil + } + raw, err := yaml.Marshal(sp) + if err != nil { + return fmt.Errorf("sending_protection: %w", err) + } + dec := yaml.NewDecoder(bytes.NewReader(raw)) + dec.KnownFields(true) + var strict SendingProtectionConfig + if err := dec.Decode(&strict); err != nil { + return fmt.Errorf("sending_protection: %w", err) + } + esa := mappingValue(sp, "external_sending_access") + if esa == nil || esa.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(esa.Content); i += 2 { + if esa.Content[i].Value != "unlocks" { + continue + } + v := esa.Content[i+1] + if v.ShortTag() == "!!null" { + return errors.New("sending_protection.external_sending_access.unlocks is null or blank; omit the key to allow every unlock, or list the unlocks (it must contain operator_approval)") + } + } + return nil +} + +// mappingValue returns the value node for key in a mapping node, or nil. +func mappingValue(m *yaml.Node, key string) *yaml.Node { + if m == nil || m.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1] + } + } + return nil +} diff --git a/internal/config/sending_protection_strict_test.go b/internal/config/sending_protection_strict_test.go new file mode 100644 index 000000000..491e4515e --- /dev/null +++ b/internal/config/sending_protection_strict_test.go @@ -0,0 +1,85 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func loadYAML(t *testing.T, body string) (*Config, error) { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return Load(path) +} + +const esaHead = "sending_protection:\n external_sending_access:\n mode: enforce\n accounts_created_at_or_after: \"1970-01-01T00:00:00Z\"\n" + +// The sending_protection block is decoded strictly: a misspelled or +// mis-cased key (or a wrong indentation that lands a key in the wrong +// mapping) fails startup instead of silently meaning "every unlock". +func TestSendingProtectionBlockIsStrict(t *testing.T) { + for name, body := range map[string]string{ + "misspelled unlocks key": esaHead + " unlock: [operator_approval]\n", + "wrong-case unlocks key": esaHead + " Unlocks: [operator_approval]\n", + "unknown sibling key": esaHead + " allow_all: true\n", + "key indented a level off": esaHead + " unlocks: [operator_approval]\n", + "unknown top-level sp key": "sending_protection:\n budget_mod: enforce\n", + } { + t.Run(name, func(t *testing.T) { + if _, err := loadYAML(t, body); err == nil { + t.Fatal("expected a startup error") + } + }) + } +} + +// Other blocks stay lenient: self-hosters carry keys from older docs. +func TestUnknownKeysOutsideSendingProtectionStillLoad(t *testing.T) { + if _, err := loadYAML(t, "some_retired_block:\n x: 1\nhttp:\n listen_addr: \":8080\"\n retired_knob: true\n"); err != nil { + t.Fatalf("lenient keys outside sending_protection must still load: %v", err) + } +} + +// An explicit null/blank unlock list must fail like `[]`, never read as +// "absent = every unlock". Absent keeps all three (nil). +func TestExternalSendingUnlocksPresence(t *testing.T) { + cfg, err := loadYAML(t, esaHead) + if err != nil { + t.Fatalf("absent unlocks: %v", err) + } + if cfg.SendingProtect.ExternalSendingAccess.Unlocks != nil { + t.Fatalf("absent unlocks must decode to nil, got %v", cfg.SendingProtect.ExternalSendingAccess.Unlocks) + } + for name, line := range map[string]string{ + "null": " unlocks: null\n", + "blank": " unlocks:\n", + "tilde": " unlocks: ~\n", + } { + t.Run(name, func(t *testing.T) { + _, err := loadYAML(t, esaHead+line) + if err == nil || !strings.Contains(err.Error(), "unlocks") { + t.Fatalf("expected an unlocks error, got %v", err) + } + }) + } + // Content errors (empty, missing operator_approval, unknown, wrong case) + // load here and are rejected by sendingpolicy.FromConfig at startup; see + // internal/sendingpolicy TestFromConfigExternalSendingUnlocks. + cfg, err = loadYAML(t, esaHead+" unlocks: []\n") + if err != nil { + t.Fatalf("`[]` decodes here and is rejected by policy validation: %v", err) + } + if u := cfg.SendingProtect.ExternalSendingAccess.Unlocks; u == nil || len(u) != 0 { + t.Fatalf("`[]` must decode to an empty non-nil list, got %#v", u) + } +} + +func TestExampleConfigStillLoads(t *testing.T) { + if _, err := Load(filepath.Join("..", "..", "config.example.yaml")); err != nil { + t.Fatalf("config.example.yaml: %v", err) + } +} diff --git a/internal/sendingpolicy/external_policy_test.go b/internal/sendingpolicy/external_policy_test.go index 182250e50..5cba9ef95 100644 --- a/internal/sendingpolicy/external_policy_test.go +++ b/internal/sendingpolicy/external_policy_test.go @@ -222,3 +222,32 @@ func TestExternalAccessUnlocksValidation(t *testing.T) { t.Fatal("a stored empty unlock set must be rejected") } } + +func TestExternalAccessExplicitFullUnlockSetHashesLikeOmitted(t *testing.T) { + omitted := DisabledPolicy() + omitted.ExternalSendingAccess = &ExternalSendingAccessPolicy{Mode: ModeEnforce, AccountsCreatedAtOrAfter: "2026-10-01T00:00:00Z"} + full := DisabledPolicy() + full.ExternalSendingAccess = &ExternalSendingAccessPolicy{Mode: ModeEnforce, AccountsCreatedAtOrAfter: "2026-10-01T00:00:00Z", + Unlocks: []ExternalUnlock{UnlockPaidEntitlement, UnlockOperatorApproval, UnlockVerifiedDomain}} + ho, err := Hash(omitted) + if err != nil { + t.Fatal(err) + } + hf, err := Hash(full) + if err != nil { + t.Fatal(err) + } + if ho != hf { + t.Fatal("an explicit full unlock set must canonicalize to the omitted form") + } + if full.ExternalSendingAccess.Unlocks == nil { + t.Fatal("canonicalizing must not mutate the caller's value") + } + // A duplicate-padded list is still rejected before canonicalization. + dup := DisabledPolicy() + dup.ExternalSendingAccess = &ExternalSendingAccessPolicy{Mode: ModeEnforce, AccountsCreatedAtOrAfter: "2026-10-01T00:00:00Z", + Unlocks: []ExternalUnlock{UnlockOperatorApproval, UnlockOperatorApproval, UnlockPaidEntitlement}} + if err := dup.Validate(); err == nil { + t.Fatal("duplicates must be rejected") + } +} diff --git a/internal/sendingpolicy/fromconfig_test.go b/internal/sendingpolicy/fromconfig_test.go index 344dd78ba..386609046 100644 --- a/internal/sendingpolicy/fromconfig_test.go +++ b/internal/sendingpolicy/fromconfig_test.go @@ -105,17 +105,24 @@ func TestFromConfigExternalSendingUnlocks(t *testing.T) { t.Fatalf("hosted unlocks = %v", got) } + // Every one of these must stop startup, at load (null/blank/misspelled + // key) or at policy validation (content) — never read as "all three". for name, body := range map[string]string{ "explicit empty list": " unlocks: []\n", + "explicit null": " unlocks: null\n", + "blank": " unlocks:\n", + "tilde": " unlocks: ~\n", "missing operator_approval": " unlocks: [verified_domain, paid_entitlement]\n", "unknown unlock": " unlocks: [operator_approval, plan]\n", + "wrong-case entry": " unlocks: [Operator_Approval]\n", + "misspelled key": " unlock: [operator_approval]\n", } { cfg, err := load(t, head+body) if err != nil { - t.Fatalf("%s: load: %v", name, err) + continue // refused at load: startup fails } if _, err := FromConfig(cfg); err == nil { - t.Fatalf("%s: must fail config validation", name) + t.Fatalf("%s: must fail startup", name) } } } diff --git a/internal/sendingpolicy/policy.go b/internal/sendingpolicy/policy.go index 2bd7e1edd..e1dd981b6 100644 --- a/internal/sendingpolicy/policy.go +++ b/internal/sendingpolicy/policy.go @@ -471,6 +471,20 @@ func (p RuntimePolicy) normalized() RuntimePolicy { } } copied.Unlocks = sorted + // The full vocabulary means exactly what an omitted key means, + // so it canonicalizes to omitted: one policy, one reviewed hash. + // Validation runs on the value as written, before this. + if len(sorted) == len(externalUnlockOrder) { + full := true + for i, u := range externalUnlockOrder { + if sorted[i] != u { + full = false + } + } + if full { + copied.Unlocks = nil + } + } } p.ExternalSendingAccess = &copied } From de44ef0b8fa8a984aee98d6b87dd1ffc31e98b98 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:11:50 +0800 Subject: [PATCH 07/13] fix(sending): refuse requests from unrestricted accounts, fence-safe request text SubmitAccessRequest now answers 409 conflict (no row, no operator mail) when the account is not currently restricted: shadow mode, outside the cohort, already approved, or entitled where paid_entitlement unlocks. System/internal classes may still file (first-party conformance) and are never notified. use_case/recipients reject control characters other than LF and TAB and Unicode line separators (CRLF is normalized to LF), and the operator email's quoting fence splits on every Unicode line break. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- api/openapi.yaml | 2 +- docs/api.md | 6 +- internal/agent/external_access.go | 13 ++- internal/agent/external_access_test.go | 8 ++ internal/httpapi/sending_access.go | 4 +- internal/httpapi/sending_access_test.go | 18 ++++ .../sendingpolicy/external_access_admin.go | 66 ++++++++++++- .../external_unlocks_integration_test.go | 97 +++++++++++++++++++ mcp/src/tools/sendingaccess.ts | 2 +- .../src/e2a/v1/generated/api/account_api.py | 6 +- .../src/v1/generated/apis/AccountApi.ts | 2 +- .../src/v1/generated/types/ObjectParamAPI.ts | 4 +- .../src/v1/generated/types/ObservableAPI.ts | 4 +- .../src/v1/generated/types/PromiseAPI.ts | 4 +- 14 files changed, 216 insertions(+), 20 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index a290013c3..28416a121 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5664,7 +5664,7 @@ paths: - account x-stability-level: beta post: - description: "Files a request for support to review this account's external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve." + description: "Files a request for support to review this account's external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve." operationId: createSendingAccessRequest requestBody: content: diff --git a/docs/api.md b/docs/api.md index 8a33f4a1f..d50860dbd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -626,7 +626,11 @@ POST /v1/account/sending-access/request File **one** request. While it is pending, resubmitting returns the same request (`200`); after a decline a new request may be filed, up to 3 per 30 -days (`429 rate_limited` beyond that — do not retry). The decision is emailed +days (`429 rate_limited` beyond that — do not retry). An account that is not +currently restricted (already approved, outside the cohort, shadow mode, or +lifted by an available unlock) gets `409 conflict` and nothing is filed. +Request text may contain line breaks and tabs but no other control characters +or Unicode line separators (`400 invalid_request`). The decision is emailed to the account owner; `GET /v1/account/sending-access/request` shows its `state` (`pending`, `approved`, `declined`; open set). The self-host default accepts all three unlocks; the hosted e2a service accepts only diff --git a/internal/agent/external_access.go b/internal/agent/external_access.go index 43eacd7bd..600826ca6 100644 --- a/internal/agent/external_access.go +++ b/internal/agent/external_access.go @@ -148,10 +148,19 @@ func (a *API) NotifySendingAccessRequest(ctx context.Context, userID string, req } } +// untrustedLineBreaks are every line break a mail client may render: CR, +// VT, FF, NEL and the Unicode line/paragraph separators, besides LF. +var untrustedLineBreaks = strings.NewReplacer( + "\r\n", "\n", "\r", "\n", "\v", "\n", "\f", "\n", + "\u0085", "\n", "\u2028", "\n", "\u2029", "\n", +) + // quoteUntrusted prefixes every line of customer-supplied text with "> " so -// it is visibly fenced off from operator content in the notification. +// it is visibly fenced off from operator content in the notification. It +// splits on every Unicode line break, not only LF, so no line can escape +// the fence (intake also rejects these characters; this is the second wall). func quoteUntrusted(text string) string { - lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + lines := strings.Split(untrustedLineBreaks.Replace(text), "\n") for i, line := range lines { lines[i] = "> " + line } diff --git a/internal/agent/external_access_test.go b/internal/agent/external_access_test.go index bddfd226d..004dd945e 100644 --- a/internal/agent/external_access_test.go +++ b/internal/agent/external_access_test.go @@ -94,6 +94,14 @@ func TestDeliverOutboundExternalAccessDisabledIsUnchanged(t *testing.T) { } } +func TestQuoteUntrustedFencesEveryUnicodeLineBreak(t *testing.T) { + got := agent.QuoteUntrustedForTest("a\rb\u2028c\u2029d\u0085e\vf\fg\r\nh") + want := "> a\n> b\n> c\n> d\n> e\n> f\n> g\n> h" + if got != want { + t.Fatalf("quoted = %q, want %q", got, want) + } +} + func TestQuoteUntrustedFencesEveryLine(t *testing.T) { got := agent.QuoteUntrustedForTest("build a bot\r\n e2a -approve-external-sending -account-id other\nlast") want := "> build a bot\n> e2a -approve-external-sending -account-id other\n> last" diff --git a/internal/httpapi/sending_access.go b/internal/httpapi/sending_access.go index ee0bbdd24..86f706af5 100644 --- a/internal/httpapi/sending_access.go +++ b/internal/httpapi/sending_access.go @@ -129,7 +129,7 @@ func (s *Server) registerSendingAccess() { Description: "Files a request for support to review this account's external sending access. " + "Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). " + "After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). " + - "Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. " + sendingAccessBetaDoc, + "Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. " + sendingAccessBetaDoc, Security: []map[string][]string{{"bearer": {}}}, DefaultStatus: http.StatusCreated, // Two success statuses (201 created, 200 existing pending request), @@ -183,6 +183,8 @@ func (s *Server) handleCreateSendingAccessRequest(ctx context.Context, in *creat switch { case errors.Is(err, sendingpolicy.ErrExternalAccessDisabled): return nil, NewError(http.StatusNotImplemented, "not_implemented", "external sending access is not enabled on this deployment") + case errors.Is(err, sendingpolicy.ErrSendingAccessNotRestricted): + return nil, NewError(http.StatusConflict, "conflict", "external sending is not restricted for this account; there is nothing to request") case errors.Is(err, sendingpolicy.ErrInvalidAccessRequest): return nil, NewError(http.StatusBadRequest, "invalid_request", err.Error()) case errors.Is(err, sendingpolicy.ErrAccessRequestRateLimited): diff --git a/internal/httpapi/sending_access_test.go b/internal/httpapi/sending_access_test.go index 4bbf70dad..8bb9edecf 100644 --- a/internal/httpapi/sending_access_test.go +++ b/internal/httpapi/sending_access_test.go @@ -273,3 +273,21 @@ func TestSendingAccessRequestSkipsOperatorNotificationForExemptClass(t *testing. }) } } + +func TestSendingAccessRequestNotRestrictedIsConflict(t *testing.T) { + notified := 0 + srv := testServer(t, func(d *Deps) { + d.SubmitSendingAccessRequest = func(context.Context, string, sendingpolicy.AccessRequestInput) (sendingpolicy.AccessRequest, bool, error) { + return sendingpolicy.AccessRequest{}, false, sendingpolicy.ErrSendingAccessNotRestricted + } + d.NotifySendingAccessRequest = func(context.Context, string, sendingpolicy.AccessRequest) { notified++ } + }) + form := map[string]any{"use_case": "x", "recipients": "y", "expected_daily_volume": 1} + code, body := sendJSON(t, http.MethodPost, srv.URL+"/v1/account/sending-access/request", "good", form) + if code != 409 || errCode(body) != "conflict" { + t.Fatalf("not restricted: %d %v", code, body) + } + if notified != 0 { + t.Fatal("a refused request must not notify the operator") + } +} diff --git a/internal/sendingpolicy/external_access_admin.go b/internal/sendingpolicy/external_access_admin.go index 752ab798f..e4a4be13e 100644 --- a/internal/sendingpolicy/external_access_admin.go +++ b/internal/sendingpolicy/external_access_admin.go @@ -34,6 +34,11 @@ var ( ErrAccessRequestRateLimited = errors.New("sendingpolicy: too many sending access requests") // ErrInvalidAccessRequest means a request field failed validation. ErrInvalidAccessRequest = errors.New("sendingpolicy: invalid sending access request") + // ErrSendingAccessNotRestricted means the account is not currently + // restricted (enforcement does not bind it, it is already approved, or + // an available unlock already lifts it): there is nothing to request, + // so no row is written and no operator is notified. + ErrSendingAccessNotRestricted = errors.New("sendingpolicy: external sending is not restricted for this account") ) // NewPolicyModule binds a module to a pool, the trust roots and the @@ -322,10 +327,35 @@ const ( accessRequestMax = 3 ) +// normalizeRequestText converts CRLF line endings to LF — the one line-break +// form the operator email's quoting fence is built around — and trims. +func normalizeRequestText(s string) string { + return strings.TrimSpace(strings.ReplaceAll(s, "\r\n", "\n")) +} + +// hasForbiddenRequestRune reports a control character other than LF and TAB, +// or a Unicode line/paragraph separator. Either could break out of the +// "> " fence that marks customer text as untrusted in the operator email. +func hasForbiddenRequestRune(s string) bool { + for _, r := range s { + switch { + case r == '\n' || r == '\t': + continue + case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f): + return true + case r == '\u2028' || r == '\u2029': + return true + } + } + return false +} + func (in AccessRequestInput) validate() error { - useCase := strings.TrimSpace(in.UseCase) - recipients := strings.TrimSpace(in.Recipients) + useCase := normalizeRequestText(in.UseCase) + recipients := normalizeRequestText(in.Recipients) switch { + case hasForbiddenRequestRune(useCase) || hasForbiddenRequestRune(recipients): + return fmt.Errorf("%w: use_case and recipients must not contain control characters or Unicode line separators (line breaks and tabs are fine)", ErrInvalidAccessRequest) case useCase == "" || len([]rune(useCase)) > MaxAccessRequestUseCase: return fmt.Errorf("%w: use_case must be 1-%d characters", ErrInvalidAccessRequest, MaxAccessRequestUseCase) case recipients == "" || len([]rune(recipients)) > MaxAccessRequestRecipients: @@ -349,9 +379,13 @@ func scanAccessRequest(row pgx.Row) (AccessRequest, error) { // is therefore idempotent while a request is pending; after a decline a new // request (an appeal) may be filed within the rolling-window cap. func (m *Module) SubmitAccessRequest(ctx context.Context, userID string, in AccessRequestInput) (AccessRequest, bool, error) { - if err := m.requireExternalAccessEnabled(ctx); err != nil { + policy, err := m.policyForRead(ctx, m.pool) + if err != nil { return AccessRequest{}, false, err } + if policy.ExternalSendingMode() == ModeDisabled { + return AccessRequest{}, false, ErrExternalAccessDisabled + } if err := in.validate(); err != nil { return AccessRequest{}, false, err } @@ -374,6 +408,30 @@ func (m *Module) SubmitAccessRequest(ctx context.Context, userID string, in Acce return AccessRequest{}, false, fmt.Errorf("sendingpolicy: read user: %w", err) } exempt := accountClassExempt(class) + if !exempt { + // Only an account the rule actually restricts right now may file: + // enforce mode, inside the cohort, not approved, and no available + // unlock already lifting it. Anything else would only mint operator + // mail with nothing to decide. System/internal classes are exempt + // from the rule; they may still file (first-party conformance) and + // are never notified. + facts, err := loadAccountAccessFacts(ctx, tx, userID) + if errors.Is(err, errAccountMissing) { + return AccessRequest{}, false, ErrAccountNotFound + } + if err != nil { + return AccessRequest{}, false, err + } + applies, err := externalAccessApplies(policy, facts) + if err != nil { + return AccessRequest{}, false, err + } + esa := policy.ExternalSendingAccess + if !applies || policy.ExternalSendingMode() != ModeEnforce || facts.approved || + (facts.entitled && esa.Allows(UnlockPaidEntitlement)) { + return AccessRequest{}, false, ErrSendingAccessNotRestricted + } + } existing, err := scanAccessRequest(tx.QueryRow(ctx, `SELECT `+accessRequestColumns+` FROM external_sending_access_requests WHERE user_id = $1 AND state = 'pending'`, userID)) if err == nil { @@ -398,7 +456,7 @@ func (m *Module) SubmitAccessRequest(ctx context.Context, userID string, in Acce INSERT INTO external_sending_access_requests (id, user_id, use_case, recipients, expected_daily_volume) VALUES ($1, $2, $3, $4, $5) RETURNING `+accessRequestColumns, - randomID("esar_"), userID, strings.TrimSpace(in.UseCase), strings.TrimSpace(in.Recipients), in.ExpectedDailyVolume)) + randomID("esar_"), userID, normalizeRequestText(in.UseCase), normalizeRequestText(in.Recipients), in.ExpectedDailyVolume)) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { diff --git a/internal/sendingpolicy/external_unlocks_integration_test.go b/internal/sendingpolicy/external_unlocks_integration_test.go index 9fdf2bbad..bba078ca7 100644 --- a/internal/sendingpolicy/external_unlocks_integration_test.go +++ b/internal/sendingpolicy/external_unlocks_integration_test.go @@ -212,3 +212,100 @@ func TestSubmitAccessRequestReportsExemptClass(t *testing.T) { } } } + +// Only an account the rule restricts right now may file: approved, entitled +// (where the paid unlock applies), out-of-cohort and shadow-mode accounts get +// ErrSendingAccessNotRestricted with no row written. Exempt classes may still +// file (first-party conformance) and are never notified upstream. +func TestSubmitAccessRequestRefusesUnrestrictedAccounts(t *testing.T) { + in := sendingpolicy.AccessRequestInput{UseCase: "synthetic use case", Recipients: "synthetic recipients", ExpectedDailyVolume: 1} + count := func(f *fixture, user string) int { + var n int + if err := f.pool.QueryRow(f.ctx, `SELECT count(*) FROM external_sending_access_requests WHERE user_id = $1`, user).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + for name, tc := range map[string]struct { + policy sendingpolicy.RuntimePolicy + setup func(f *fixture, user string) + want error + }{ + "restricted standard account files": {esaPolicy(sendingpolicy.ModeEnforce), func(*fixture, string) {}, nil}, + "already approved": {esaPolicy(sendingpolicy.ModeEnforce), func(f *fixture, u string) { f.setApproved(u, true) }, sendingpolicy.ErrSendingAccessNotRestricted}, + "paid entitlement where it unlocks": {esaPolicy(sendingpolicy.ModeEnforce), func(f *fixture, u string) { f.setEntitled(u, true) }, sendingpolicy.ErrSendingAccessNotRestricted}, + "paid entitlement under approval-only": {esaUnlockPolicy([]sendingpolicy.ExternalUnlock{sendingpolicy.UnlockOperatorApproval}), + func(f *fixture, u string) { f.setEntitled(u, true) }, nil}, + "outside the cohort": {esaPolicy(sendingpolicy.ModeEnforce), func(f *fixture, u string) { + f.exec(`UPDATE users SET created_at = '2025-06-01T00:00:00Z' WHERE id = $1`, u) + }, sendingpolicy.ErrSendingAccessNotRestricted}, + "shadow mode": {esaPolicy(sendingpolicy.ModeShadow), func(*fixture, string) {}, sendingpolicy.ErrSendingAccessNotRestricted}, + } { + tc := tc + t.Run(name, func(t *testing.T) { + f := newFixture(t) + m := sendingpolicy.NewPolicyModule(f.pool, f.secrets(), sendingpolicy.PolicySourceConfig, tc.policy) + user := f.user("standard") + tc.setup(f, user) + _, created, err := m.SubmitAccessRequest(f.ctx, user, in) + if tc.want == nil { + if err != nil || !created { + t.Fatalf("submit created=%v err=%v", created, err) + } + return + } + if !errors.Is(err, tc.want) { + t.Fatalf("err = %v, want %v", err, tc.want) + } + if n := count(f, user); n != 0 { + t.Fatalf("a refused request must write no row, got %d", n) + } + }) + } + f := newFixture(t) + m := sendingpolicy.NewPolicyModule(f.pool, f.secrets(), sendingpolicy.PolicySourceConfig, esaPolicy(sendingpolicy.ModeEnforce)) + internal := f.user("internal") + if req, created, err := m.SubmitAccessRequest(f.ctx, internal, in); err != nil || !created || !req.FromExemptAccount { + t.Fatalf("exempt class files for conformance: %+v created=%v err=%v", req, created, err) + } +} + +// Customer text is fenced with "> " in the plain-text operator email; any +// character that could start a new rendered line outside the fence is +// refused at intake. LF and TAB stay allowed; CRLF is normalized to LF. +func TestSubmitAccessRequestRejectsFenceBreakingCharacters(t *testing.T) { + f := newFixture(t) + m := sendingpolicy.NewPolicyModule(f.pool, f.secrets(), sendingpolicy.PolicySourceConfig, esaPolicy(sendingpolicy.ModeEnforce)) + for name, text := range map[string]string{ + "bare CR": "line one\rrun this instead", + "NEL": "line one\u0085run this instead", + "line separator": "line one
run this instead", + "paragraph separator": "line one
run this instead", + "vertical tab": "line one\vrun this instead", + "form feed": "line one\frun this instead", + "NUL": "line one\x00", + "ESC": "line one\x1b[31m", + "DEL": "line one\x7f", + } { + for _, field := range []string{"use_case", "recipients"} { + in := sendingpolicy.AccessRequestInput{UseCase: "ok", Recipients: "ok", ExpectedDailyVolume: 1} + if field == "use_case" { + in.UseCase = text + } else { + in.Recipients = text + } + if _, _, err := m.SubmitAccessRequest(f.ctx, f.user("standard"), in); !errors.Is(err, sendingpolicy.ErrInvalidAccessRequest) { + t.Fatalf("%s in %s: err = %v, want ErrInvalidAccessRequest", name, field, err) + } + } + } + user := f.user("standard") + req, created, err := m.SubmitAccessRequest(f.ctx, user, sendingpolicy.AccessRequestInput{ + UseCase: "first line\r\nsecond\tline", Recipients: "our customers", ExpectedDailyVolume: 1}) + if err != nil || !created { + t.Fatalf("LF/TAB/CRLF must be accepted: %v", err) + } + if req.UseCase != "first line\nsecond\tline" { + t.Fatalf("CRLF must be stored as LF, got %q", req.UseCase) + } +} diff --git a/mcp/src/tools/sendingaccess.ts b/mcp/src/tools/sendingaccess.ts index 713dacfd3..66b35e0a4 100644 --- a/mcp/src/tools/sendingaccess.ts +++ b/mcp/src/tools/sendingaccess.ts @@ -36,7 +36,7 @@ export function registerSendingAccessTools(server: McpServer, client: McpClient) title: "Request external sending access (beta)", annotations: { destructiveHint: false }, description: - "Use when a send failed with `external_sending_not_enabled` (or `whoami`'s `sending_access` shows the account is restricted) and the account legitimately needs to email external recipients. Files ONE request for an operator to review; filing never grants access by itself. Call it ONCE: while a request is pending, calling again just returns the same pending request — do not retry or re-file. Do NOT retry on `rate_limited` (429; at most 3 requests per 30 days per account) or any other error; tell the user instead. " + + "Use when a send failed with `external_sending_not_enabled` (or `whoami`'s `sending_access` shows the account is restricted) and the account legitimately needs to email external recipients. Files ONE request for an operator to review; filing never grants access by itself. Call it ONCE: while a request is pending, calling again just returns the same pending request — do not retry or re-file. Do NOT retry on `rate_limited` (429; at most 3 requests per 30 days per account), `conflict` (409: the account is not restricted — already approved, or the control does not apply to it — so there is nothing to request) or any other error; tell the user instead. " + DECISION_NOTE + " Describe the real use case and recipients truthfully and specifically — vague or misleading requests are declined. `whoami`'s `sending_access.available_unlocks` lists every route this deployment accepts (`operator_approval` always; `verified_domain` / `paid_entitlement` only where listed). BETA. Account scope only.", inputSchema: strictInputSchema({ 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 8cb22c46c..793bb2c2d 100644 --- a/sdks/python/src/e2a/v1/generated/api/account_api.py +++ b/sdks/python/src/e2a/v1/generated/api/account_api.py @@ -365,7 +365,7 @@ async def create_sending_access_request( ) -> SendingAccessRequestView: """Request external sending access (beta) - Files a request for support to review this account's external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. + Files a request for support to review this account's external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. :param sending_access_request_input: (required) :type sending_access_request_input: SendingAccessRequestInput @@ -433,7 +433,7 @@ async def create_sending_access_request_with_http_info( ) -> ApiResponse[SendingAccessRequestView]: """Request external sending access (beta) - Files a request for support to review this account's external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. + Files a request for support to review this account's external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. :param sending_access_request_input: (required) :type sending_access_request_input: SendingAccessRequestInput @@ -501,7 +501,7 @@ async def create_sending_access_request_without_preload_content( ) -> RESTResponseType: """Request external sending access (beta) - Files a request for support to review this account's external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. + Files a request for support to review this account's external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. :param sending_access_request_input: (required) :type sending_access_request_input: SendingAccessRequestInput diff --git a/sdks/typescript/src/v1/generated/apis/AccountApi.ts b/sdks/typescript/src/v1/generated/apis/AccountApi.ts index 99a473045..5c77fe4aa 100644 --- a/sdks/typescript/src/v1/generated/apis/AccountApi.ts +++ b/sdks/typescript/src/v1/generated/apis/AccountApi.ts @@ -81,7 +81,7 @@ export class AccountApiRequestFactory extends BaseAPIRequestFactory { } /** - * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. + * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. * Request external sending access (beta) * @param sendingAccessRequestInput */ diff --git a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts index 43086db80..37907ca82 100644 --- a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts @@ -353,7 +353,7 @@ export class ObjectAccountApi { } /** - * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. + * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. * Request external sending access (beta) * @param param the request object */ @@ -362,7 +362,7 @@ export class ObjectAccountApi { } /** - * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. + * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. * Request external sending access (beta) * @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 1dfbe57eb..ee88e80d3 100644 --- a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts @@ -224,7 +224,7 @@ export class ObservableAccountApi { } /** - * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. + * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. * Request external sending access (beta) * @param sendingAccessRequestInput */ @@ -249,7 +249,7 @@ export class ObservableAccountApi { } /** - * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. + * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. * Request external sending access (beta) * @param sendingAccessRequestInput */ diff --git a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts index ed50452ea..fd4237584 100644 --- a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts +++ b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts @@ -207,7 +207,7 @@ export class PromiseAccountApi { } /** - * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. + * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. * Request external sending access (beta) * @param sendingAccessRequestInput */ @@ -218,7 +218,7 @@ export class PromiseAccountApi { } /** - * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. + * Files a request for support to review this account\'s external sending access. Idempotent while a request is pending: submitting again returns the existing pending request (200) instead of creating another (201). After a decline a new request may be filed as an appeal, up to 3 requests per 30 days (429 rate_limited beyond that). Filing a request never grants access by itself. 409 conflict when the account is not currently restricted (enforcement does not apply to it, it is already approved, or an available unlock already lifts the restriction) — nothing is filed. 501 not_implemented when the deployment does not enable external sending access. Account-scoped credentials only. Beta: external sending access is a platform control that ships disabled; this surface may evolve. * Request external sending access (beta) * @param sendingAccessRequestInput */ From dc070f238d2a490296ce7fa6de9536e249cdcf92 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:13:18 +0800 Subject: [PATCH 08/13] feat(sending): operator request queue listing and pending-request warning -list-external-sending-requests prints pending requests (every request with -all): id, account id, state, times, expected volume and the current grant, never customer text. A direct -approve-external-sending while a request is pending warns to re-run with -external-sending-request-id instead of leaving it undecided silently. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- cmd/e2a/main.go | 2 + cmd/e2a/sending_policy.go | 41 ++++++++++- cmd/e2a/sending_policy_test.go | 68 +++++++++++++++++++ docs/design/async-message-pipeline.md | 8 ++- .../sendingpolicy/external_access_admin.go | 47 +++++++++++++ 5 files changed, 163 insertions(+), 3 deletions(-) diff --git a/cmd/e2a/main.go b/cmd/e2a/main.go index aef8f952b..674a742b8 100644 --- a/cmd/e2a/main.go +++ b/cmd/e2a/main.go @@ -128,6 +128,8 @@ func main() { flag.BoolVar(&spFlags.approveExternal, "approve-external-sending", false, "grant an account shared-identity external sending (requires -account-id, -expected-external-sending-revision, -reason; optional -external-sending-request-id), then exit") flag.BoolVar(&spFlags.revokeExternal, "revoke-external-sending", false, "revoke an account's shared-identity external sending grant (requires -account-id, -expected-external-sending-revision, -reason), then exit") flag.BoolVar(&spFlags.declineExternal, "decline-external-sending-request", false, "decline a pending external sending request without changing the grant (requires -account-id, -external-sending-request-id), then exit") + flag.BoolVar(&spFlags.listExternal, "list-external-sending-requests", false, "list pending external sending requests (id, account, created_at, volume, current grant; add -all for every request), then exit") + flag.BoolVar(&spFlags.listAll, "all", false, "with -list-external-sending-requests: include decided requests") flag.StringVar(&spFlags.accountID, "account-id", "", "account (user) id an external sending command acts on") flag.Int64Var(&spFlags.expectedExternal, "expected-external-sending-revision", -1, "external sending access revision the operator inspected (CAS)") flag.StringVar(&spFlags.requestID, "external-sending-request-id", "", "pending external sending request an approve/decline decides") diff --git a/cmd/e2a/sending_policy.go b/cmd/e2a/sending_policy.go index 6638dcdfa..89628fda2 100644 --- a/cmd/e2a/sending_policy.go +++ b/cmd/e2a/sending_policy.go @@ -36,6 +36,8 @@ type sendingProtectionFlags struct { approveExternal bool revokeExternal bool declineExternal bool + listExternal bool + listAll bool accountID string expectedExternal int64 requestID string @@ -63,14 +65,14 @@ type sendingProtectionFlags struct { func (f *sendingProtectionFlags) commandRequested() bool { return f.inspect || f.activate || f.register || f.attest || f.capabilities || f.reconcile || - f.inspectExternal || f.approveExternal || f.revokeExternal || f.declineExternal || + f.inspectExternal || f.approveExternal || f.revokeExternal || f.declineExternal || f.listExternal || f.pauseAccount || f.resumeAccount || f.inspectPause } func (f *sendingProtectionFlags) selectedCount() int { n := 0 for _, set := range []bool{f.inspect, f.activate, f.register, f.attest, f.capabilities, f.reconcile, - f.inspectExternal, f.approveExternal, f.revokeExternal, f.declineExternal, + f.inspectExternal, f.approveExternal, f.revokeExternal, f.declineExternal, f.listExternal, f.pauseAccount, f.resumeAccount, f.inspectPause} { if set { n++ @@ -133,6 +135,8 @@ func runSendingProtectionCommand(ctx context.Context, cfg *config.Config, pool * return runPrintCapabilities(source, secrets, stdout) case f.reconcile: return runReconcileLegacySendingJobs(ctx, pool, sendingpolicy.NewGate(pool, secrets, source, policy), stdout) + case f.listExternal: + return runListExternalRequests(ctx, sendingpolicy.NewPolicyModule(pool, secrets, source, policy), f.listAll, stdout) case f.inspectExternal, f.approveExternal, f.revokeExternal, f.declineExternal: module := sendingpolicy.NewPolicyModule(pool, secrets, source, policy) return runExternalSendingCommand(ctx, module, newDecisionNotifier(cfg, pool, module), f, stdout) @@ -368,6 +372,11 @@ func runExternalSendingCommand(ctx context.Context, module *sendingpolicy.Module // direct grant (for example pre-granting existing accounts before a // rollout) has no request to answer. sendDecisionNotice(ctx, notifier, f.requestID, stdout) + } else if f.approveExternal && res.Record.PendingRequestID != "" { + // A direct grant does not decide the account's open request; left + // alone it stays pending forever and the owner is never told. + fmt.Fprintf(stdout, "warning: request %s is still pending; re-run with -external-sending-request-id %s to decide it and email the account owner\n", + res.Record.PendingRequestID, res.Record.PendingRequestID) } if !res.Record.Approved && res.Record.PaidEntitled && unlockAvailable(res.Record.AvailableUnlocks, sendingpolicy.UnlockPaidEntitlement) { fmt.Fprintf(stdout, "warning: the account still holds the paid-base entitlement, which independently allows external sending; pause the account to stop all sending\n") @@ -509,3 +518,31 @@ func sendDecisionNotice(ctx context.Context, notifier decisionNotifier, requestI } fmt.Fprintf(stdout, "decision_notice: sent to the account owner\n") } + +// runListExternalRequests prints the operator's request queue: pending +// requests by default, every request with -all. Ids, states, times and +// numbers only — never customer free text or an address. +func runListExternalRequests(ctx context.Context, module *sendingpolicy.Module, all bool, stdout io.Writer) error { + reqs, err := module.ListAccessRequests(ctx, all) + if err != nil { + return err + } + scope := "pending" + if all { + scope = "all" + } + fmt.Fprintf(stdout, "requests (%s): %d\n", scope, len(reqs)) + for _, r := range reqs { + fmt.Fprintf(stdout, "\n") + fmt.Fprintf(stdout, "request_id: %s\n", r.ID) + fmt.Fprintf(stdout, "account_id: %s\n", r.AccountID) + fmt.Fprintf(stdout, "state: %s\n", r.State) + fmt.Fprintf(stdout, "created_at: %s\n", r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")) + if r.DecidedAt != nil { + fmt.Fprintf(stdout, "decided_at: %s\n", r.DecidedAt.UTC().Format("2006-01-02T15:04:05Z")) + } + fmt.Fprintf(stdout, "expected_daily_volume: %d\n", r.ExpectedDailyVolume) + fmt.Fprintf(stdout, "external_sending_approved: %v\n", r.Approved) + } + return nil +} diff --git a/cmd/e2a/sending_policy_test.go b/cmd/e2a/sending_policy_test.go index f32ef92bf..33f00378d 100644 --- a/cmd/e2a/sending_policy_test.go +++ b/cmd/e2a/sending_policy_test.go @@ -496,3 +496,71 @@ func TestExternalSendingDecisionNotice(t *testing.T) { t.Fatalf("out=%q", out.String()) } } + +func TestListExternalSendingRequestsAndPendingWarning(t *testing.T) { + ctx := context.Background() + pool := testutil.TestDB(t) + clearEnvForTest(t) + cfg := spTestConfig() + cfg.SendingProtect.ExternalSendingAccess = &config.ExternalSendingAccessConfig{Mode: "enforce", AccountsCreatedAtOrAfter: "1970-01-01T00:00:00Z", + Unlocks: []string{"operator_approval"}} + policy, err := sendingpolicy.FromConfig(cfg) + if err != nil { + t.Fatal(err) + } + module := sendingpolicy.NewPolicyModule(pool, sendingpolicy.Secrets{}, sendingpolicy.PolicySourceConfig, policy) + file := func(user string) string { + t.Helper() + if _, err := pool.Exec(ctx, `INSERT INTO users (id, email, google_subject) VALUES ($1, $1 || '@list-cmd.example.test', 'sub-' || $1)`, user); err != nil { + t.Fatal(err) + } + req, _, err := module.SubmitAccessRequest(ctx, user, sendingpolicy.AccessRequestInput{UseCase: "synthetic secret use case", Recipients: "synthetic", ExpectedDailyVolume: 42}) + if err != nil { + t.Fatal(err) + } + return req.ID + } + reqA := file("usr_list_a") + reqB := file("usr_list_b") + if err := module.DeclineExternalAccessRequest(ctx, "usr_list_b", reqB, "cli:test"); err != nil { + t.Fatal(err) + } + run := func(f *sendingProtectionFlags) string { + t.Helper() + var out bytes.Buffer + if err := runSendingProtectionCommand(ctx, cfg, pool, sendingpolicy.Secrets{}, f, &out); err != nil { + t.Fatalf("command: %v", err) + } + return out.String() + } + + out := run(&sendingProtectionFlags{listExternal: true}) + for _, want := range []string{"requests (pending): 1", "request_id: " + reqA, "account_id: usr_list_a", + "state: pending", "expected_daily_volume: 42", "external_sending_approved: false", "created_at:"} { + if !strings.Contains(out, want) { + t.Fatalf("pending listing missing %q:\n%s", want, out) + } + } + if strings.Contains(out, reqB) { + t.Fatalf("a decided request must not appear without -all:\n%s", out) + } + if strings.Contains(out, "secret use case") || strings.Contains(out, "@") { + t.Fatalf("the listing must not print customer text or addresses:\n%s", out) + } + all := run(&sendingProtectionFlags{listExternal: true, listAll: true}) + if !strings.Contains(all, "requests (all): 2") || !strings.Contains(all, reqB) || !strings.Contains(all, "state: declined") || !strings.Contains(all, "decided_at:") { + t.Fatalf("-all listing:\n%s", all) + } + + // A direct grant while a request is pending warns and does not decide it. + var buf bytes.Buffer + if err := runExternalSendingCommand(ctx, module, &fakeDecisionNotifier{}, &sendingProtectionFlags{approveExternal: true, accountID: "usr_list_a", expectedExternal: 0, reason: "direct"}, &buf); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "warning: request "+reqA+" is still pending; re-run with -external-sending-request-id "+reqA) { + t.Fatalf("missing pending warning:\n%s", buf.String()) + } + if latest, _ := module.LatestAccessRequest(ctx, "usr_list_a"); latest == nil || latest.State != "pending" { + t.Fatalf("a direct grant must not auto-decide the request: %+v", latest) + } +} diff --git a/docs/design/async-message-pipeline.md b/docs/design/async-message-pipeline.md index 5c5300ea7..09b111ffd 100644 --- a/docs/design/async-message-pipeline.md +++ b/docs/design/async-message-pipeline.md @@ -416,7 +416,13 @@ deployment's notification identity (`notifications.from_address` / `reply_to`), authorized through the gate as a `customer_notification` operation keyed by the request (`op_esad_`); operator-authored copy only, never the customer's free text. A failed notice prints a warning and -never fails the command. The operator notification of a NEW request is skipped +never fails the command. `-list-external-sending-requests` (add `-all` for +decided ones) prints the review queue — request id, account id, state, times, +expected volume and the account's current grant, never customer text — and a +direct grant while a request is pending prints a warning to re-run with +`-external-sending-request-id`. Requests from accounts the rule does not +currently restrict are refused with `409 conflict`. The operator notification +of a NEW request is skipped for system/internal (`account_class`-exempt) accounts, which the rule never binds. Operator grants are local server commands (`-approve-external-sending` / `-revoke-external-sending` diff --git a/internal/sendingpolicy/external_access_admin.go b/internal/sendingpolicy/external_access_admin.go index e4a4be13e..dfd1015dc 100644 --- a/internal/sendingpolicy/external_access_admin.go +++ b/internal/sendingpolicy/external_access_admin.go @@ -494,3 +494,50 @@ func (m *Module) LatestAccessRequest(ctx context.Context, userID string) (*Acces } return &r, nil } + +// AccessRequestListing is one row of the operator's request queue. Account +// id, state and numbers only — no customer free text and no address. +type AccessRequestListing struct { + ID string + AccountID string + State string + CreatedAt time.Time + DecidedAt *time.Time + ExpectedDailyVolume int + // Approved is the account's CURRENT shared-identity grant. + Approved bool +} + +// maxAccessRequestListing bounds one listing; the queue is a review +// worklist, not an export. +const maxAccessRequestListing = 500 + +// ListAccessRequests returns pending requests (or, with all, every request) +// oldest first, each with the account's current grant. It is the operator's +// queue: the new-request email is a notification, not the system of record. +func (m *Module) ListAccessRequests(ctx context.Context, all bool) ([]AccessRequestListing, error) { + rows, err := m.pool.Query(ctx, ` + SELECT r.id, r.user_id, r.state, r.created_at, r.decided_at, r.expected_daily_volume, + COALESCE(c.external_sending_approved, false) + FROM external_sending_access_requests AS r + LEFT JOIN account_sending_controls AS c ON c.user_id = r.user_id + WHERE $1 OR r.state = 'pending' + ORDER BY r.created_at, r.id + LIMIT $2`, all, maxAccessRequestListing) + if err != nil { + return nil, fmt.Errorf("sendingpolicy: list sending access requests: %w", err) + } + defer rows.Close() + var out []AccessRequestListing + for rows.Next() { + var l AccessRequestListing + if err := rows.Scan(&l.ID, &l.AccountID, &l.State, &l.CreatedAt, &l.DecidedAt, &l.ExpectedDailyVolume, &l.Approved); err != nil { + return nil, fmt.Errorf("sendingpolicy: scan sending access request: %w", err) + } + out = append(out, l) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("sendingpolicy: list sending access requests: %w", err) + } + return out, nil +} From 945aac760c68a112e1ff1d73125ddd3cd035386d Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:14:57 +0800 Subject: [PATCH 09/13] fix(web): honor available_unlocks in the review composer and 'below' copy The review-queue preflight suppressed the restriction warning for any sending-verified agent domain; it now does so only where verified_domain is an available unlock (hosted approval-only accounts see the warning). The approval-only recovery sentence says 'below' only when the request form is actually rendered beneath it. Also gofmt of a test file. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- .../external_unlocks_integration_test.go | 2 +- .../PendingRow.sendingAccess.test.tsx | 21 +++++++++++++++++++ .../(app)/reviews/_components/PendingRow.tsx | 8 ++++--- .../app/(app)/sending-access/page.test.tsx | 10 ++++++++- web/src/app/(app)/sending-access/page.tsx | 2 +- .../components/SendingAccessNotice.test.tsx | 4 +++- web/src/lib/sendingAccess.test.ts | 12 +++++++++-- web/src/lib/sendingAccess.ts | 11 +++++++--- 8 files changed, 58 insertions(+), 12 deletions(-) diff --git a/internal/sendingpolicy/external_unlocks_integration_test.go b/internal/sendingpolicy/external_unlocks_integration_test.go index bba078ca7..0fce5f77e 100644 --- a/internal/sendingpolicy/external_unlocks_integration_test.go +++ b/internal/sendingpolicy/external_unlocks_integration_test.go @@ -232,7 +232,7 @@ func TestSubmitAccessRequestRefusesUnrestrictedAccounts(t *testing.T) { want error }{ "restricted standard account files": {esaPolicy(sendingpolicy.ModeEnforce), func(*fixture, string) {}, nil}, - "already approved": {esaPolicy(sendingpolicy.ModeEnforce), func(f *fixture, u string) { f.setApproved(u, true) }, sendingpolicy.ErrSendingAccessNotRestricted}, + "already approved": {esaPolicy(sendingpolicy.ModeEnforce), func(f *fixture, u string) { f.setApproved(u, true) }, sendingpolicy.ErrSendingAccessNotRestricted}, "paid entitlement where it unlocks": {esaPolicy(sendingpolicy.ModeEnforce), func(f *fixture, u string) { f.setEntitled(u, true) }, sendingpolicy.ErrSendingAccessNotRestricted}, "paid entitlement under approval-only": {esaUnlockPolicy([]sendingpolicy.ExternalUnlock{sendingpolicy.UnlockOperatorApproval}), func(f *fixture, u string) { f.setEntitled(u, true) }, nil}, diff --git a/web/src/app/(app)/reviews/_components/PendingRow.sendingAccess.test.tsx b/web/src/app/(app)/reviews/_components/PendingRow.sendingAccess.test.tsx index 9e7e310b5..f92e73b77 100644 --- a/web/src/app/(app)/reviews/_components/PendingRow.sendingAccess.test.tsx +++ b/web/src/app/(app)/reviews/_components/PendingRow.sendingAccess.test.tsx @@ -140,6 +140,27 @@ describe("PendingRow — external sending access preflight", () => { expect(screen.queryByText(/External sending is restricted/)).not.toBeInTheDocument(); }); + it("hosted approval-only policy: a verified agent domain does NOT suppress the warning", async () => { + stage({ + account: { + ...restrictedAccount, + sending_access: { ...restrictedAccount.sending_access, available_unlocks: ["operator_approval"] }, + }, + domains: [ + { + domain: "acme.dev", + verified: true, + capabilities: { inbound: "verified", outbound: "verified" }, + }, + ], + }); + render( {}} onResolved={() => {}} />); + + const warning = await screen.findByRole("alert"); + expect(warning).toHaveTextContent("External sending is restricted for this account"); + expect(warning).toHaveTextContent("customer@bigco.example"); + }); + it("does not warn when the account isn't restricted", async () => { stage({ account: notRestrictedAccount }); render( {}} onResolved={() => {}} />); diff --git a/web/src/app/(app)/reviews/_components/PendingRow.tsx b/web/src/app/(app)/reviews/_components/PendingRow.tsx index 2b331b529..748c7923b 100644 --- a/web/src/app/(app)/reviews/_components/PendingRow.tsx +++ b/web/src/app/(app)/reviews/_components/PendingRow.tsx @@ -39,6 +39,7 @@ import { EmailHtmlBody } from "../../../components/messages/EmailHtmlBody"; import { disallowedRecipients, isSendingRestricted, + unlockAvailable, parseExternalSendingNotEnabledError, parseRecipientList, } from "../../../../lib/sendingAccess"; @@ -264,8 +265,9 @@ export function PendingRow({ // Client-side recipient preflight (guidance only — the server decides). // Skipped when the agent's own domain already has a verified custom - // sending identity, since the shared-identity restriction may not apply - // to it. + // sending identity AND the deployment accepts verified_domain as an + // unlock; where it does not (hosted: approval only), a verified domain + // lifts nothing and the warning must still show. const agentDomain = agentEmail.split("@")[1] ?? ""; const domainRecord = accessDomains?.find((d) => d.domain === agentDomain); const domainSendingVerified = domainRecord @@ -274,7 +276,7 @@ export function PendingRow({ const restrictedSend = wantsAccessCheck && isSendingRestricted(accessAccount?.sending_access) && - !domainSendingVerified; + !(domainSendingVerified && unlockAvailable(accessAccount?.sending_access, "verified_domain")); const candidateRecipients = editing ? [...parseRecipientList(to), ...parseRecipientList(cc), ...parseRecipientList(bcc)] : [...(msg?.to ?? []), ...(msg?.cc ?? []), ...(msg?.bcc ?? [])]; diff --git a/web/src/app/(app)/sending-access/page.test.tsx b/web/src/app/(app)/sending-access/page.test.tsx index c76acd57a..ac2dc3333 100644 --- a/web/src/app/(app)/sending-access/page.test.tsx +++ b/web/src/app/(app)/sending-access/page.test.tsx @@ -147,10 +147,18 @@ describe("/sending-access", () => { }); it("shows the under-review state and hides the form for a pending request", async () => { - stage({ account: restrictedAccount, requestGet: requestView("pending") }); + stage({ + account: { + ...restrictedAccount, + sending_access: { ...restrictedAccount.sending_access, available_unlocks: ["operator_approval"] }, + }, + requestGet: requestView("pending"), + }); render(); expect(await screen.findByText("Your request is under review")).toBeInTheDocument(); + // The form is hidden while pending, so the copy must not point "below". + expect(screen.queryByText(/request approval below/)).not.toBeInTheDocument(); // The decision email makes this promise true. expect(screen.getByText("We'll follow up by email once an operator decides.")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Submit request" })).not.toBeInTheDocument(); diff --git a/web/src/app/(app)/sending-access/page.tsx b/web/src/app/(app)/sending-access/page.tsx index a5e7ec07f..dd29af3cf 100644 --- a/web/src/app/(app)/sending-access/page.tsx +++ b/web/src/app/(app)/sending-access/page.tsx @@ -174,7 +174,7 @@ export default function SendingAccessPage() {
) : restricted ? ( (() => { - const copy = sendingAccessNoticeCopy(status, { billingEnabled }); + const copy = sendingAccessNoticeCopy(status, { billingEnabled, formBelow: showForm }); const offered = offeredUnlocks(status, { billingEnabled }); return (
diff --git a/web/src/app/components/SendingAccessNotice.test.tsx b/web/src/app/components/SendingAccessNotice.test.tsx index 92caf1e66..85e3a9590 100644 --- a/web/src/app/components/SendingAccessNotice.test.tsx +++ b/web/src/app/components/SendingAccessNotice.test.tsx @@ -81,7 +81,9 @@ describe("SendingAccessNotice", () => { render(); const links = screen.getAllByRole("link").map((l) => l.textContent); expect(links).toEqual(["Request approval"]); - expect(screen.getByText(/To email other recipients, request approval below\./)).toBeInTheDocument(); + // The notice links to the form; it is not "below" here. + expect(screen.getByText(/To email other recipients, request approval\.$/)).toBeInTheDocument(); + expect(screen.queryByText(/below/)).not.toBeInTheDocument(); }); it("verified_domain listed: offers Verify a domain after Request approval", () => { diff --git a/web/src/lib/sendingAccess.test.ts b/web/src/lib/sendingAccess.test.ts index b3aff968f..11e4d49ce 100644 --- a/web/src/lib/sendingAccess.test.ts +++ b/web/src/lib/sendingAccess.test.ts @@ -99,10 +99,10 @@ describe("sendingAccessNoticeCopy", () => { }); it.each([ - [["operator_approval"], true, "To email other recipients, request approval below."], + [["operator_approval"], true, "To email other recipients, request approval."], [["operator_approval", "verified_domain"], true, "To email other recipients, verify your own domain or request approval."], [["operator_approval", "paid_entitlement"], true, "To email other recipients, request approval or activate a paid base plan."], - [["operator_approval", "paid_entitlement"], false, "To email other recipients, request approval below."], + [["operator_approval", "paid_entitlement"], false, "To email other recipients, request approval."], ])("unlocks %j (billing %s) → %s", (unlocks, billingEnabled, sentence) => { const copy = sendingAccessNoticeCopy({ ...base, available_unlocks: unlocks }, { billingEnabled }); expect(copy.body.endsWith(sentence)).toBe(true); @@ -110,6 +110,14 @@ describe("sendingAccessNoticeCopy", () => { }); }); +it("says 'below' only when the request form renders under the copy", () => { + const approvalOnly = { ...base, available_unlocks: ["operator_approval"] }; + expect(sendingAccessNoticeCopy(approvalOnly, { billingEnabled: false, formBelow: true }).body).toMatch( + /request approval below\.$/, + ); + expect(sendingAccessNoticeCopy(approvalOnly, { billingEnabled: false }).body).toMatch(/request approval\.$/); +}); + describe("unlock-aware grants", () => { it("a paid entitlement is not a grant where paid_entitlement is not an unlock", () => { const hosted = { ...base, paid_external_sending_entitled: true, available_unlocks: ["operator_approval"] }; diff --git a/web/src/lib/sendingAccess.ts b/web/src/lib/sendingAccess.ts index d23193989..ce55a16d5 100644 --- a/web/src/lib/sendingAccess.ts +++ b/web/src/lib/sendingAccess.ts @@ -108,12 +108,12 @@ export type SendingAccessNoticeCopy = { headline: string; body: string }; /** Disclosure copy for the restriction banner (dashboard + onboarding + * /sending-access). Callers must already have confirmed * `isSendingRestricted(status)` — this always returns the "restricted" - * copy. The headline says nothing about inboxes (an account may have none + * copy. Pass `formBelow` only when the request form renders beneath it. The headline says nothing about inboxes (an account may have none * yet); the recovery sentence lists only the routes this deployment * honors. */ export function sendingAccessNoticeCopy( status: SendingAccessStatus, - opts: { billingEnabled: boolean }, + opts: { billingEnabled: boolean; formBelow?: boolean }, ): SendingAccessNoticeCopy { const headline = "External sending is restricted for this account."; const offered = offeredUnlocks(status, opts); @@ -123,7 +123,12 @@ export function sendingAccessNoticeCopy( if (offered.paid) routes.push("activate a paid base plan"); let recovery: string; if (routes.length === 1) { - recovery = "To email other recipients, request approval below."; + // "below" only where the request form is actually rendered under the + // copy (the /sending-access page with no pending request); the inbox + // notice links to it instead. + recovery = opts.formBelow + ? "To email other recipients, request approval below." + : "To email other recipients, request approval."; } else if (routes.length === 2) { recovery = `To email other recipients, ${routes[0]} or ${routes[1]}.`; } else { From cf36e20055ef450141ba118cf7e4629f31baf624 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:37:35 +0800 Subject: [PATCH 10/13] fix(web): judge the review-composer warning only after domains load restrictedSend waits for the domains read to settle (or fail), so the hosted approval-only case is actually tested (it fails with the unlockAvailable term reverted) and self-host no longer flashes a false warning before a verified domain is known. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- .../PendingRow.sendingAccess.test.tsx | 33 ++++++++++++++++--- .../(app)/reviews/_components/PendingRow.tsx | 8 ++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/web/src/app/(app)/reviews/_components/PendingRow.sendingAccess.test.tsx b/web/src/app/(app)/reviews/_components/PendingRow.sendingAccess.test.tsx index f92e73b77..6ec40b622 100644 --- a/web/src/app/(app)/reviews/_components/PendingRow.sendingAccess.test.tsx +++ b/web/src/app/(app)/reviews/_components/PendingRow.sendingAccess.test.tsx @@ -3,7 +3,7 @@ // file's fixtures untouched; this file adds the extra GET /v1/agents, // /v1/domains, and /v1/account fetches the preflight needs. -import { render, screen, within } from "../../../../test-utils/swr"; +import { render, screen, waitFor, within } from "../../../../test-utils/swr"; import userEvent from "@testing-library/user-event"; import { PendingRow } from "./PendingRow"; import type { PendingMessageSummary } from "../../../components/types"; @@ -156,9 +156,34 @@ describe("PendingRow — external sending access preflight", () => { }); render( {}} onResolved={() => {}} />); - const warning = await screen.findByRole("alert"); - expect(warning).toHaveTextContent("External sending is restricted for this account"); - expect(warning).toHaveTextContent("customer@bigco.example"); + // Wait until the domains read has settled, so the verified domain is + // actually known when the warning is judged. + await waitFor(() => expect(mockFetch).toHaveBeenCalledWith("/v1/domains", expect.anything())); + await screen.findByText("Hello, your refund is on the way."); + await waitFor(() => { + const warning = screen.getByRole("alert"); + expect(warning).toHaveTextContent("External sending is restricted for this account"); + expect(warning).toHaveTextContent("customer@bigco.example"); + }); + }); + + it("default unlocks: a verified agent domain suppresses the warning after domains settle", async () => { + stage({ + domains: [ + { + domain: "acme.dev", + verified: true, + capabilities: { inbound: "verified", outbound: "verified" }, + }, + ], + }); + render( {}} onResolved={() => {}} />); + + await waitFor(() => expect(mockFetch).toHaveBeenCalledWith("/v1/domains", expect.anything())); + await screen.findByText("Hello, your refund is on the way."); + // Let every pending fetch resolve, then assert no warning ever renders. + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByText(/External sending is restricted/)).not.toBeInTheDocument(); }); it("does not warn when the account isn't restricted", async () => { diff --git a/web/src/app/(app)/reviews/_components/PendingRow.tsx b/web/src/app/(app)/reviews/_components/PendingRow.tsx index 748c7923b..8f712cfd7 100644 --- a/web/src/app/(app)/reviews/_components/PendingRow.tsx +++ b/web/src/app/(app)/reviews/_components/PendingRow.tsx @@ -146,7 +146,7 @@ export function PendingRow({ wantsAccessCheck ? agentsKey : null, () => listAgents(), ); - const { data: accessDomains } = useSWR( + const { data: accessDomains, error: accessDomainsError } = useSWR( wantsAccessCheck ? domainsKey : null, () => listDomains().catch(() => [] as DomainInfo[]), ); @@ -273,8 +273,14 @@ export function PendingRow({ const domainSendingVerified = domainRecord ? outboundCapability(domainRecord) === "verified" : false; + // Decide only once the domains read has settled: before it lands we cannot + // tell whether a verified domain lifts the restriction, and warning early + // would flash a false warning on self-host deployments with one. A failed + // domains read falls back to "no verified domain" (warn — guidance only). + const domainsSettled = accessDomains !== undefined || accessDomainsError !== undefined; const restrictedSend = wantsAccessCheck && + domainsSettled && isSendingRestricted(accessAccount?.sending_access) && !(domainSendingVerified && unlockAvailable(accessAccount?.sending_access, "verified_domain")); const candidateRecipients = editing From 1753e9ee6a90bf8db6e9e51e0ff81303c84cedbd Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:37:35 +0800 Subject: [PATCH 11/13] fix(config): strict sending_protection decode resolves anchors One strict decode of the whole document with only sending_protection typed and every other top-level key in an inline map, so anchors, aliases and merge keys defined elsewhere resolve; the null-unlocks check follows aliases and merge keys. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- internal/config/config.go | 70 ++++++++++++------- .../config/sending_protection_strict_test.go | 26 +++++++ 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 1cea77ea9..b197cfdea 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "io" "log" "net/mail" "net/netip" @@ -1306,6 +1307,20 @@ func absoluteHTTPURL(raw string) (*url.URL, error) { // three" and bypass the empty-list rejection. Presence is therefore detected // on the YAML node itself. func checkSendingProtectionStrict(data []byte) error { + // One strict decode of the WHOLE document, so anchors, aliases and merge + // keys defined anywhere resolve exactly as in the lenient decode. Only + // sending_protection is typed; every other top-level key lands in the + // inline map, which keeps the rest of the file lenient. + var strict struct { + SP SendingProtectionConfig `yaml:"sending_protection"` + Rest map[string]yaml.Node `yaml:",inline"` + } + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&strict); err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("sending_protection: %w", err) + } + var root yaml.Node if err := yaml.Unmarshal(data, &root); err != nil { return err @@ -1313,45 +1328,50 @@ func checkSendingProtectionStrict(data []byte) error { if len(root.Content) == 0 { return nil } - sp := mappingValue(root.Content[0], "sending_protection") - if sp == nil || sp.ShortTag() == "!!null" { + esa := mappingValue(mappingValue(root.Content[0], "sending_protection"), "external_sending_access") + if esa == nil { return nil } - raw, err := yaml.Marshal(sp) - if err != nil { - return fmt.Errorf("sending_protection: %w", err) - } - dec := yaml.NewDecoder(bytes.NewReader(raw)) - dec.KnownFields(true) - var strict SendingProtectionConfig - if err := dec.Decode(&strict); err != nil { - return fmt.Errorf("sending_protection: %w", err) - } - esa := mappingValue(sp, "external_sending_access") - if esa == nil || esa.Kind != yaml.MappingNode { - return nil - } - for i := 0; i+1 < len(esa.Content); i += 2 { - if esa.Content[i].Value != "unlocks" { - continue - } - v := esa.Content[i+1] - if v.ShortTag() == "!!null" { - return errors.New("sending_protection.external_sending_access.unlocks is null or blank; omit the key to allow every unlock, or list the unlocks (it must contain operator_approval)") - } + if v := mappingValue(esa, "unlocks"); v != nil && v.ShortTag() == "!!null" { + return errors.New("sending_protection.external_sending_access.unlocks is null or blank; omit the key to allow every unlock, or list the unlocks (it must contain operator_approval)") } return nil } // mappingValue returns the value node for key in a mapping node, or nil. +// Aliases are followed, and `<<` merge keys are searched after the mapping's +// own keys (an explicit key wins, as in YAML merge semantics). func mappingValue(m *yaml.Node, key string) *yaml.Node { + m = resolveAlias(m) if m == nil || m.Kind != yaml.MappingNode { return nil } for i := 0; i+1 < len(m.Content); i += 2 { if m.Content[i].Value == key { - return m.Content[i+1] + return resolveAlias(m.Content[i+1]) + } + } + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value != "<<" { + continue + } + merged := resolveAlias(m.Content[i+1]) + sources := []*yaml.Node{merged} + if merged != nil && merged.Kind == yaml.SequenceNode { + sources = merged.Content + } + for _, src := range sources { + if v := mappingValue(src, key); v != nil { + return v + } } } return nil } + +func resolveAlias(n *yaml.Node) *yaml.Node { + for depth := 0; n != nil && n.Kind == yaml.AliasNode && depth < 16; depth++ { + n = n.Alias + } + return n +} diff --git a/internal/config/sending_protection_strict_test.go b/internal/config/sending_protection_strict_test.go index 491e4515e..069515a5e 100644 --- a/internal/config/sending_protection_strict_test.go +++ b/internal/config/sending_protection_strict_test.go @@ -83,3 +83,29 @@ func TestExampleConfigStillLoads(t *testing.T) { t.Fatalf("config.example.yaml: %v", err) } } + +// Anchors, aliases and merge keys defined outside the block resolve in the +// strict pass exactly as in the lenient one — and strictness and the null +// check still apply through them. +func TestSendingProtectionStrictResolvesAnchors(t *testing.T) { + const anchors = "x-unlocks: &unl [operator_approval]\n" + + "x-esa: &esa\n mode: enforce\n accounts_created_at_or_after: \"1970-01-01T00:00:00Z\"\n" + cfg, err := loadYAML(t, anchors+"sending_protection:\n external_sending_access:\n <<: *esa\n unlocks: *unl\n") + if err != nil { + t.Fatalf("anchors/aliases/merge keys must load: %v", err) + } + esa := cfg.SendingProtect.ExternalSendingAccess + if esa == nil || esa.Mode != "enforce" || len(esa.Unlocks) != 1 || esa.Unlocks[0] != "operator_approval" { + t.Fatalf("aliased block decoded wrong: %+v", esa) + } + whole := "x-sp: &sp\n external_sending_access:\n mode: enforce\n accounts_created_at_or_after: \"1970-01-01T00:00:00Z\"\n unlocks: [operator_approval]\nsending_protection: *sp\n" + if _, err := loadYAML(t, whole); err != nil { + t.Fatalf("an aliased sending_protection block must load: %v", err) + } + if _, err := loadYAML(t, "x-esa: &esa\n mode: enforce\n accounts_created_at_or_after: \"1970-01-01T00:00:00Z\"\n unlock: [operator_approval]\nsending_protection:\n external_sending_access: *esa\n"); err == nil { + t.Fatal("a misspelled key reached through an alias must still fail") + } + if _, err := loadYAML(t, "x-null: &n null\n"+esaHead+" unlocks: *n\n"); err == nil { + t.Fatal("a null unlocks reached through an alias must still fail") + } +} From 72d24cd44034c61a048cc77f12c854ea7d911c93 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:37:35 +0800 Subject: [PATCH 12/13] fix(sending): request listing order/truncation, -all guard, bidi, SDK docs -list-external-sending-requests -all lists newest first and prints a truncated line at the 500-row bound; -all without the list command is rejected before the server starts. Request text also rejects bidi embedding/override/isolate controls. The SDK requestSendingAccess docs name the 409 conflict for unrestricted accounts. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- cmd/e2a/main.go | 3 ++ cmd/e2a/sending_policy.go | 22 +++++++-- cmd/e2a/sending_policy_test.go | 47 ++++++++++++++++++- docs/api.md | 5 +- .../sendingpolicy/external_access_admin.go | 40 +++++++++++----- .../external_unlocks_integration_test.go | 9 ++++ sdks/python/src/e2a/v1/client.py | 6 ++- sdks/typescript/src/v1/client.ts | 7 ++- 8 files changed, 118 insertions(+), 21 deletions(-) diff --git a/cmd/e2a/main.go b/cmd/e2a/main.go index 674a742b8..e762b3cf4 100644 --- a/cmd/e2a/main.go +++ b/cmd/e2a/main.go @@ -149,6 +149,9 @@ func main() { flag.BoolVar(&acctFlags.escalateAbuse, "escalate-deleted-account-to-abuse", false, "after purge: write abuse-class tombstones for every identifier digest in a purged account's summary and extend the summary to the abuse hold (requires -deleted-account-id, -reason), then exit") flag.IntVar(&acctFlags.holdDays, "tombstone-hold-days", 0, "hold length in days for -extend-identity-tombstones") flag.Parse() + if err := spFlags.validateStandalone(); err != nil { + log.Fatalf("%v", err) + } cfg, err := config.Load(*configPath) if err != nil { diff --git a/cmd/e2a/sending_policy.go b/cmd/e2a/sending_policy.go index 89628fda2..a97d75004 100644 --- a/cmd/e2a/sending_policy.go +++ b/cmd/e2a/sending_policy.go @@ -69,6 +69,16 @@ func (f *sendingProtectionFlags) commandRequested() bool { f.pauseAccount || f.resumeAccount || f.inspectPause } +// validateStandalone rejects modifier flags given without the command they +// modify, before anything starts: `-all` alone would otherwise be ignored +// and the server would boot as if nothing had been asked. +func (f *sendingProtectionFlags) validateStandalone() error { + if f.listAll && !f.listExternal { + return errors.New("-all is only valid with -list-external-sending-requests") + } + return nil +} + func (f *sendingProtectionFlags) selectedCount() int { n := 0 for _, set := range []bool{f.inspect, f.activate, f.register, f.attest, f.capabilities, f.reconcile, @@ -111,6 +121,9 @@ func runSendingProtectionCommand(ctx context.Context, cfg *config.Config, pool * if f.selectedCount() != 1 { return errors.New("exactly one sending-protection command may be given per invocation") } + if err := f.validateStandalone(); err != nil { + return err + } source, err := sendingpolicy.SourceFromConfig(cfg) if err != nil { @@ -523,15 +536,18 @@ func sendDecisionNotice(ctx context.Context, notifier decisionNotifier, requestI // requests by default, every request with -all. Ids, states, times and // numbers only — never customer free text or an address. func runListExternalRequests(ctx context.Context, module *sendingpolicy.Module, all bool, stdout io.Writer) error { - reqs, err := module.ListAccessRequests(ctx, all) + reqs, truncated, err := module.ListAccessRequests(ctx, all) if err != nil { return err } - scope := "pending" + scope := "pending, oldest first" if all { - scope = "all" + scope = "all, newest first" } fmt.Fprintf(stdout, "requests (%s): %d\n", scope, len(reqs)) + if truncated { + fmt.Fprintf(stdout, "truncated: listing stopped at %d requests\n", sendingpolicy.MaxAccessRequestListing) + } for _, r := range reqs { fmt.Fprintf(stdout, "\n") fmt.Fprintf(stdout, "request_id: %s\n", r.ID) diff --git a/cmd/e2a/sending_policy_test.go b/cmd/e2a/sending_policy_test.go index 33f00378d..60cb221b4 100644 --- a/cmd/e2a/sending_policy_test.go +++ b/cmd/e2a/sending_policy_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "os" "strings" "testing" @@ -535,7 +536,7 @@ func TestListExternalSendingRequestsAndPendingWarning(t *testing.T) { } out := run(&sendingProtectionFlags{listExternal: true}) - for _, want := range []string{"requests (pending): 1", "request_id: " + reqA, "account_id: usr_list_a", + for _, want := range []string{"requests (pending, oldest first): 1", "request_id: " + reqA, "account_id: usr_list_a", "state: pending", "expected_daily_volume: 42", "external_sending_approved: false", "created_at:"} { if !strings.Contains(out, want) { t.Fatalf("pending listing missing %q:\n%s", want, out) @@ -548,7 +549,7 @@ func TestListExternalSendingRequestsAndPendingWarning(t *testing.T) { t.Fatalf("the listing must not print customer text or addresses:\n%s", out) } all := run(&sendingProtectionFlags{listExternal: true, listAll: true}) - if !strings.Contains(all, "requests (all): 2") || !strings.Contains(all, reqB) || !strings.Contains(all, "state: declined") || !strings.Contains(all, "decided_at:") { + if !strings.Contains(all, "requests (all, newest first): 2") || strings.Index(all, reqB) > strings.Index(all, reqA) || strings.Contains(all, "truncated:") || !strings.Contains(all, "state: declined") || !strings.Contains(all, "decided_at:") { t.Fatalf("-all listing:\n%s", all) } @@ -564,3 +565,45 @@ func TestListExternalSendingRequestsAndPendingWarning(t *testing.T) { t.Fatalf("a direct grant must not auto-decide the request: %+v", latest) } } + +func TestAllFlagRequiresListCommand(t *testing.T) { + f := &sendingProtectionFlags{listAll: true} + if err := f.validateStandalone(); err == nil { + t.Fatal("-all without -list-external-sending-requests must be rejected before the server starts") + } + if err := (&sendingProtectionFlags{listAll: true, listExternal: true}).validateStandalone(); err != nil { + t.Fatalf("-all with the list command is valid: %v", err) + } + if err := (&sendingProtectionFlags{}).validateStandalone(); err != nil { + t.Fatalf("no flags is valid: %v", err) + } +} + +func TestListExternalSendingRequestsReportsTruncation(t *testing.T) { + ctx := context.Background() + pool := testutil.TestDB(t) + module := sendingpolicy.NewPolicyModule(pool, sendingpolicy.Secrets{}, sendingpolicy.PolicySourceConfig, sendingpolicy.DisabledPolicy()) + if _, err := pool.Exec(ctx, ` + INSERT INTO users (id, email, google_subject) + SELECT 'usr_trunc_' || g, 'usr_trunc_' || g || '@trunc.example.test', 'sub_trunc_' || g FROM generate_series(1, $1) AS g`, + sendingpolicy.MaxAccessRequestListing+1); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO external_sending_access_requests (id, user_id, use_case, recipients, expected_daily_volume, created_at) + SELECT 'esar_trunc_' || g, 'usr_trunc_' || g, 'synthetic', 'synthetic', 1, now() - make_interval(secs => g) + FROM generate_series(1, $1) AS g`, sendingpolicy.MaxAccessRequestListing+1); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if err := runListExternalRequests(ctx, module, true, &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), fmt.Sprintf("truncated: listing stopped at %d requests", sendingpolicy.MaxAccessRequestListing)) { + t.Fatal("a listing that hit the bound must say so") + } + // Newest first: the most recent request (g=1) is shown, the oldest dropped. + if !strings.Contains(out.String(), "esar_trunc_1\n") || strings.Contains(out.String(), fmt.Sprintf("esar_trunc_%d\n", sendingpolicy.MaxAccessRequestListing+1)) { + t.Fatal("-all must keep the newest requests when truncating") + } +} diff --git a/docs/api.md b/docs/api.md index d50860dbd..08cd07175 100644 --- a/docs/api.md +++ b/docs/api.md @@ -629,8 +629,9 @@ request (`200`); after a decline a new request may be filed, up to 3 per 30 days (`429 rate_limited` beyond that — do not retry). An account that is not currently restricted (already approved, outside the cohort, shadow mode, or lifted by an available unlock) gets `409 conflict` and nothing is filed. -Request text may contain line breaks and tabs but no other control characters -or Unicode line separators (`400 invalid_request`). The decision is emailed +Request text may contain line breaks and tabs but no other control characters, +Unicode line separators, or bidi override/isolate controls +(`400 invalid_request`). The decision is emailed to the account owner; `GET /v1/account/sending-access/request` shows its `state` (`pending`, `approved`, `declined`; open set). The self-host default accepts all three unlocks; the hosted e2a service accepts only diff --git a/internal/sendingpolicy/external_access_admin.go b/internal/sendingpolicy/external_access_admin.go index dfd1015dc..39dd4e2f1 100644 --- a/internal/sendingpolicy/external_access_admin.go +++ b/internal/sendingpolicy/external_access_admin.go @@ -334,7 +334,7 @@ func normalizeRequestText(s string) string { } // hasForbiddenRequestRune reports a control character other than LF and TAB, -// or a Unicode line/paragraph separator. Either could break out of the +// a Unicode line/paragraph separator, or a bidi override/isolate control. Either could break out of the // "> " fence that marks customer text as untrusted in the operator email. func hasForbiddenRequestRune(s string) bool { for _, r := range s { @@ -345,6 +345,10 @@ func hasForbiddenRequestRune(s string) bool { return true case r == '\u2028' || r == '\u2029': return true + case (r >= '\u202a' && r <= '\u202e') || (r >= '\u2066' && r <= '\u2069'): + // Bidi embedding/override/isolate controls can visually reorder + // the operator email so customer text reads as operator text. + return true } } return false @@ -512,32 +516,46 @@ type AccessRequestListing struct { // worklist, not an export. const maxAccessRequestListing = 500 -// ListAccessRequests returns pending requests (or, with all, every request) -// oldest first, each with the account's current grant. It is the operator's -// queue: the new-request email is a notification, not the system of record. -func (m *Module) ListAccessRequests(ctx context.Context, all bool) ([]AccessRequestListing, error) { +// MaxAccessRequestListing exposes the listing bound for callers that report +// truncation. +const MaxAccessRequestListing = maxAccessRequestListing + +// ListAccessRequests returns the operator's queue, each row with the +// account's current grant: pending requests oldest first (review order), or +// with all every request NEWEST first, so the bound drops the oldest history +// rather than the latest activity. truncated reports that more rows exist +// than the bound returned. The new-request email is a notification, not the +// system of record. +func (m *Module) ListAccessRequests(ctx context.Context, all bool) (reqs []AccessRequestListing, truncated bool, err error) { + order := "r.created_at, r.id" + if all { + order = "r.created_at DESC, r.id DESC" + } rows, err := m.pool.Query(ctx, ` SELECT r.id, r.user_id, r.state, r.created_at, r.decided_at, r.expected_daily_volume, COALESCE(c.external_sending_approved, false) FROM external_sending_access_requests AS r LEFT JOIN account_sending_controls AS c ON c.user_id = r.user_id WHERE $1 OR r.state = 'pending' - ORDER BY r.created_at, r.id - LIMIT $2`, all, maxAccessRequestListing) + ORDER BY `+order+` + LIMIT $2`, all, maxAccessRequestListing+1) if err != nil { - return nil, fmt.Errorf("sendingpolicy: list sending access requests: %w", err) + return nil, false, fmt.Errorf("sendingpolicy: list sending access requests: %w", err) } defer rows.Close() var out []AccessRequestListing for rows.Next() { var l AccessRequestListing if err := rows.Scan(&l.ID, &l.AccountID, &l.State, &l.CreatedAt, &l.DecidedAt, &l.ExpectedDailyVolume, &l.Approved); err != nil { - return nil, fmt.Errorf("sendingpolicy: scan sending access request: %w", err) + return nil, false, fmt.Errorf("sendingpolicy: scan sending access request: %w", err) } out = append(out, l) } if err := rows.Err(); err != nil { - return nil, fmt.Errorf("sendingpolicy: list sending access requests: %w", err) + return nil, false, fmt.Errorf("sendingpolicy: list sending access requests: %w", err) + } + if len(out) > maxAccessRequestListing { + return out[:maxAccessRequestListing], true, nil } - return out, nil + return out, false, nil } diff --git a/internal/sendingpolicy/external_unlocks_integration_test.go b/internal/sendingpolicy/external_unlocks_integration_test.go index 0fce5f77e..35fd3742c 100644 --- a/internal/sendingpolicy/external_unlocks_integration_test.go +++ b/internal/sendingpolicy/external_unlocks_integration_test.go @@ -286,6 +286,15 @@ func TestSubmitAccessRequestRejectsFenceBreakingCharacters(t *testing.T) { "NUL": "line one\x00", "ESC": "line one\x1b[31m", "DEL": "line one\x7f", + "LRE": "line one\u202a", + "RLE": "line one\u202b", + "PDF": "line one\u202c", + "LRO": "line one\u202d", + "RLO": "line one\u202eesrever", + "LRI": "line one\u2066", + "RLI": "line one\u2067", + "FSI": "line one\u2068", + "PDI": "line one\u2069", } { for _, field := range []string{"use_case", "recipients"} { in := sendingpolicy.AccessRequestInput{UseCase: "ok", Recipients: "ok", ExpectedDailyVolume: 1} diff --git a/sdks/python/src/e2a/v1/client.py b/sdks/python/src/e2a/v1/client.py index 380ab60aa..a92f42af5 100644 --- a/sdks/python/src/e2a/v1/client.py +++ b/sdks/python/src/e2a/v1/client.py @@ -1486,7 +1486,11 @@ async def request_sending_access( Idempotent while a request is pending — calling this again returns the existing pending request instead of creating another. After a decline, a new request may be filed as an appeal (``rate_limited`` beyond 3 per - 30 days). Filing a request never grants access by itself. + 30 days). Raises :class:`~e2a.v1.errors.E2AError` with code + ``conflict`` (409) when the account is not currently restricted + (already approved, outside the rollout, or lifted by an available + unlock) — nothing is filed; do not retry. Filing a request never + grants access by itself. Account-scoped credentials only. A first filing answers 201 and a resubmit-while-pending answers 200 diff --git a/sdks/typescript/src/v1/client.ts b/sdks/typescript/src/v1/client.ts index 41e1bc4ae..9eb4e71a3 100644 --- a/sdks/typescript/src/v1/client.ts +++ b/sdks/typescript/src/v1/client.ts @@ -974,8 +974,11 @@ class AccountResource { * File ONE request; the decision is emailed to the account owner. Idempotent * while a request is pending: submitting again returns the SAME pending * request instead of creating a second one. Capped at 3 requests per 30 - * days (`E2ARateLimitError` beyond that). Filing never grants access by - * itself. Account-scoped credentials only. + * days (`E2ARateLimitError` beyond that). Throws `E2AError` with code + * `conflict` (409) when the account is not currently restricted (already + * approved, outside the rollout, or lifted by an available unlock) — + * nothing is filed; do not retry. Filing never grants access by itself. + * Account-scoped credentials only. */ requestSendingAccess(body: SendingAccessRequestInput): Promise { return call(() => this.api.createSendingAccessRequest(body)); From 6241e8d2f69a9ac17fb15e31ac02935b5e5cc6c8 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:37:35 +0800 Subject: [PATCH 13/13] test(contract): SDK request lifecycle on a dedicated restricted account The contract server seeds a second in-cohort account (E2A_TEST_RESTRICTED_SDK_API_KEY) used by no shared scenario. The TS and Python SDK contract suites run the full request lifecycle on it and assert that the unrestricted primary account gets 409 conflict mapped to E2AConflictError, with nothing filed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- cmd/e2a-contract-server/main.go | 6 +- internal/testutil/contract_server.go | 42 ++++++- sdks/python/tests/test_contract.py | 35 ++++-- .../test/v1/contract-client.test.ts | 112 +++++++++--------- 4 files changed, 130 insertions(+), 65 deletions(-) diff --git a/cmd/e2a-contract-server/main.go b/cmd/e2a-contract-server/main.go index 3c9adebf5..575146a71 100644 --- a/cmd/e2a-contract-server/main.go +++ b/cmd/e2a-contract-server/main.go @@ -36,14 +36,16 @@ func main() { // file with `set -a`, so the runners pick both up with no workflow change. // E2A_TEST_RESTRICTED_API_KEY authenticates the only account inside the // external-sending-access cohort (see testutil.ContractExternalAccessCutoff). + // E2A_TEST_RESTRICTED_SDK_API_KEY authenticates a second in-cohort account + // 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_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\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\n", srv.BaseURL, srv.APIKey, srv.CappedAPIKey, srv.OverCapAPIKey, srv.RestrictedAPIKey, - srv.DisposableTrashAPIKey, srv.DisposableEraseAPIKey, srv.ReadOnlyAPIKey, + srv.DisposableTrashAPIKey, srv.DisposableEraseAPIKey, srv.ReadOnlyAPIKey, srv.RestrictedSDKAPIKey, ) 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 7c277a76f..4aa17dfa3 100644 --- a/internal/testutil/contract_server.go +++ b/internal/testutil/contract_server.go @@ -89,6 +89,11 @@ type ContractServer struct { // owner-mailbox proof for its sign-in address. RestrictedAPIKey string RestrictedUserID string + // RestrictedSDKAPIKey authenticates a second in-cohort (restricted) + // account reserved for the SDK contract suites' sending-access request + // lifecycle, so they never race the raw-HTTP scenario that must be the + // first filer on the scenario restricted account. No scenario uses it. + RestrictedSDKAPIKey string // DisposableTrashAPIKey and DisposableEraseAPIKey authenticate two // throwaway accounts that exist only to be deleted: the account-deletion // scenarios trash one (DELETE /v1/account) and permanently erase the @@ -388,6 +393,16 @@ func StartContractServer(ctx context.Context, dbURL string) (*ContractServer, er return nil, err } + restrictedSDKKey, err := seedRestrictedSDKAccount(ctx, pool, store) + if err != nil { + _ = smtpServer.Close() + _ = httpServer.Shutdown(context.Background()) + _ = httpLn.Close() + wsHub.Close() + pool.Close() + return nil, err + } + readOnlyUser, readOnlyKey, err := seedReadOnlyAccount(ctx, pool, store) if err != nil { _ = smtpServer.Close() @@ -425,6 +440,7 @@ func StartContractServer(ctx context.Context, dbURL string) (*ContractServer, er ReadOnlyUserID: readOnlyUser, RestrictedAPIKey: restrictedKey, RestrictedUserID: restrictedUser, + RestrictedSDKAPIKey: restrictedSDKKey, BaseURL: "http://" + httpLn.Addr().String(), APIKey: key.PlaintextKey, UserID: user.ID, @@ -480,6 +496,28 @@ func seedRestrictedAccount(ctx context.Context, pool *pgxpool.Pool, store *ident if err != nil { return "", "", err } + return restrictAccount(ctx, pool, store, user, []string{ContractRestrictedAgent, ContractRestrictedPeer}, "contract-restricted-key") +} + +// ContractRestrictedSDKOwner is the synthetic owner of the SDK-only +// restricted account. +const ContractRestrictedSDKOwner = "restricted-sdk-owner@example.test" + +// seedRestrictedSDKAccount seeds the second restricted account used only by +// the SDK contract suites' request lifecycle. +func seedRestrictedSDKAccount(ctx context.Context, pool *pgxpool.Pool, store *identity.Store) (string, error) { + user, err := store.CreateOrGetUser(ctx, ContractRestrictedSDKOwner, "Contract Restricted SDK", "google-contract-restricted-sdk") + if err != nil { + return "", err + } + _, key, err := restrictAccount(ctx, pool, store, user, nil, "contract-restricted-sdk-key") + return key, err +} + +// restrictAccount dates the account after the contract cohort cutoff (so the +// rule binds it), gives it owner-mailbox proof, creates its agents, and mints +// a key. +func restrictAccount(ctx context.Context, pool *pgxpool.Pool, store *identity.Store, user *identity.User, agents []string, keyName string) (string, string, error) { if _, err := pool.Exec(ctx, ` UPDATE users SET created_at = '3000-01-01T00:00:00Z', @@ -489,12 +527,12 @@ func seedRestrictedAccount(ctx context.Context, pool *pgxpool.Pool, store *ident WHERE id = $1`, user.ID); err != nil { return "", "", err } - for _, addr := range []string{ContractRestrictedAgent, ContractRestrictedPeer} { + for _, addr := range agents { if _, err := store.CreateAgentWithLimit(ctx, addr, "agents.localhost", "Restricted Bot", user.ID, 0); err != nil { return "", "", err } } - key, err := store.CreateAPIKey(ctx, user.ID, "contract-restricted-key", nil) + key, err := store.CreateAPIKey(ctx, user.ID, keyName, nil) if err != nil { return "", "", err } diff --git a/sdks/python/tests/test_contract.py b/sdks/python/tests/test_contract.py index 5af29e716..b237fdff8 100644 --- a/sdks/python/tests/test_contract.py +++ b/sdks/python/tests/test_contract.py @@ -46,7 +46,7 @@ import pytest import yaml -from e2a.v1 import E2AClient, E2ANotFoundError +from e2a.v1 import E2AClient, E2AConflictError, E2ANotFoundError from e2a.v1.generated.models import PageMessageLifecycleTransition # NOTE: the runner drives the server over raw HTTP (a thin scenario interpreter, @@ -61,6 +61,9 @@ CAPPED_API_KEY = os.environ.get("E2A_TEST_CAPPED_API_KEY", "") OVERCAP_API_KEY = os.environ.get("E2A_TEST_OVERCAP_API_KEY", "") RESTRICTED_API_KEY = os.environ.get("E2A_TEST_RESTRICTED_API_KEY", "") +# A second in-cohort account reserved for the SDK request lifecycle (no shared +# scenario uses it). Absent against a deployed server — that test skips. +RESTRICTED_SDK_API_KEY = os.environ.get("E2A_TEST_RESTRICTED_SDK_API_KEY", "") # The contract server's two throwaway accounts that exist only to be deleted by # the account-deletion scenarios (once each per server). Absent against a # deployed server — those scenarios then skip. @@ -1392,13 +1395,13 @@ def test_client_send_managed_unsubscribe_is_accepted_and_held(): @requires_contract_server +@pytest.mark.skipif(not RESTRICTED_SDK_API_KEY, reason="needs E2A_TEST_RESTRICTED_SDK_API_KEY") def test_client_sending_access_request_lifecycle(): - # Runs as the PRIMARY account (not the restricted cohort account): no - # scenario in scenarios.yaml files a sending-access request as the primary - # account (external_sending_access_request_intake deliberately reserves - # the restricted account for that, "which no other scenario files - # requests for"), so the primary account starts with none filed here. - with E2AClient(API_KEY, base_url=BASE_URL) as client: + # Runs as the contract server's SDK-only RESTRICTED account: inside the + # external-sending-access cohort, never used by any shared scenario, so + # it starts with no request and this test is its only filer. (Only a + # restricted account may file; see the conflict test below.) + with E2AClient(RESTRICTED_SDK_API_KEY, base_url=BASE_URL) as client: with pytest.raises(E2ANotFoundError) as ei: client.account.get_sending_access_request() assert ei.value.code == "not_found" @@ -1429,3 +1432,21 @@ def test_client_sending_access_request_lifecycle(): assert resubmitted.id == created.id assert resubmitted.use_case == "sdk contract probe" assert resubmitted.expected_daily_volume == 250 + + +@requires_contract_server +def test_client_sending_access_request_unrestricted_account_is_conflict(): + # The PRIMARY account is outside the contract cohort (unrestricted), so + # there is nothing to request: 409 conflict mapped to E2AConflictError, + # and nothing is filed. + with E2AClient(API_KEY, base_url=BASE_URL) as client: + with pytest.raises(E2AConflictError) as ei: + client.account.request_sending_access( + use_case="sdk contract probe", + recipients="our own customers who signed up", + expected_daily_volume=1, + ) + assert ei.value.code == "conflict" + assert ei.value.status == 409 + with pytest.raises(E2ANotFoundError): + client.account.get_sending_access_request() diff --git a/sdks/typescript/test/v1/contract-client.test.ts b/sdks/typescript/test/v1/contract-client.test.ts index db7496763..7599e2215 100644 --- a/sdks/typescript/test/v1/contract-client.test.ts +++ b/sdks/typescript/test/v1/contract-client.test.ts @@ -11,12 +11,11 @@ * Requires env vars (same as contract.test.ts): * E2A_TEST_BASE_URL — test server URL * E2A_TEST_API_KEY — valid API key for the test user - * E2A_TEST_RESTRICTED_API_KEY — optional; key for the contract server's - * account inside the external-sending-access enforcement cohort (see - * contract.test.ts). The account.getSendingAccessRequest / - * .requestSendingAccess coverage below runs as that account and skips - * without it (a deployed staging target has no such account), mirroring - * how the capped/over-cap accounts are treated. + * E2A_TEST_RESTRICTED_SDK_API_KEY — optional; key for the contract server's + * SDK-only account inside the external-sending-access enforcement cohort. + * The account.getSendingAccessRequest / .requestSendingAccess lifecycle + * below runs as that account and skips without it (a deployed staging + * target has no such account), mirroring the capped/over-cap accounts. * * Contract-server send topology (cmd/e2a-contract-server): the real River * enqueuer is wired but its outbound worker is not started, so external sends @@ -27,11 +26,11 @@ */ import { describe, it, expect } from "vitest"; import { E2AClient } from "../../src/v1/client.js"; -import { E2ANotFoundError } from "../../src/v1/errors.js"; +import { E2AConflictError, E2ANotFoundError } from "../../src/v1/errors.js"; const baseUrl = process.env.E2A_TEST_BASE_URL; const apiKey = process.env.E2A_TEST_API_KEY; -const restrictedApiKey = process.env.E2A_TEST_RESTRICTED_API_KEY; +const restrictedSdkApiKey = process.env.E2A_TEST_RESTRICTED_SDK_API_KEY; /** Shared-domain slug — must satisfy the server's ^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$ * rule (2–40 chars, no underscores). */ @@ -221,60 +220,65 @@ describe.skipIf(!baseUrl || !apiKey)("E2AClient contract (high-level)", () => { }); }); -// The external-sending-access request-intake flow is only safe to exercise -// against the DEDICATED restricted account (tests/contract/scenarios.yaml's -// external_sending_access_request_intake): a filed request is private support -// history with no customer-delete affordance, so running this against the -// shared primary contract-server account would leave permanent litter on it. -// Skips gracefully without the key, exactly like the capped/over-cap accounts -// in contract.test.ts. -// -// That scenario is ALSO the one raw-HTTP caller that may be the FIRST to file -// a request on this account: it asserts a fresh 201 on its own `file_request` -// step, and vitest gives no ordering guarantee between separate test files -// (this one and contract.test.ts run concurrently). This test therefore never -// creates unconditionally — it only replays `requestSendingAccess` once a -// request already exists (idempotent-while-pending, so a replay is always -// safe), which still proves the ergonomic method live either way: -// - a request already exists (the common case: the scenario's several -// create/resubmit steps are fast) → exercises the create/replay decode -// path against a real 200. -// - nothing has been filed yet → exercises getSendingAccessRequest's live -// 404 → E2ANotFoundError mapping instead, and skips the create call so it -// can never race the scenario for first-filer status. -describe.skipIf(!baseUrl || !restrictedApiKey)( +// The external-sending-access request lifecycle runs as the contract server's +// SDK-only RESTRICTED account (E2A_TEST_RESTRICTED_SDK_API_KEY): inside the +// cohort, used by no shared scenario, so it starts with no request and this +// test is its only filer — no race with the raw-HTTP scenario that must be +// the first filer on the scenario restricted account. A filed request is +// private support history with no customer delete, which is why it is kept +// off the shared primary account. Skips without the key (a deployed target +// has no such account), like the capped/over-cap accounts. +describe.skipIf(!baseUrl || !restrictedSdkApiKey)( "E2AClient contract (restricted account: external sending access)", () => { - const client = new E2AClient({ apiKey: restrictedApiKey!, baseUrl: baseUrl! }); + const client = new E2AClient({ apiKey: restrictedSdkApiKey!, baseUrl: baseUrl! }); - it("account.getSendingAccessRequest reads the account's request state, replaying account.requestSendingAccess only when one already exists", async () => { - let latest; - try { - latest = await client.account.getSendingAccessRequest(); - } catch (err) { - expect(err).toBeInstanceOf(E2ANotFoundError); - } + it("getSendingAccessRequest → requestSendingAccess → get → idempotent replay", async () => { + await expect(client.account.getSendingAccessRequest()).rejects.toBeInstanceOf(E2ANotFoundError); - if (!latest) return; // Nothing filed yet in this run — see comment above. + const created = await client.account.requestSendingAccess({ + useCase: "sdk contract probe", + recipients: "our own customers who signed up", + expectedDailyVolume: 250, + }); + expect(created.state).toBe("pending"); + expect(created.useCase).toBe("sdk contract probe"); + expect(created.expectedDailyVolume).toBe(250); + expect(created.createdAt).toBeInstanceOf(Date); - expect(["pending", "approved", "declined"]).toContain(latest.state); - expect(latest.expectedDailyVolume).toBeGreaterThan(0); - expect(latest.createdAt).toBeInstanceOf(Date); + const fetched = await client.account.getSendingAccessRequest(); + expect(fetched.id).toBe(created.id); + expect(fetched.state).toBe("pending"); + // Idempotent while pending: different fields, SAME request back (200). const replay = await client.account.requestSendingAccess({ - useCase: "contract-client coverage probe", - recipients: "our own customers", - expectedDailyVolume: 25, + useCase: "a different reason", + recipients: "someone else", + expectedDailyVolume: 999, }); - if (latest.state === "pending") { - // Idempotent while pending: filing again returns the SAME request. - expect(replay.id).toBe(latest.id); - expect(replay.state).toBe("pending"); - } else { - // A decided (approved/declined) request allows a fresh appeal — - // a NEW request, not a replay of the old one. - expect(replay.state).toBe("pending"); - } + expect(replay.id).toBe(created.id); + expect(replay.useCase).toBe("sdk contract probe"); + expect(replay.expectedDailyVolume).toBe(250); + }); + }, +); + +// The PRIMARY account is outside the contract cohort (unrestricted): there is +// nothing to request, so the server answers 409 conflict, mapped to +// E2AConflictError, and nothing is filed. +describe.skipIf(!baseUrl || !apiKey || !restrictedSdkApiKey)( + "E2AClient contract (unrestricted account: external sending access)", + () => { + const client = new E2AClient({ apiKey: apiKey!, baseUrl: baseUrl! }); + + it("requestSendingAccess on an unrestricted account is E2AConflictError (409 conflict)", async () => { + const err = await client.account + .requestSendingAccess({ useCase: "sdk contract probe", recipients: "our own customers", expectedDailyVolume: 1 }) + .then(() => undefined, (e: unknown) => e); + expect(err).toBeInstanceOf(E2AConflictError); + expect((err as E2AConflictError).code).toBe("conflict"); + expect((err as E2AConflictError).status).toBe(409); + await expect(client.account.getSendingAccessRequest()).rejects.toBeInstanceOf(E2ANotFoundError); }); }, );