Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
11 changes: 9 additions & 2 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4467,14 +4467,21 @@ 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
owner_recipient_verified:
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.
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <timestamp> (from trash)` line appears. `--json` always
includes the raw `sending_access` object when present.
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions cli/src/__tests__/sending-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
23 changes: 23 additions & 0 deletions cli/src/__tests__/whoami.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion cli/src/bin/e2a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text> What you're building and why (1-2000 chars)
--recipients <text> Who you'll email (1-1000 chars)
--volume <n> Expected recipients per day (1-1000000)
Expand Down
57 changes: 43 additions & 14 deletions cli/src/commands/sending-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,11 +24,40 @@ export interface SendingAccessRequestOptions {
export const SENDING_ACCESS_REQUEST_USAGE =
"usage: e2a sending-access request --use-case <text> --recipients <text> --volume <n> [--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 <text> --recipients <text> --volume <n>";

/** 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 {
Expand All @@ -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 <text> " +
"--recipients <text> --volume <n>)"
);
if (paidUnlocks(access)) {
return "External sending: allowed (paid plan entitlement).";
}
return restrictedLine(access);
}

function describeRequest(req: SendingAccessRequestView): string {
Expand Down Expand Up @@ -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");
}

Expand All @@ -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`,
);
}
11 changes: 5 additions & 6 deletions cli/src/commands/whoami.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -56,12 +57,10 @@ export async function whoami(opts: WhoamiOptions): Promise<void> {
// 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 <text> " +
"--recipients <text> --volume <n>)\n",
);
if (access && isRestricted(access)) {
process.stdout.write(restrictedLine(access) + "\n");
}
}
6 changes: 4 additions & 2 deletions cmd/e2a-contract-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading