feat(sending): configurable external-sending unlocks, MCP request tools, decision notices - #1052
Merged
Merged
Conversation
…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
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.unlocksoperator_approval,verified_domain,paid_entitlement.omitempty), so stored policy hashes stay valid.operator_approval: the request/approve path must always work. Both are also rejected when a stored DB policy is read.RuntimePolicyat every stage: preflight, acceptance,ConsumeAttempt, andRedeemProviderCall. The stages cannot disagree:verified_domain, step 3 (own verified identity) no longer applies;paid_entitlement, step 4 ignoresexternal_sending_entitled.accounts_created_at_or_afteris still the cohort cutoff.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_accessgainsavailable_unlocks: string[], the effective set in canonical order.paid_external_sending_entitledkeeps reporting the fact; its description now says it lifts the restriction only whenpaid_entitlementis listed.oasdiff: no breaking changes.external_sending_not_enabledmessage now mentions a verified domain only where the deployment honors it.Symmetric client changes
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 scenarioexternal_sending_access_restricted_accountasserts the default set.e2a sending-access statusprintsavailable unlocks: ....statusandwhoamioffer only the routes the deployment honors.paid_entitlementis listed.get_sending_access_request(read) andrequest_sending_access(write) tools.request_sending_accessis inMUTATING_TOOLS,TOOL_OPERATIONS, and_meta["e2a/mutating"], so a frozen account getsaccount_read_only.request_pending/rate_limited; the decision is emailed to the account owner;whoami'ssending_access.available_unlocksexplains what can lift the restriction.send_email) descriptions point atrequest_sending_accessand qualify the domain/paid routes with "depending on the deployment'savailable_unlocks".40-mcp-sending-accessfeeds the MCP coverage gate.available_unlocks: "Request approval" always leads. "Verify a domain" appears only ifverified_domainis listed. "Choose a paid plan" appears only ifpaid_entitlementis listed and billing is enabled.Decision notice email
-approve-external-sending -external-sending-request-id …and-decline-external-sending-requestnow email the account owner after the decision commits.customer_notificationgate operation keyed by the request (op_esad_<request>, new notification sourcesending_access_decision). It gets the same Reserve/ConsumeAttempt/ProviderSubmitter authorization, budget and pause handling as other notices.notifications.from_address/reply_to) to the owner address the gate resolves./sending-accesslink.warning:line and never fails the command, because the decision stands.Daily synthetic request email
The operator notification for a new request is skipped when the filing account's server-owned
account_classis exempt from the rule (system/internal). The scheduled conformance account is seeded asinternal(cmd/e2a-prober/seed_conformance.go). Standard and demo accounts still notify.Test evidence
operator_approval, unknown, duplicate, stored empty list, YAML nil vs[]);available_unlocks;make testrun, plusspec-check,generate-sdk-check,openapi-compat-check, SDK op coverage, SDK version sync, and text integrity.Hosted rollout (operator, in order)
-print-capabilitieslistsexternal_sending_unlockson all of them.-approve-external-sendingwithout a request id, so no email goes out). Moving the cutoff to the epoch pulls every existing account into the cohort, andunlocks: [operator_approval]removes the paid and domain unlocks they may rely on today.accounts_created_at_or_after: "1970-01-01T00:00:00Z"andunlocks: [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