Skip to content

feat(sending): configurable external-sending unlocks, MCP request tools, decision notices - #1052

Merged
jiashuoz merged 14 commits into
mainfrom
feat/external-sending-admission-gate
Sep 27, 2026
Merged

jiashuoz merged 14 commits into
mainfrom
feat/external-sending-admission-gate

Conversation

@jiashuoz

Copy link
Copy Markdown
Member

Motivation

External sending access used to lift the restriction on its own in three ways: a verified custom domain, an operator grant, or the billing-issued paid entitlement. In a 2026-09-26 incident, a paid signup made with a stolen card used the instant paid unlock to send a phishing burst within hours. Hosted e2a is moving to restricted by default, with explicit operator approval as the only unlock. A verified domain or a paid plan becomes evidence the operator weighs, not an automatic unlock.

Self-hosters keep their choice. This is configuration, not a hardcoded policy change. Target release: 1.12.0 (semver-minor).

Config: sending_protection.external_sending_access.unlocks

sending_protection:
  external_sending_access:
    mode: enforce
    accounts_created_at_or_after: "1970-01-01T00:00:00Z"
    unlocks: [operator_approval]          # hosted
    # unlocks omitted = [operator_approval, verified_domain, paid_entitlement] (default)
  • The vocabulary is closed: operator_approval, verified_domain, paid_entitlement.
  • Omitted means all three. Behavior is unchanged, and so is the canonical policy hash (omitempty), so stored policy hashes stay valid.
  • An empty list is a startup error. A policy that no route can satisfy must fail loudly, not silently deny everyone. The same goes for a list without operator_approval: the request/approve path must always work. Both are also rejected when a stored DB policy is read.
  • The set is sorted into canonical order during normalization, so reordering the list does not change the reviewed hash.
  • The single decision function reads the set from the same RuntimePolicy at every stage: preflight, acceptance, ConsumeAttempt, and RedeemProviderCall. The stages cannot disagree:
    • without verified_domain, step 3 (own verified identity) no longer applies;
    • without paid_entitlement, step 4 ignores external_sending_entitled.
  • The metrics route vocabulary is unchanged. accounts_created_at_or_after is still the cohort cutoff.
  • New runtime-policy capability marker: external_sending_unlocks. Older binaries reject the key, so the ops deploy gate must see the marker on every slot before it activates a DB policy that carries the key.

API (additive only)

GET /v1/account → sending_access gains available_unlocks: string[], the effective set in canonical order.

  • It is optional on the wire, so an SDK talking to an older server still parses. Clients treat absent as all three.
  • No field is removed or renamed. paid_external_sending_entitled keeps reporting the fact; its description now says it lifts the restriction only when paid_entitlement is listed.
  • oasdiff: no breaking changes.
  • The 403 external_sending_not_enabled message now mentions a verified domain only where the deployment honors it.

Symmetric client changes

  • OpenAPI + generated SDKs regenerated with make generate. The TS/Python ergonomic clients document the field, and there are unit tests for decoding from current and pre-field servers. The shared contract scenario external_sending_access_restricted_account asserts the default set.
  • CLI:
    • e2a sending-access status prints available unlocks: ....
    • status and whoami offer only the routes the deployment honors.
    • A paid plan counts as a grant only where paid_entitlement is listed.
  • MCP: new get_sending_access_request (read) and request_sending_access (write) tools.
    • Both are admin tier. request_sending_access is in MUTATING_TOOLS, TOOL_OPERATIONS, and _meta["e2a/mutating"], so a frozen account gets account_read_only.
    • The descriptions say: file ONE request; don't retry on request_pending/rate_limited; the decision is emailed to the account owner; whoami's sending_access.available_unlocks explains what can lift the restriction.
    • The send/reply/forward (and legacy send_email) descriptions point at request_sending_access and qualify the domain/paid routes with "depending on the deployment's available_unlocks".
    • Tool catalog goes 78 → 80; plugin manifests are regenerated and the plugin is bumped to 0.9.6.
    • New e2e-prod suite 40-mcp-sending-access feeds the MCP coverage gate.
  • Web:
    • Guidance comes from available_unlocks: "Request approval" always leads. "Verify a domain" appears only if verified_domain is listed. "Choose a paid plan" appears only if paid_entitlement is listed and billing is enabled.
    • Also folded in from a staging walk-through:
      • The headline no longer says "Your inbox is ready" (it now reads "External sending is restricted for this account.").
      • The recovery sentence is a proper list.
      • An approved account sees one "External sending is enabled" card that names the route.
      • The declined card states the 3-per-30-days refile rule.
      • The form now says "Requests are reviewed by an operator; you'll get an email when a decision is made."

Decision notice email

-approve-external-sending -external-sending-request-id … and -decline-external-sending-request now email the account owner after the decision commits.

  • The mail goes out as a customer_notification gate operation keyed by the request (op_esad_<request>, new notification source sending_access_decision). It gets the same Reserve/ConsumeAttempt/ProviderSubmitter authorization, budget and pause handling as other notices.
  • It is sent from the deployment's notification identity (notifications.from_address / reply_to) to the owner address the gate resolves.
  • Neutral system tone, operator-authored copy only. The customer's free text is never echoed back.
  • Approved → external sending is enabled, with a dashboard link. Declined → the request was declined, a new one can be filed (3 per 30 days), with a /sending-access link.
  • A failed or held notice prints a warning: line and never fails the command, because the decision stands.
  • A direct grant with no request (for example, pre-granting) sends nothing.

Daily synthetic request email

The operator notification for a new request is skipped when the filing account's server-owned account_class is exempt from the rule (system/internal). The scheduled conformance account is seeded as internal (cmd/e2a-prober/seed_conformance.go). Standard and demo accounts still notify.

Test evidence

  • Go, integration tests against real Postgres:
    • every unlock set × route × stage (preflight, acceptance, authorization, redemption);
    • config/policy validation (empty, missing operator_approval, unknown, duplicate, stored empty list, YAML nil vs []);
    • hash stability and order-independence;
    • status/operator readback of available_unlocks;
    • the exempt-class flag on submit, and the HTTP notification skip;
    • the decision notice through the real gate plus a fake SMTP server (approved, declined, pending refused, paused account held, relay failure);
    • the CLI command succeeding with a failing, absent, or missing-relay notifier;
    • the 403 copy per unlock set.
  • Full make test run, plus spec-check, generate-sdk-check, openapi-compat-check, SDK op coverage, SDK version sync, and text integrity.
  • TS SDK, Python SDK (pytest + mypy), CLI, MCP, web (Jest + eslint + tsc), plugin validation/version-bump/packaging tests, and the MCP tool-catalog compat check.

Hosted rollout (operator, in order)

  1. Ship this release (1.12.0) to every slot and confirm -print-capabilities lists external_sending_unlocks on all of them.
  2. Pre-grant every legitimate account first (-approve-external-sending without a request id, so no email goes out). Moving the cutoff to the epoch pulls every existing account into the cohort, and unlocks: [operator_approval] removes the paid and domain unlocks they may rely on today.
  3. Only then set accounts_created_at_or_after: "1970-01-01T00:00:00Z" and unlocks: [operator_approval], and activate through the reviewed-hash CAS.

No customer data appears in this PR; all identifiers are synthetic.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW

🤖 Generated with Claude Code

https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW

jiashuoz and others added 14 commits September 27, 2026 19:33
…ices

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Regenerated TS and Python bases pick up the additive optional field; the
ergonomic clients document it, tests cover decoding from current and
pre-field servers, and the shared contract scenario asserts the default set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
status prints the deployment's unlock set; status and whoami offer only
honored recovery routes and treat a paid plan as a grant only where
paid_entitlement is listed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Two admin-tier tools wrap POST/GET /v1/account/sending-access/request.
request_sending_access is mutating (refused with account_read_only on a
frozen account). Descriptions tell an agent to file once, not retry on a
pending request or rate limit, that the decision is emailed to the account
owner, and that whoami's sending_access.available_unlocks explains what can
lift the restriction. The send/reply/forward descriptions point at the new
tool and stop promising an unconditional domain or paid unlock. Tool catalog
78 -> 80 (plugin manifests and version bumped); e2e-prod suite 40 covers
both tools for the MCP coverage gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
The restriction notice and /sending-access offer Verify a domain only
where verified_domain is listed and Choose a paid plan only where
paid_entitlement is listed and billing is enabled; approval always leads.
The headline no longer assumes an inbox exists, the recovery sentence is a
proper list, an approved account sees one 'External sending is enabled'
card naming the route, the declined card states the 3-per-30-days refile
rule, and the form notes that decisions arrive by email. A paid
entitlement that is not an unlock no longer reads as a grant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…ning

-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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
… 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
@jiashuoz
jiashuoz merged commit ddfb9fa into main Sep 27, 2026
47 of 48 checks passed
@jiashuoz
jiashuoz deleted the feat/external-sending-admission-gate branch September 27, 2026 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant