diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index b1d4f14c0..3e9644411 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -7,12 +7,12 @@
},
"metadata": {
"description": "e2a plugins for Claude Code — open-source email API for applications and AI agents",
- "version": "0.9.5"
+ "version": "0.9.6"
},
"plugins": [
{
"name": "e2a",
- "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, HITL approval, structured SPF/DKIM/DMARC evidence, and a queryable event log. 78 MCP tools over hosted streamable HTTP with OAuth.",
+ "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, HITL approval, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.",
"category": "productivity",
"source": "./plugins/e2a",
"homepage": "https://e2a.dev"
diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json
index 4e8e52141..13a26916d 100644
--- a/.cursor-plugin/marketplace.json
+++ b/.cursor-plugin/marketplace.json
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "e2a — open-source email API for applications and AI agents (MCP configuration and canonical docs).",
- "version": "0.9.5"
+ "version": "0.9.6"
},
"plugins": [
{
diff --git a/api/openapi.yaml b/api/openapi.yaml
index d20cfa880..28416a121 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.
@@ -5657,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/cli/CHANGELOG.md b/cli/CHANGELOG.md
index 0028e46dc..b275e437b 100644
--- a/cli/CHANGELOG.md
+++ b/cli/CHANGELOG.md
@@ -2,6 +2,13 @@
## Unreleased
+**Changed:** `e2a sending-access status` prints an `available unlocks: ...`
+line from the deployment's `sending_access.available_unlocks`, and both it and
+`e2a whoami` offer only the recovery routes the deployment honors. A paid plan
+counts as a grant only where `paid_entitlement` is listed (a server that omits
+the field is read as accepting all three, as before). `e2a sending-access
+request` now says the decision is emailed to the account owner.
+
**Added:** `e2a account delete [--permanent] [--yes] [--json]`. By default the
account is moved to the trash — every API key, OAuth grant, and dashboard
session is revoked and sending stops immediately, but the account is
diff --git a/cli/README.md b/cli/README.md
index 06830b83b..aca04c567 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -66,7 +66,8 @@ e2a keys create --agent bot@acme.com
Show the key identity: user, scope, bound agent, plan. When the deployment
reports `sending_access` (beta — see `e2a sending-access` below) and this
account is currently restricted to the narrow shared-identity allowlist, an
-extra `External sending: restricted (...)` line points at how to recover. If
+extra `External sending: restricted (...)` line points at how to recover —
+naming only the routes the deployment's `available_unlocks` honors. If
the account was restored from the trash (`e2a account delete` below), an
extra `restored: (from trash)` line appears. `--json` always
includes the raw `sending_access` object when present.
@@ -383,10 +384,13 @@ External sending access is a platform control on the shared sending identity:
while an account is restricted, it may only send to its verified account
email and to agent inboxes in the same account — any other To/Cc/Bcc refuses
the whole send with `external_sending_not_enabled` (see `e2a send`/`e2a
-reply` below). `status` shows the current restriction and the account's
-latest access request, if any; `request` files a new one for support to
-review. Filing never grants access by itself, and is capped at 3 requests per
-30 days.
+reply` below). `status` shows the current restriction, the deployment's
+`available unlocks` (`operator_approval` always; `verified_domain` and
+`paid_entitlement` only where the deployment accepts them — the hosted service
+accepts approval only), and the account's latest access request, if any;
+`request` files a new one for an operator to review. File one request: the
+decision is emailed to the account owner. Filing never grants access by
+itself, and is capped at 3 requests per 30 days.
```bash
e2a sending-access status
diff --git a/cli/src/__tests__/sending-access.test.ts b/cli/src/__tests__/sending-access.test.ts
index b593cee29..d5f582c5d 100644
--- a/cli/src/__tests__/sending-access.test.ts
+++ b/cli/src/__tests__/sending-access.test.ts
@@ -130,6 +130,47 @@ describe("sending-access commands", () => {
expect(out).toContain("allowed (paid plan entitlement)");
});
+ it("status lists available unlocks and offers only honored routes", async () => {
+ for (const [unlocks, wantDomain, wantPaid] of [
+ [["operator_approval"], false, false],
+ [["operator_approval", "verified_domain"], true, false],
+ [["operator_approval", "paid_entitlement"], false, true],
+ [undefined, true, true],
+ ] as const) {
+ stdout.mockClear();
+ mockAccountGet.mockResolvedValue(
+ makeAccount({ ...RESTRICTED, ...(unlocks ? { availableUnlocks: [...unlocks] } : {}) }),
+ );
+ mockGetRequest.mockRejectedValue(
+ new E2ANotFoundError({ code: "not_found", message: "none", status: 404, retryable: false }),
+ );
+ const { sendingAccessStatus } = await import("../commands/sending-access.js");
+ await sendingAccessStatus({});
+ const out = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join("");
+ expect(out).toContain("External sending: restricted");
+ expect(out).toContain("request approval");
+ expect(out.includes("verified domain")).toBe(wantDomain);
+ expect(out.includes("paid plan")).toBe(wantPaid);
+ expect(out).toContain(
+ `available unlocks: ${(unlocks ?? ["operator_approval", "verified_domain", "paid_entitlement"]).join(", ")}`,
+ );
+ }
+ });
+
+ it("a paid entitlement is not reported as a grant under approval-only unlocks", async () => {
+ mockAccountGet.mockResolvedValue(
+ makeAccount({ ...RESTRICTED, paidExternalSendingEntitled: true, availableUnlocks: ["operator_approval"] }),
+ );
+ mockGetRequest.mockRejectedValue(
+ new E2ANotFoundError({ code: "not_found", message: "none", status: 404, retryable: false }),
+ );
+ const { sendingAccessStatus } = await import("../commands/sending-access.js");
+ await sendingAccessStatus({});
+ const out = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join("");
+ expect(out).toContain("External sending: restricted");
+ expect(out).not.toContain("allowed (paid plan entitlement)");
+ });
+
it("reports the operator-approved grant", async () => {
mockAccountGet.mockResolvedValue(
makeAccount({ ...RESTRICTED, sharedExternalApproved: true }),
diff --git a/cli/src/__tests__/whoami.test.ts b/cli/src/__tests__/whoami.test.ts
index 3a170dde1..2401a37d8 100644
--- a/cli/src/__tests__/whoami.test.ts
+++ b/cli/src/__tests__/whoami.test.ts
@@ -190,6 +190,29 @@ describe("whoami command", () => {
expect(output).not.toContain("External sending:");
});
+ it("a paid plan is not a grant where available_unlocks omits paid_entitlement", async () => {
+ mockAccountGet.mockResolvedValue(
+ makeAccount({
+ sendingAccess: {
+ enforcementApplies: true,
+ sharedExternalApproved: false,
+ paidExternalSendingEntitled: true,
+ ownerRecipientVerified: true,
+ availableUnlocks: ["operator_approval"],
+ },
+ }),
+ );
+ const { whoami } = await import("../commands/whoami.js");
+ await whoami({});
+
+ const output = mockStdout.mock.calls.map((c: unknown[]) => c[0]).join("");
+ expect(output).toContain("External sending: restricted");
+ expect(output).toContain("request approval");
+ // Only routes the deployment honors are offered.
+ expect(output).not.toContain("verified domain");
+ expect(output).not.toContain("paid plan");
+ });
+
it("says nothing new when sending_access is omitted (older/self-host deployment)", async () => {
mockAccountGet.mockResolvedValue(makeAccount());
const { whoami } = await import("../commands/whoami.js");
diff --git a/cli/src/bin/e2a.ts b/cli/src/bin/e2a.ts
index a5ed9515c..3428571bb 100644
--- a/cli/src/bin/e2a.ts
+++ b/cli/src/bin/e2a.ts
@@ -124,7 +124,7 @@ Usage:
e2a sending-access status Beta: show this account's external-sending restriction
and its latest access request, if any
--json Raw { sendingAccess, latestRequest } objects
- e2a sending-access request Beta: ask support to review external sending access
+ e2a sending-access request Beta: ask an operator to review external sending access
--use-case What you're building and why (1-2000 chars)
--recipients Who you'll email (1-1000 chars)
--volume Expected recipients per day (1-1000000)
diff --git a/cli/src/commands/sending-access.ts b/cli/src/commands/sending-access.ts
index fac0fbfac..c529acec1 100644
--- a/cli/src/commands/sending-access.ts
+++ b/cli/src/commands/sending-access.ts
@@ -7,7 +7,8 @@ import { EXIT, fail } from "../exit.js";
// identity. `status` reads GET /v1/account's additive `sending_access` object
// plus the account's latest request (if any); `request` files a new one via
// POST /v1/account/sending-access/request. Filing never grants access by
-// itself — only support (or a paid plan) does that.
+// itself — an operator's approval does (or, where the deployment's
+// `available_unlocks` lists them, a verified domain or a paid plan).
export interface SendingAccessStatusOptions {
json?: boolean;
@@ -23,11 +24,40 @@ export interface SendingAccessRequestOptions {
export const SENDING_ACCESS_REQUEST_USAGE =
"usage: e2a sending-access request --use-case --recipients --volume [--json]";
+/** The deployment's unlock set. A server that predates `available_unlocks`
+ * omits it and accepts all three, so absence reads as all three. */
+export function availableUnlocks(access: SendingAccessView): string[] {
+ return access.availableUnlocks ?? ["operator_approval", "verified_domain", "paid_entitlement"];
+}
+
+/** True when the paid entitlement actually lifts the restriction here. */
+function paidUnlocks(access: SendingAccessView): boolean {
+ return access.paidExternalSendingEntitled && availableUnlocks(access).includes("paid_entitlement");
+}
+
/** True exactly when the account is restricted to the narrow allowed-recipient
- * set right now — enforcement applies and neither grant (operator approval or
- * a paid plan) is in effect. Mirrors the server's own decision at send time. */
-function isRestricted(access: SendingAccessView): boolean {
- return access.enforcementApplies && !access.sharedExternalApproved && !access.paidExternalSendingEntitled;
+ * set right now — enforcement applies and no account-level grant in effect
+ * (operator approval, or a paid plan where the deployment accepts one).
+ * Mirrors the server's own decision at send time. */
+export function isRestricted(access: SendingAccessView): boolean {
+ return access.enforcementApplies && !access.sharedExternalApproved && !paidUnlocks(access);
+}
+
+const REQUEST_HINT =
+ "e2a sending-access request --use-case --recipients --volume ";
+
+/** The one-line restricted summary shared by `whoami` and `status`: what the
+ * account can still reach, and only the recovery routes the deployment
+ * honors. */
+export function restrictedLine(access: SendingAccessView): string {
+ const unlocks = availableUnlocks(access);
+ const routes = [`request approval with: ${REQUEST_HINT}`];
+ if (unlocks.includes("verified_domain")) routes.push("or send from your own verified domain");
+ if (unlocks.includes("paid_entitlement")) routes.push("or choose a paid plan");
+ return (
+ "External sending: restricted (send to your verified account email and agent inboxes in " +
+ `this account; ${routes.join(" ")})`
+ );
}
function describeAccess(access: SendingAccessView | undefined): string {
@@ -39,17 +69,13 @@ function describeAccess(access: SendingAccessView | undefined): string {
if (!access.enforcementApplies) {
return "External sending: unrestricted (this control does not apply to this account).";
}
- if (access.paidExternalSendingEntitled) {
- return "External sending: allowed (paid plan entitlement).";
- }
if (access.sharedExternalApproved) {
return "External sending: allowed (approved by an operator).";
}
- return (
- "External sending: restricted (send to your verified account email and agent inboxes in " +
- "this account; request approval with: e2a sending-access request --use-case " +
- "--recipients --volume )"
- );
+ if (paidUnlocks(access)) {
+ return "External sending: allowed (paid plan entitlement).";
+ }
+ return restrictedLine(access);
}
function describeRequest(req: SendingAccessRequestView): string {
@@ -81,6 +107,9 @@ export async function sendingAccessStatus(opts: SendingAccessStatusOptions): Pro
}
process.stdout.write(describeAccess(account.sendingAccess) + "\n");
+ if (account.sendingAccess) {
+ process.stdout.write(`available unlocks: ${availableUnlocks(account.sendingAccess).join(", ")}\n`);
+ }
if (latest) process.stdout.write(describeRequest(latest) + "\n");
}
@@ -105,7 +134,7 @@ export async function sendingAccessRequest(opts: SendingAccessRequestOptions): P
process.stdout.write(`${result.id}\t${result.state}\n`);
process.stderr.write(
result.state === "pending"
- ? "Filed (or already pending) — support will review it. Check back with: e2a sending-access status\n"
+ ? "Filed (or already pending) — an operator will review it and the account owner will get an email with the decision. Do not re-file. Check back with: e2a sending-access status\n"
: `Note: the account's latest request is already ${result.state}; this filing may be a new appeal.\n`,
);
}
diff --git a/cli/src/commands/whoami.ts b/cli/src/commands/whoami.ts
index 4a56c040e..a1c11d478 100644
--- a/cli/src/commands/whoami.ts
+++ b/cli/src/commands/whoami.ts
@@ -1,5 +1,6 @@
import { createClient } from "../sdk.js";
import { loadConfig } from "../config.js";
+import { isRestricted, restrictedLine } from "./sending-access.js";
export interface WhoamiOptions {
json?: boolean;
@@ -56,12 +57,10 @@ export async function whoami(opts: WhoamiOptions): Promise {
// misleading "unrestricted". Only surface a line when this account is
// ACTUALLY restricted right now (enforced, and neither grant applies) —
// an unrestricted or already-approved account gets no new noise here.
+ // A paid plan counts as a grant only where the deployment's
+ // `available_unlocks` lists paid_entitlement.
const access = account.sendingAccess;
- if (access && access.enforcementApplies && !access.sharedExternalApproved && !access.paidExternalSendingEntitled) {
- process.stdout.write(
- "External sending: restricted (send to your verified account email and agent inboxes in " +
- "this account; request approval with: e2a sending-access request --use-case " +
- "--recipients --volume )\n",
- );
+ if (access && isRestricted(access)) {
+ process.stdout.write(restrictedLine(access) + "\n");
}
}
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/cmd/e2a/main.go b/cmd/e2a/main.go
index aef8f952b..e762b3cf4 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")
@@ -147,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 b0e8b864a..a97d75004 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"
)
@@ -31,6 +36,8 @@ type sendingProtectionFlags struct {
approveExternal bool
revokeExternal bool
declineExternal bool
+ listExternal bool
+ listAll bool
accountID string
expectedExternal int64
requestID string
@@ -58,14 +65,24 @@ 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
}
+// 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,
- 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++
@@ -104,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 {
@@ -128,8 +148,11 @@ 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:
- 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 +336,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 +356,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 +380,18 @@ 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)
+ } 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")
}
return nil
@@ -375,11 +410,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 +488,77 @@ 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")
+}
+
+// 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, truncated, err := module.ListAccessRequests(ctx, all)
+ if err != nil {
+ return err
+ }
+ scope := "pending, oldest first"
+ if 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)
+ 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 9dfe39c52..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"
@@ -330,7 +331,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 +411,199 @@ 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())
+ }
+}
+
+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, 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)
+ }
+ }
+ 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, 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)
+ }
+
+ // 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)
+ }
+}
+
+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/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..08cd07175 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,46 @@ 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). 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,
+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
+`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..09b111ffd 100644
--- a/docs/design/async-message-pipeline.md
+++ b/docs/design/async-message-pipeline.md
@@ -385,9 +385,46 @@ 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. `-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`
with a revision CAS and append-only `external_sending_access_events`); no API
credential can grant access. Owner-mailbox proof
diff --git a/docs/runbooks/mcp-server.md b/docs/runbooks/mcp-server.md
index 8303d5cb5..db2e54696 100644
--- a/docs/runbooks/mcp-server.md
+++ b/docs/runbooks/mcp-server.md
@@ -116,7 +116,7 @@ design.
- **Symptoms**: requests succeed, but the credential is served at
least-privilege **agent scope**: account-scoped callers see the tool list
- shrink from 78 tools to the 21 runtime tools, and the per-request default
+ shrink from 80 tools to the 21 runtime tools, and the per-request default
agent is unset (explicit `email` required).
- **Detection**: `auth_resolution` WARNING `whoami probe failed; serving
least-privilege fallback`; `mcp_auth_resolutions_total{result="fallback"}`.
diff --git a/internal/agent/external_access.go b/internal/agent/external_access.go
index 0ef5331f9..600826ca6 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
@@ -133,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 64af1b0c8..004dd945e 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"
@@ -93,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"
@@ -146,3 +155,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..b197cfdea 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1,8 +1,10 @@
package config
import (
+ "bytes"
"errors"
"fmt"
+ "io"
"log"
"net/mail"
"net/netip"
@@ -575,9 +577,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
@@ -777,6 +786,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
@@ -1283,3 +1295,83 @@ 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 {
+ // 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
+ }
+ if len(root.Content) == 0 {
+ return nil
+ }
+ esa := mappingValue(mappingValue(root.Content[0], "sending_protection"), "external_sending_access")
+ if esa == nil {
+ return nil
+ }
+ 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 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
new file mode 100644
index 000000000..069515a5e
--- /dev/null
+++ b/internal/config/sending_protection_strict_test.go
@@ -0,0 +1,111 @@
+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)
+ }
+}
+
+// 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")
+ }
+}
diff --git a/internal/httpapi/sending_access.go b/internal/httpapi/sending_access.go
index 67bdc45ad..86f706af5 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.
@@ -116,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),
@@ -170,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):
@@ -182,7 +197,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..8bb9edecf 100644
--- a/internal/httpapi/sending_access_test.go
+++ b/internal/httpapi/sending_access_test.go
@@ -195,3 +195,99 @@ 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)
+ }
+ })
+ }
+}
+
+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/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..85e3a9590 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,44 @@ 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"]);
+ // 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", () => {
+ 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,58 @@ 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."],
+ [["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."],
+ ])("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.");
+ });
+});
+
+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"] };
+ 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..ce55a16d5 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,67 @@ 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. 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 = "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) {
+ // "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 {
+ 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 ──────────────────────────────────────────────────