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(`
+ 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 ? (
-
+ 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() {
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);
});
},
);