From faafd98600f7489aed75cde6fe040de8b57d3ef1 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Fri, 17 Jul 2026 23:22:49 -0700 Subject: [PATCH 01/17] docs(design): batch send spec and MVP implementation plan Draft design doc for POST /v1/agents/{email}/batches. Resend-shape wire (N independent BatchMessage items), Resend philosophy (100-item cap, idempotency, minimal knobs), stricter-than-both error model (accept-time all-or-nothing on validation, per-item skip only for suppression). Covers: API contract (sections 1, 8), semantics (2), data model (3), rate limiting (4), HITL interaction (5), idempotency (6), observability (7), accept-transaction sketch (9), 2-PR implementation plan (10), post-MVP polish (11), testing (12), rollout (13), and a full decision journal (14) with 15 design decisions, each documented with options considered / decision / reasoning / reversibility. Status: draft, awaiting mentor review. Feature will ship behind E2A_BATCH_SEND_ENABLED flag (default false) so merge and enable are separable. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Le Xiao --- docs/design/batch-send.md | 640 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100644 docs/design/batch-send.md diff --git a/docs/design/batch-send.md b/docs/design/batch-send.md new file mode 100644 index 000000000..abf5dc6df --- /dev/null +++ b/docs/design/batch-send.md @@ -0,0 +1,640 @@ +# Batch Send — Design Spec and MVP Implementation Plan + +**Status:** Draft. Awaiting mentor review before implementation. +**Scope:** Add a batch-send endpoint that accepts up to 100 independent messages in one request. Each recipient receives its own SMTP-separate message with its own `message_id`, its own state machine, and its own River retry envelope. Reuses the existing async pipeline; introduces a parent `batches` row and a `batch_id` correlation. +**Not in scope for MVP** *(reserved for post-MVP polish)*: batch-level HITL approval, batch cancellation, `listBatches`, `wait=sent` variant for batch. Per-item content, per-item templates, per-item attachments, per-item cc/bcc/reply_to — all **ARE** in MVP as a natural consequence of the Resend-shaped request (each batch item is a self-contained near-copy of `SendEmailRequest`, inheriting the single-send content model). See §14 Q8 for the shape rationale. + +**Background.** Today `POST /v1/agents/{email}/messages` sends ONE message with up to 50 combined `to/cc/bcc` recipients delivered in a single SMTP envelope — every recipient sees the other addresses in the message headers ([internal/httpapi/outbound.go:179](../../internal/httpapi/outbound.go), `maxRecipients=50`). AI agents driving outbound need N *independent* messages: each recipient receives their own separate email and cannot see the other recipients. e2a's positioning is specifically the "agent-first" case — the [README](../../README.md) tagline is *"Give your AI agents a real, authenticated email address"* and every product concept (conversation threading, HITL, per-agent screening, 10-day retention, in-DB attachments) is built around **1-on-1 agent↔human conversations**, not marketing broadcasts. AI agents' unique capability is programmatic per-recipient content generation (LLM-generated outreach, personalized transactional emails, per-user follow-ups), so batch send's dominant use case is "N heterogeneous 1-on-1 sends in one API call" — not "1 shared body to a subscriber list." There is no batch write path in the /v1 surface today (`api/openapi.yaml`, `internal/httpapi/*`). The async-send contract already reserved a `batch_id` correlation field on webhook events for this feature (see [`async-send-contract.md` §4.4](async-send-contract.md)). This document specifies the contract, the reconciled all-or-nothing semantics, the storage/lifecycle model, and a phased implementation plan that reuses the existing River outbound pipeline verbatim so the new surface area is confined to the accept side of the wire. + +## 0.5 Terminology + +Definitions used throughout this doc — surfaced up-front so readers unfamiliar with e2a's template system, mail-merge semantics, or the Resend-vs-SendGrid axis can follow §14 without side-quests. + +- **Template** — a stored subject/text/html body with `{{variable}}` placeholders. Implemented in [internal/emailtemplate/emailtemplate.go](../../internal/emailtemplate/emailtemplate.go) as a hand-rolled restricted subset of Mustache: `{{ident}}` (HTML-escaped) and `{{{ident}}}` (raw); no loops/conditionals/partials — those syntaxes are reserved parse errors so a future upgrade stays additive. Currently **Beta**. Rendered server-side at accept-tx time with `template_data`. Referenced from a send request via `template_id` (opaque) or `template_alias` (human handle); mutually exclusive with literal `subject`/`text`/`html`. **Batch send does not add any new template features** — it inherits the existing beta template system per batch item. +- **Fanout** — the pattern where one API call produces **N *independent* outbound messages**, one per recipient, each with its own `message_id`, its own SMTP envelope, its own retry envelope. The recipient of one message never sees the addresses of the others. Contrast **envelope batching** (cc/bcc), which produces ONE message with N recipients visible to each other. This document's `POST /batches` implements fanout. Fanout does NOT imply shared content — each item can be fully independent (see mail-merge below). +- **Mail-merge** — fanout **with per-recipient content variation**: recipient A gets content_A, recipient B gets content_B, etc. In our design, mail-merge is **native to the MVP shape** — each `BatchMessage` item in the request carries its own subject/text/html (or its own `template_id`+`template_data`), so per-recipient variation is expressed as "different bodies per item" rather than a server-side templating loop. Callers who want fully identical content across recipients just duplicate the shared body across items. +- **Resend-shape vs SendGrid-shape** — two competing wire shapes for a batch endpoint. Resend's `/emails/batch` accepts N complete, independent `Email` objects (the "bulk-submit N single-sends" model). SendGrid's `/mail/send` with personalizations accepts ONE shared content block + a `personalizations[]` array with per-recipient overrides (the "one email to a list" model). **e2a batches (MVP) is Resend-shape** — see §14 Q8. +- **`BatchMessage`** — the request-body sub-type for one item in a batch. Field-for-field a near-clone of `SendEmailRequest` minus `from` (agent is the path param, shared across the batch) — so `to`/`cc`/`bcc`/`subject`/`text`/`html`/`template_id`/`template_alias`/`template_data`/`reply_to`/`conversation_id`/`attachments` all exist per item, with the same caps and XOR rules as single-send. +- **Accept-tx** — the single Postgres transaction inside `handleSendBatch` that (a) inserts the `batches` row, (b) inserts up-to-N `messages` rows (one per non-suppressed item), (c) enqueues that many River jobs, (d) commits the idempotency-key completion. Either all four succeed or none do. See §9. +- **Screening** — the outbound protection step that produces a verdict per message: `Block` (refuse), `Review` (hold for HITL), `Flag` (send with annotation), or `Allow`. Configured via `internal/httpapi/protection.go` `ProtectionConfigView`. Batch send's HITL-refuse gate (§5.1) and block-whole-batch rule (§14 Q14) both derive from this verdict; screening runs **per item** because each item has its own content. + +## 1. Public API contract + +### 1.1 Endpoint + +- `POST /v1/agents/{email}/batches` — `operationId: sendBatch`. Registered in `internal/httpapi/outbound.go` alongside `sendMessage`. The `{email}` path parameter selects the sending agent, identical resolution to `sendMessage` (ownership check via `resolveOwnedAgent`). +- `GET /v1/batches/{batch_id}` — `operationId: getBatch`. Returns the batch header + per-status counts (§7.2). +- `listMessages` (`GET /v1/messages`) grows an optional `batch_id` query filter (§7.3). + +Only the POST and GET-batch endpoints add surface area; the list filter is one column added to an existing cursor struct. + +### 1.2 Request shape (`SendBatchRequest`) + +```jsonc +{ + "messages": [ + { + "to": ["alice@example.com"], + "subject": "Alice, your Q3 report is ready", + "text": "Hi Alice, ...", + "html": "

Hi Alice, ...

" + }, + { + "to": ["bob@example.com"], + "template_alias": "welcome", + "template_data": { "name": "Bob", "plan": "Pro" } + }, + { + "to": ["carol@example.com"], + "subject": "Ping", + "text": "Carol, quick follow-up on yesterday...", + "reply_to": "carol-thread@yourdomain.com", // per-item override wins over batch-level default + "attachments": [ { "filename": "notes.pdf", "content_base64": "..." } ] + } + // 1..100 items + ], + "reply_to": "support@yourdomain.com" // OPTIONAL batch-level default; each item may override +} +``` + +- `messages[]` — **required**, length in `[1, 100]`. Each item is a `BatchMessage` — field-for-field a near-clone of `SendEmailRequest` minus `from`. See §0.5 Terminology for the full field list. All XOR rules from single-send apply per item (literal `subject`/`text`/`html` XOR `template_id`/`template_alias`+`template_data`). +- **Per-item independence is native to MVP** — each item carries its own `to`/`cc`/`bcc`, content (literal or template), attachments, `conversation_id`, and `reply_to`. Callers who need mail-merge produce N items with N different bodies (or N different `template_data` maps against the same `template_alias`); callers who want identical content across recipients duplicate the body across items. +- **Batch-level fields that apply as defaults**: + - `reply_to` — if the batch body sets `reply_to` and an item leaves it unset, the batch value applies. Per-item `reply_to` always wins if present. This is the ONLY MVP batch-level default; every other content field must live on the item. Rationale: `reply_to` is the field most callers want uniform (e.g. `support@…`) and it's the only single-value scalar where duplication would be pure boilerplate. + - Future batch-level defaults (§11 polish) may add `conversation_id` propagation if telemetry shows it, but MVP keeps the surface minimal. +- **Attachment size ceiling (§14 Q15 decision)**: each item honors the single-send per-item cap (≤10 attachments per item, single attachment ≤10 MiB, item combined ≤25 MiB — matching `SendEmailRequest`). ADDITIONALLY the **batch-level combined attachment bytes across all items must be ≤ 25 MiB**. Callers can distribute freely: 100 items × 250 KiB, 5 items × 5 MiB + 95 empty, one item with 25 MiB + 99 empty (all equivalent to a "shared attachment" scenario) — all valid. +- Header: `Idempotency-Key` supported (path+body-hash scheme, `internal/idempotency/store.go:120`); replay returns the original 202 response verbatim. +- The `Prefer: return=minimal` / `wait=sent` shortcut from single-send is **not** offered in MVP (§2.4 rationale). + +The sending agent (`from` of every message) is the path agent — no `from` in the body, same as single-send ([internal/httpapi/outbound.go:864-866](../../internal/httpapi/outbound.go)). This is the single field that stays batch-uniform because it derives from the URL, not the body. + +### 1.3 Response shape (`SendBatchResponse`) + +`202 Accepted` — batches always return 202 at MVP (no synchronous shortcut). The body: + +```jsonc +{ + "batch_id": "bat_...", + "results": [ + { "message_id": "msg_..." }, + { "message_id": "msg_..." }, + { "suppressed": { "address": "spammy@example.com", "reason": "hard_bounce" } }, + { "message_id": "msg_..." } + // ... length == len(request.messages) + ], + "accepted": 98, + "suppressed_count": 2 +} +``` + +- `batch_id` — durable id, format `bat_<26-char base32 lower>` (same alphabet as `msg_` per project convention). +- `results[]` — **positionally aligned with `request.messages`**. Each slot is a discriminated union: + - **Accepted item**: `{ "message_id": "msg_..." }` — a `messages` row was inserted and a River `outbound_send` job enqueued. + - **Suppressed item**: `{ "suppressed": { "address": "...", "reason": "..." } }` — the (first) recipient address in this item hit the suppression list; no `messages` row exists. `reason` is the suppression-list category (`hard_bounce` / `complaint` / `unsubscribe` / `manual`) as recorded in the `suppressions` table. + This shape preserves per-item correlation (the i-th request item produced `results[i]`) while making the caller's success/skip branching explicit. +- `accepted` — count of `results[]` entries whose shape is `{ message_id }`. Redundant but useful for logs and metrics. +- `suppressed_count` — count of `results[]` entries whose shape is `{ suppressed }`. Also redundant; useful for zero-check without walking `results[]`. + +**Per-item suppression semantics** — when a `BatchMessage` has multiple recipients (`to`/`cc`/`bcc`) and ANY one of them is on the suppression list, the whole item is dropped (`results[i]` becomes `{ suppressed }`). This matches the single-send behavior where a suppressed recipient in the envelope fails the whole message; batch send does NOT partially drop recipients within a single item's SMTP envelope. + +### 1.4 Response codes + +Send-time (accept-tx) — **all-or-nothing on validation** (§2.1): + +| HTTP | Code | Trigger | +|---|---|---| +| **202** | — | Batch durably persisted; up-to-N messages enqueued (some may be suppressed per-item, reported via `results[]` — §1.3) | +| **400** | `invalid_request` | Body malformed; XOR (literal vs template) violated on any item; item field misses per-item validation | +| **400** | `invalid_recipient` | Any recipient address in any item fails RFC 5322 parse. `details.item_index`, `details.address` | +| **400** | `too_many_messages` | `len(messages) > 100` or `< 1`. `details` = `TooManyMessagesDetails { max_messages: 100, provided }` — new typed detail (§8) | +| **400** | `duplicate_recipient` | Same recipient address appears in two different items' `to` sets (batch-wide dedup — see §14 Q11). `details.address`, `details.item_indices` | +| **400** | `domain_not_verified` | Agent's sending domain isn't verified | +| **402** | `limit_exceeded` | Batch would exceed the account's monthly send quota (measured as N, §4.2) | +| **403** | `blocked_by_policy` | Screening returns Block for any item — content-scan block on that item's content, or a per-item recipient-policy block under `action=block`. `details.item_index`, `details.reason` (§14 Q14, all-or-nothing) | +| **403** | `batch_hitl_unsupported` | *(new code, §5)* Agent has HITL enabled — MVP refuses batch | +| **409** | `idempotency_in_flight` | Same `Idempotency-Key` currently running | +| **413** | `payload_too_large` | ANY per-item attachment cap exceeded (single attachment ≤10 MiB, item combined ≤25 MiB) OR **batch-level combined attachment bytes > 25 MiB** (§14 Q15). `details.scope` = `item` \| `batch`; on `item`, `details.item_index`; on `batch`, `details.computed_batch_bytes` + `details.max_batch_bytes` | +| **422** | `idempotency_key_reuse` | Same key + different body | +| **429** | `rate_limited` | Adding N would exceed the agent's send-per-minute rate limit. `details.retry_after_seconds` (existing shape) | + +Delivery-time (post-accept, per-message) — same webhook events, per-message `email.sent`/`email.failed`/`email.deferred` etc., **each carrying `batch_id`** (already reserved in `async-send-contract.md §4.4`). + +## 2. Semantics — the reconciled "all or nothing" + +The word "all-or-nothing" has two failure classes hiding under it. The MVP treats them differently on purpose. + +### 2.1 Structural errors → all-or-nothing (reject the whole batch) + +Validation runs **per item** for content-related checks, but any single failure rejects the whole batch. If **any** of the following is true, the request is rejected with an appropriate 4xx **before** any DB row is inserted: + +- Malformed body / unknown field / any item violates the `SendEmailRequest` schema (missing required fields, wrong types, XOR of literal vs template violated) → 400 `invalid_request`. `details.item_index` when the failure is per-item. +- Any recipient address in any item fails RFC 5322 parse → 400 `invalid_recipient` with `details.item_index` + `details.address` +- `len(messages) > 100` or `< 1` → 400 `too_many_messages` (new code) +- Same recipient address appears in the `to` set of two different items → 400 `duplicate_recipient` (**not silently deduplicated** — silent dedup would send N-k messages when the caller asked for N and there's no way to know whether the duplicate was intentional; see §14 Q11). Only cross-item duplicates in `to` are checked; duplicates in cc/bcc across items are allowed (matches how real callers cc a common address across items). +- Any item exceeds per-item attachment caps (single attachment > 10 MiB, item combined > 25 MiB) → 413 `payload_too_large` with `details.scope = "item"`, `details.item_index` +- **Batch-level combined attachment bytes across all items > 25 MiB** → 413 `payload_too_large` with `details.scope = "batch"`, `details.computed_batch_bytes`, `details.max_batch_bytes` (§14 Q15) +- Sending domain not verified → 400 `domain_not_verified` +- Screening produces a **block** verdict for any item — either that item's content trips a content-scan block, or any recipient in that item's envelope is not in the outbound `allowlist`/`domain` gate under `action=block` → 403 `blocked_by_policy` with `details.item_index`, `details.reason` (§14 Q14 all-or-nothing; block-only agents still reach batch, HITL agents were already refused at §5.1) +- Adding N sends would exceed rate limit or plan quota → 429 `rate_limited` / 402 `limit_exceeded` (see §4) +- Agent has HITL enabled (`OutboundPolicyAction=="review"` OR `OutboundScanSensitivity!="off"`, §14 Q13) → 403 `batch_hitl_unsupported` (§5) + +Frozen invariant: **if a 4xx is returned from `sendBatch`, zero messages exist.** Callers can retry with the same `Idempotency-Key` after fixing the input without worrying about half-sent state. + +### 2.2 Business filters → per-item skip (do NOT reject the whole batch) + +The only per-item filter in MVP is **suppression list membership** (existing `recipient_suppressed` semantics on single-send). Rationale: + +- The suppression list is compliance infrastructure that e2a *maintains on the caller's behalf* — hard-bounces, complaints, unsubscribe list entries. In a real 100-address list from any long-running product, 1-3% will be suppressed. All major North American ESPs (Postmark, SendGrid, Mailgun, Resend, SES) handle this per-item, not per-batch. Rejecting the batch on the ESP's own compliance work punishes the caller for e2a doing its job. +- Suppression is checked at send-time against the `suppressions` table, not derivable from the request body — so it belongs to the "per-item business filter" class, distinct from structural validation. + +Behavior: +- Any `BatchMessage` item whose `to` envelope contains a suppressed address is dropped in its entirety. No `messages` row is inserted for that item; no River job is enqueued. The item's slot in `results[]` becomes `{ suppressed: { address, reason } }` (§1.3). +- `accepted` = number of items whose slot in `results[]` is `{ message_id }`; `suppressed_count` = number of slots that are `{ suppressed }`. +- If *every* item is suppressed, still return 202 — every `results[]` slot is `{ suppressed }`, `accepted: 0`, `suppressed_count: N`. This is a legitimate outcome per §14 Q9 (the caller submitted valid input; e2a's own compliance layer filtered everything). +- If a single item has multiple recipients (`to` array of length ≥ 2, or cc/bcc addresses) and ANY of them is suppressed, the whole item is dropped. Batch send does NOT partially prune recipients from within an item's SMTP envelope — that would silently change what the caller asked to send. + +### 2.3 Delivery-time semantics (post-accept) + +Once accepted, each message is a standalone entity. Each has its own River `outbound_send` job (`OutboundSendArgs{ MessageID: msgID }`, [internal/outboundsend/worker.go:57-61](../../internal/outboundsend/worker.go)). Retries, snoozing, terminal reconciliation are unchanged from single-send: +- 6 attempts with 30s → 2m → 10m → 1h → 4h backoff ([worker.go:33-42](../../internal/outboundsend/worker.go)) +- Outage-class errors snooze 5m without burning attempts, up to a 72h horizon from `messages.created_at` +- 4xx SMTP → transient (retry); 5xx SMTP → permanent (JobCancel + `markFailed`); connection-class → outage (snooze) +- Terminal reconciler ([terminal_reconcile.go](../../internal/outboundsend/terminal_reconcile.go)) sweeps stuck rows every 1 min + +No batch-level retry envelope exists. If 3 of 100 messages permanently fail SMTP (5xx), those 3 emit `email.failed` webhook events; the other 97 continue independently. This IS partial success at the delivery layer — and that is unavoidable for email (once a message is out the door you cannot un-send it). + +### 2.4 No `wait=sent` in MVP + +Single-send offers `wait=sent` (poll up to 15s, up to 20s ceiling per async-send-contract §2.3). Batch does not: +- The polling shape (`PollSendOutcome`, [outbound.go:760-786](../../internal/httpapi/outbound.go)) is single-message. Extending to N-message poll would multiply worker load and add contract complexity for a use case (agent fires 100 emails then blocks 20s) that isn't real. +- Batch is inherently asynchronous by user intent — the caller is fanning out, not waiting on delivery. + +## 3. Data model + +### 3.1 New `batches` table + +```sql +CREATE TABLE batches ( + batch_id TEXT PRIMARY KEY, -- 'bat_' + account_id UUID NOT NULL REFERENCES accounts(account_id) ON DELETE CASCADE, + agent_id UUID NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE, + requested INTEGER NOT NULL, -- len(request.messages) + accepted INTEGER NOT NULL, -- requested minus suppression drops + suppressed_json JSONB NOT NULL DEFAULT '[]', + -- [{ item_index: int, address: str, reason: str }] captured at accept. + -- Mirrors the response `results[]` slots whose shape is { suppressed }. + request_id TEXT NOT NULL, -- for audit trail + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX batches_agent_created_at_idx ON batches (agent_id, created_at DESC); +``` + +Rationale for storing `suppressed_json` on the batch row (not per-message): a suppressed item produces NO `messages` row, so there is no other durable place to record the drop. The batch row is the only place that remembers "item i was in your request but we skipped it because address X hit the suppression list." + +### 3.2 `messages` gains `batch_id` + +```sql +ALTER TABLE messages ADD COLUMN batch_id TEXT + REFERENCES batches(batch_id) ON DELETE SET NULL; +CREATE INDEX messages_batch_id_idx ON messages (batch_id) WHERE batch_id IS NOT NULL; +``` + +- NULL for single-sends. Non-null for batch children. +- `ON DELETE SET NULL` — deleting a batch row must not cascade-delete messages (messages are the record of what was sent; a batch is metadata). The 90-day retention/janitor sweep for messages is unchanged. + +### 3.3 State model + +**Messages** — no change. Same `accepted → sending → sent → {delivered, deferred, bounced, complained, failed}` from [internal/delivery/status.go](../../internal/delivery/status.go). The batch itself has no lifecycle state — it is a static header describing what was accepted. Rollup is computed on read (§7.1). + +## 4. Rate limiting and quota + +### 4.1 The two limits, and where each applies + +e2a has two capacity concepts (from [internal/httpapi/errors.go](../../internal/httpapi/errors.go) and [outbound.go:64-66](../../internal/httpapi/outbound.go)): + +- **`rate_limited` (429)** — throughput. Currently 60 sends/agent/minute (sliding window, `sendLimit` in [internal/agent/api.go:448](../../internal/agent/api.go)). Retryable. +- **`limit_exceeded` (402)** — monthly quota / plan. Not retryable. + +### 4.2 How batch counts against them + +**A batch of N `BatchMessage` items counts as N sends against BOTH limits.** (Suppression-filtered items don't count — the accept-time reservation uses `len(keep)`, not `len(messages)`.) Rationale: + +- Rate limits and quotas are throughput/capacity signals. A batch of 100 puts the same load on downstream SMTP as 100 individual sends — same DKIM signings, same SMTP conns from the worker pool, same billing units. Counting a batch as "1" would create arbitrage: a user could bypass rate-limiting by wrapping their sends in `batches` calls. +- The industry standard for send-count / quota limits is per-message; batch is a *transport* optimization (fewer HTTP round trips), not a *rate* one. Postmark, SendGrid, Mailgun all count per-message against send quotas. + +### 4.3 Rate-limit check happens at accept, not per-item + +`checkSendLimit(agentID, count=N)` is called once, before any DB writes. If it would exceed, the whole batch is rejected with 429 (§2.1). Retry-after is the existing `retry_after_seconds` computed by [internal/ratelimit/ratelimit.go:40-72](../../internal/ratelimit/ratelimit.go). The current `AllowWithRetryAfter` takes one hit at a time; we add a variant `AllowN(count int)` that atomically reserves N slots or rejects. + +### 4.4 HTTP-layer request rate limit — out of scope + +If e2a later adds a request-level rate limit (like Postmark's 100 req/min API cap), that layer *should* count a batch as 1 (it's one HTTP request). No such limit exists today; noted for completeness. + +## 5. HITL interaction + +HITL (human-in-the-loop review) is e2a's differentiator — an agent's outbound can be gated on a reviewer's approval before the SMTP submit ([internal/agent/api.go](../../internal/agent/api.go), `HoldForApprovalCore`). Batch send must not silently break HITL guarantees; the MVP decision is to sidestep the interaction entirely: + +### 5.1 MVP: refuse batch for HITL-enabled agents + +If `agentUsesHITL(agent)` returns true — i.e., `OutboundPolicyAction == "review"` **or** `OutboundScanSensitivity != "off"` (see §14 Q13 for the frozen formula and rationale) — `sendBatch` rejects with: + +- **HTTP 403** `batch_hitl_unsupported` (new code) +- `details.hint`: `"batch send is not available for agents with HITL enabled; use single-send POST /v1/agents/{email}/messages per recipient, or disable HITL on this agent"` + +**Block-only agents (`Gate.Action == "block"` with `Scan.Sensitivity == "off"`) are NOT considered HITL** and DO reach the batch send path. Block verdicts encountered during accept-time screening fail the whole batch with 403 `blocked_by_policy` per §14 Q14 — block is an automated policy filter, not a review workflow. + +Rationale: HITL creates a per-message review UI that is designed for one-at-a-time inspection. A batch of 100 messages would either (a) create 100 review items — usability nightmare for the reviewer, (b) create one collective review — new UI surface, or (c) short-circuit review — silently unsafe. All three are v1-polish scope, not MVP. + +### 5.2 Polish v1: batch-level review (deferred) + +Reviewer sees one review item for the whole batch (template + recipient count + first 3 recipients preview + a "show all N" link). One approve/reject decision releases or fails the whole batch. This is what Salesforce campaign send-approval looks like and matches how humans actually want to review "send X to 500 people." + +### 5.3 Polish v2 (may never ship): per-message review + +Every message in a batch becomes an independent review item. Only useful for tightly regulated verticals (legal, medical). Deferred until a customer asks. + +## 6. Idempotency + +### 6.1 Same shape as single-send + +- `Idempotency-Key` header, `(user_id, key)` scope, 24h TTL, hash covers path + raw body — no code changes to [internal/idempotency/store.go](../../internal/idempotency/store.go). +- Replay (`OutcomeReplay`) returns the cached 202 body byte-for-byte, including `batch_id` and `message_ids`. `Idempotent-Replayed: true` header set. +- Body mismatch → 422 `idempotency_key_reuse`. +- Concurrent same-key → 409 `idempotency_in_flight`. + +### 6.2 Complete inside the batch accept-tx + +The idempotency completion (`CompleteTx`, [store.go:270-282](../../internal/idempotency/store.go)) is called inside the same DB transaction that inserts the batch row + all N message rows + enqueues all N River jobs. If the tx fails, no state changes; caller sees a 5xx and can retry safely with the same key. + +## 7. Observability + +### 7.1 `GET /v1/batches/{batch_id}` — batch header + status rollup + +Response body: + +```jsonc +{ + "batch_id": "bat_...", + "agent_id": "agt_...", + "requested": 100, + "accepted": 98, + "suppressed": [ + { "item_index": 1, "address": "spammy@example.com", "reason": "complaint" }, + { "item_index": 47, "address": "hardbounce@example.com", "reason": "hard_bounce" } + ], + "created_at": "2026-07-16T21:58:06Z", + "status_rollup": { + "accepted": 5, + "sending": 10, + "sent": 40, + "delivered": 30, + "deferred": 3, + "bounced": 10, + "complained": 0, + "failed": 0 + } +} +``` + +`status_rollup` is computed on read via a single grouped query against `messages` (indexed on `batch_id`). Not cached — batch observation is a rare operation (poll after send), cheap enough at 100 rows per batch. + +### 7.2 `listMessages?batch_id=...` + +Adds `batch_id` to the existing `messagesCursor` filter struct ([internal/httpapi/messages.go:368-382](../../internal/httpapi/messages.go)). Cursor pagination over batch children, same shape as unfiltered list. Necessary for callers who want per-recipient detail beyond the aggregate rollup. + +### 7.3 Webhook events + +Every send-related event (`email.sent`, `email.failed`, `email.deferred`, `email.delivered`, `email.bounced`, `email.complained`) already carries an optional `batch_id` per async-send-contract §4.4. Batch children populate this field; single-sends leave it empty. **No new event types.** Callers subscribe once and their existing handlers work for batch outputs unchanged. + +### 7.4 No `GET /v1/batches` list endpoint in MVP + +We defer a "list all my batches" endpoint. Callers who want batch history can query messages filtered by any predicate; batch is a header, not a first-class entity worth listing. If demand emerges post-GA, add `listBatches` following the standard cursor pattern. + +## 8. Error contract additions + +Three new codes must be added to [internal/httpapi/error_catalog.go](../../internal/httpapi/error_catalog.go): + +- `too_many_messages` — HTTP 400, retryable=false. New typed detail (batch-specific; distinct from single-send's `too_many_recipients` which stays intact): + ```go + type TooManyMessagesDetails struct { + MaxMessages int `json:"max_messages"` + Provided int `json:"provided"` + } + ``` +- `duplicate_recipient` — HTTP 400, retryable=false. New typed detail: + ```go + type DuplicateRecipientDetails struct { + Address string `json:"address"` + ItemIndices []int `json:"item_indices"` // cross-item positions of the duplicate `to` + } + ``` +- `batch_hitl_unsupported` — HTTP 403, retryable=false. `details.hint` (existing `HintDetails` shape). + +Two existing error codes need per-item `details.item_index` extension to disambiguate WHICH item in a batch tripped the check: + +- `invalid_request`, `invalid_recipient`, `template_render_failed`, `payload_too_large`, `blocked_by_policy` — extend their existing typed details with an optional `ItemIndex *int json:"item_index,omitempty"` field. Present only on batch responses; absent on single-send responses (backward-compatible). + +`payload_too_large` additionally gains a `Scope` discriminator (`"item"` | `"batch"`) to distinguish per-item cap breaches from the batch-level 25 MiB cap (§14 Q15). + +No changes to the envelope shape ([internal/httpapi/errors.go:40-61](../../internal/httpapi/errors.go)); adding new codes and extending typed details is expected under the "open set" policy documented at `errors.go:57`. + +## 9. Accept-transaction sketch + +The single DB transaction the batch handler runs, in order. Each step listed as its own numbered item; short-circuit on the first failure with the mapped 4xx. Because each `BatchMessage` item is a near-clone of `SendEmailRequest`, most steps below reuse the single-send helpers **called in a loop** rather than being reimplemented — this is the biggest architectural win of the Resend-shape. + +1. **Idempotency claim** — `idempotencyGuard.Claim(request)`. If replay, return cached 202. If in-flight, 409. If mismatch, 422. +2. **Structural validation** — parse request; then for each item run `validateOutboundBody(item)` (existing single-send validator at [internal/httpapi/outbound.go](../../internal/httpapi/outbound.go)): RFC 5322 on `to`/`cc`/`bcc`, XOR of literal vs template, size caps on subject/text/html, attachment per-item caps. First failure short-circuits with 400 `invalid_request` or `invalid_recipient` (`details.item_index` set). Batch-level structural checks: `len(messages) ∈ [1, 100]` (else 400 `too_many_messages`); cross-item `to` duplicate detection (else 400 `duplicate_recipient`); sum of all items' `attachments[].size_bytes` ≤ 25 MiB (else 413 `payload_too_large` with `details.scope="batch"`, §14 Q15). +3. **HITL gate** — call `agentUsesHITL(agent)` (§14 Q13 formula). If true → 403 `batch_hitl_unsupported`. +4. **Domain verification** — `checkDomainVerified(agentID)` (existing single-send code). +5. **Per-item screening (block whole-batch on any block verdict)** — for each item, call `screenOutbound(agent, item.composedContent, item.envelopeAddresses)` (existing `screenOutbound` at [internal/agent/api.go:1248](../../internal/agent/api.go)) with THAT item's content and recipient list. If ANY item returns `verdict.Block()`, reject the whole batch with 403 `blocked_by_policy` (`details.item_index` set) and emit an audit event per single-send convention. §14 Q14 all-or-nothing: block never silently drops in batch. Screening is called **N times in a loop** because each item's content and recipient set differ — but the underlying screener is the single-send one, unchanged. +6. **Suppression check** — collect the `to` addresses from all items, SELECT from `suppressions` for the union in one query, partition items into `keep[]` (no address suppressed) and `suppressed[]` (any address in `to` suppressed). Per §14 Q9, an all-suppressed batch (`len(keep) == 0`) is a valid 202 with all `results[]` slots as `{ suppressed }`. +7. **Rate/quota reservation** — `checkSendLimit(agentID, count=len(keep))` reserves the throughput slots atomically (`AllowN(count)` variant, §4.3); `checkMonthlyQuota(accountID, count=len(keep))` on the plan quota. Attachment storage per §14 Q10 is content-hash deduped — sum unique-hash bytes across the batch's attachments and call `checkAttachmentStorageQuota(accountID, uniqueBytes)`; egress cost is naturally captured by the N-multiplied `checkSendLimit`. All rejections release any partial reservations. +8. **Per-item template render** (for items using `template_id`/`template_alias`) — for each such item, call the existing single-send `resolveTemplate(item)` + `emailtemplate.Render(item.template_data)` — a per-item render, once per item. `template_render_failed` on any item's failure short-circuits with 400 (`details.item_index`). Items using literal `subject`/`text`/`html` skip this step. This is the mail-merge behavior described in §0.5: N different `template_data` maps against the same template alias produce N different rendered bodies. +9. **Prepare `MessageForAccept` structs for each item in `keep[]`** — one struct per accepted item, each with its own minted `message_id` (`ulid`), its own rendered content (from step 8 or the literal fields), its own `to`/`cc`/`bcc` envelope. `batch_id` is minted here too (a `bat_` value); every prepared struct references it. +10. **Open DB tx** and inside it: + - a. INSERT `batches` row (id = the minted `batch_id`, `requested = len(messages)`, `accepted = len(keep)`, `suppressed_json` = the drop list with `{item_index, address, reason}`) + - b. Bulk-INSERT `len(keep)` `messages` rows with `batch_id`, `delivery_status='accepted'` (single round trip via `CreateOutboundMessagesTx`, §10 Phase B) + - c. Do NOT insert `message_recipients` at accept-time (matches single-send — those rows are written at `MarkSent`, [internal/identity/delivery_store.go:156-195](../../internal/identity/delivery_store.go)). + - d. `outboundEnq.EnqueueBatchTx(ctx, tx, msgIDs)` — enqueues `len(keep)` `OutboundSendArgs` in one `river.InsertManyTx` call. The `outbound_send` worker is unchanged — it sees N distinct jobs with distinct `MessageID` args. + - e. `idemCompleteTx` — commit the idempotency-key completion with the 202 response body cached (`batch_id`, `results[]`, `accepted`, `suppressed_count`). +11. **Commit** — on success, return 202. On any error, tx rolls back; no state changes; the reservation from step 7 is released; caller sees the appropriate 4xx/5xx. + +**Consistency note on step 10c** — single-send inserts `message_recipients` rows at `MarkSent` time (post-SMTP), not at accept ([internal/identity/delivery_store.go:156-195](../../internal/identity/delivery_store.go)). Batch follows the same pattern for consistency: don't pre-populate `message_recipients` at accept-tx. Rollup queries and per-recipient status pages continue to work off `messages` for the pre-SMTP window. + +**Complexity note** — steps 2, 5, 8 all iterate N times but call unchanged single-send helpers. The batch handler is thus roughly `handleSendMessage in a loop + batch-level cross-item checks (dup, attachment sum, rate reservation) + one bulk-INSERT + one InsertManyTx`. This is the concrete meaning of "Resend-shape reuses the single-send code path" — it's a loop, not a reimplementation. + +## 10. Implementation plan (2 PRs) + +Ships as **2 PRs** — matching the project's convention of landing a coherent feature per PR (cf. PR #536 `feat(auth): generic external-auth`, PR #370 `Add per-domain sending ramp-up` — both single-PR features with internal commit sequencing). Every commit that touches the spec runs `make spec` + `make generate-sdk`; contract drift is already CI-gated. + +### PR 1: Design doc + backend feature + +One PR delivering the complete server-side batch-send capability. Commits are ordered so the reviewer reads the design doc first, then follows the implementation top-down. + +**Commit sequence:** + +1. **`docs/design/batch-send.md`** — this file. Lands as the first commit so any reviewer can sign off on the shape before reading a single line of code. +2. **OpenAPI spec + error catalog** — `sendBatch` and `getBatch` operations; `SendBatchRequest` / `SendBatchResponse` / `BatchView` / `BatchMessage` / `BatchResult` schemas in `api/openapi.yaml`; register `too_many_messages`, `duplicate_recipient`, `batch_hitl_unsupported` in `internal/httpapi/error_catalog.go`; fixture JSONs under `api/fixtures/errors/`; regenerate SDK bases via `make generate-sdk` (hand-written wrappers land in PR 2). Contract-only, no runtime behavior. +3. **Migration + storage layer** — `migrations/NNN_batches.sql` (new `batches` table + `messages.batch_id` column + index). `internal/store` methods: `CreateBatchTx`, `GetBatch`, `BatchStatusRollup(batchID)`, and the bulk-insert `CreateOutboundMessagesTx(msgs []MessageForAccept)` (bulk variant of the single-row inserter at [identity/delivery_store.go](../../internal/identity/delivery_store.go)). +4. **Handler + accept-tx** — `internal/httpapi/outbound.go` registers `sendBatch` and implements `handleSendBatch` following §9; `internal/agent/api.go` gets `DeliverBatch(ctx, req)` (the batch analog of `DeliverOutbound` driving §9 steps 3–9); `internal/outboundsend/jobs.go` gets `EnqueueBatchTx(ctx, tx, msgIDs)` wrapping `river.InsertManyTx`; `internal/ratelimit/ratelimit.go` gains `AllowN(count int)` (§4.3). +5. **Observability** — `getBatch` handler in `internal/httpapi/`; `messagesCursor` extended with a `batch_id` filter and wired into `handleListMessages`; `PublishTx` argument plumbing so batch children populate the `batch_id` field on webhook event payloads (field is already reserved in the event schema per `async-send-contract.md §4.4`). +6. **Tests** — contract tests for the happy path (100 accepted) and every validation error in §1.4; storage-rollback tests injecting failure at each step of §9 (verify zero orphan state); Mailpit-based integration in `tests/` (submit 5-recipient batch → 5 SMTP deliveries → 5 `email.sent` events all carrying the same `batch_id` → `GET /v1/batches/{id}` rollup shows 5 delivered); prober scenario for a scheduled loopback batch. +7. **User-facing docs** — `docs/api.md` gains a batch section; `docs/events.md` notes that `batch_id` is now populated on batch children. + +**Feature flag.** Ships behind `E2A_BATCH_SEND_ENABLED` (default `false`) — merge and enable are separable (§13). + +**Estimated size:** ~20–25 files across 7 commits. + +**Fault line if the mentor asks to split.** A natural break sits between commits 4 and 5 — cut into "design + spec + backend core" (commits 1-4) and "observability + tests + docs" (commits 5-7). Still 3 PRs total, still fewer than the phased-6-PR plan. + +### PR 2: Clients (SDKs + CLI) + +Depends on PR 1 merged. Adds hand-written client wrappers now that the generated bases are stable. + +- **TS SDK** (`sdks/typescript/src/`) — `client.batches.send(...)` and `client.batches.get(...)` over the generated base; types re-exported at the top-level entry. +- **Python SDK** (`sdks/python/`) — the same for both sync and async clients. +- **CLI** (`cli/src/`) — `e2a batch send --agent --messages messages.jsonl` reading JSON-lines input where each line is one `BatchMessage`; exit codes follow the frozen contract (0 = 202 accepted, 3 = validation, 4 = server error), matching `e2a send`. +- SDK-level tests hit a mock server; CLI exit-code test. + +**Estimated size:** ~10–15 files across 3 languages. + +--- + +**Total: 2 PRs.** Design + backend + observability + tests + user docs in one coherent PR; clients in a follow-up. If PR 1 review feedback surfaces a scope disagreement, splitting at the fault line above is a documented option. + +## 11. Post-MVP polish (deferred, listed for scope clarity) + +Two items previously listed here — per-recipient template variables and per-recipient content overrides (cc/bcc/reply_to/subject/attachments) — are **ALREADY IN MVP** as a natural consequence of the Resend-shaped request (each `BatchMessage` is a self-contained near-clone of `SendEmailRequest`, so per-item content variation is expressed by having the caller send different bodies per item). What remains in polish is genuinely orthogonal to the request shape: + +- **Batch-level HITL review** — §5.2. Adds a `pending_review` state to the batch row, a batch review item in the reviewer UI, one approve/reject decision releasing the whole batch. Unlocks batch send for HITL-enabled agents (currently refused at MVP per §5.1). +- **Batch cancellation** — `POST /v1/batches/{id}/cancel`. Sets all still-`accepted` children to a terminal `cancelled` state, evicts their River jobs. Only useful if we build (a) review-held batches (needs the HITL polish above) or (b) a scheduled-send feature. +- **`listBatches`** — cursor endpoint over `batches` for the "show me my recent batches" dashboard use case. Follows the standard cursor pattern (`internal/httpapi/messages.go:342-382`). Deferred because in the meantime `listMessages?batch_id=…` already answers most callers; `listBatches` adds convenience, not capability. +- **Batch-level shared field expansion** — if telemetry shows callers frequently duplicate the same field (e.g. `conversation_id`, tags, a common `attachments` list) across every item, add batch-level defaults + per-item override for those fields. Pure wire-efficiency, no new semantics. MVP ships with `reply_to` as the only batch-level default per §1.2. +- **`wait=sent` for batch** — probably never; the shape is a bad fit (§2.4). Listed only so a future reader knows this was considered and dropped, not overlooked. + +**Note on template polish** — the template system itself (Mustache subset, beta status, no loops/conditions) is orthogonal to batch send. Any template-engine polish (adding `{{#if}}`/`{{#each}}`, promoting from beta to stable, adding partials/includes) is a template-system project, not a batch-send project. Batch send inherits whatever the template system supports at that moment — no additional work on batch send is needed to consume future template features. + +## 12. Testing plan (summary — details in Phase F) + +- **Contract tests** — every 4xx/5xx path from §1.4; happy path with `Idempotency-Key`; replay; mismatch; in-flight. +- **Storage tests** — accept-tx rollback under simulated failure at each step of §9 (idempotency, message insert, River insert, complete). Verify zero orphan state after rollback. +- **Integration tests** — end-to-end via Mailpit: submit 5-recipient batch → verify 5 SMTP deliveries → verify 5 `email.sent` webhook events all carrying same `batch_id` → verify `GET /v1/batches/{id}` rollup shows 5 delivered. +- **Load smoke** — one batch of 100, measure accept-tx latency at p50/p95 (target: <100ms accept, all 100 River jobs enqueued in-transaction). +- **Prober scenario** — a scheduled canary that fires a batch to loopback and verifies terminal states. + +## 13. Rollout / migration notes + +- Purely additive to the /v1 surface — no back-compat concerns. Callers who ignore `batches` see no change. +- The `messages.batch_id` column is nullable; existing rows and existing single-send inserts are unaffected. +- Feature can ship dark: register the endpoint but keep it behind an internal feature flag (e.g., `E2A_BATCH_SEND_ENABLED=false` default) until Phase F testing passes. Flag flip is a config change, no code redeploy. + +## 14. Design decision journal (frozen 2026-07-16) + +This section is the record of *why* the spec looks the way it does — the questions raised while designing this feature, the options weighed, the decisions taken, and the reasoning behind each. It is written for a future maintainer (or the reviewer at GA+6mo) who needs to understand whether a decision can be revisited without breaking a load-bearing invariant. + +Organized in three tiers: +- **Tier 1 — Foundational framing**: what "batch send" and "all or nothing" actually mean. (Q1, Q2) +- **Tier 2 — Strategic model choices**: MVP semantics with reference to North-American ESP norms (suppression, rate limits, HITL, attachments, provider modeling). (Q3–Q8) +- **Tier 3 — MVP detail decisions**: contract-level details asked and answered before Phase C. (Q9–Q15) + +### Tier 1 — Foundational framing + +#### Q1. What does "batch send" actually mean in e2a's context? + +- **Options considered.** + - (a) *Envelope batch (cc/bcc)*: one message with N recipients on to+cc+bcc, one SMTP envelope — each recipient sees the other addresses. + - (b) *Fanout*: N *independent* messages, one recipient each (or one small recipient group each), N SMTP envelopes — recipients do not see each other. +- **Decision:** **(b) Fanout.** Batch send introduces one API call that creates N independent messages, each with its own `message_id`, its own content, and its own SMTP delivery. +- **Reasoning.** Envelope batching (a) already exists on `POST /v1/agents/{email}/messages` with a combined 50-recipient cap ([internal/httpapi/outbound.go:179](../../internal/httpapi/outbound.go)). Every AI-agent outbound use-case (LLM-personalized outreach, per-user transactional notifications, per-lead follow-ups) requires (b) — recipient A must not see recipient B in the message headers, and their content is almost always different from A's. All North-American ESPs offering "batch" (Resend `/emails/batch`, SendGrid mail-merge, Postmark batch API) implement (b). Naming this feature "batch send" was consistent with peer terminology. +- **What fanout does NOT imply.** Fanout is orthogonal to whether content is shared or per-item. Both Resend's shape (fully independent content per item) and SendGrid's personalizations shape (shared content template + per-recipient variables) are fanout patterns. The content-model choice is Q8, not Q1. + +#### Q2. What does "all or nothing" mean concretely? + +- **Options considered.** + - (a) *Both accept-time and delivery-time all-or-nothing*: if any of N recipients ends up as an SMTP failure (bounce, defer, permanent 5xx), retract the entire batch and mark all N failed. + - (b) *Only accept-time all-or-nothing*: the ACCEPT decision is binary (batch is fully accepted or fully rejected); once accepted, each message goes through the normal per-message retry pipeline independently. +- **Decision:** **(b) Accept-time all-or-nothing; delivery-time per-message.** See §2.1 vs §2.3. +- **Reasoning.** (a) is physically impossible for email — once a message is submitted to an upstream MTA, it cannot be un-sent. Retract-on-partial-failure is not a real option; the mentor's brief could only have meant (b). This interpretation also aligns with the existing e2a error contract, which has NO partial-success envelope shape ([internal/httpapi/errors.go](../../internal/httpapi/errors.go)) — a batch endpoint that returned "partial-success at accept" would introduce a shape inconsistent with every other /v1 endpoint. Peer providers (Resend, SendGrid, Postmark) all take shape (b). + +### Tier 2 — Strategic model choices (semantics with commercial context) + +#### Q3. Suppression list hit — reject the batch or skip per-item? + +- **Options considered.** + - (a) *Batch-level*: any suppressed recipient in the batch → reject entire batch (strict all-or-nothing). + - (b) *Per-item*: drop suppressed recipients, accept the rest, report drops in a `suppressed[]` response field. +- **Decision:** **(b) Per-item skip.** See §2.2. +- **Reasoning.** Suppression is *compliance infrastructure e2a maintains on the caller's behalf* — hard-bounces, complaints, unsubscribe list entries, mandated by CAN-SPAM (US) and CASL (Canada). Every major North-American ESP handles suppression per-item, not per-batch: **Postmark** rejects the individual message with per-message error; **SendGrid** silently skips and reports in Activity Feed; **Mailgun** skips and logs; **Resend** returns per-item errors; **AWS SES** silently skips. Rejecting the whole batch on the ESP's own compliance work is punishing the caller for e2a doing its job — a 1-3% suppression rate is normal on any long-running contact list, and the caller cannot pre-filter (the suppression store is server-owned). This is the one legitimate "per-item" concession inside an otherwise all-or-nothing accept contract; the response's `suppressed[]` array preserves per-caller visibility. + +#### Q4. Rate limit — count a batch as 1 request or N sends? + +- **Options considered.** + - (a) *Count as 1*: batch is one API call, decrement rate-limit budget by 1. + - (b) *Count as N*: batch consumes N slots against send-per-minute throughput and monthly send quota. +- **Decision:** **(b) Count as N.** See §4. +- **Reasoning.** The industry standard is **two distinct rate limits**: an *HTTP request rate* (per-second/minute API cap) that counts a batch as 1, and a *send quota* (per-hour/day or per-account) that counts as N. e2a's current `sendLimit` at 60/min/agent ([internal/agent/api.go:448](../../internal/agent/api.go)) is the second kind — send throughput, not API request throughput — so a batch must consume N slots. Counting as 1 would create arbitrage: two users at identical downstream load see different rate-limit behavior based on whether they wrap sends in a batch. Postmark, SendGrid, and Mailgun all use "count as N" for their send quotas. If e2a later adds an HTTP-request-layer rate limit, that layer *should* count a batch as 1 (§4.4) — orthogonal. + +#### Q5. Batch size cap — 100, 500, or 1000? + +- **Options considered.** 100 (Resend); 500 (mid-range); 1000 (SendGrid personalizations). +- **Decision:** **100 for MVP.** See §1.2. +- **Reasoning.** 100 matches Resend's cap (the closer peer, Q8). Larger caps increase accept-tx latency (100 message inserts + 100 River enqueues fit comfortably in a single Postgres transaction; 1000 does too but with less headroom for concurrent workload). Reversible upward — raising the cap post-GA is additive; lowering it is a breaking change. + +#### Q6. HITL interaction — batch-level review, per-message review, or refuse for HITL-enabled? + +- **Options considered.** + - (a) *Per-message review*: batch of 100 creates 100 review items in the reviewer UI. + - (b) *Batch-level review*: one review item for the whole batch (template + recipient count + first-N preview), one approve/reject decision releases or fails the whole batch. + - (c) *Refuse batch for HITL-enabled agents*: 403 error, caller must use single-send. +- **Decision:** **MVP = (c). v1 polish = (b). v2 (maybe never) = (a).** See §5. +- **Reasoning.** HITL is e2a's differentiator; batch send must not silently break its guarantees. (a) is a usability nightmare — a reviewer reading 100 near-identical drafts is worse than paging through them one-at-a-time. (b) is the correct long-term shape (matches Salesforce's campaign-approval UX, HubSpot's send-approval flow) but requires new review-UI surface area — an entire vertical of scope not needed to prove the batch primitive works. (c) is the "scope isolation" move: refuse the interaction, document the escape hatch (disable HITL or use single-send per recipient). MVP ships without touching the HITL system at all. HITL detection formula is Q13. + +#### Q7. Attachments — shared, per-item, or reference-based? + +- **Options considered.** + - (a) *Batch-level shared*: attachments live in the batch body, same one blob delivered to every item. + - (b) *Per-item*: each `BatchMessage` carries its own `attachments[]` (Resend's shape). Each item can have zero attachments, or the same, or completely different. + - (c) *Reference-based (media library)*: upload once out-of-band, reference by attachment id from within the batch — client can compose per-item references cheaply. +- **Decision:** **(b) Per-item with a batch-wide 25 MiB total ceiling.** See §1.2, §14 Q15. +- **Reasoning.** With Q8's decision to adopt Resend-shape (each item is a near-clone of `SendEmailRequest`), per-item attachments come for free — inherited directly from the single-send request model. The pathological case where per-item + no-ceiling would blow up ("100 items × 25 MiB = 2.5 GiB request body") is bounded by adding a **batch-level combined ≤ 25 MiB cap** on top of the existing per-item cap (Q15). Callers who want the shared-attachment shape (one 25 MiB PDF to 100 recipients) express it by putting the attachment on one item and referencing it in the others — or, more commonly, by keeping their batches modest in total attachment weight. Option (a) is subsumed: it becomes a client-side convention rather than a wire-shape restriction. Option (c) requires a separate `POST /attachments` endpoint that e2a does not have; it's deferred to a future storage-attachment redesign, unrelated to batch send. +- **Reversibility.** Fully reversible in both directions: adding a batch-level shared `attachments[]` field as an optional default (like `reply_to` in §1.2) is a superset; tightening the batch-level cap or removing per-item attachments is a breaking change and hard to reverse. + +#### Q8. Should batch-send follow Resend's or SendGrid's model? + +The honest answer separates two axes — **request shape** (what the wire looks like) and **API philosophy** (what the endpoint is trying to be). Our design lands **Resend-shape on the wire, Resend-philosophy on the defaults, and stricter-than-both on error handling**. This is the single biggest architectural decision in this document; it drives the shape of §1.2, §1.3, §9, and §11. + +- **Peer models on the wire.** + - **Resend `/emails/batch`**: N complete, independent `Email` objects in one call. Each item carries its own `to`/`subject`/`body`/`attachments`. The batch has no "shared content" concept — the mental model is "bulk-submit N single-sends." Cap: 100. Idempotency: yes. + - **SendGrid `/mail/send` with personalizations**: ONE shared content block (subject + body + template ref) + `personalizations[]` — a per-recipient list where each entry has `to`, optional `substitutions` (variables), optional per-recipient overrides. Cap: ~1000 personalizations. Server-side Handlebars-like mail-merge is the intended primary use. + +- **Where our MVP lands on each axis.** + + | Dimension | Resend `/emails/batch` | SendGrid personalizations | **e2a batches (MVP)** | + |---|---|---|---| + | Request shape | N independent items | 1 shared content + N personalizations | **N independent `BatchMessage` items** ← Resend-shape | + | Per-item variables / body | ✅ (native — each item has full body) | ✅ (`substitutions` maps against shared content) | **✅ (native — each item can carry its own body OR its own `template_data`)** | + | Per-item subject/body override | ✅ | ✅ | ✅ (each item's `subject`/`text`/`html` is its own) | + | Per-item attachments | ✅ | ❌ | ✅ (each item's `attachments[]` is its own; batch-wide 25 MiB total cap — Q15) | + | Batch cap | 100 | ~1000 | **100** — Resend-aligned | + | Idempotency | ✅ | ❌ | ✅ (reuses e2a's Idempotency-Key) | + | Error return | per-item errors returned | complex per-personalization errors | **all-or-nothing on validation + per-item `suppressed[]`** — stricter than either | + | Server-side mail-merge language | ❌ (client renders) | ✅ (Handlebars-like) | Mustache subset per item (existing beta) — no server-side loops/conditions | + | API-shape "vibe" | minimal, opinionated | rich, many knobs, marketing-oriented | **minimal, opinionated** — Resend-aligned | + | Dominant use case | "Bulk-submit heterogeneous sends from an application" | "Mail-merge to a subscriber list" | **"AI-agent fanout: N heterogeneous 1-on-1 sends generated per-recipient"** ← Resend fit | + +- **Decision (frozen).** MVP adopts **Resend request shape** (each `BatchMessage` is a self-contained near-clone of `SendEmailRequest`) with **Resend API philosophy** (100-item cap, idempotency, minimal per-item knobs, opinionated defaults, no server-side mail-merge language), and a **stricter-than-both error model** (accept-time all-or-nothing on validation; per-item skip only for suppression per §2.2). The only field promoted to batch-level as a default is `reply_to` (§1.2 rationale). + +- **Why Resend-shape fits e2a specifically.** + - **The README says so.** The tagline is "*Give your AI agents a real, authenticated email address*." Every product concept — conversation threading, HITL per-message review, in-Postgres attachments (no S3/GCS), 10-day retention, outbound-body scrub-on-terminal — describes **1-on-1 agent↔human transactional conversations**, not marketing broadcast. There is no `list`/`audience`/`campaign`/`segment` concept anywhere in the /v1 surface. e2a's users are not marketing operators; they are developers building AI agents that generate content programmatically per-recipient. + - **AI agents' unique capability is per-recipient generation.** An LLM can trivially produce 50 different outbound emails ("outreach to Alice about her recent order X" / "outreach to Bob about his recent order Y") without a template system — that's the value of driving email with an agent. A batch API that forces a shared content block would leave that capability unreachable through `batches`, sending callers back to a single-send loop. SendGrid-shape's central affordance (mail-merge templates + variables) is aimed at a persona (marketing operator) that e2a explicitly does not target. + - **Reuse of the single-send code path.** Each `BatchMessage` is field-for-field almost a `SendEmailRequest`. The batch handler is roughly *"validate/screen/render each item via the existing single-send helpers in a loop, then one bulk-INSERT + one River `InsertManyTx`"* — see §9. This is measurably less new code than a SendGrid-shape handler that would need to introduce a shared-content + per-recipient-overrides merge layer that has no analog in single-send. + - **Templates still work.** MVP inherits the existing beta template system directly at the item level — an item can set `template_alias` + `template_data` and get server-side rendered. So callers who WANT the shared-template-with-per-recipient-variables model can achieve it by (a) creating a template once via `POST /v1/templates`, (b) putting the same `template_alias` on every item with different `template_data`. That's mail-merge, expressed one level up. + +- **Why NOT SendGrid-shape.** + - The killer use case for SendGrid personalizations is "one campaign to a subscriber list of 500, rendered from a Handlebars template." e2a does not have subscriber lists, campaigns, or Handlebars. Adopting the shape would import a wire language that maps to zero features. + - SendGrid-shape MVP would force AI-agent callers with per-recipient content to fall back to a single-send loop for their most common use case — degrading `batches` from "the way batch is done" to "the way announcements are done." That's a product-fit failure. + +- **Why stricter-than-both on errors.** + - Both peers return per-item errors uniformly. Our model is: **validation failures reject the whole batch** (§2.1) and **only suppression is a per-item skip** (§2.2). The mentor's brief explicitly asked for all-or-nothing semantics, and e2a's already-frozen `/v1` error contract has NO partial-success envelope shape ([internal/httpapi/errors.go](../../internal/httpapi/errors.go)) — introducing one just for `/batches` would break contract symmetry. + +- **Reversibility.** Shape reversal to SendGrid-shape is a **major breaking change** (wire shape + accept-tx flow both differ). Reversal to per-item errors is a superset (previously-4xx-ing input becomes 202 with per-item error slots) — safe additively. Adding batch-level shared defaults beyond `reply_to` (attachments, `conversation_id`) is additive — safe. In short: the shape choice is expensive to walk back; the error-strictness choice can be relaxed later without hurting callers. + +- **Correction from earlier drafts.** An earlier iteration of this doc chose SendGrid-shape MVP under the assumption that e2a's dominant use case was "one shared body to a list." Closer reading of the README + FAQ + data-handling posture made clear that e2a's dominant use case is per-recipient per-item content — the opposite fit. The 2026-07-16 revision reverses that choice and rewrites §1–§9 to match. + +### Tier 3 — MVP detail decisions + +Contract-level questions asked during the design pass. Reversibility noted per item — decisions marked "reversible" can be flipped post-GA without breaking clients; decisions marked "hard to reverse" would need a codegen bump. + +#### Q9. All-suppressed edge case — return 202 with `accepted: 0`, or 422 `all_recipients_suppressed`? + +- **Decision: 202 with `accepted: 0`.** `suppressed[]` in the response body carries all 100 dropped addresses. +- **Reasoning.** `body.status` / `accepted` is the discriminator per the project's "always branch on body, never on HTTP status" doctrine (`async-send-contract.md §2.1`). The request itself is legitimate — the drop is compliance work e2a does on the caller's behalf, not a caller mistake — so a 4xx would mis-classify it. Client code stays simple: parse the success response, branch on `accepted > 0`. +- **Reversibility.** Reversible (flipping to 422 is one branch in the handler + a new error-catalog entry). Document intended behavior in `api.md`. + +#### Q10. Attachment cost — 1 unit or N units against quota? + +- **Decision: split by cost layer.** + - **Physical storage quota (accept-time)**: **1 unit** — content-hash dedup means one physical blob regardless of N. + - **Send/egress quota**: **N units** — captured naturally by `checkSendLimit(count=N)` (each SMTP submission is one egress event). + - **Commercial billing**: **out of scope for this spec** — orthogonal, deferred to pricing/product. +- **Reasoning.** The quota check must reflect actual server-side cost, not customer-facing billing. Physical storage is genuinely 1× (dedup); egress is genuinely N× (each SMTP recipient is a separate submission). Conflating the two — either 1× everywhere or N× everywhere — is dishonest either about resource use or about pricing. +- **Reversibility.** Fully reversible; quota checks are internal calls, no wire-format implication. + +#### Q11. Duplicate recipient — reject with 400 or silently dedupe? + +- **Decision: reject with 400 `duplicate_recipient`.** `details.address` + `details.indices` name the offending entry. +- **Reasoning.** Duplicates are usually a caller-side bug (bad list construction, CSV double-paste). Silent dedupe hides the bug indefinitely; explicit rejection surfaces it once so the caller fixes their input. Matches Resend and Postmark. Also matches e2a's "opinionated request-side validation" stance (request bodies are strict, `additionalProperties: false`; responses are open — [internal/httpapi/protection.go](../../internal/httpapi/protection.go) preamble discusses this asymmetry). +- **Reversibility.** Reversible in the caller-friendly direction — adding silent dedupe with a new `deduplicated[]` response array is a superset of the current behavior (previously 400ed input now 202-succeeds), so no existing client breaks. + +#### Q12. Batch of 1 — allow `len(messages) == 1`, or forbid? + +- **Decision: allow.** `len(messages) ∈ [1, 100]`. +- **Reasoning.** SDK ergonomics. A caller with a variable-length `users[]` writes `batches.send({messages: users.map(u => buildMessage(u))})` without a branch on `if (len == 1) use single-send`. The cost is one extra row in `batches` per length-1 batch — negligible. Semantic purity ("a batch should be plural") does not justify the boilerplate every caller would otherwise write. +- **Reversibility.** **Hard to reverse.** Forbidding `len == 1` post-GA would break every caller using the natural pattern above. If a change is ever needed, it must be forward-compatible (e.g., server-side auto-forward to single-send). + +#### Q13. HITL detection — single bool on the agent record, or derived from protection config? + +- **Decision: derived from protection config, per-flag.** + ```go + // Batch send is refused iff the agent's outbound protection can produce a Review verdict. + // Content-scan-enabled agents can hold on shared content; review-gated agents can hold on + // recipient policy. Block-only configs are NOT HITL (no human in the loop) — those go through + // §2.1's block-trigger path instead (§14 Q14). + func agentUsesHITL(ag *identity.AgentIdentity) bool { + return ag.OutboundPolicyAction == "review" || + ag.OutboundScanSensitivity != "off" + } + ``` +- **Reasoning.** No single "HITL enabled" bool exists on the agent. HITL is a derived property of the outbound protection config (`ProtectionConfigView`, [internal/httpapi/protection.go](../../internal/httpapi/protection.go)): action=review OR scan!=off can produce a review-hold. action=block is *automated* denial (no human in the loop), so is NOT HITL and does NOT refuse the batch — block-triggered messages go through Q14's whole-batch reject instead. Missing scan sensitivity here would let content-scan-configured agents silently accept a batch that then floods the review queue. +- **Correction from the initial draft.** The reference to `internal/protectionconfig/` in an earlier draft was wrong — no such package exists in the repo. Correct locations: `internal/httpapi/protection.go` (API surface) + `internal/identity/protection.go` (server-side threshold derivation). +- **Reversibility.** Per-direction. Adding `block` to the HITL set (make batch refuse block-only agents too) converts current behavior to a proper subset — safe. Removing `scan` from the set is a contract narrowing (batch accepts more, then some messages land in review queue post-accept) — client-visible behavior change; hard to reverse cleanly. + +#### Q14. Block-trigger handling — whole-batch 403 or per-item skip? + +- **Decision: whole-batch 403 `blocked_by_policy`.** Any item that trips a block verdict (content-scan block on that item's content, or a per-recipient gate block under `action=block` for any address in that item's envelope) rejects the entire batch. `details.item_index` identifies the offending item. +- **Reasoning.** Block is the strong side of the automated-policy invariant: existing single-send returns 403 for a blocked message, and no "silently skipped" block outcome exists anywhere in the current send path. Downgrading block to per-item skip in batch would (a) create a shape asymmetry with single-send that any security review will flag, and (b) hide the fact that some part of the batch was refused by policy — which is exactly the information a caller under review needs. +- **Reversibility.** Reversible in the caller-friendly direction: adding a `blocked[]` per-item response array (analogous to `suppressed[]`) is a superset of the current behavior. But: doing so weakens a security invariant, so any reversal should require an explicit security sign-off. + +#### Q15. Attachment size ceiling — how to bound a batch of per-item attachments? + +With Q7 landing on per-item attachments (each `BatchMessage` carries its own `attachments[]` inherited from `SendEmailRequest`), the naive request-body ceiling explodes: 100 items × 25 MiB per item = 2.5 GiB. Some ceiling on the batch total is required. + +- **Options considered.** + - (a) *Sum of per-item caps*: no batch-level cap; request body bounded only by `100 × 25 MiB = 2.5 GiB`. Practical for the wire? No — most reverse proxies (nginx default `client_max_body_size` 1 MiB; ALB soft cap around 5 MiB unless raised; ingress controllers similar) cap far below this, and Postgres request-tx memory pressure is real. + - (b) *Force shared attachments at batch level*: strip per-item `attachments[]`; add a batch-level `attachments[]` field that applies to every item. Simple ceiling (25 MiB). Contradicts Q7/Q8's decision to keep per-item independence. + - (c) *Per-item + batch-level combined cap*: keep per-item `attachments[]`, and additionally enforce `sum(item.attachments.size_bytes) ≤ 25 MiB` across the batch. Caller can distribute freely (100 × 250 KiB, 5 × 5 MiB + 95 empty, 1 × 25 MiB + 99 empty). +- **Decision:** **(c) Per-item attachments + batch-level 25 MiB total cap.** +- **Reasoning.** Preserves Q7's per-item independence (single-send request shape at the item level, no shared-attachments concept to explain). Bounds the wire — 25 MiB is the existing single-send ceiling, so no reverse-proxy or backend limit needs raising for batch endpoints. Flexible: the caller who wants the "shared attachment" scenario expresses it by putting the blob on one item (25 MiB) with the other 99 empty, which is exactly what "shared attachment" means physically (one blob content-hash deduped in storage — Q10). Enforcement is one line in the validator (`sum(bytes) ≤ 25*1024*1024`). +- **Enforcement.** §9 step 2 (structural validation), before rate-limit reservation. 413 `payload_too_large` with `details.scope = "batch"`, `details.computed_batch_bytes`, `details.max_batch_bytes = 26214400`. +- **Reversibility.** Fully reversible upward (raising the 25 MiB cap is additive) and downward with warning (lowering breaks existing accepting callers). If real-world traffic shows the cap is too tight (e.g. compliance-heavy verticals shipping 50-page contracts), raise per traffic data — do not raise reflexively at MVP. + +### Where these decisions are enforced in the code + +| Decision | Enforcement point | +|---|---| +| Q1 fanout, not envelope | new endpoint `POST /v1/agents/{email}/batches` (§1.1) | +| Q2 accept-time all-or-nothing | §2.1 rejection list; §9 steps 2, 5 short-circuit pre-tx | +| Q3 suppression per-item | §9 step 6 partitions items into `keep[]` and `suppressed[]`; response `results[]` slot is `{ suppressed }` for dropped items | +| Q4 rate limit N | `AllowN(count=len(keep))` variant in §9 step 7 | +| Q5 cap 100 | schema `minItems: 1, maxItems: 100` on `messages` | +| Q6 HITL-refuse (MVP) | §9 step 3 `agentUsesHITL(agent)` gate → 403 `batch_hitl_unsupported` | +| Q7 per-item attachments + batch cap | request schema — `BatchMessage.attachments[]` per item; batch total ≤ 25 MiB enforced in §9 step 2 (see also Q15) | +| Q8 Resend-shape wire + Resend-philosophy defaults + stricter errors | overall API contract (§1) — `messages: [BatchMessage]`, 100 cap, `Idempotency-Key`, no server-side mail-merge, all-or-nothing validation + per-item `suppressed[]` in `results[]` | +| Q9 all-suppressed → 202 | `handleSendBatch` returns 202 unconditionally when `len(keep) == 0`; all `results[]` slots are `{ suppressed }` | +| Q10 attachment quota 1 for storage / N for egress | `checkAttachmentStorageQuota(uniqueBytes)` + `checkSendLimit(count=N)` in §9 step 7 | +| Q11 duplicate reject | pre-tx validation loop (§9 step 2): detect cross-item `to` duplicates → 400 `duplicate_recipient` | +| Q12 batch-of-1 allowed | schema `minItems: 1, maxItems: 100` | +| Q13 HITL check formula | `agentUsesHITL()` in §9 step 3 | +| Q14 block whole-batch | `screenOutbound()` called per item in §9 step 5 — any `Block()` → 403 with `details.item_index` | +| Q15 batch-level 25 MiB attachment cap | §9 step 2: `sum(item.attachments.size_bytes) ≤ 25 MiB` else 413 `payload_too_large` `details.scope="batch"` | + +--- + +## Change log + +- 2026-07-16 (Le Xiao): initial draft. Decisions reconciled with mentor's brief (`all or nothing`, `resend vs sendgrid`, `templates existing but polish`) after research into existing send pipeline, idempotency, River queue, error contract, and rate limits. +- 2026-07-16 (Le Xiao): §14 all 6 open questions resolved. Corrected `internal/protectionconfig/` → `internal/httpapi/protection.go` + `internal/identity/protection.go`. HITL detection formula frozen. Block-trigger handling frozen (whole-batch 403). §2.1, §5.1, §9 updated to reflect the new §14 decisions. +- 2026-07-16 (Le Xiao): §14 expanded into a full decision journal — Tier 1 (foundational framing: what "batch send" means, what "all or nothing" means), Tier 2 (strategic model choices with North-American ESP benchmarks: suppression, rate limit, batch cap, HITL model, attachments, Resend vs SendGrid), Tier 3 (Q9-Q14 MVP details). Each Q now carries `Options considered / Decision / Reasoning / Reversibility`. Enforcement cross-ref table extended to all 14 questions. +- 2026-07-16 (Le Xiao): Added §0.5 Terminology (template / fanout / mail-merge / per-recipient variables / accept-tx / screening) so §14 is readable without deep e2a familiarity. Rewrote §14 Q8 to be honest about the hybrid — SendGrid-shape wire + Resend-philosophy defaults + stricter-than-both error model. Original "Resend-shaped" characterization was true on the philosophy axis and misleading on the wire axis; the new answer separates the two. +- 2026-07-16 (Le Xiao): **Shape reversal — MVP now Resend-shape (per-item content), not SendGrid-shape (shared content).** Triggered by re-reading the README + FAQ + data-handling posture, which unambiguously position e2a as "1-on-1 agent↔human transactional conversations," not marketing-broadcast. AI-agent callers' unique capability is per-recipient content generation, so a SendGrid-shape MVP that forced shared content would leave the dominant use case unreachable through `batches`. Full rewrite of §0.5 Terminology (fanout no longer implies shared content; mail-merge is now MVP-native), §1.2 (`recipients: [{to}]` → `messages: [BatchMessage]`), §1.3 (`message_ids[]` → `results[]` with per-item `{ message_id }` \| `{ suppressed }` discriminated slots), §1.4 (error codes gain `item_index` disambiguation; `too_many_recipients` → `too_many_messages`), §2.1 (per-item structural validation), §2.2 (per-item suppression skip mechanics), §3.1 (`suppressed_json` shape gains `item_index`), §7.1 (GET rollup response shape), §8 (three new error codes and existing-code `item_index`/`scope` extensions), §9 (per-item render/screen loop, no shared content render step), §11 (per-recipient variables + per-item overrides move from polish to MVP; polish shrinks to 4 items + a note). §14 Q1 refined; Q7 rewritten (per-item attachments); Q8 rewritten (Resend-shape decision + rationale from README evidence); new Q15 added (batch-level 25 MiB attachment cap). §14 enforcement table refreshed. Template polish was NOT added — batch send inherits the beta template system as-is; any template-engine enhancement is a separate project. +- 2026-07-16 (Le Xiao): §10 rewritten from a 4-6 phased-PR plan into a **2-PR plan** — matching the project's observed convention in `tokencanopy/e2a` (features like PR #536 external-auth and PR #370 per-domain ramp-up ship as single-PR features with internal commit sequencing, not fanned out across many small PRs). PR 1 = design doc (commit 1) + OpenAPI spec + migration + handler + observability + tests + user docs (7 commits total, ~20-25 files, feature-flagged behind `E2A_BATCH_SEND_ENABLED`). PR 2 = TS SDK + Python SDK + CLI (~10-15 files). A fault line between commits 4 and 5 documented in case the mentor requests a split during review. From 028843fae770918e119062e2abf762f3893a1cae Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Fri, 17 Jul 2026 23:46:12 -0700 Subject: [PATCH 02/17] feat(api): register batch-send error codes + typed details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 3 error codes that POST /v1/agents/{email}/batches will emit, plus typed detail structs and doc updates. The handler + operation registration land in a follow-up commit; this commit is contract prep so those follow-ups can focus on runtime behavior. Contract-only, no runtime behavior yet. See docs/design/batch-send.md §1.4, §5.1, §8, §14 (Q11, Q13, Q14, Q15). New error codes (internal/httpapi/error_catalog.go): - too_many_messages (400) — batch messages[] over the per-request cap - duplicate_recipient (400) — same address in `to` across multiple items - batch_hitl_unsupported (403) — batch refused for HITL-enabled agents New typed details (internal/httpapi/errors.go): - TooManyMessagesDetails { max_messages, provided } - DuplicateRecipientDetails { address, item_indices } Extended existing typed details: - PayloadTooLargeDetails gains optional item_index for batch responses - PayloadTooLargeDetails Scope known-values now includes "batch" (batch-send total attachment bytes across all items — §14 Q15) Emitter helpers in internal/httpapi/batch_errors.go — private constructors the batch handler will call when it lands. Contains maxBatchMessages = 100, the shared cap constant. Error fixtures under api/fixtures/errors/ mirror the typed-details shapes. docs/api.md gains rows for the three new codes. api/openapi.yaml and SDK bases regenerated via `make spec` + `make generate-sdk`. `make test-unit` and `make spec-check` pass. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Le Xiao --- .../errors/batch_hitl_unsupported.json | 1 + api/fixtures/errors/duplicate_recipient.json | 1 + api/fixtures/errors/too_many_messages.json | 1 + docs/api.md | 3 + internal/httpapi/batch_errors.go | 44 ++++++++ internal/httpapi/error_catalog.go | 3 + internal/httpapi/errors.go | 46 ++++++-- .../models/duplicate_recipient_details.py | 102 +++++++++++++++++ .../models/payload_too_large_details.py | 8 +- .../models/too_many_messages_details.py | 103 ++++++++++++++++++ .../models/DuplicateRecipientDetails.ts | 49 +++++++++ .../models/PayloadTooLargeDetails.ts | 12 +- .../models/TooManyMessagesDetails.ts | 49 +++++++++ 13 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 api/fixtures/errors/batch_hitl_unsupported.json create mode 100644 api/fixtures/errors/duplicate_recipient.json create mode 100644 api/fixtures/errors/too_many_messages.json create mode 100644 internal/httpapi/batch_errors.go create mode 100644 sdks/python/src/e2a/v1/generated/models/duplicate_recipient_details.py create mode 100644 sdks/python/src/e2a/v1/generated/models/too_many_messages_details.py create mode 100644 sdks/typescript/src/v1/generated/models/DuplicateRecipientDetails.ts create mode 100644 sdks/typescript/src/v1/generated/models/TooManyMessagesDetails.ts diff --git a/api/fixtures/errors/batch_hitl_unsupported.json b/api/fixtures/errors/batch_hitl_unsupported.json new file mode 100644 index 000000000..3cbaa2c58 --- /dev/null +++ b/api/fixtures/errors/batch_hitl_unsupported.json @@ -0,0 +1 @@ +{"error":{"code":"batch_hitl_unsupported","message":"batch send is not available for agents with HITL enabled","request_id":"req_fixture"}} diff --git a/api/fixtures/errors/duplicate_recipient.json b/api/fixtures/errors/duplicate_recipient.json new file mode 100644 index 000000000..00f9d1366 --- /dev/null +++ b/api/fixtures/errors/duplicate_recipient.json @@ -0,0 +1 @@ +{"error":{"code":"duplicate_recipient","message":"same recipient address appears in more than one batch item","request_id":"req_fixture","details":{"address":"alice@example.com","item_indices":[3,17]}}} diff --git a/api/fixtures/errors/too_many_messages.json b/api/fixtures/errors/too_many_messages.json new file mode 100644 index 000000000..7354e5ce2 --- /dev/null +++ b/api/fixtures/errors/too_many_messages.json @@ -0,0 +1 @@ +{"error":{"code":"too_many_messages","message":"too many messages in batch","request_id":"req_fixture","details":{"max_messages":100,"provided":101}}} diff --git a/docs/api.md b/docs/api.md index c3d28d12c..9713c5033 100644 --- a/docs/api.md +++ b/docs/api.md @@ -304,6 +304,7 @@ retryable ones (the per-row retry notes in the table below are authoritative). | `unauthorized` | 401 | Missing or invalid credentials (REST and the WebSocket handshake). | | `forbidden` | 403 | Authenticated but not allowed (key scope, cross-tenant access). | | `blocked_by_policy` | 403 | **Experimental.** The outbound message was blocked by the agent's outbound policy gate. | +| `batch_hitl_unsupported` | 403 | Batch send refused because the agent has HITL enabled (`outbound.gate.action=review` or `outbound.scan.sensitivity != off`). Use single-send per recipient or disable HITL on the agent. | | **Validation** | | | | `invalid_request` | 400 / 422 | The canonical input-validation code — malformed (400) or semantically invalid (422). `error.details` carries the per-field list. | | `invalid_cursor` | 400 | Bad pagination cursor — drop it and re-fetch from the start. | @@ -311,6 +312,8 @@ retryable ones (the per-row retry notes in the table below are authoritative). | `invalid_domain`, `invalid_slug`, `invalid_recipient`, `invalid_attachment`, `invalid_template`, `invalid_event_type`, `invalid_webhook_url`, `invalid_expires_at`, `invalid_scope` | 400 | Field/resource-specific refinements of `invalid_request`. | | `reserved_domain` | 400 | The domain is reserved by the deployment (e.g. the shared domain). | | `too_many_recipients` | 400 | Send/reply/forward recipient count over the cap. | +| `too_many_messages` | 400 | Batch-send `messages[]` count over the per-request cap (100). Distinct from `too_many_recipients` which caps recipients within one message. | +| `duplicate_recipient` | 400 | Batch-send: the same recipient address appears in the `to` set of more than one item. Deduplicate the input; e2a does NOT silently drop duplicates. | | `template_render_failed`, `template_rendered_empty` | 400 | Template send: rendering failed / produced an empty body. | | `recipient_suppressed` | 422 | A recipient is on the account-wide or exact sending-agent suppression list — un-suppress or drop it. | | **Not found / gone** | | | diff --git a/internal/httpapi/batch_errors.go b/internal/httpapi/batch_errors.go new file mode 100644 index 000000000..cc926b0cf --- /dev/null +++ b/internal/httpapi/batch_errors.go @@ -0,0 +1,44 @@ +package httpapi + +import "net/http" + +// Emitter helpers for the three batch-send error codes registered in +// error_catalog.go. Kept here so the /v1 error-vocabulary test +// (TestErrorCodeVocabularyMatchesCatalog) sees a literal Code: string for each +// batch code; the batch handler lands in a later commit and will call these. +// See docs/design/batch-send.md §1.4, §5.1, §14 (Q11/Q15) for the contract. + +// errBatchHITLUnsupported is emitted when a batch-send request targets an agent +// whose outbound protection could produce a Review verdict — i.e. HITL is +// enabled per §14 Q13 (OutboundPolicyAction=="review" OR +// OutboundScanSensitivity!="off"). MVP refuses batch send for these agents so +// the batch primitive does not silently break HITL guarantees (§5.1); the +// caller must use single-send per recipient or disable HITL. +func errBatchHITLUnsupported() *ErrorEnvelope { + return NewError(http.StatusForbidden, "batch_hitl_unsupported", + "batch send is not available for agents with HITL enabled") +} + +// errTooManyMessages is emitted when a batch-send request's messages[] array +// has more items than the per-request cap (100 for MVP — see §14 Q5) or fewer +// than 1. Distinct from too_many_recipients, which caps recipients WITHIN a +// single message. +func errTooManyMessages(provided int) *ErrorEnvelope { + return NewError(http.StatusBadRequest, "too_many_messages", "too many messages in batch"). + WithDetails(TooManyMessagesDetails{MaxMessages: maxBatchMessages, Provided: provided}) +} + +// errDuplicateRecipient is emitted when the same recipient address appears in +// the `to` set of more than one item in a batch request. Silent deduplication +// would hide caller-side bugs — see §14 Q11. +func errDuplicateRecipient(address string, itemIndices []int) *ErrorEnvelope { + return NewError(http.StatusBadRequest, "duplicate_recipient", + "same recipient address appears in more than one batch item"). + WithDetails(DuplicateRecipientDetails{Address: address, ItemIndices: itemIndices}) +} + +// maxBatchMessages is the per-request cap on len(messages) for POST /v1/agents/{email}/batches. +// See docs/design/batch-send.md §14 Q5. Kept here (not in the handler file) so +// the errTooManyMessages helper — and its future call sites — share the single +// source of truth for the cap constant. +const maxBatchMessages = 100 diff --git a/internal/httpapi/error_catalog.go b/internal/httpapi/error_catalog.go index ed7e99688..2689a021c 100644 --- a/internal/httpapi/error_catalog.go +++ b/internal/httpapi/error_catalog.go @@ -21,6 +21,7 @@ var errorCodeCatalog = []errorCodeContract{ {Code: "unauthorized", Status: "401", Family: "auth"}, {Code: "forbidden", Status: "403", Family: "auth"}, {Code: "blocked_by_policy", Status: "403", Family: "auth"}, + {Code: "batch_hitl_unsupported", Status: "403", Family: "auth"}, {Code: "invalid_request", Status: "400 / 422", Family: "validation", DetailsSchema: "ValidationErrorDetails"}, {Code: "invalid_cursor", Status: "400", Family: "validation"}, {Code: "invalid_filter", Status: "400", Family: "validation"}, @@ -35,6 +36,8 @@ var errorCodeCatalog = []errorCodeContract{ {Code: "invalid_scope", Status: "400", Family: "validation"}, {Code: "reserved_domain", Status: "400", Family: "validation"}, {Code: "too_many_recipients", Status: "400", Family: "validation", DetailsSchema: "TooManyRecipientsDetails"}, + {Code: "too_many_messages", Status: "400", Family: "validation", DetailsSchema: "TooManyMessagesDetails"}, + {Code: "duplicate_recipient", Status: "400", Family: "validation", DetailsSchema: "DuplicateRecipientDetails"}, {Code: "template_render_failed", Status: "400", Family: "validation"}, {Code: "template_rendered_empty", Status: "400", Family: "validation"}, {Code: "recipient_suppressed", Status: "422", Family: "validation"}, diff --git a/internal/httpapi/errors.go b/internal/httpapi/errors.go index 7eb627750..a63dc1ab4 100644 --- a/internal/httpapi/errors.go +++ b/internal/httpapi/errors.go @@ -54,7 +54,7 @@ type ErrorEnvelope struct { // ErrorBody is the inner object of the envelope. type ErrorBody struct { - Code string `json:"code" doc:"Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status."` + Code string `json:"code" doc:"Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, batch_hitl_unsupported, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, too_many_messages, duplicate_recipient, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), batch_hitl_unsupported (403, batch send refused for HITL-enabled agent — use single-send per recipient or disable HITL). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, too_many_messages (batch: cap on messages[]), duplicate_recipient (batch: same address in to across items), template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status."` Message string `json:"message" doc:"Human-readable explanation. Not for branching — use code."` Details any `json:"details,omitempty" doc:"Optional structured context, polymorphic by code. Treat it as an open object keyed off code; unknown codes and fields must be preserved."` RequestID string `json:"request_id" doc:"Echoes the X-Request-Id response header so a failing call is greppable in logs."` @@ -83,12 +83,14 @@ type ValidationErrorDetails struct { // understands the extensions can offer stable typed views for known codes. func (ErrorBody) TransformSchema(r huma.Registry, s *huma.Schema) *huma.Schema { detailTypes := map[string]reflect.Type{ - "ValidationErrorDetails": reflect.TypeOf(ValidationErrorDetails{}), - "TooManyRecipientsDetails": reflect.TypeOf(TooManyRecipientsDetails{}), - "PayloadTooLargeDetails": reflect.TypeOf(PayloadTooLargeDetails{}), - "LimitExceededDetails": reflect.TypeOf(LimitExceededDetails{}), - "RateLimitedDetails": reflect.TypeOf(RateLimitedDetails{}), - "RetryAfterDetails": reflect.TypeOf(RetryAfterDetails{}), + "ValidationErrorDetails": reflect.TypeOf(ValidationErrorDetails{}), + "TooManyRecipientsDetails": reflect.TypeOf(TooManyRecipientsDetails{}), + "TooManyMessagesDetails": reflect.TypeOf(TooManyMessagesDetails{}), + "DuplicateRecipientDetails": reflect.TypeOf(DuplicateRecipientDetails{}), + "PayloadTooLargeDetails": reflect.TypeOf(PayloadTooLargeDetails{}), + "LimitExceededDetails": reflect.TypeOf(LimitExceededDetails{}), + "RateLimitedDetails": reflect.TypeOf(RateLimitedDetails{}), + "RetryAfterDetails": reflect.TypeOf(RetryAfterDetails{}), } for name, typ := range detailTypes { r.Schema(typ, true, name) @@ -139,14 +141,38 @@ type TooManyRecipientsDetails struct { Provided int `json:"provided" minimum:"1" doc:"Combined recipient count supplied by the caller."` } +// TooManyMessagesDetails is the typed error.details payload for a batch-send +// request that exceeds the per-request cap on len(messages). Distinct from +// TooManyRecipientsDetails, which caps recipients within a single message — +// batch send caps ITEMS in the batch (each of which is a full message). +type TooManyMessagesDetails struct { + MaxMessages int `json:"max_messages" minimum:"1" doc:"Maximum BatchMessage items per batch request."` + Provided int `json:"provided" minimum:"1" doc:"Item count supplied by the caller."` +} + +// DuplicateRecipientDetails is the typed error.details payload for a batch-send +// request whose messages[].to sets contain the same recipient address across +// more than one item. Cross-item duplicates in `to` are rejected up front (see +// design/batch-send.md §2.1, §14 Q11); duplicates within cc/bcc across items +// are allowed (common shape when a shared address cc's every item). +type DuplicateRecipientDetails struct { + Address string `json:"address" doc:"The recipient address that appears in more than one item's to."` + ItemIndices []int `json:"item_indices" nullable:"false" doc:"Positions in the request's messages[] where the address appears in to. Length ≥ 2."` +} + type PayloadTooLargeDetails struct { // Scope is an OPEN set (evolving response-side vocabulary): a new byte - // budget (e.g. a future batch or template cap) means a new value here, - // and that must not break spec-generated clients. - Scope string `json:"scope" doc:"Which byte budget was exceeded. Open set: new values may be added over time, so treat these as strings and tolerate unknown values. Known values: composed_message, attachment, attachments_total, request_body."` + // budget (e.g. a future template cap) means a new value here, and that + // must not break spec-generated clients. + Scope string `json:"scope" doc:"Which byte budget was exceeded. Open set: new values may be added over time, so treat these as strings and tolerate unknown values. Known values: composed_message, attachment, attachments_total, request_body, batch (batch-send total attachment bytes across all items)."` ActualBytes int64 `json:"actual_bytes" minimum:"0" doc:"Observed byte count. Exact when Content-Length or decoded content is available; for chunked request bodies this is the lower bound observed before rejection."` MaxBytes int64 `json:"max_bytes" minimum:"1" doc:"Maximum bytes accepted for this scope."` Filename string `json:"filename,omitempty" doc:"Attachment filename when scope is attachment."` + // ItemIndex is populated only for batch-send responses when the failure is + // tied to a specific item in the messages[] array. Absent for single-send + // responses (backward-compatible extension); when Scope="batch" the total + // is over the whole request and ItemIndex is also absent. + ItemIndex *int `json:"item_index,omitempty" doc:"For batch-send: the offending item's index in messages[]. Absent for single-send responses and for batch-wide scope violations."` } // LimitExceededDetails is the typed `error.details` payload carried by a 402 diff --git a/sdks/python/src/e2a/v1/generated/models/duplicate_recipient_details.py b/sdks/python/src/e2a/v1/generated/models/duplicate_recipient_details.py new file mode 100644 index 000000000..f798a4993 --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/duplicate_recipient_details.py @@ -0,0 +1,102 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self + +class DuplicateRecipientDetails(BaseModel): + """ + DuplicateRecipientDetails + """ # noqa: E501 + address: StrictStr = Field(description="The recipient address that appears in more than one item's to.") + item_indices: List[StrictInt] = Field(description="Positions in the request's messages[] where the address appears in to. Length ≥ 2.") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["address", "item_indices"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of DuplicateRecipientDetails from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of DuplicateRecipientDetails from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "address": obj.get("address"), + "item_indices": obj.get("item_indices") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/python/src/e2a/v1/generated/models/payload_too_large_details.py b/sdks/python/src/e2a/v1/generated/models/payload_too_large_details.py index 350b6dca2..adc144caf 100644 --- a/sdks/python/src/e2a/v1/generated/models/payload_too_large_details.py +++ b/sdks/python/src/e2a/v1/generated/models/payload_too_large_details.py @@ -17,7 +17,7 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictStr +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr from typing import Any, ClassVar, Dict, List, Optional from typing_extensions import Annotated from typing import Optional, Set @@ -29,10 +29,11 @@ class PayloadTooLargeDetails(BaseModel): """ # noqa: E501 actual_bytes: Annotated[int, Field(strict=True, ge=0)] = Field(description="Observed byte count. Exact when Content-Length or decoded content is available; for chunked request bodies this is the lower bound observed before rejection.") filename: Optional[StrictStr] = Field(default=None, description="Attachment filename when scope is attachment.") + item_index: Optional[StrictInt] = Field(default=None, description="For batch-send: the offending item's index in messages[]. Absent for single-send responses and for batch-wide scope violations.") max_bytes: Annotated[int, Field(strict=True, ge=1)] = Field(description="Maximum bytes accepted for this scope.") - scope: StrictStr = Field(description="Which byte budget was exceeded. Open set: new values may be added over time, so treat these as strings and tolerate unknown values. Known values: composed_message, attachment, attachments_total, request_body.") + scope: StrictStr = Field(description="Which byte budget was exceeded. Open set: new values may be added over time, so treat these as strings and tolerate unknown values. Known values: composed_message, attachment, attachments_total, request_body, batch (batch-send total attachment bytes across all items).") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["actual_bytes", "filename", "max_bytes", "scope"] + __properties: ClassVar[List[str]] = ["actual_bytes", "filename", "item_index", "max_bytes", "scope"] model_config = ConfigDict( populate_by_name=True, @@ -94,6 +95,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "actual_bytes": obj.get("actual_bytes"), "filename": obj.get("filename"), + "item_index": obj.get("item_index"), "max_bytes": obj.get("max_bytes"), "scope": obj.get("scope") }) diff --git a/sdks/python/src/e2a/v1/generated/models/too_many_messages_details.py b/sdks/python/src/e2a/v1/generated/models/too_many_messages_details.py new file mode 100644 index 000000000..051d1f878 --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/too_many_messages_details.py @@ -0,0 +1,103 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field +from typing import Any, ClassVar, Dict, List +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self + +class TooManyMessagesDetails(BaseModel): + """ + TooManyMessagesDetails + """ # noqa: E501 + max_messages: Annotated[int, Field(strict=True, ge=1)] = Field(description="Maximum BatchMessage items per batch request.") + provided: Annotated[int, Field(strict=True, ge=1)] = Field(description="Item count supplied by the caller.") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["max_messages", "provided"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of TooManyMessagesDetails from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of TooManyMessagesDetails from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "max_messages": obj.get("max_messages"), + "provided": obj.get("provided") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/typescript/src/v1/generated/models/DuplicateRecipientDetails.ts b/sdks/typescript/src/v1/generated/models/DuplicateRecipientDetails.ts new file mode 100644 index 000000000..91423ef28 --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/DuplicateRecipientDetails.ts @@ -0,0 +1,49 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http.js'; + +export class DuplicateRecipientDetails { + /** + * The recipient address that appears in more than one item\'s to. + */ + 'address': string; + /** + * Positions in the request\'s messages[] where the address appears in to. Length ≥ 2. + */ + 'itemIndices': Array; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "address", + "baseName": "address", + "type": "string", + "format": "" + }, + { + "name": "itemIndices", + "baseName": "item_indices", + "type": "Array", + "format": "int64" + } ]; + + static getAttributeTypeMap() { + return DuplicateRecipientDetails.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/sdks/typescript/src/v1/generated/models/PayloadTooLargeDetails.ts b/sdks/typescript/src/v1/generated/models/PayloadTooLargeDetails.ts index abc333869..655657dbc 100644 --- a/sdks/typescript/src/v1/generated/models/PayloadTooLargeDetails.ts +++ b/sdks/typescript/src/v1/generated/models/PayloadTooLargeDetails.ts @@ -22,11 +22,15 @@ export class PayloadTooLargeDetails { */ 'filename'?: string; /** + * For batch-send: the offending item\'s index in messages[]. Absent for single-send responses and for batch-wide scope violations. + */ + 'itemIndex'?: number; + /** * Maximum bytes accepted for this scope. */ 'maxBytes': number; /** - * Which byte budget was exceeded. Open set: new values may be added over time, so treat these as strings and tolerate unknown values. Known values: composed_message, attachment, attachments_total, request_body. + * Which byte budget was exceeded. Open set: new values may be added over time, so treat these as strings and tolerate unknown values. Known values: composed_message, attachment, attachments_total, request_body, batch (batch-send total attachment bytes across all items). */ 'scope': string; @@ -47,6 +51,12 @@ export class PayloadTooLargeDetails { "type": "string", "format": "" }, + { + "name": "itemIndex", + "baseName": "item_index", + "type": "number", + "format": "int64" + }, { "name": "maxBytes", "baseName": "max_bytes", diff --git a/sdks/typescript/src/v1/generated/models/TooManyMessagesDetails.ts b/sdks/typescript/src/v1/generated/models/TooManyMessagesDetails.ts new file mode 100644 index 000000000..cfb900eab --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/TooManyMessagesDetails.ts @@ -0,0 +1,49 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http.js'; + +export class TooManyMessagesDetails { + /** + * Maximum BatchMessage items per batch request. + */ + 'maxMessages': number; + /** + * Item count supplied by the caller. + */ + 'provided': number; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "maxMessages", + "baseName": "max_messages", + "type": "number", + "format": "int64" + }, + { + "name": "provided", + "baseName": "provided", + "type": "number", + "format": "int64" + } ]; + + static getAttributeTypeMap() { + return TooManyMessagesDetails.attributeTypeMap; + } + + public constructor() { + } +} From 261b209ad6756d5ff61ce1edc56331c3fe89dd46 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Mon, 20 Jul 2026 11:30:40 -0700 Subject: [PATCH 03/17] =?UTF-8?q?feat(batch-send):=20storage=20layer=20?= =?UTF-8?q?=E2=80=94=20batches=20table=20+=20store=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the persistence layer for the batch-send feature: Migration 067_batches.sql: - New batches table (batch_id PK, user_id + agent_id FKs, requested, accepted, suppressed_json, request_id, created_at) with CHECK on requested/accepted ranges (1..100, 0..requested). - ADD messages.batch_id (nullable FK to batches, ON DELETE SET NULL — messages outlive the batch header). - Indexes: batches (user_id, created_at DESC) and (agent_id, created_at DESC) for per-owner/per-agent listing; partial index on messages.batch_id WHERE batch_id IS NOT NULL for the rollup GROUP BY. internal/identity/batches.go: - Batch, BatchSuppressedItem, BatchStatusRollup, OutboundMessageInput types (see docs/design/batch-send.md §3, §7.1, §9). - CreateBatchTx: insert one batches row inside the caller's tx. - GetBatch: fetch by id (returns nil on not-found, matching GetMessage). - BatchStatusRollupByID: grouped-by-delivery_status query for GET /v1/batches/{id} rollup — computed on read, not cached (§7.1). - CreateOutboundMessagesTx: bulk-insert variant of CreateOutboundMessageTx sharing one tx; loops tx.Exec since the codebase has no pgx.CopyFrom prior art and 100-row inserts against a warm pool fit the ≤100ms accept-tx budget (§12). - NewBatchID: bat_ minter matching NewMessageID convention. internal/identity/batches_test.go: - Full unit + integration coverage against testutil.TestDB. - 10 test cases: insert+fetch round-trip, empty-suppressed default to '[]', validation error path, GetBatch not-found, empty-batch rollup, bulk insert + FK back to batches + rollup sanity, empty-input bulk, missing-agent-id bulk error, id prefix + uniqueness. Docs: - docs/design/batch-send.md §3.1 corrected — the initial draft referenced accounts(account_id)/agents(agent_id) with UUID FKs that don't exist in this codebase. Real chain is users.id (TEXT usr_...) → agent_identities.id (TEXT agt_...); there is no accounts table (the word appears only in the /v1 AccountView projection). Schema in the design doc + this migration now reflect that. All identity-package tests pass against a local Postgres (postgres://e2a:e2a@localhost:5433/e2a_test?sslmode=disable, 105s). make test-unit and make spec-check remain green (no schema changes to openapi.yaml in this commit; those land with the handler in a follow-up). Co-Authored-By: Claude Opus 4.7 Signed-off-by: Le Xiao --- docs/design/batch-send.md | 19 +- internal/identity/batches.go | 292 ++++++++++++++++++++++++++++ internal/identity/batches_test.go | 311 ++++++++++++++++++++++++++++++ migrations/067_batches.sql | 80 ++++++++ 4 files changed, 694 insertions(+), 8 deletions(-) create mode 100644 internal/identity/batches.go create mode 100644 internal/identity/batches_test.go create mode 100644 migrations/067_batches.sql diff --git a/docs/design/batch-send.md b/docs/design/batch-send.md index abf5dc6df..115c11b7c 100644 --- a/docs/design/batch-send.md +++ b/docs/design/batch-send.md @@ -175,20 +175,23 @@ Single-send offers `wait=sent` (poll up to 15s, up to 20s ceiling per async-send ```sql CREATE TABLE batches ( - batch_id TEXT PRIMARY KEY, -- 'bat_' - account_id UUID NOT NULL REFERENCES accounts(account_id) ON DELETE CASCADE, - agent_id UUID NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE, - requested INTEGER NOT NULL, -- len(request.messages) - accepted INTEGER NOT NULL, -- requested minus suppression drops - suppressed_json JSONB NOT NULL DEFAULT '[]', + batch_id TEXT PRIMARY KEY, -- 'bat_' + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- batch owner + agent_id TEXT NOT NULL REFERENCES agent_identities(id) ON DELETE CASCADE, -- sending agent + requested INTEGER NOT NULL, -- len(request.messages) + accepted INTEGER NOT NULL, -- requested minus suppression drops + suppressed_json JSONB NOT NULL DEFAULT '[]', -- [{ item_index: int, address: str, reason: str }] captured at accept. -- Mirrors the response `results[]` slots whose shape is { suppressed }. - request_id TEXT NOT NULL, -- for audit trail - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + request_id TEXT NOT NULL DEFAULT '', -- for audit trail + created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +CREATE INDEX batches_user_created_at_idx ON batches (user_id, created_at DESC); CREATE INDEX batches_agent_created_at_idx ON batches (agent_id, created_at DESC); ``` +**Type corrections from the initial draft.** The initial draft referenced `accounts(account_id)` and `agents(agent_id)` with `UUID` FKs. Neither exists in this codebase — the actual ownership chain is `users.id` (TEXT, `usr_...`) → `agent_identities.id` (TEXT, `agt_...`); there is no `accounts` table (the word appears only in the /v1 `AccountView` API resource, which is a projection over users + limits + usage). The schema above uses the real column shapes and reference targets; migration `067_batches.sql` embeds this SQL. + Rationale for storing `suppressed_json` on the batch row (not per-message): a suppressed item produces NO `messages` row, so there is no other durable place to record the drop. The batch row is the only place that remembers "item i was in your request but we skipped it because address X hit the suppression list." ### 3.2 `messages` gains `batch_id` diff --git a/internal/identity/batches.go b/internal/identity/batches.go new file mode 100644 index 000000000..5302aa04d --- /dev/null +++ b/internal/identity/batches.go @@ -0,0 +1,292 @@ +package identity + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Batch is the parent header row for a batch-send request. One batches row +// is inserted per successful POST /v1/agents/{email}/batches accept-tx; each +// resulting messages row carries the batch_id back to this parent. Items +// dropped by the suppression filter get no messages row — the drop is +// captured in SuppressedJSON. See docs/design/batch-send.md §3, §7.1. +type Batch struct { + BatchID string `json:"batch_id"` + UserID string `json:"user_id"` + AgentID string `json:"agent_id"` + Requested int `json:"requested"` + Accepted int `json:"accepted"` + SuppressedJSON []byte `json:"-"` // opaque JSONB; decode via DecodeSuppressed + RequestID string `json:"request_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// BatchSuppressedItem is one entry inside batches.suppressed_json — the +// record of an item that was dropped by the suppression filter during the +// accept-tx (docs/design/batch-send.md §1.3, §2.2). Field names match the +// wire shape returned by GET /v1/batches/{id}. +type BatchSuppressedItem struct { + ItemIndex int `json:"item_index"` + Address string `json:"address"` + Reason string `json:"reason"` +} + +// DecodeSuppressed unmarshals the opaque SuppressedJSON blob into a typed +// slice. Returns an empty (non-nil) slice when the blob is empty or is the +// canonical '[]' default — callers can iterate the result without a +// nil-check. +func (b *Batch) DecodeSuppressed() ([]BatchSuppressedItem, error) { + if len(b.SuppressedJSON) == 0 { + return []BatchSuppressedItem{}, nil + } + var items []BatchSuppressedItem + if err := json.Unmarshal(b.SuppressedJSON, &items); err != nil { + return nil, fmt.Errorf("decode suppressed_json: %w", err) + } + if items == nil { + return []BatchSuppressedItem{}, nil + } + return items, nil +} + +// BatchStatusRollup is the per-status count of the batch's child messages +// rows, computed by BatchStatusRollupByID via one grouped query. Statuses +// with no matching rows read as zero — no distinction between "field absent" +// and "count is zero". Field set mirrors internal/delivery/status.go's +// terminal + in-flight vocabulary (accepted → sending → sent → +// {delivered, deferred, bounced, complained, failed}). +type BatchStatusRollup struct { + Accepted int `json:"accepted"` + Sending int `json:"sending"` + Sent int `json:"sent"` + Delivered int `json:"delivered"` + Deferred int `json:"deferred"` + Bounced int `json:"bounced"` + Complained int `json:"complained"` + Failed int `json:"failed"` +} + +// OutboundMessageInput bundles the arguments CreateOutboundMessagesTx accepts +// for one message. Mirrors CreateOutboundMessageTx's positional args as a +// struct so a batch of N doesn't need a 14-arg loop body. When BatchID is +// non-empty the resulting messages row is linked back to that batch header +// via the FK added in migration 067. See docs/design/batch-send.md §9 +// (accept-tx step 10.b). +type OutboundMessageInput struct { + AgentID string + ToRecipients []string + CC []string + BCC []string + Subject string + MsgType string + Method string + ProviderMessageID string + ConversationID string + RawMessage []byte + DeliveryStatus string + EnvelopeFrom string + SentAs string + BatchID string // when part of a batch; empty for single-send (nulled in SQL) +} + +// NewBatchID mints a durable batch id in the project's `_` form +// (see NewMessageID). Callers that mint many ids in one accept-tx should +// call this once per batch, not per message. +func NewBatchID() string { + return "bat_" + generateID() +} + +// CreateBatchTx inserts a batches row inside the caller's transaction. The +// caller is expected to have populated every field on the *Batch — Batch. +// SuppressedJSON is passed through opaquely, so the caller controls the +// exact byte-level shape (marshaling of []BatchSuppressedItem happens at the +// accept-tx site where the drops were computed). Empty SuppressedJSON is +// stored as the '[]'::jsonb default. +func (s *Store) CreateBatchTx(ctx context.Context, tx pgx.Tx, b *Batch) error { + if b == nil { + return errors.New("batch: nil input") + } + if b.BatchID == "" { + return errors.New("batch: empty batch_id") + } + if b.UserID == "" { + return errors.New("batch: empty user_id") + } + if b.AgentID == "" { + return errors.New("batch: empty agent_id") + } + if b.CreatedAt.IsZero() { + b.CreatedAt = time.Now() + } + suppressed := b.SuppressedJSON + if len(suppressed) == 0 { + suppressed = []byte("[]") + } + _, err := tx.Exec(ctx, + `INSERT INTO batches (batch_id, user_id, agent_id, requested, accepted, suppressed_json, request_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)`, + b.BatchID, b.UserID, b.AgentID, b.Requested, b.Accepted, suppressed, b.RequestID, b.CreatedAt, + ) + if err != nil { + return fmt.Errorf("insert batches: %w", err) + } + return nil +} + +// GetBatch fetches a single batch row by id. Returns nil (with no error) +// when the batch does not exist — mirrors the not-found convention of +// GetMessage and friends. +func (s *Store) GetBatch(ctx context.Context, batchID string) (*Batch, error) { + if batchID == "" { + return nil, errors.New("batch: empty batch_id") + } + var b Batch + err := s.pool.QueryRow(ctx, + `SELECT batch_id, user_id, agent_id, requested, accepted, suppressed_json, request_id, created_at + FROM batches WHERE batch_id = $1`, + batchID, + ).Scan(&b.BatchID, &b.UserID, &b.AgentID, &b.Requested, &b.Accepted, &b.SuppressedJSON, &b.RequestID, &b.CreatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("select batches: %w", err) + } + return &b, nil +} + +// BatchStatusRollupByID computes the delivery-status histogram over the +// batch's child messages rows in one grouped query. Not cached — batch +// observation is a rare, poll-after-send operation and at ≤100 rows per +// batch the query is cheap (the partial index in migration 067 makes the +// WHERE batch_id = $1 scan a bounded lookup). See docs/design/batch-send.md +// §7.1. +// +// Returns an empty rollup (all zeros) if the batch has no children rows — +// this is the correct outcome for an all-suppressed batch (§14 Q9), and +// distinguishable from a bad batch_id via a prior GetBatch check. +func (s *Store) BatchStatusRollupByID(ctx context.Context, batchID string) (*BatchStatusRollup, error) { + if batchID == "" { + return nil, errors.New("batch: empty batch_id") + } + rows, err := s.pool.Query(ctx, + `SELECT COALESCE(delivery_status, ''), count(*) + FROM messages + WHERE batch_id = $1 + GROUP BY delivery_status`, + batchID, + ) + if err != nil { + return nil, fmt.Errorf("rollup batches: %w", err) + } + defer rows.Close() + + rollup := &BatchStatusRollup{} + for rows.Next() { + var status string + var n int + if err := rows.Scan(&status, &n); err != nil { + return nil, fmt.Errorf("scan rollup row: %w", err) + } + switch status { + case "accepted", "": + // Empty string tolerates any legacy pre-async-pipeline row that + // might exist without a delivery_status; it should not happen for + // batch children (which are always inserted with an explicit + // status) but is defensively grouped with `accepted`. + rollup.Accepted += n + case "sending": + rollup.Sending += n + case "sent": + rollup.Sent += n + case "delivered": + rollup.Delivered += n + case "deferred": + rollup.Deferred += n + case "bounced": + rollup.Bounced += n + case "complained": + rollup.Complained += n + case "failed": + rollup.Failed += n + } + // Unknown statuses are silently dropped — the message model is an + // open set (async-send-contract §3.1) and the rollup is not the + // place to enforce a closed vocabulary. If a new status is added, + // this switch is where to extend the rollup type. + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iter rollup rows: %w", err) + } + return rollup, nil +} + +// CreateOutboundMessagesTx inserts multiple outbound messages inside the +// caller's transaction, matching the single-row CreateOutboundMessageTx +// semantics per input. All inputs share the same tx, so either every row +// commits or none do (matching the batch accept-tx atomicity in +// docs/design/batch-send.md §9 step 10.d). +// +// Returns the resulting Messages in the same order as the inputs (positional, +// no re-sort). If any single input fails, the tx is left dirty for the +// caller to Rollback — this function does NOT rollback on the caller's +// behalf, matching WithTx's outer-scope discipline. +// +// This is a straight loop of tx.Exec calls — the repo has no established +// pgx.CopyFrom or pgx.Batch prior art, and a 100-row loop against a warm +// pool is well within the ≤100ms accept-tx budget (docs/design/batch-send.md +// §12 load-smoke target). +func (s *Store) CreateOutboundMessagesTx(ctx context.Context, tx pgx.Tx, inputs []OutboundMessageInput) ([]*Message, error) { + if len(inputs) == 0 { + return nil, nil + } + out := make([]*Message, 0, len(inputs)) + now := time.Now() + for i := range inputs { + in := &inputs[i] + if in.AgentID == "" { + return nil, fmt.Errorf("batch item %d: empty agent_id", i) + } + id := "msg_" + generateID() + var recipient string + if len(in.ToRecipients) > 0 { + recipient = in.ToRecipients[0] + } + m := &Message{ + ID: id, + AgentID: in.AgentID, + Direction: "outbound", + Recipient: recipient, + Subject: in.Subject, + Type: in.MsgType, + Method: in.Method, + ProviderMessageID: in.ProviderMessageID, + ConversationID: in.ConversationID, + CreatedAt: now, + ExpiresAt: now.Add(MessageTTL), + ToRecipients: in.ToRecipients, + CC: in.CC, + BCC: in.BCC, + RawMessage: in.RawMessage, + Sender: in.AgentID, + DeliveryStatus: in.DeliveryStatus, + SentAs: in.SentAs, + } + _, err := tx.Exec(ctx, + `INSERT INTO messages (id, agent_id, direction, recipient, subject, message_type, method, provider_message_id, conversation_id, created_at, expires_at, to_recipients, cc, bcc, status, sender, raw_message, delivery_status, sent_as, envelope_from, batch_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)`, + m.ID, m.AgentID, m.Direction, m.Recipient, m.Subject, m.Type, m.Method, m.ProviderMessageID, m.ConversationID, m.CreatedAt, m.ExpiresAt, m.ToRecipients, m.CC, m.BCC, MessageStatusSent, m.Sender, nullIfEmptyBytes(m.RawMessage), nullIfEmpty(in.DeliveryStatus), nullIfEmpty(in.SentAs), nullIfEmpty(in.EnvelopeFrom), nullIfEmpty(in.BatchID), + ) + if err != nil { + return nil, fmt.Errorf("batch item %d insert: %w", i, err) + } + m.Status = MessageStatusSent + out = append(out, m) + } + return out, nil +} diff --git a/internal/identity/batches_test.go b/internal/identity/batches_test.go new file mode 100644 index 000000000..eec700e88 --- /dev/null +++ b/internal/identity/batches_test.go @@ -0,0 +1,311 @@ +package identity_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/jackc/pgx/v5" + + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/testutil" +) + +// batchTestSetup provisions a user + verified domain + agent and returns +// (userID, agentID). Similar to convoTestSetup (see conversations_test.go) +// but also surfaces userID because the batches table needs both FKs. +func batchTestSetup(t *testing.T, store *identity.Store, prefix string) (userID, agentID string) { + t.Helper() + ctx := context.Background() + user, err := store.CreateOrGetUser(ctx, "owner-"+prefix+"@example.com", "Owner", "google-"+prefix) + if err != nil { + t.Fatalf("CreateOrGetUser: %v", err) + } + domain := prefix + ".example.com" + if _, err := store.ClaimOrCreateDomain(ctx, domain, user.ID); err != nil { + t.Fatalf("ClaimOrCreateDomain: %v", err) + } + if err := store.VerifyDomain(ctx, domain, user.ID); err != nil { + t.Fatalf("VerifyDomain: %v", err) + } + agent, err := store.CreateAgent(ctx, "bot@"+domain, domain, "", "https://example.com/webhook", "", user.ID) + if err != nil { + t.Fatalf("CreateAgent: %v", err) + } + return user.ID, agent.ID +} + +func TestCreateBatchTx_InsertAndFetch(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + userID, agentID := batchTestSetup(t, store, "batch-insert") + + batchID := identity.NewBatchID() + dropped := []identity.BatchSuppressedItem{ + {ItemIndex: 3, Address: "hardbounce@example.com", Reason: "hard_bounce"}, + } + droppedJSON, err := json.Marshal(dropped) + if err != nil { + t.Fatalf("marshal suppressed: %v", err) + } + in := &identity.Batch{ + BatchID: batchID, + UserID: userID, + AgentID: agentID, + Requested: 10, + Accepted: 9, + SuppressedJSON: droppedJSON, + RequestID: "req_test_insert", + } + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + return store.CreateBatchTx(ctx, tx, in) + }); err != nil { + t.Fatalf("CreateBatchTx: %v", err) + } + + got, err := store.GetBatch(ctx, batchID) + if err != nil { + t.Fatalf("GetBatch: %v", err) + } + if got == nil { + t.Fatalf("GetBatch: nil after insert") + } + if got.BatchID != batchID { + t.Errorf("BatchID: got %q, want %q", got.BatchID, batchID) + } + if got.UserID != userID { + t.Errorf("UserID: got %q, want %q", got.UserID, userID) + } + if got.AgentID != agentID { + t.Errorf("AgentID: got %q, want %q", got.AgentID, agentID) + } + if got.Requested != 10 || got.Accepted != 9 { + t.Errorf("counts: requested=%d accepted=%d, want 10/9", got.Requested, got.Accepted) + } + if got.RequestID != "req_test_insert" { + t.Errorf("RequestID: got %q", got.RequestID) + } + if got.CreatedAt.IsZero() { + t.Errorf("CreatedAt is zero") + } + decoded, err := got.DecodeSuppressed() + if err != nil { + t.Fatalf("DecodeSuppressed: %v", err) + } + if len(decoded) != 1 || decoded[0].Address != "hardbounce@example.com" || decoded[0].ItemIndex != 3 || decoded[0].Reason != "hard_bounce" { + t.Errorf("suppressed round-trip mismatch: %+v", decoded) + } +} + +func TestCreateBatchTx_EmptySuppressedDefaultsToArray(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + userID, agentID := batchTestSetup(t, store, "batch-empty-supp") + + batchID := identity.NewBatchID() + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + return store.CreateBatchTx(ctx, tx, &identity.Batch{ + BatchID: batchID, UserID: userID, AgentID: agentID, Requested: 5, Accepted: 5, + }) + }); err != nil { + t.Fatalf("CreateBatchTx: %v", err) + } + got, err := store.GetBatch(ctx, batchID) + if err != nil || got == nil { + t.Fatalf("GetBatch: got=%v err=%v", got, err) + } + decoded, err := got.DecodeSuppressed() + if err != nil { + t.Fatalf("DecodeSuppressed: %v", err) + } + if len(decoded) != 0 { + t.Errorf("expected empty suppressed slice, got %d items", len(decoded)) + } +} + +func TestCreateBatchTx_ValidationErrors(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + + cases := []struct { + name string + batch *identity.Batch + }{ + {"nil batch", nil}, + {"empty batch_id", &identity.Batch{UserID: "u", AgentID: "a"}}, + {"empty user_id", &identity.Batch{BatchID: "bat_x", AgentID: "a"}}, + {"empty agent_id", &identity.Batch{BatchID: "bat_x", UserID: "u"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := store.WithTx(ctx, func(tx pgx.Tx) error { + return store.CreateBatchTx(ctx, tx, tc.batch) + }) + if err == nil { + t.Fatalf("expected error, got nil") + } + }) + } +} + +func TestGetBatch_NotFound(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + + got, err := store.GetBatch(ctx, "bat_nonexistent_00000000000000") + if err != nil { + t.Fatalf("GetBatch on missing id returned err: %v", err) + } + if got != nil { + t.Fatalf("GetBatch on missing id returned non-nil: %+v", got) + } +} + +func TestBatchStatusRollupByID_EmptyBatch(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + userID, agentID := batchTestSetup(t, store, "batch-rollup-empty") + + // Insert a batch with no child messages (all suppressed). + batchID := identity.NewBatchID() + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + return store.CreateBatchTx(ctx, tx, &identity.Batch{ + BatchID: batchID, UserID: userID, AgentID: agentID, Requested: 3, Accepted: 0, + }) + }); err != nil { + t.Fatalf("CreateBatchTx: %v", err) + } + + rollup, err := store.BatchStatusRollupByID(ctx, batchID) + if err != nil { + t.Fatalf("BatchStatusRollupByID: %v", err) + } + // Every counter should be zero — all-suppressed batch is valid per §14 Q9. + if rollup.Accepted+rollup.Sending+rollup.Sent+rollup.Delivered+ + rollup.Deferred+rollup.Bounced+rollup.Complained+rollup.Failed != 0 { + t.Errorf("expected all zeros, got %+v", rollup) + } +} + +func TestCreateOutboundMessagesTx_BulkAndRollup(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + userID, agentID := batchTestSetup(t, store, "batch-bulk") + + batchID := identity.NewBatchID() + // Insert batch header + 3 messages in one tx (mirrors the real accept-tx + // shape from docs/design/batch-send.md §9 step 10). + var msgs []*identity.Message + err := store.WithTx(ctx, func(tx pgx.Tx) error { + if err := store.CreateBatchTx(ctx, tx, &identity.Batch{ + BatchID: batchID, UserID: userID, AgentID: agentID, Requested: 3, Accepted: 3, + }); err != nil { + return err + } + inputs := []identity.OutboundMessageInput{ + {AgentID: agentID, ToRecipients: []string{"a@example.com"}, Subject: "hi a", MsgType: "send", Method: "smtp", DeliveryStatus: "accepted", BatchID: batchID}, + {AgentID: agentID, ToRecipients: []string{"b@example.com"}, Subject: "hi b", MsgType: "send", Method: "smtp", DeliveryStatus: "sent", BatchID: batchID}, + {AgentID: agentID, ToRecipients: []string{"c@example.com"}, Subject: "hi c", MsgType: "send", Method: "smtp", DeliveryStatus: "delivered", BatchID: batchID}, + } + var err error + msgs, err = store.CreateOutboundMessagesTx(ctx, tx, inputs) + return err + }) + if err != nil { + t.Fatalf("accept-tx: %v", err) + } + if len(msgs) != 3 { + t.Fatalf("expected 3 messages, got %d", len(msgs)) + } + // Positional alignment — msgs[i] corresponds to inputs[i]. + for i, want := range []string{"a@example.com", "b@example.com", "c@example.com"} { + if msgs[i].Recipient != want { + t.Errorf("msgs[%d].Recipient: got %q, want %q", i, msgs[i].Recipient, want) + } + if msgs[i].ID == "" { + t.Errorf("msgs[%d].ID is empty", i) + } + } + + // Verify each row is actually persisted and carries batch_id. + for i, m := range msgs { + var storedBatchID string + if err := pool.QueryRow(ctx, `SELECT batch_id FROM messages WHERE id = $1`, m.ID).Scan(&storedBatchID); err != nil { + t.Fatalf("select msgs[%d].batch_id: %v", i, err) + } + if storedBatchID != batchID { + t.Errorf("msgs[%d].batch_id in DB: got %q, want %q", i, storedBatchID, batchID) + } + } + + // Rollup should reflect the 3 different delivery_status values. + rollup, err := store.BatchStatusRollupByID(ctx, batchID) + if err != nil { + t.Fatalf("BatchStatusRollupByID: %v", err) + } + if rollup.Accepted != 1 { + t.Errorf("Accepted: got %d, want 1", rollup.Accepted) + } + if rollup.Sent != 1 { + t.Errorf("Sent: got %d, want 1", rollup.Sent) + } + if rollup.Delivered != 1 { + t.Errorf("Delivered: got %d, want 1", rollup.Delivered) + } + if rollup.Sending+rollup.Deferred+rollup.Bounced+rollup.Complained+rollup.Failed != 0 { + t.Errorf("unexpected non-zero counters: %+v", rollup) + } +} + +func TestCreateOutboundMessagesTx_EmptyInput(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + + err := store.WithTx(ctx, func(tx pgx.Tx) error { + msgs, err := store.CreateOutboundMessagesTx(ctx, tx, nil) + if err != nil { + return err + } + if len(msgs) != 0 { + t.Errorf("expected empty result, got %d", len(msgs)) + } + return nil + }) + if err != nil { + t.Fatalf("empty input tx: %v", err) + } +} + +func TestCreateOutboundMessagesTx_MissingAgentIDFails(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + + err := store.WithTx(ctx, func(tx pgx.Tx) error { + _, err := store.CreateOutboundMessagesTx(ctx, tx, []identity.OutboundMessageInput{ + {AgentID: ""}, // invalid + }) + return err + }) + if err == nil { + t.Fatalf("expected error for empty AgentID input") + } +} + +func TestNewBatchID_HasExpectedPrefix(t *testing.T) { + id := identity.NewBatchID() + if len(id) < 5 || id[:4] != "bat_" { + t.Errorf("expected 'bat_' prefix, got %q", id) + } + id2 := identity.NewBatchID() + if id == id2 { + t.Errorf("consecutive NewBatchID calls collided: %q", id) + } +} diff --git a/migrations/067_batches.sql b/migrations/067_batches.sql new file mode 100644 index 000000000..d53e931c9 --- /dev/null +++ b/migrations/067_batches.sql @@ -0,0 +1,80 @@ +-- 067_batches.sql +-- +-- Storage for the batch-send feature — the primitive that lets one API call +-- fan out N independent messages, each with its own message_id/state/retry +-- envelope. See docs/design/batch-send.md §3 for the full data-model rationale; +-- this migration lands the SQL side of that design. +-- +-- One `batches` row per POST /v1/agents/{email}/batches accept-tx (§1.1); each +-- `messages` row created by that accept-tx carries the minted `batch_id` back +-- to the parent. Items dropped by the suppression filter (§2.2) get NO +-- `messages` row — the drop is recorded on the batch header in +-- `suppressed_json` so the caller can still see what was filtered. +-- +-- FK strategy — chosen so `batches` and `messages` have opposite lifecycles: +-- batches.user_id ON DELETE CASCADE — user deletion cascades; the +-- batch header has no meaning +-- once the owner is gone. +-- batches.agent_id ON DELETE CASCADE — same reasoning; an agent's +-- batch history dies with the +-- agent (matches how messages +-- cascade on agent_id today). +-- messages.batch_id (below) ON DELETE SET NULL — deleting a batch row MUST +-- NOT cascade-delete messages; +-- messages are the record of +-- what was sent and outlive +-- the batch header. The +-- 90-day retention/janitor +-- sweep on messages is +-- unchanged. +-- +-- Idempotent: CREATE TABLE / CREATE INDEX / ADD COLUMN all use IF NOT EXISTS. +-- Additive only — no destructive ALTERs. + +CREATE TABLE IF NOT EXISTS batches ( + batch_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES agent_identities(id) ON DELETE CASCADE, + requested INTEGER NOT NULL, + accepted INTEGER NOT NULL, + suppressed_json JSONB NOT NULL DEFAULT '[]'::jsonb, + request_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Sanity constraints on the counters. `requested` matches the accepted-time +-- cap on len(request.messages) (docs/design/batch-send.md §14 Q5); `accepted` +-- is `requested` minus per-item suppression drops (§2.2). NOT VALID is not +-- used because batches is a new table with no historical rows to scan. +ALTER TABLE batches DROP CONSTRAINT IF EXISTS batches_requested_range_check; +ALTER TABLE batches ADD CONSTRAINT batches_requested_range_check + CHECK (requested >= 1 AND requested <= 100); + +ALTER TABLE batches DROP CONSTRAINT IF EXISTS batches_accepted_range_check; +ALTER TABLE batches ADD CONSTRAINT batches_accepted_range_check + CHECK (accepted >= 0 AND accepted <= requested); + +-- Per-owner listing is the primary read path (a future listBatches endpoint — +-- §11 polish — plus dashboard queries). DESC on created_at so the most-recent +-- page needs no reverse scan. +CREATE INDEX IF NOT EXISTS batches_user_created_at_idx + ON batches (user_id, created_at DESC); + +-- Per-agent listing supports GET /v1/agents/{email}/batches if we add it later. +CREATE INDEX IF NOT EXISTS batches_agent_created_at_idx + ON batches (agent_id, created_at DESC); + +-- messages.batch_id — nullable FK back to the batches row for messages +-- created as children of a batch. NULL for single-send messages (the +-- overwhelming majority of rows), so a partial index (below) keeps the +-- index small. +ALTER TABLE messages + ADD COLUMN IF NOT EXISTS batch_id TEXT REFERENCES batches(batch_id) ON DELETE SET NULL; + +-- Partial index — skip the NULLs. Rollup query for GET /v1/batches/{id} +-- (docs/design/batch-send.md §7.1) is +-- SELECT delivery_status, count(*) FROM messages WHERE batch_id = $1 GROUP BY delivery_status +-- and this partial index makes that a single-table lookup at ≤100 rows +-- per batch. +CREATE INDEX IF NOT EXISTS messages_batch_id_idx + ON messages (batch_id) WHERE batch_id IS NOT NULL; From 7f404d06172787c825ef158afc06a6f58c6db18c Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Mon, 20 Jul 2026 13:12:43 -0700 Subject: [PATCH 04/17] feat(batch-send): handler + accept-transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements POST /v1/agents/{email}/batches end-to-end — the fanout accept path that was scaffolded by the design doc (commit 1), error catalog (commit 2), and storage layer (commit 3). A batch of up to 100 items is validated, screened, suppression-filtered, rate-reserved, composed, and durably accepted in one transaction (batches row + N messages rows + N River outbound_send jobs + idempotency completion), then each message flows through the existing single-send delivery pipeline unchanged. internal/ratelimit/ratelimit.go: - AllowN(key, n): atomic all-or-nothing reservation of N slots against the sliding window. Batch counts as N sends (§4.2, §14 Q4). Unit tests cover reserve-all-or-none, overflow, zero no-op, n>max clean retry. internal/jobs/jobs.go + internal/outboundsend/jobs.go: - Widen Enqueuer with InsertManyTx; add EnqueueBatchTx wrapping river.InsertManyTx so N jobs enqueue in one round-trip within the accept-tx (§9 step 10.d). Test fakes across inboundprocess / webhookdelivery / webhookpub gain the interface stub. internal/agent/batch.go (DeliverBatch): - The accept-tx orchestrator (§9 steps 3-10): HITL gate (agentUsesHITL — §14 Q13), batch-wide suppression partition (fail-open), per-item screening with whole-batch block (§14 Q14), AllowN rate reservation, per-item compose, and the single WithTx that inserts the batches header + bulk messages + EnqueueBatchTx + per-row send_job_id stamp + idempotency completion. Whole-batch all-or-nothing on any error. - Widen OutboundEnqueuer with EnqueueBatchTx; add RetryAfter to OutboundError so the 429 path carries a Retry-After hint. internal/identity/delivery_store.go: - SuppressedAddressesWithSource: address→source map for populating the per-item suppressed result reason (§1.3). internal/httpapi/batch.go (handleSendBatch): - SendBatchRequest / BatchMessage / SendBatchResponse / BatchResult wire types (§1.2, §1.3). Registers the sendBatch Huma operation. Per-item template resolve + validation (reusing single-send helpers in a loop), batch-level checks (len bounds, cross-item duplicate reject §14 Q11, 25 MiB combined-attachment cap §14 Q15), reply_to batch default, idempotency wrapper, and error mapping with per-item details.item_index. - Wired via Deps.DeliverBatch (apiserver → agent.API.DeliverBatch); nil DeliverBatch returns 501 not_implemented. Tests: - internal/httpapi/batch_test.go: 12 handler contract tests — happy path, too_many_messages, duplicate_recipient, batch attachment cap (scope= batch), suppression partial-drop, all-suppressed 202, HITL 403, blocked_by_policy, rate_limited, per-item item_index, nil-DeliverBatch 501, reply_to default. - internal/agent/batch_test.go: 4 DeliverBatch integration tests against real Postgres — happy path (batches + messages + job stamp + rollup), suppression partial-drop, all-suppressed, HITL refusal. These caught an off-by-N in result.Items sizing (fixed: size by original request count, not keepIndexes cap). api/openapi.yaml + SDK bases regenerated (make spec + make generate-sdk). Serialized integration suite green across all touched packages; make spec-check clean. Hand-written SDK wrappers + CLI land in PR 2. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Le Xiao --- internal/agent/api.go | 5 + internal/agent/batch.go | 425 +++++++++++++++++ internal/agent/batch_test.go | 249 ++++++++++ internal/agent/outbound_async.go | 5 + internal/agent/outbound_async_test.go | 23 + internal/apiserver/apiserver.go | 2 + internal/httpapi/batch.go | 436 ++++++++++++++++++ internal/httpapi/batch_test.go | 389 ++++++++++++++++ internal/httpapi/httpapi.go | 11 +- internal/identity/batches.go | 3 +- internal/identity/delivery_store.go | 39 ++ internal/inboundprocess/reconcile_test.go | 7 + internal/jobs/jobs.go | 6 +- internal/outboundsend/jobs.go | 42 ++ internal/ratelimit/ratelimit.go | 61 +++ internal/ratelimit/ratelimit_test.go | 63 +++ internal/webhookdelivery/worker_test.go | 7 + .../fanout_worker_integration_test.go | 6 + .../src/e2a/v1/generated/api/messages_api.py | 330 +++++++++++++ .../e2a/v1/generated/models/batch_message.py | 131 ++++++ .../e2a/v1/generated/models/batch_result.py | 106 +++++ .../models/batch_suppressed_result.py | 102 ++++ .../v1/generated/models/send_batch_request.py | 111 +++++ .../generated/models/send_batch_response.py | 114 +++++ .../src/v1/generated/models/BatchMessage.ts | 150 ++++++ .../src/v1/generated/models/BatchResult.ts | 50 ++ .../generated/models/BatchSuppressedResult.ts | 49 ++ .../v1/generated/models/SendBatchRequest.ts | 50 ++ .../v1/generated/models/SendBatchResponse.ts | 70 +++ 29 files changed, 3039 insertions(+), 3 deletions(-) create mode 100644 internal/agent/batch.go create mode 100644 internal/agent/batch_test.go create mode 100644 internal/httpapi/batch.go create mode 100644 internal/httpapi/batch_test.go create mode 100644 sdks/python/src/e2a/v1/generated/models/batch_message.py create mode 100644 sdks/python/src/e2a/v1/generated/models/batch_result.py create mode 100644 sdks/python/src/e2a/v1/generated/models/batch_suppressed_result.py create mode 100644 sdks/python/src/e2a/v1/generated/models/send_batch_request.py create mode 100644 sdks/python/src/e2a/v1/generated/models/send_batch_response.py create mode 100644 sdks/typescript/src/v1/generated/models/BatchMessage.ts create mode 100644 sdks/typescript/src/v1/generated/models/BatchResult.ts create mode 100644 sdks/typescript/src/v1/generated/models/BatchSuppressedResult.ts create mode 100644 sdks/typescript/src/v1/generated/models/SendBatchRequest.ts create mode 100644 sdks/typescript/src/v1/generated/models/SendBatchResponse.ts diff --git a/internal/agent/api.go b/internal/agent/api.go index 63eb6f9ed..8e45c416a 100644 --- a/internal/agent/api.go +++ b/internal/agent/api.go @@ -1279,6 +1279,11 @@ type OutboundError struct { // Details carries optional code-specific structured context across the // agent/httpapi boundary. It stays transport-neutral to avoid a package cycle. Details map[string]any + // RetryAfter is the Retry-After seconds hint for retryable errors + // (429 rate_limited, 503 limits_unavailable). Zero means "no hint"; + // the httpapi layer copies non-zero values to both the Retry-After + // header and the details' retry_after_seconds field. + RetryAfter int } func (e *OutboundError) Error() string { return e.Msg } diff --git a/internal/agent/batch.go b/internal/agent/batch.go new file mode 100644 index 000000000..07793f124 --- /dev/null +++ b/internal/agent/batch.go @@ -0,0 +1,425 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/jackc/pgx/v5" + + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/outbound" +) + +// BatchAcceptResult is the outcome of a batch-send accept-tx. Items is +// positional — Items[i] corresponds to request item i and is either +// "accepted" (MessageID non-empty) or "suppressed" (Suppressed non-nil). +// See docs/design/batch-send.md §1.3. +type BatchAcceptResult struct { + BatchID string + Items []BatchAcceptItem +} + +// BatchAcceptItem is the discriminated union of one slot in the batch +// response: exactly one of MessageID / Suppressed is set. MessageID +// non-empty means the accept-tx inserted a `messages` row (delivery_status +// = accepted) and enqueued a River outbound_send job for it. Suppressed +// non-nil means the item's `to` envelope contained a suppression-list +// address; no row was inserted. +type BatchAcceptItem struct { + MessageID string + Suppressed *SuppressedInfo +} + +// SuppressedInfo describes one dropped batch item — the first suppressed +// recipient address found in the item's `to` list plus the suppressions. +// source category (bounce / complaint / manual). Populated at accept-tx +// time from suppressions. +type SuppressedInfo struct { + Address string + Reason string +} + +// BatchAcceptIdemCompleter is the batch-send analog of AcceptIdemCompleter: +// a closure the handler builds to write the idempotency cache row inside +// the same tx that inserts batches + messages + enqueues jobs. Only +// invoked once on success. +type BatchAcceptIdemCompleter func(ctx context.Context, tx pgx.Tx, result *BatchAcceptResult) error + +// agentUsesHITL returns true when the agent's outbound protection config +// could produce a Review verdict — i.e. some send would be held for HITL +// review. Batch-send MVP refuses these agents outright rather than +// silently breaking HITL guarantees; see docs/design/batch-send.md §5.1 +// and §14 Q13 for the frozen formula and reasoning. +// +// Block-only agents (Gate.Action == "block" with Scan.Sensitivity == +// "off") are NOT HITL — block is automated denial, no human in the loop. +// Those agents reach the batch handler and any per-item Block verdict +// fails the whole batch with 403 blocked_by_policy (§14 Q14). +func agentUsesHITL(ag *identity.AgentIdentity) bool { + if ag == nil { + return false + } + return ag.OutboundPolicyAction == "review" || + (ag.OutboundScanSensitivity != "" && ag.OutboundScanSensitivity != "off") +} + +// DeliverBatch is the accept-tx orchestrator for batch send — the batch +// analog of DeliverOutbound. Runs §9 steps 3-10 in one shot: HITL gate, +// per-item screening, suppression partition, rate reservation, per-item +// compose, and the single DB tx that inserts the batches row + N messages +// rows + N River outbound_send jobs + the idempotency cache row. +// +// Inputs: +// - items — one outbound.SendRequest per BatchMessage the caller +// submitted. The handler has already run per-item structural +// validation (RFC 5322, XOR, per-item attachment caps) AND the +// batch-level checks (dedup, len bounds, sum-of-attachments cap) so +// DeliverBatch can proceed without those. +// - idemCompleteTx — closure that writes the idempotency cache row +// inside the accept-tx; nil disables idempotency completion for this +// request. +// +// Returns: +// - *BatchAcceptResult with the minted BatchID + a positional Items +// slice (one entry per input item). Empty MessageID + non-nil +// Suppressed = dropped. +// - *OutboundError on any all-or-nothing failure path (HITL gate, +// block verdict on any item, rate-limit reservation, compose error, +// tx failure). The caller maps this to the appropriate HTTP status. +// +// The whole-batch invariant: if this function returns a non-nil error, +// zero rows exist (matches the frozen accept-time all-or-nothing +// invariant in docs/design/batch-send.md §2.1). +func (a *API) DeliverBatch( + ctx context.Context, + user *identity.User, + agent *identity.AgentIdentity, + items []outbound.SendRequest, + idemCompleteTx BatchAcceptIdemCompleter, +) (*BatchAcceptResult, *OutboundError) { + if len(items) == 0 { + return nil, &OutboundError{Status: http.StatusBadRequest, Code: "invalid_request", Msg: "batch must contain at least one message"} + } + + // Step 3 (§9): HITL gate. Refuse HITL-enabled agents outright. Content + // scan + policy=review both count as HITL per §14 Q13. + if agentUsesHITL(agent) { + return nil, &OutboundError{ + Status: http.StatusForbidden, + Code: "batch_hitl_unsupported", + Msg: "batch send is not available for agents with HITL enabled; use single-send POST /v1/agents/{email}/messages per recipient, or disable HITL on this agent", + } + } + + // Queue-wired guard (mirrors DeliverOutbound). Missing queue wiring is + // a startup bug; fail closed rather than submit inline. + if a.outboundEnq == nil { + log.Printf("[api] batch: outbound queue unavailable: agent=%s items=%d", agent.Domain, len(items)) + return nil, &OutboundError{Status: http.StatusInternalServerError, Code: "internal_error", Msg: "outbound delivery queue unavailable"} + } + + // Step 6 (§9): Batch-wide suppression check — one query for the union + // of all `to` addresses, partition items into keep[] and suppressed[]. + // §2.2: an item is dropped as a whole if ANY of its recipients hits + // the suppression list. + suppressionByAddr, err := a.suppressionLookupForBatch(ctx, user.ID, items) + if err != nil { + // Fail-open on suppression store errors (matches + // checkSuppression's send-time posture) — a suppression store + // outage must not block a batch that would otherwise succeed. + // The check is a compliance feature, not a security gate. + log.Printf("[api] batch suppression lookup failed (allowing send): %v", err) + suppressionByAddr = nil + } + + // Capture the original request length BEFORE partitioning reassigns + // items to the keep subset — the response result.Items must have one + // slot per ORIGINAL request item (accepted + suppressed), positionally + // aligned with what the caller submitted. + originalCount := len(items) + + // Partition items into keep[] (proceed) and record suppressed drops. + items, keepIndexes, suppressedItems := partitionSuppressed(items, suppressionByAddr) + + // Step 5 (§9): Per-item screening. Block verdict on ANY item fails + // the whole batch (§14 Q14 — all-or-nothing on block, matches + // single-send's blocked_by_policy behavior). We screen only the + // items we would proceed with — a suppressed item is dropped by + // compliance, not policy, so it's out of scope for screening. + for i, item := range items { + verdict := a.screenOutbound(ctx, agent, item) + if verdict.Block() { + originalIndex := keepIndexes[i] + auditID := blockAuditID(agent.ID, item) + a.auditRowless(ctx, agent, auditID, item, verdict) + a.emitBlockedOutbound(agent, auditID, item, verdict) + return nil, &OutboundError{ + Status: http.StatusForbidden, + Code: "blocked_by_policy", + Msg: fmt.Sprintf("batch item %d blocked by outbound policy", originalIndex), + } + } + if verdict.Review() { + // Should be unreachable — agentUsesHITL above short-circuits + // review-configured agents. Defence in depth: if content + // scan somehow lands a review verdict on a scan=off agent + // (a config drift), refuse rather than silently hold. + return nil, &OutboundError{ + Status: http.StatusForbidden, + Code: "batch_hitl_unsupported", + Msg: "batch item would require review; batch send is not available for agents that trigger review", + } + } + } + + // Step 7 (§9): Rate/quota reservation. AllowN atomically reserves + // len(items) slots or rejects. Suppressed items don't count (§4.3). + // The reservation happens outside the DB tx because a partial reserve + // (Allow reserving a slot, then tx failing) is fine — the slot ages + // out of the window naturally, matching single-send's pre-tx check. + if len(items) > 0 && a.sendLimit != nil { + ok, retryAfter := a.sendLimit.AllowN(agent.ID, len(items)) + if !ok { + secs := int(retryAfter.Seconds()) + if secs < 1 { + secs = 1 + } + return nil, &OutboundError{ + Status: http.StatusTooManyRequests, + Code: "rate_limited", + Msg: fmt.Sprintf("rate limit exceeded — batch of %d sends would exceed the per-agent throughput cap (max 60/min)", len(items)), + Details: map[string]any{"retry_after_seconds": secs}, + RetryAfter: secs, + } + } + } + + // Step 8 & 9 (§9): Per-item compose. Templates were already resolved + // at the handler layer (matching single-send's prepare()); Compose + // runs recipient normalize + DKIM sign + MIME assembly here. + composed := make([]composedBatchItem, len(items)) + for i, item := range items { + comp, cerr := a.sender.ComposeForAccept(agent, item) + if cerr != nil { + if outbound.IsValidationError(cerr) { + return nil, &OutboundError{ + Status: http.StatusBadRequest, + Code: "invalid_request", + Msg: fmt.Sprintf("batch item %d: %s", keepIndexes[i], cerr.Error()), + } + } + log.Printf("[api] batch compose failed: agent=%s item=%d error=%v", agent.Domain, keepIndexes[i], cerr) + return nil, &OutboundError{ + Status: http.StatusInternalServerError, + Code: "internal_error", + Msg: fmt.Sprintf("compose failed on batch item %d: %v", keepIndexes[i], cerr), + } + } + composed[i] = composedBatchItem{req: item, comp: comp} + } + + // Step 10 (§9): Accept-tx. All-or-nothing DB commit: + // a. batches header row + // b. bulk-insert N messages rows (delivery_status='accepted', batch_id set) + // c. River InsertManyTx enqueues N outbound_send jobs + // d. UPDATE messages SET send_job_id per row (loop; N ≤ 100) + // e. idempotency cache row + batchID := identity.NewBatchID() + suppressedJSON, err := marshalSuppressedItems(suppressedItems) + if err != nil { + log.Printf("[api] batch: marshal suppressed_json failed: %v", err) + return nil, &OutboundError{Status: http.StatusInternalServerError, Code: "internal_error", Msg: "failed to serialize suppression records"} + } + + result := &BatchAcceptResult{ + BatchID: batchID, + Items: make([]BatchAcceptItem, originalCount), + } + + if txErr := a.store.WithTx(ctx, func(tx pgx.Tx) error { + if err := a.store.CreateBatchTx(ctx, tx, &identity.Batch{ + BatchID: batchID, + UserID: user.ID, + AgentID: agent.ID, + Requested: len(items) + len(suppressedItems), + Accepted: len(items), + SuppressedJSON: suppressedJSON, + }); err != nil { + return fmt.Errorf("create batch: %w", err) + } + + if len(composed) == 0 { + // All-suppressed batch (§14 Q9) — still a valid 202. No + // messages to insert, no jobs to enqueue, but the batches + // header + idempotency completion still commit atomically. + if idemCompleteTx != nil { + if err := idemCompleteTx(ctx, tx, result); err != nil { + return fmt.Errorf("idempotency complete: %w", err) + } + } + return nil + } + + inputs := make([]identity.OutboundMessageInput, len(composed)) + for i, c := range composed { + inputs[i] = identity.OutboundMessageInput{ + AgentID: agent.ID, + ToRecipients: c.comp.To, + CC: c.comp.CC, + BCC: c.comp.BCC, + Subject: c.req.Subject, + MsgType: "send", + Method: c.comp.Method, + ConversationID: c.req.ConversationID, + RawMessage: c.comp.Raw, + DeliveryStatus: "accepted", + EnvelopeFrom: c.comp.EnvelopeFrom, + SentAs: c.comp.SentAs, + BatchID: batchID, + } + } + msgs, err := a.store.CreateOutboundMessagesTx(ctx, tx, inputs) + if err != nil { + return fmt.Errorf("bulk insert messages: %w", err) + } + msgIDs := make([]string, len(msgs)) + for i, m := range msgs { + msgIDs[i] = m.ID + } + jobIDs, err := a.outboundEnq.EnqueueBatchTx(ctx, tx, msgIDs) + if err != nil { + return fmt.Errorf("enqueue batch jobs: %w", err) + } + // Stamp send_job_id per message (loop — no bulk stamp helper today, + // and N ≤ 100 is well within the accept-tx budget). + for i, jobID := range jobIDs { + if err := a.store.StampSendJobIDTx(ctx, tx, msgIDs[i], jobID); err != nil { + return fmt.Errorf("stamp send_job_id item %d: %w", i, err) + } + } + + // Fill in Items[i] for accepted slots. + for i, id := range msgIDs { + originalIndex := keepIndexes[i] + result.Items[originalIndex] = BatchAcceptItem{MessageID: id} + } + + if idemCompleteTx != nil { + if err := idemCompleteTx(ctx, tx, result); err != nil { + return fmt.Errorf("idempotency complete: %w", err) + } + } + return nil + }); txErr != nil { + log.Printf("[api] batch accept tx failed: agent=%s items=%d error=%v", agent.Domain, len(items), txErr) + return nil, &OutboundError{Status: http.StatusInternalServerError, Code: "internal_error", Msg: "failed to accept batch for send"} + } + + // Fill in suppressed slots on result.Items (accepted slots were + // filled inside the tx). + for _, drop := range suppressedItems { + result.Items[drop.ItemIndex] = BatchAcceptItem{ + Suppressed: &SuppressedInfo{Address: drop.Address, Reason: drop.Reason}, + } + } + + log.Printf("[batch:%s] agent=%s accepted=%d suppressed=%d", batchID, agent.EmailAddress(), len(composed), len(suppressedItems)) + return result, nil +} + +// composedBatchItem carries a batch item's composed MIME + its original +// SendRequest through the accept-tx step. Kept small — no need to hold on +// to per-item state beyond what CreateOutboundMessagesTx and +// StampSendJobIDTx need. +type composedBatchItem struct { + req outbound.SendRequest + comp *outbound.ComposeResult +} + +// suppressedDrop is the internal per-item drop record used to build the +// batches.suppressed_json blob and the response result.Items slot. Field +// names match identity.BatchSuppressedItem so JSON marshaling is a direct +// re-serialization. +type suppressedDrop struct { + ItemIndex int `json:"item_index"` + Address string `json:"address"` + Reason string `json:"reason"` +} + +// suppressionLookupForBatch queries the suppression list ONCE for the +// union of every batch item's `to` addresses and returns a map of +// address → source. On error the caller is expected to fail-open (see +// DeliverBatch's use-site). +func (a *API) suppressionLookupForBatch(ctx context.Context, userID string, items []outbound.SendRequest) (map[string]string, error) { + all := make([]string, 0) + seen := map[string]struct{}{} + for _, item := range items { + for _, addr := range item.To { + if _, ok := seen[addr]; ok { + continue + } + seen[addr] = struct{}{} + all = append(all, addr) + } + } + if len(all) == 0 { + return map[string]string{}, nil + } + return a.store.SuppressedAddressesWithSource(ctx, userID, all) +} + +// partitionSuppressed splits items into keep[] (unaffected) and dropped[] +// (any `to` address in the suppression map). Returns: +// - keep: the subset of items that proceed +// - keepIndexes: original positions in the input, positionally aligned +// with keep (keep[i] came from items[keepIndexes[i]]) +// - dropped: one suppressedDrop per removed item, with its original +// ItemIndex + the first suppressed address that caused the drop +func partitionSuppressed(items []outbound.SendRequest, suppressionByAddr map[string]string) ( + keep []outbound.SendRequest, keepIndexes []int, dropped []suppressedDrop, +) { + keep = make([]outbound.SendRequest, 0, len(items)) + keepIndexes = make([]int, 0, len(items)) + if len(suppressionByAddr) == 0 { + // No suppressed addresses — every item is kept, no drops. Fast path. + for i, item := range items { + keep = append(keep, item) + keepIndexes = append(keepIndexes, i) + } + return keep, keepIndexes, nil + } + for i, item := range items { + var hit string + for _, addr := range item.To { + if _, ok := suppressionByAddr[identity.NormalizeEmail(addr)]; ok { + hit = addr + break + } + } + if hit != "" { + reason := suppressionByAddr[identity.NormalizeEmail(hit)] + if reason == "" { + reason = "manual" + } + dropped = append(dropped, suppressedDrop{ItemIndex: i, Address: hit, Reason: reason}) + continue + } + keep = append(keep, item) + keepIndexes = append(keepIndexes, i) + } + return keep, keepIndexes, dropped +} + +// marshalSuppressedItems produces the JSONB payload for +// batches.suppressed_json — a stable array shape mirroring the wire slot +// in SendBatchResponse.results[i].suppressed. Empty input → the +// canonical '[]' bytes, matching the DEFAULT on the column. +func marshalSuppressedItems(drops []suppressedDrop) ([]byte, error) { + if len(drops) == 0 { + return []byte("[]"), nil + } + return json.Marshal(drops) +} diff --git a/internal/agent/batch_test.go b/internal/agent/batch_test.go new file mode 100644 index 000000000..a52d4fe24 --- /dev/null +++ b/internal/agent/batch_test.go @@ -0,0 +1,249 @@ +package agent_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/jackc/pgx/v5" + + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/outbound" +) + +// TestDeliverBatch_HappyPath is the end-to-end accept-tx check: a batch of 3 +// external sends durably persists a batches header + 3 messages rows (each +// delivery_status='accepted', each carrying the minted batch_id and a stamped +// send_job_id), enqueues 3 outbound_send jobs, and returns a positional +// result with 3 message ids. +func TestDeliverBatch_HappyPath(t *testing.T) { + api, store, _, _ := setupAsyncAPI(t) + ctx := context.Background() + user, ag := selfAgent(t, store, "batchhappy") + + items := []outbound.SendRequest{ + {From: ag.EmailAddress(), To: []string{"alice@gmail.com"}, Subject: "hi alice", Body: "a"}, + {From: ag.EmailAddress(), To: []string{"bob@gmail.com"}, Subject: "hi bob", Body: "b"}, + {From: ag.EmailAddress(), To: []string{"carol@gmail.com"}, Subject: "hi carol", Body: "c"}, + } + res, oerr := api.DeliverBatch(ctx, user, ag, items, nil) + if oerr != nil { + t.Fatalf("DeliverBatch: status=%d code=%s msg=%s", oerr.Status, oerr.Code, oerr.Msg) + } + if res.BatchID == "" { + t.Fatal("empty BatchID") + } + if len(res.Items) != 3 { + t.Fatalf("Items len = %d, want 3", len(res.Items)) + } + for i, item := range res.Items { + if item.MessageID == "" { + t.Errorf("Items[%d] has no message id: %+v", i, item) + } + if item.Suppressed != nil { + t.Errorf("Items[%d] unexpectedly suppressed", i) + } + } + + // Batch header persisted with requested=3 accepted=3. + batch, err := store.GetBatch(ctx, res.BatchID) + if err != nil || batch == nil { + t.Fatalf("GetBatch: batch=%v err=%v", batch, err) + } + if batch.Requested != 3 || batch.Accepted != 3 { + t.Errorf("counts requested=%d accepted=%d, want 3/3", batch.Requested, batch.Accepted) + } + if batch.UserID != user.ID || batch.AgentID != ag.ID { + t.Errorf("ownership user=%q agent=%q", batch.UserID, batch.AgentID) + } + + // Each message row is accepted, has the batch_id, and a stamped send_job_id. + for _, item := range res.Items { + var ( + deliveryStatus, batchID string + sendJobID *int64 + ) + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, + `SELECT delivery_status, COALESCE(batch_id,''), send_job_id FROM messages WHERE id=$1`, + item.MessageID, + ).Scan(&deliveryStatus, &batchID, &sendJobID) + }); err != nil { + t.Fatalf("read message %s: %v", item.MessageID, err) + } + if deliveryStatus != "accepted" { + t.Errorf("msg %s delivery_status=%q, want accepted", item.MessageID, deliveryStatus) + } + if batchID != res.BatchID { + t.Errorf("msg %s batch_id=%q, want %q", item.MessageID, batchID, res.BatchID) + } + if sendJobID == nil { + t.Errorf("msg %s has no send_job_id stamped", item.MessageID) + } + } + + // Rollup reflects 3 accepted. + rollup, err := store.BatchStatusRollupByID(ctx, res.BatchID) + if err != nil { + t.Fatalf("rollup: %v", err) + } + if rollup.Accepted != 3 { + t.Errorf("rollup.Accepted = %d, want 3", rollup.Accepted) + } +} + +// TestDeliverBatch_SuppressionPartialDrop: a batch where one item's recipient +// is on the suppression list drops that item (no message row) while the rest +// proceed. The drop is recorded in batches.suppressed_json and surfaced in the +// positional result. +func TestDeliverBatch_SuppressionPartialDrop(t *testing.T) { + api, store, _, _ := setupAsyncAPI(t) + ctx := context.Background() + user, ag := selfAgent(t, store, "batchsupp") + + if _, err := store.AddSuppression(ctx, user.ID, "bounced@gmail.com", "hard bounce", "bounce", ""); err != nil { + t.Fatalf("AddSuppression: %v", err) + } + + items := []outbound.SendRequest{ + {From: ag.EmailAddress(), To: []string{"good1@gmail.com"}, Subject: "a", Body: "a"}, + {From: ag.EmailAddress(), To: []string{"bounced@gmail.com"}, Subject: "b", Body: "b"}, + {From: ag.EmailAddress(), To: []string{"good2@gmail.com"}, Subject: "c", Body: "c"}, + } + res, oerr := api.DeliverBatch(ctx, user, ag, items, nil) + if oerr != nil { + t.Fatalf("DeliverBatch: %+v", oerr) + } + if len(res.Items) != 3 { + t.Fatalf("Items len = %d, want 3", len(res.Items)) + } + // Slot 0 and 2 accepted, slot 1 suppressed. + if res.Items[0].MessageID == "" || res.Items[2].MessageID == "" { + t.Errorf("expected slots 0,2 accepted: %+v", res.Items) + } + if res.Items[1].Suppressed == nil { + t.Fatalf("expected slot 1 suppressed, got %+v", res.Items[1]) + } + if res.Items[1].Suppressed.Address != "bounced@gmail.com" || res.Items[1].Suppressed.Reason != "bounce" { + t.Errorf("suppressed info = %+v", res.Items[1].Suppressed) + } + if res.Items[1].MessageID != "" { + t.Errorf("suppressed slot must not carry a message id: %q", res.Items[1].MessageID) + } + + // Batch header: requested=3, accepted=2, suppressed_json has 1 entry. + batch, err := store.GetBatch(ctx, res.BatchID) + if err != nil || batch == nil { + t.Fatalf("GetBatch: %v", err) + } + if batch.Requested != 3 || batch.Accepted != 2 { + t.Errorf("counts requested=%d accepted=%d, want 3/2", batch.Requested, batch.Accepted) + } + dropped, err := batch.DecodeSuppressed() + if err != nil { + t.Fatalf("DecodeSuppressed: %v", err) + } + if len(dropped) != 1 || dropped[0].ItemIndex != 1 || dropped[0].Address != "bounced@gmail.com" { + raw, _ := json.Marshal(dropped) + t.Errorf("suppressed_json = %s", raw) + } + + // Only 2 messages rows exist for this batch. + var count int + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, `SELECT count(*) FROM messages WHERE batch_id=$1`, res.BatchID).Scan(&count) + }); err != nil { + t.Fatalf("count messages: %v", err) + } + if count != 2 { + t.Errorf("messages rows = %d, want 2", count) + } +} + +// TestDeliverBatch_AllSuppressed: every item suppressed → still a valid accept +// (§14 Q9). Batches header persists requested=N accepted=0, zero messages rows. +func TestDeliverBatch_AllSuppressed(t *testing.T) { + api, store, _, _ := setupAsyncAPI(t) + ctx := context.Background() + user, ag := selfAgent(t, store, "batchallsupp") + + if _, err := store.AddSuppression(ctx, user.ID, "a@gmail.com", "", "complaint", ""); err != nil { + t.Fatalf("AddSuppression a: %v", err) + } + if _, err := store.AddSuppression(ctx, user.ID, "b@gmail.com", "", "manual", ""); err != nil { + t.Fatalf("AddSuppression b: %v", err) + } + + items := []outbound.SendRequest{ + {From: ag.EmailAddress(), To: []string{"a@gmail.com"}, Subject: "a", Body: "a"}, + {From: ag.EmailAddress(), To: []string{"b@gmail.com"}, Subject: "b", Body: "b"}, + } + res, oerr := api.DeliverBatch(ctx, user, ag, items, nil) + if oerr != nil { + t.Fatalf("DeliverBatch: %+v", oerr) + } + for i, item := range res.Items { + if item.Suppressed == nil { + t.Errorf("Items[%d] should be suppressed: %+v", i, item) + } + } + batch, err := store.GetBatch(ctx, res.BatchID) + if err != nil || batch == nil { + t.Fatalf("GetBatch: %v", err) + } + if batch.Accepted != 0 || batch.Requested != 2 { + t.Errorf("counts requested=%d accepted=%d, want 2/0", batch.Requested, batch.Accepted) + } +} + +// TestDeliverBatch_HITLAgentRefused: an agent with an outbound review gate is +// refused batch send outright (§5.1). No batch/message rows are created. +func TestDeliverBatch_HITLAgentRefused(t *testing.T) { + api, store, _, _ := setupAsyncAPI(t) + ctx := context.Background() + user, ag := selfAgent(t, store, "batchhitl") + + // Turn on an outbound review gate — makes agentUsesHITL true. + if err := store.UpdateAgentProtection(ctx, ag.ID, user.ID, identity.ProtectionConfig{ + InboundGatePolicy: identity.OutboundPolicyOpen, + InboundGateAction: "flag", + InboundScanSensitivity: "off", + OutboundGatePolicy: identity.OutboundPolicyOpen, + OutboundGateAction: "review", + OutboundScanSensitivity: "off", + HITLTTLSeconds: 604800, + HITLExpirationAction: "reject", + }); err != nil { + t.Fatalf("UpdateAgentProtection: %v", err) + } + // Reload so the in-memory agent carries the new posture. + ag, err := store.GetAgentByEmail(ctx, ag.EmailAddress()) + if err != nil { + t.Fatalf("GetAgentByEmail: %v", err) + } + + items := []outbound.SendRequest{ + {From: ag.EmailAddress(), To: []string{"a@gmail.com"}, Subject: "a", Body: "a"}, + } + res, oerr := api.DeliverBatch(ctx, user, ag, items, nil) + if oerr == nil { + t.Fatalf("expected batch_hitl_unsupported, got result %+v", res) + } + if oerr.Code != "batch_hitl_unsupported" { + t.Errorf("code = %q, want batch_hitl_unsupported", oerr.Code) + } +} + +// TestAgentUsesHITL is a table test for the HITL-detection formula (§14 Q13): +// review OR scan!=off is HITL; block-only and flag/off are not. +func TestDeliverBatch_agentUsesHITLFormula(t *testing.T) { + // This exercises the public behavior via DeliverBatch since agentUsesHITL + // is unexported; the block-only case should reach the send path (not be + // refused as HITL). We verify block-only is NOT refused as HITL by + // checking a block-only agent gets past the HITL gate (it then proceeds + // to normal screening/accept). Covered indirectly by the HITL test above + // (review => refused) and the happy path (flag/off => accepted). A + // dedicated block-only assertion belongs with screening tests; noted here + // so the formula's two arms are both covered by the suite. + t.Skip("formula arms covered by HappyPath (off) + HITLAgentRefused (review); block-only path is a screening-layer concern") +} diff --git a/internal/agent/outbound_async.go b/internal/agent/outbound_async.go index 0be28ac14..b7da21a5d 100644 --- a/internal/agent/outbound_async.go +++ b/internal/agent/outbound_async.go @@ -50,6 +50,11 @@ type OutboundEnqueuer interface { // (scheduled send). Same outbox transaction as EnqueueSendTx; only the job's // first-run time differs. *outboundsend.Jobs satisfies it. EnqueueScheduledSendTx(ctx context.Context, tx pgx.Tx, messageID string, at time.Time) (int64, error) + // EnqueueBatchTx enqueues N outbound_send jobs in one round-trip + // within the caller's tx. Returns job ids positionally aligned with + // messageIDs so the accept-tx can stamp messages.send_job_id per row. + // Used by DeliverBatch; single-send stays on EnqueueSendTx. + EnqueueBatchTx(ctx context.Context, tx pgx.Tx, messageIDs []string) ([]int64, error) } // outboundSendStore implements outboundsend.Store over identity.Store + diff --git a/internal/agent/outbound_async_test.go b/internal/agent/outbound_async_test.go index 093ae34f9..24fae5b32 100644 --- a/internal/agent/outbound_async_test.go +++ b/internal/agent/outbound_async_test.go @@ -92,6 +92,17 @@ func (f *fakeOutboundEnqueuer) CancelTx(_ context.Context, _ pgx.Tx, jobID int64 return nil } +// EnqueueBatchTx satisfies the widened OutboundEnqueuer interface. Batch +// tests use a dedicated fake below; single-send fake tests never invoke +// this method — a call would indicate a wiring mistake. +func (f *fakeOutboundEnqueuer) EnqueueBatchTx(_ context.Context, _ pgx.Tx, messageIDs []string) ([]int64, error) { + ids := make([]int64, len(messageIDs)) + for i := range ids { + ids[i] = f.jobID + int64(i) + } + return ids, nil +} + type txSentinelEnqueuer struct{} func (txSentinelEnqueuer) EnqueueSendTx(ctx context.Context, tx pgx.Tx, messageID string) (int64, error) { @@ -104,6 +115,18 @@ func (s txSentinelEnqueuer) EnqueueScheduledSendTx(ctx context.Context, tx pgx.T return s.EnqueueSendTx(ctx, tx, messageID) } +func (s txSentinelEnqueuer) EnqueueBatchTx(ctx context.Context, tx pgx.Tx, messageIDs []string) ([]int64, error) { + ids := make([]int64, len(messageIDs)) + for i, mid := range messageIDs { + id, err := s.EnqueueSendTx(ctx, tx, mid) + if err != nil { + return nil, err + } + ids[i] = id + } + return ids, nil +} + func installTask6DurableJobs(t *testing.T, pool *pgxpool.Pool) { t.Helper() if _, err := pool.Exec(context.Background(), `DROP TABLE IF EXISTS task6_durable_jobs; CREATE TABLE task6_durable_jobs (id bigserial PRIMARY KEY, message_id text NOT NULL UNIQUE)`); err != nil { diff --git a/internal/apiserver/apiserver.go b/internal/apiserver/apiserver.go index ccea1d3b4..7c51a35bb 100644 --- a/internal/apiserver/apiserver.go +++ b/internal/apiserver/apiserver.go @@ -192,6 +192,7 @@ func BuildDeps(p Params) httpapi.Deps { EventsEnabled: p.EventsEnabled, Idempotency: p.Idempotency, DeliverOutbound: p.API.DeliverOutbound, + DeliverBatch: p.API.DeliverBatch, SendTest: p.API.SendTestCore, PollSendOutcome: p.Store.GetSendOutcome, ApprovePending: p.API.ApprovePendingCore, @@ -242,6 +243,7 @@ func BuildDeps(p Params) httpapi.Deps { }, ListProtectionEventsByMessage: p.Store.ListProtectionEventsByMessage, + GetUsage: func(ctx context.Context, userID string) httpapi.LimitsUsageView { var u httpapi.LimitsUsageView if n, err := p.UsageStore.CountAgentsByUser(ctx, userID); err == nil { diff --git a/internal/httpapi/batch.go b/internal/httpapi/batch.go new file mode 100644 index 000000000..efd6a00c4 --- /dev/null +++ b/internal/httpapi/batch.go @@ -0,0 +1,436 @@ +package httpapi + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "reflect" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/jackc/pgx/v5" + + "github.com/tokencanopy/e2a/internal/agent" + "github.com/tokencanopy/e2a/internal/idempotency" + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/outbound" +) + +// --------------------------------------------------------------------------- +// Wire types (§1.2, §1.3) +// --------------------------------------------------------------------------- + +// BatchMessage is one item in a batch-send request. Field-for-field a +// near-clone of SendEmailRequest minus `from` (the sending agent is the +// path parameter, shared across the batch). See docs/design/batch-send.md +// §0.5 and §1.2 for the shape rationale. +type BatchMessage struct { + To []string `json:"to" nullable:"false" maxLength:"320" doc:"Primary recipients for this item. Same per-item cap as single-send (50 combined across to+cc+bcc). Each item's envelope is independent — item i's cc/bcc are not visible in item j."` + CC []string `json:"cc,omitempty" nullable:"false" maxLength:"320" doc:"Cc recipients for this item."` + BCC []string `json:"bcc,omitempty" nullable:"false" maxLength:"320" doc:"Bcc recipients for this item."` + Subject string `json:"subject,omitempty" maxLength:"2000" doc:"Literal subject. Required unless a template reference is used. Same caps as single-send."` + Body string `json:"text,omitempty" maxLength:"1048576" doc:"Plain-text body."` + HTMLBody string `json:"html,omitempty" maxLength:"1048576" doc:"HTML body."` + TemplateID string `json:"template_id,omitempty" doc:"Send using a stored template. Mutually exclusive with template_alias and with literal subject/text/html on this item."` + TemplateAlias string `json:"template_alias,omitempty" doc:"Human-handle alternative to template_id."` + TemplateData TemplateData `json:"template_data,omitempty" doc:"Per-item template data. Populated freely across items — this is what makes native mail-merge possible without a server-side templating loop (docs/design/batch-send.md §0.5)."` + ConversationID string `json:"conversation_id,omitempty" maxLength:"200" doc:"Caller-assigned conversation (thread) id. Auto-minted per item if omitted."` + ReplyTo string `json:"reply_to,omitempty" maxLength:"320" doc:"Per-item Reply-To override. If empty, the batch-level reply_to default (if set) applies."` + Attachments []outbound.Attachment `json:"attachments,omitempty" nullable:"false" doc:"Per-item attachments. Single attachment ≤ 10 MiB, item combined ≤ 25 MiB. Additionally the SUM across all batch items must be ≤ 25 MiB (docs/design/batch-send.md §14 Q15)."` +} + +// SendBatchRequest is the body of POST /v1/agents/{email}/batches. Each +// item in Messages is a self-contained near-SendEmailRequest; ReplyTo at +// this level is a batch-wide default applied to items that leave their +// own ReplyTo empty (the only MVP batch-level field per §1.2). +type SendBatchRequest struct { + Messages []BatchMessage `json:"messages" minItems:"1" maxItems:"100" nullable:"false" doc:"1..100 BatchMessage items. Each item is a self-contained near-clone of SendEmailRequest minus from — its own to/cc/bcc/content/attachments/template/reply_to. Ordering is significant: results[] in the response is positionally aligned with this array."` + ReplyTo string `json:"reply_to,omitempty" maxLength:"320" doc:"Optional batch-level Reply-To default. Applied to any item that leaves reply_to empty; a per-item value always wins. This is the ONLY batch-level default in MVP; every other field is per-item (docs/design/batch-send.md §1.2)."` +} + +// BatchResult is one slot in the results[] array. Discriminated: +// - Accepted item: MessageID non-empty, Suppressed absent. +// - Suppressed item: MessageID empty, Suppressed populated. +type BatchResult struct { + MessageID string `json:"message_id,omitempty" doc:"Minted message id when the item was accepted (delivery_status='accepted' persisted and River outbound_send job enqueued). Absent when Suppressed is present."` + Suppressed *BatchSuppressedResult `json:"suppressed,omitempty" doc:"Present when the item was dropped by the suppression-list filter (docs/design/batch-send.md §2.2). No message row exists for a suppressed slot; the caller can un-suppress via DELETE /v1/account/suppressions/{address} and resubmit."` +} + +// BatchSuppressedResult describes one dropped item — the first +// suppression-list address found in the item's `to` list plus the +// suppressions.source category. Matches identity.BatchSuppressedItem +// (the durable record stored in batches.suppressed_json) minus the +// item_index which is implicit in results[]'s position. +type BatchSuppressedResult struct { + Address string `json:"address" doc:"The recipient address in this item's to list that triggered the drop. If multiple addresses in the same item are suppressed, only the first is surfaced (dropping the whole item is enough signal)."` + Reason string `json:"reason" doc:"The suppression-list category from suppressions.source. Known values: bounce, complaint, manual. Open set — treat as string and tolerate unknown values."` +} + +// SendBatchResponse is the 202 body of POST /v1/agents/{email}/batches. +// Results is positionally aligned with SendBatchRequest.Messages; +// Accepted/SuppressedCount are convenience counters callers can use +// without walking Results. +type SendBatchResponse struct { + BatchID string `json:"batch_id" doc:"Durable id for this batch (bat_). Use GET /v1/batches/{batch_id} to retrieve the header + status rollup."` + Results []BatchResult `json:"results" nullable:"false" doc:"One slot per request item, positionally aligned. Each slot is either {message_id} (accepted) or {suppressed:{address,reason}} (dropped by the suppression filter)."` + Accepted int `json:"accepted" doc:"Count of results[] slots that are {message_id}. Redundant with results[] but convenient for logging + zero-check."` + SuppressedCount int `json:"suppressed_count" doc:"Count of results[] slots that are {suppressed}. Zero when no per-item drops occurred."` +} + +// sendBatchInput is the Huma input struct for POST /v1/agents/{email}/batches. +// Mirrors createMessageInput's shape (path email + Idempotency-Key header + +// RawBody for body-hash idempotency + Body). +type sendBatchInput struct { + Address string `path:"email"` + RawBody []byte // populated by Huma for idempotency body-hashing (see runIdempotent) + IdempotencyKey string `header:"Idempotency-Key" doc:"Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch)."` + Body SendBatchRequest +} + +// sendBatchOutput bridges the DeliverBatch result to the wire. +type sendBatchOutput struct { + Status int + Body SendBatchResponse +} + +// --------------------------------------------------------------------------- +// Registration (§1.1) +// --------------------------------------------------------------------------- + +// maxBatchAttachmentBytes is the batch-wide combined-attachment cap (§14 +// Q15). Applied on top of the per-item cap (already enforced by +// validateOutboundBody / validateAttachments). +const maxBatchAttachmentBytes = 25 * 1024 * 1024 // 25 MiB + +// maxBatchRequestBodyBytes bounds the raw HTTP request body Huma will +// accept before returning 413 payload_too_large. Sized to comfortably +// fit 100 items at the per-item body caps (subject 2 KiB + text 1 MiB + +// html 1 MiB) plus the batch attachment ceiling (25 MiB) + overhead. +// Base-64 attachment encoding is 4/3 the byte size, so 25 MiB of +// attachments occupy ~33.3 MiB on the wire; add ~5 MiB per item × 100 = +// 500 MiB overhead theoretical max, but real batches don't put max +// bodies on every item — settle for 60 MiB. +const maxBatchRequestBodyBytes = 60 * 1024 * 1024 // 60 MiB + +// batchAccepted202 returns the 202 response schema declaration for +// sendBatch. Mirrors accepted202 in outbound.go but for the batch shape. +func (s *Server) batchAccepted202() *huma.Response { + return s.jsonResponse(reflect.TypeOf(SendBatchResponse{}), "SendBatchResponse", + "Batch accepted — durably persisted, with up to len(request.messages) River outbound_send jobs enqueued. results[] is positionally aligned with request.messages; each slot is either {message_id} (accepted) or {suppressed} (dropped by the suppression filter — the request itself is not an error). An all-suppressed batch (every slot suppressed) is also a 202; accepted=0.") +} + +// registerSendBatch is called from the API constructor to wire the +// sendBatch operation. Kept as a method-on-Server to match the other +// per-endpoint register* helpers in outbound.go. +func (s *Server) registerSendBatch() { + huma.Register(s.API, huma.Operation{ + OperationID: "sendBatch", + Method: http.MethodPost, + Path: "/v1/agents/{email}/batches", + Summary: "Send a batch of up to 100 emails", + Tags: []string{"messages"}, + Description: "Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract.\n\nMVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant.", + Security: []map[string][]string{{"bearer": {}}}, + MaxBodyBytes: maxBatchRequestBodyBytes, + Responses: map[string]*huma.Response{ + "202": s.batchAccepted202(), + "400": s.jsonResponse(reflect.TypeOf(ErrorEnvelope{}), "ErrorEnvelope", + "Bad Request — request-shape/validation failure. error.code includes invalid_request, invalid_recipient, too_many_messages, duplicate_recipient, invalid_attachment (undecodable base64). Per-item errors carry details.item_index identifying the offending messages[] index; batch-wide errors omit it."), + "402": s.limitExceededResponse(), + "403": s.errorEnvelopeResponse(), + "409": s.idempotencyInFlightResponse(), + "413": s.outboundPayloadTooLargeResponse(), + "422": s.idempotencyReuseResponse(), + "429": s.rateLimitedResponse(), + "default": s.errorEnvelopeResponse(), + }, + }, s.handleSendBatch) +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +// handleSendBatch implements POST /v1/agents/{email}/batches per the +// design doc's accept-tx sketch (§9). Flow: +// 1. Auth + resolveOwnedAgent. +// 2. Structural validation on the batch as a whole (len, cross-item +// dup, sum of attachments). +// 3. Idempotency claim (runIdempotent) wrapping the whole accept path. +// 4. Per-item validate + template resolve + compose (mirrors single- +// send's prepare()). +// 5. Delegate to agent.API.DeliverBatch, which runs the accept-tx +// (HITL gate, per-item screening, suppression partition, rate +// reservation, DB tx that inserts batches + messages bulk + River +// jobs + idempotency completion). +// 6. Map the BatchAcceptResult to the wire SendBatchResponse. +func (s *Server) handleSendBatch(ctx context.Context, in *sendBatchInput) (*sendBatchOutput, error) { + ag, err := s.resolveOwnedAgent(ctx, in.Address) + if err != nil { + return nil, err + } + user, uerr := s.requireUser(ctx) + if uerr != nil { + return nil, uerr + } + body := in.Body + if err := validateBatchStructure(&body); err != nil { + return nil, err + } + + // Idempotency route is agent-scoped (matches single-send). A batch + // on the same key with an identical body replays the cached 202; + // same key + different body → 422; concurrent same key → 409. + route := "/v1/agents/" + ag.ID + "/batches" + + status, response, ferr := runIdempotentNS(s, ctx, user.ID, idemUserNS, in.IdempotencyKey, route, in.RawBody, func() (int, SendBatchResponse, error) { + return s.deliverBatch(ctx, user, ag, body, in.IdempotencyKey) + }) + if ferr != nil { + return nil, ferr + } + return &sendBatchOutput{Status: status, Body: response}, nil +} + +// deliverBatch runs the batch handler's per-item preparation and +// delegates to agent.API.DeliverBatch for the accept-tx. Extracted from +// handleSendBatch so the idempotency wrapper can invoke it as a closure. +func (s *Server) deliverBatch(ctx context.Context, user *identity.User, ag *identity.AgentIdentity, body SendBatchRequest, idemKey string) (int, SendBatchResponse, error) { + items := make([]outbound.SendRequest, len(body.Messages)) + for i := range body.Messages { + bm := body.Messages[i] + + // Per-item send-body shape (same checks as single-send). + asSend := SendEmailRequest{ + To: bm.To, CC: bm.CC, BCC: bm.BCC, + Subject: bm.Subject, Body: bm.Body, HTMLBody: bm.HTMLBody, + TemplateID: bm.TemplateID, TemplateAlias: bm.TemplateAlias, TemplateData: bm.TemplateData, + ConversationID: bm.ConversationID, ReplyTo: bm.ReplyTo, Attachments: bm.Attachments, + } + if env := validateSendTemplateShape(&asSend); env != nil { + return 0, SendBatchResponse{}, envelopeWithItemIndex(env, i) + } + if env := s.resolveSendTemplate(ctx, user.ID, &asSend); env != nil { + return 0, SendBatchResponse{}, envelopeWithItemIndex(env, i) + } + if env := s.validateOutboundBody(asSend.Subject, asSend.Body, asSend.To, asSend.CC, asSend.BCC, asSend.ConversationID); env != nil { + return 0, SendBatchResponse{}, envelopeWithItemIndex(env, i) + } + + // Apply the batch-level reply_to default only when the item + // didn't set its own. Per-item ReplyTo always wins (§1.2). + replyTo := asSend.ReplyTo + if replyTo == "" { + replyTo = body.ReplyTo + } + if env := validateReplyTo(replyTo); env != nil { + return 0, SendBatchResponse{}, envelopeWithItemIndex(env, i) + } + + items[i] = outbound.SendRequest{ + From: ag.EmailAddress(), + To: asSend.To, + CC: asSend.CC, + BCC: asSend.BCC, + Subject: asSend.Subject, + Body: asSend.Body, + HTMLBody: asSend.HTMLBody, + ConversationID: asSend.ConversationID, + ReplyTo: replyTo, + Attachments: asSend.Attachments, + } + } + + // Batch-wide checks that can only run after per-item template resolve + // (rendered content is subject to the same per-item attachment cap). + if env := checkBatchAttachmentSum(items); env != nil { + return 0, SendBatchResponse{}, env + } + if env := checkCrossItemDuplicates(items); env != nil { + return 0, SendBatchResponse{}, env + } + + // Wire an idempotency-completion closure that writes inside the accept-tx. + var idemCompleteTx agent.BatchAcceptIdemCompleter + if idemKey != "" && s.deps.Idempotency != nil { + nsKey := idemUserNS + idemKey + uid := user.ID + idemCompleteTx = func(ctx context.Context, tx pgx.Tx, result *agent.BatchAcceptResult) error { + resp := sendBatchResponseFromAcceptResult(result) + raw, mErr := json.Marshal(resp) + if mErr != nil { + raw = []byte("{}") + } + return s.deps.Idempotency.CompleteTx(ctx, tx, uid, nsKey, idempotency.CachedResponse{ + StatusCode: http.StatusAccepted, + ContentType: "application/json", + Body: raw, + }) + } + } + + if s.deps.DeliverBatch == nil { + return 0, SendBatchResponse{}, NewError(http.StatusNotImplemented, "not_implemented", + "batch send is not enabled on this deployment") + } + result, oerr := s.deps.DeliverBatch(ctx, user, ag, items, idemCompleteTx) + if oerr != nil { + return 0, SendBatchResponse{}, envelopeFromOutboundError(oerr) + } + return http.StatusAccepted, sendBatchResponseFromAcceptResult(result), nil +} + +// sendBatchResponseFromAcceptResult maps the agent-layer BatchAcceptResult +// into the wire-facing SendBatchResponse. Positionally aligned; counts +// are derived from the item shapes. +func sendBatchResponseFromAcceptResult(r *agent.BatchAcceptResult) SendBatchResponse { + if r == nil { + return SendBatchResponse{} + } + resp := SendBatchResponse{BatchID: r.BatchID, Results: make([]BatchResult, len(r.Items))} + for i, item := range r.Items { + if item.Suppressed != nil { + resp.Results[i] = BatchResult{Suppressed: &BatchSuppressedResult{ + Address: item.Suppressed.Address, + Reason: item.Suppressed.Reason, + }} + resp.SuppressedCount++ + continue + } + resp.Results[i] = BatchResult{MessageID: item.MessageID} + resp.Accepted++ + } + return resp +} + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +// validateBatchStructure runs the checks that don't depend on template +// resolution: array bounds. Per-item structural checks + cross-item dup +// + attachment sum are done AFTER template render inside deliverBatch, +// because rendered content shape informs some of them. +func validateBatchStructure(body *SendBatchRequest) *ErrorEnvelope { + n := len(body.Messages) + if n < 1 || n > 100 { + return NewError(http.StatusBadRequest, "too_many_messages", + fmt.Sprintf("messages[] must contain 1..100 items; got %d", n)). + WithDetails(TooManyMessagesDetails{MaxMessages: 100, Provided: n}) + } + return nil +} + +// checkBatchAttachmentSum enforces the §14 Q15 batch-wide cap: the sum +// of every item's attachment byte totals must be ≤ 25 MiB. The per-item +// caps (single attachment ≤ 10 MiB, item combined ≤ 25 MiB) are enforced +// by the shared attachment validator on the composed SendRequest. +func checkBatchAttachmentSum(items []outbound.SendRequest) *ErrorEnvelope { + var total int64 + for _, item := range items { + for _, att := range item.Attachments { + // Per-item validateAttachments has already accepted every + // item's attachments individually, so each Data is valid + // base64. Whitespace-strip + decode gives the exact byte + // count for the batch sum, matching the per-item cap + // accounting. + clean := stripBase64Whitespace(att.Data) + decoded, err := base64.StdEncoding.DecodeString(clean) + if err != nil { + // Should not happen — per-item validation ran first — + // but if it does, fall back to an approximation so we + // still enforce a bound rather than silently pass a + // giant payload through. + total += int64(base64.StdEncoding.DecodedLen(len(clean))) + continue + } + total += int64(len(decoded)) + } + } + if total > maxBatchAttachmentBytes { + return NewError(http.StatusRequestEntityTooLarge, "payload_too_large", + "combined attachment bytes across all batch items exceed the batch cap"). + WithDetails(PayloadTooLargeDetails{ + Scope: "batch", + ActualBytes: total, + MaxBytes: maxBatchAttachmentBytes, + }) + } + return nil +} + +// stripBase64Whitespace removes CR/LF/space/tab that RFC 2045 allows in a +// base64 body, matching the pre-decode step validateAttachments does. +func stripBase64Whitespace(s string) string { + return strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' || r == ' ' || r == '\t' { + return -1 + } + return r + }, s) +} + +// checkCrossItemDuplicates rejects a batch where the same recipient +// address appears in the `to` set of two or more items. Silent dedupe +// would send N-k messages when the caller asked for N and hide the +// input mistake — §14 Q11 chose reject. Only cross-item `to` duplicates +// are checked; duplicates within one item (or in cc/bcc) are the same +// pattern the existing single-send validator already handles. +func checkCrossItemDuplicates(items []outbound.SendRequest) *ErrorEnvelope { + seen := map[string][]int{} + for i, item := range items { + for _, addr := range item.To { + norm := strings.ToLower(strings.TrimSpace(addr)) + seen[norm] = append(seen[norm], i) + } + } + for addr, indexes := range seen { + if len(indexes) < 2 { + continue + } + return NewError(http.StatusBadRequest, "duplicate_recipient", + "same recipient address appears in more than one batch item"). + WithDetails(DuplicateRecipientDetails{Address: addr, ItemIndices: indexes}) + } + return nil +} + +// envelopeWithItemIndex is a helper for stamping an item_index onto a +// per-item validation error. It mutates the details in place when the +// error carries a typed detail struct known to have an ItemIndex field +// (currently just PayloadTooLargeDetails); otherwise it falls back to a +// generic details.item_index injection so the caller can still find the +// offending item. +func envelopeWithItemIndex(env *ErrorEnvelope, itemIndex int) *ErrorEnvelope { + if env == nil { + return nil + } + // The typed details are opaque here — we can't reach in and set + // ItemIndex without knowing the type. Simplest is to overlay a + // generic map that both preserves the code and stamps the index. + // Callers reading `details.item_index` see it either way. + if env.Err.Details == nil { + env.Err.Details = map[string]any{"item_index": itemIndex} + return env + } + // If details is already a map, augment in place. + if m, ok := env.Err.Details.(map[string]any); ok { + m["item_index"] = itemIndex + return env + } + // If details is a typed struct, re-marshal into a map and add the + // field. This keeps every existing field name intact while adding + // item_index. + raw, err := json.Marshal(env.Err.Details) + if err != nil { + env.Err.Details = map[string]any{"item_index": itemIndex} + return env + } + m := map[string]any{} + _ = json.Unmarshal(raw, &m) + m["item_index"] = itemIndex + env.Err.Details = m + return env +} diff --git a/internal/httpapi/batch_test.go b/internal/httpapi/batch_test.go new file mode 100644 index 000000000..1163311ad --- /dev/null +++ b/internal/httpapi/batch_test.go @@ -0,0 +1,389 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/tokencanopy/e2a/internal/agent" + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/outbound" +) + +// batchTestDeps builds a Deps wired for batch-handler tests: a fixed user, +// one verified agent, and a DeliverBatch stub the caller supplies. The stub +// receives the already-validated + composed SendRequest slice, so tests can +// assert on what the handler produced and control the accept-tx outcome. +func batchTestDeps(deliver func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError)) Deps { + return Deps{ + Authenticator: func(r *http.Request) (*identity.User, error) { return &identity.User{ID: "u_1"}, nil }, + GetAgent: func(ctx context.Context, address string) (*identity.AgentIdentity, error) { + if address == "bot@acme.com" { + return &identity.AgentIdentity{ID: "bot@acme.com", Email: "bot@acme.com", UserID: "u_1", DomainVerified: true}, nil + } + return nil, errors.New("not found") + }, + DeliverBatch: deliver, + } +} + +// postBatch fires a POST /v1/agents/bot@acme.com/batches with the given body +// and returns the decoded status + response map. +func postBatch(t *testing.T, srv *httptest.Server, body any, idemKey string) (int, map[string]any) { + t.Helper() + b, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", srv.URL+"/v1/agents/bot@acme.com/batches", bytes.NewReader(b)) + req.Header.Set("Authorization", "Bearer good") + req.Header.Set("Content-Type", "application/json") + if idemKey != "" { + req.Header.Set("Idempotency-Key", idemKey) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} + +func TestSendBatch_HappyPath(t *testing.T) { + var gotItems []outbound.SendRequest + srv := httptest.NewServer(New(batchTestDeps( + func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) { + gotItems = items + res := &agent.BatchAcceptResult{BatchID: "bat_test", Items: make([]agent.BatchAcceptItem, len(items))} + for i := range items { + res.Items[i] = agent.BatchAcceptItem{MessageID: "msg_" + string(rune('a'+i))} + } + return res, nil + }))) + t.Cleanup(srv.Close) + + status, out := postBatch(t, srv, map[string]any{ + "messages": []map[string]any{ + {"to": []string{"alice@x.com"}, "subject": "Hi Alice", "text": "hello alice"}, + {"to": []string{"bob@x.com"}, "subject": "Hi Bob", "text": "hello bob"}, + }, + }, "") + + if status != http.StatusAccepted { + t.Fatalf("status = %d, want 202; body=%v", status, out) + } + if out["batch_id"] != "bat_test" { + t.Errorf("batch_id = %v, want bat_test", out["batch_id"]) + } + if out["accepted"].(float64) != 2 { + t.Errorf("accepted = %v, want 2", out["accepted"]) + } + results, _ := out["results"].([]any) + if len(results) != 2 { + t.Fatalf("results len = %d, want 2", len(results)) + } + // The handler must have mapped each BatchMessage to a SendRequest with + // the path agent as From. + if len(gotItems) != 2 || gotItems[0].From != "bot@acme.com" { + t.Errorf("DeliverBatch got items = %+v", gotItems) + } + if gotItems[0].To[0] != "alice@x.com" || gotItems[1].To[0] != "bob@x.com" { + t.Errorf("item recipients wrong: %+v", gotItems) + } +} + +func TestSendBatch_TooManyMessages(t *testing.T) { + srv := httptest.NewServer(New(batchTestDeps( + func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) { + t.Fatal("DeliverBatch should not run when validation rejects the batch") + return nil, nil + }))) + t.Cleanup(srv.Close) + + msgs := make([]map[string]any, 101) + for i := range msgs { + msgs[i] = map[string]any{"to": []string{"x@y.com"}, "subject": "s", "text": "t"} + } + status, out := postBatch(t, srv, map[string]any{"messages": msgs}, "") + // 101 items exceeds the schema maxItems:100 — Huma rejects it at the + // validation layer with 422 invalid_request (the framework's + // array-bounds check fires before the handler's too_many_messages). + if status != http.StatusBadRequest && status != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 400/422; body=%v", status, out) + } +} + +func TestSendBatch_DuplicateRecipient(t *testing.T) { + srv := httptest.NewServer(New(batchTestDeps( + func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) { + t.Fatal("DeliverBatch should not run for a duplicate-recipient batch") + return nil, nil + }))) + t.Cleanup(srv.Close) + + status, out := postBatch(t, srv, map[string]any{ + "messages": []map[string]any{ + {"to": []string{"dup@x.com"}, "subject": "a", "text": "a"}, + {"to": []string{"other@x.com"}, "subject": "b", "text": "b"}, + {"to": []string{"dup@x.com"}, "subject": "c", "text": "c"}, + }, + }, "") + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%v", status, out) + } + errObj, _ := out["error"].(map[string]any) + if errObj["code"] != "duplicate_recipient" { + t.Errorf("code = %v, want duplicate_recipient", errObj["code"]) + } + details, _ := errObj["details"].(map[string]any) + if details["address"] != "dup@x.com" { + t.Errorf("details.address = %v, want dup@x.com", details["address"]) + } +} + +func TestSendBatch_BatchAttachmentSumExceeded(t *testing.T) { + srv := httptest.NewServer(New(batchTestDeps( + func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) { + t.Fatal("DeliverBatch should not run when the batch attachment cap is exceeded") + return nil, nil + }))) + t.Cleanup(srv.Close) + + // Two items, each with a ~15 MiB attachment → 30 MiB total > 25 MiB cap. + // Each item is individually under the per-item 25 MiB cap. + blob := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("A"), 15*1024*1024)) + att := []map[string]any{{"filename": "big.bin", "content_type": "application/octet-stream", "data": blob}} + status, out := postBatch(t, srv, map[string]any{ + "messages": []map[string]any{ + {"to": []string{"a@x.com"}, "subject": "a", "text": "a", "attachments": att}, + {"to": []string{"b@x.com"}, "subject": "b", "text": "b", "attachments": att}, + }, + }, "") + if status != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413; body=%v", status, out) + } + errObj, _ := out["error"].(map[string]any) + if errObj["code"] != "payload_too_large" { + t.Errorf("code = %v, want payload_too_large", errObj["code"]) + } + details, _ := errObj["details"].(map[string]any) + if details["scope"] != "batch" { + t.Errorf("details.scope = %v, want batch", details["scope"]) + } +} + +func TestSendBatch_SuppressionPartialDrop(t *testing.T) { + srv := httptest.NewServer(New(batchTestDeps( + func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) { + // Simulate item 1 dropped by suppression. + return &agent.BatchAcceptResult{ + BatchID: "bat_supp", + Items: []agent.BatchAcceptItem{ + {MessageID: "msg_a"}, + {Suppressed: &agent.SuppressedInfo{Address: "bounced@x.com", Reason: "bounce"}}, + {MessageID: "msg_c"}, + }, + }, nil + }))) + t.Cleanup(srv.Close) + + status, out := postBatch(t, srv, map[string]any{ + "messages": []map[string]any{ + {"to": []string{"a@x.com"}, "subject": "a", "text": "a"}, + {"to": []string{"bounced@x.com"}, "subject": "b", "text": "b"}, + {"to": []string{"c@x.com"}, "subject": "c", "text": "c"}, + }, + }, "") + if status != http.StatusAccepted { + t.Fatalf("status = %d, want 202; body=%v", status, out) + } + if out["accepted"].(float64) != 2 { + t.Errorf("accepted = %v, want 2", out["accepted"]) + } + if out["suppressed_count"].(float64) != 1 { + t.Errorf("suppressed_count = %v, want 1", out["suppressed_count"]) + } + results, _ := out["results"].([]any) + slot1, _ := results[1].(map[string]any) + supp, _ := slot1["suppressed"].(map[string]any) + if supp["address"] != "bounced@x.com" || supp["reason"] != "bounce" { + t.Errorf("results[1].suppressed = %v", supp) + } + if _, hasMsg := slot1["message_id"]; hasMsg { + t.Errorf("suppressed slot must not carry message_id: %v", slot1) + } +} + +func TestSendBatch_AllSuppressed(t *testing.T) { + srv := httptest.NewServer(New(batchTestDeps( + func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) { + return &agent.BatchAcceptResult{ + BatchID: "bat_allsupp", + Items: []agent.BatchAcceptItem{ + {Suppressed: &agent.SuppressedInfo{Address: "a@x.com", Reason: "complaint"}}, + }, + }, nil + }))) + t.Cleanup(srv.Close) + + status, out := postBatch(t, srv, map[string]any{ + "messages": []map[string]any{{"to": []string{"a@x.com"}, "subject": "a", "text": "a"}}, + }, "") + // §14 Q9: all-suppressed is still a valid 202 with accepted:0. + if status != http.StatusAccepted { + t.Fatalf("status = %d, want 202; body=%v", status, out) + } + if out["accepted"].(float64) != 0 { + t.Errorf("accepted = %v, want 0 for all-suppressed batch", out["accepted"]) + } +} + +func TestSendBatch_HITLUnsupported(t *testing.T) { + srv := httptest.NewServer(New(batchTestDeps( + func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) { + return nil, &agent.OutboundError{Status: http.StatusForbidden, Code: "batch_hitl_unsupported", Msg: "batch send not available for HITL agents"} + }))) + t.Cleanup(srv.Close) + + status, out := postBatch(t, srv, map[string]any{ + "messages": []map[string]any{{"to": []string{"a@x.com"}, "subject": "a", "text": "a"}}, + }, "") + if status != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%v", status, out) + } + errObj, _ := out["error"].(map[string]any) + if errObj["code"] != "batch_hitl_unsupported" { + t.Errorf("code = %v, want batch_hitl_unsupported", errObj["code"]) + } +} + +func TestSendBatch_BlockedByPolicy(t *testing.T) { + srv := httptest.NewServer(New(batchTestDeps( + func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) { + return nil, &agent.OutboundError{Status: http.StatusForbidden, Code: "blocked_by_policy", Msg: "batch item 0 blocked by outbound policy"} + }))) + t.Cleanup(srv.Close) + + status, out := postBatch(t, srv, map[string]any{ + "messages": []map[string]any{{"to": []string{"a@x.com"}, "subject": "a", "text": "a"}}, + }, "") + if status != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%v", status, out) + } + errObj, _ := out["error"].(map[string]any) + if errObj["code"] != "blocked_by_policy" { + t.Errorf("code = %v, want blocked_by_policy", errObj["code"]) + } +} + +func TestSendBatch_RateLimited(t *testing.T) { + srv := httptest.NewServer(New(batchTestDeps( + func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) { + return nil, &agent.OutboundError{Status: http.StatusTooManyRequests, Code: "rate_limited", Msg: "over cap", Details: map[string]any{"retry_after_seconds": 30}, RetryAfter: 30} + }))) + t.Cleanup(srv.Close) + + status, out := postBatch(t, srv, map[string]any{ + "messages": []map[string]any{{"to": []string{"a@x.com"}, "subject": "a", "text": "a"}}, + }, "") + if status != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429; body=%v", status, out) + } + errObj, _ := out["error"].(map[string]any) + if errObj["code"] != "rate_limited" { + t.Errorf("code = %v, want rate_limited", errObj["code"]) + } +} + +func TestSendBatch_InvalidRecipientCarriesItemIndex(t *testing.T) { + srv := httptest.NewServer(New(batchTestDeps( + func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) { + t.Fatal("DeliverBatch should not run when an item has an invalid recipient") + return nil, nil + }))) + t.Cleanup(srv.Close) + + status, out := postBatch(t, srv, map[string]any{ + "messages": []map[string]any{ + {"to": []string{"ok@x.com"}, "subject": "a", "text": "a"}, + {"to": []string{"not-an-email"}, "subject": "b", "text": "b"}, + }, + }, "") + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%v", status, out) + } + errObj, _ := out["error"].(map[string]any) + details, _ := errObj["details"].(map[string]any) + // The per-item error must carry item_index=1 so the caller can find the + // bad item (§8 item_index extension). + if details == nil { + t.Fatalf("expected details with item_index, got none; body=%v", out) + } + if idx, ok := details["item_index"].(float64); !ok || int(idx) != 1 { + t.Errorf("details.item_index = %v, want 1", details["item_index"]) + } +} + +func TestSendBatch_NotImplementedWhenDeliverBatchNil(t *testing.T) { + deps := batchTestDeps(nil) // DeliverBatch nil + srv := httptest.NewServer(New(deps)) + t.Cleanup(srv.Close) + + status, out := postBatch(t, srv, map[string]any{ + "messages": []map[string]any{{"to": []string{"a@x.com"}, "subject": "a", "text": "a"}}, + }, "") + if status != http.StatusNotImplemented { + t.Fatalf("status = %d, want 501; body=%v", status, out) + } + errObj, _ := out["error"].(map[string]any) + if errObj["code"] != "not_implemented" { + t.Errorf("code = %v, want not_implemented", errObj["code"]) + } +} + +// TestSendBatch_ReplyToDefault verifies the batch-level reply_to default is +// applied to an item that omits its own, and a per-item value wins. +func TestSendBatch_ReplyToDefault(t *testing.T) { + var gotItems []outbound.SendRequest + srv := httptest.NewServer(New(batchTestDeps( + func(ctx context.Context, u *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, ic agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) { + gotItems = items + res := &agent.BatchAcceptResult{BatchID: "bat_rt", Items: make([]agent.BatchAcceptItem, len(items))} + for i := range items { + res.Items[i] = agent.BatchAcceptItem{MessageID: "msg_x"} + } + return res, nil + }))) + t.Cleanup(srv.Close) + + status, _ := postBatch(t, srv, map[string]any{ + "reply_to": "support@acme.com", + "messages": []map[string]any{ + {"to": []string{"a@x.com"}, "subject": "a", "text": "a"}, // inherits batch reply_to + {"to": []string{"b@x.com"}, "subject": "b", "text": "b", "reply_to": "special@acme.com"}, // overrides + }, + }, "") + if status != http.StatusAccepted { + t.Fatalf("status = %d, want 202", status) + } + if gotItems[0].ReplyTo != "support@acme.com" { + t.Errorf("item 0 ReplyTo = %q, want support@acme.com (batch default)", gotItems[0].ReplyTo) + } + if gotItems[1].ReplyTo != "special@acme.com" { + t.Errorf("item 1 ReplyTo = %q, want special@acme.com (per-item wins)", gotItems[1].ReplyTo) + } +} + +// sanity: exercise stripBase64Whitespace directly. +func TestStripBase64Whitespace(t *testing.T) { + in := "aGVs\r\nbG8g\t d29ybGQ=" + got := stripBase64Whitespace(in) + if strings.ContainsAny(got, " \r\n\t") { + t.Errorf("whitespace not stripped: %q", got) + } +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index be376eb6f..c923aec72 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -230,7 +230,15 @@ type Deps struct { // outbound (the shared live delivery path extracted from agent.API) DeliverOutbound func(ctx context.Context, user *identity.User, ag *identity.AgentIdentity, req outbound.SendRequest, msgType, replyToEmailMessageID string, referenced *identity.Message, idemCompleteTx agent.AcceptIdemCompleter) (*agent.OutboundResult, *agent.OutboundError) - SendTest func(ctx context.Context, ag *identity.AgentIdentity) (*agent.OutboundResult, *agent.OutboundError) + // DeliverBatch is the batch-send accept-tx orchestrator, called by + // handleSendBatch. Same shape as DeliverOutbound but for a slice of + // SendRequest items — see docs/design/batch-send.md §9. Optional; when + // nil the /v1/agents/{email}/batches operation is still registered but + // returns 501 not_implemented for every call (matches the + // nil-DeliverOutbound behavior of single-send). Wired in apiserver + // via agent.API.DeliverBatch. + DeliverBatch func(ctx context.Context, user *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, idemCompleteTx agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) + SendTest func(ctx context.Context, ag *identity.AgentIdentity) (*agent.OutboundResult, *agent.OutboundError) // PollSendOutcome reads an async send's current delivery_status for wait=sent. // Optional — nil disables the wait valve (accepted is returned immediately). PollSendOutcome func(ctx context.Context, messageID string) (identity.SendOutcome, error) @@ -736,6 +744,7 @@ func (s *Server) registerOperations() { s.registerEngagements() s.registerAPIKeys() s.registerOutbound() + s.registerSendBatch() s.registerReviews() // Not an operation: exports the typed per-event `data` payload schemas // (EmailReceivedData, …) into components.schemas for docs + codegen. diff --git a/internal/identity/batches.go b/internal/identity/batches.go index 5302aa04d..05b74a74a 100644 --- a/internal/identity/batches.go +++ b/internal/identity/batches.go @@ -253,6 +253,7 @@ func (s *Store) CreateOutboundMessagesTx(ctx context.Context, tx pgx.Tx, inputs return nil, fmt.Errorf("batch item %d: empty agent_id", i) } id := "msg_" + generateID() + expiresAt := now.Add(7 * 24 * time.Hour) var recipient string if len(in.ToRecipients) > 0 { recipient = in.ToRecipients[0] @@ -268,7 +269,7 @@ func (s *Store) CreateOutboundMessagesTx(ctx context.Context, tx pgx.Tx, inputs ProviderMessageID: in.ProviderMessageID, ConversationID: in.ConversationID, CreatedAt: now, - ExpiresAt: now.Add(MessageTTL), + ExpiresAt: &expiresAt, ToRecipients: in.ToRecipients, CC: in.CC, BCC: in.BCC, diff --git a/internal/identity/delivery_store.go b/internal/identity/delivery_store.go index 3be22a369..7fff5d715 100644 --- a/internal/identity/delivery_store.go +++ b/internal/identity/delivery_store.go @@ -1076,6 +1076,45 @@ func (s *Store) SuppressedAddresses(ctx context.Context, userID string, addrs [] return out, rows.Err() } +// SuppressedAddressesWithSource returns address→source for every address in +// addrs that is suppressed for the user. Used by batch-send accept-tx to +// populate the per-item `suppressed` slot in the response with the reason +// category (bounce / complaint / manual), per +// docs/design/batch-send.md §1.3. +// +// The `source` values come straight from suppressions.source (see +// migrations/031_delivery_feedback.sql). Empty input → empty map (not nil, +// so callers can iterate without a nil-check). +func (s *Store) SuppressedAddressesWithSource(ctx context.Context, userID string, addrs []string) (map[string]string, error) { + out := map[string]string{} + if len(addrs) == 0 { + return out, nil + } + norm := make([]string, 0, len(addrs)) + for _, a := range addrs { + norm = append(norm, NormalizeEmail(a)) + } + rows, err := s.pool.Query(ctx, + `SELECT address, source FROM suppressions WHERE user_id = $1 AND address = ANY($2)`, + userID, norm, + ) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var address, source string + if err := rows.Scan(&address, &source); err != nil { + return nil, err + } + out[address] = source + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + // ListSuppressions returns the user's suppression list, newest first. // ListSuppressions returns one page of the user's suppressed addresses, // newest first, keyset-paginated on (created_at, address). The caller passes diff --git a/internal/inboundprocess/reconcile_test.go b/internal/inboundprocess/reconcile_test.go index a37880499..c1a258340 100644 --- a/internal/inboundprocess/reconcile_test.go +++ b/internal/inboundprocess/reconcile_test.go @@ -89,6 +89,13 @@ func (f *fakeEnq) InsertTx(_ context.Context, _ pgx.Tx, _ river.JobArgs, _ *rive return &rivertype.JobInsertResult{Job: &rivertype.JobRow{ID: f.n}}, nil } +// InsertManyTx is unused by this test's fake — it only exercises single-job +// enqueue paths — but is required by the widened jobs.Enqueuer interface +// (see internal/jobs/jobs.go). Present to satisfy the interface only. +func (f *fakeEnq) InsertManyTx(_ context.Context, _ pgx.Tx, _ []river.InsertManyParams) ([]*rivertype.JobInsertResult, error) { + panic("fakeEnq.InsertManyTx: not implemented in this test suite") +} + // TestReconcilePending covers the startup cutover: an accepted intake row with no job // (a crash between insert and enqueue, or a pre-async row) gets a job stamped, and a // re-run does not re-enqueue it (idempotent via the process_job_id IS NULL guard). diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index e15de931a..9a65b83a3 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -28,10 +28,14 @@ import ( // whole river.Client — keeps the store/agent layer testable with a fake and River // swappable. Insert enqueues immediately; InsertTx enqueues WITHIN the caller's // transaction (the outbox pattern: the job commits atomically with the business -// write and can never be lost). *river.Client[pgx.Tx] satisfies this directly. +// write and can never be lost). InsertManyTx enqueues N jobs in one round-trip +// within the caller's tx — used by batch-send accept-tx (docs/design/batch-send.md +// §9 step 10.d) to enqueue up to 100 outbound_send jobs atomically with the +// batches + messages inserts. *river.Client[pgx.Tx] satisfies all three directly. type Enqueuer interface { Insert(ctx context.Context, args river.JobArgs, opts *river.InsertOpts) (*rivertype.JobInsertResult, error) InsertTx(ctx context.Context, tx pgx.Tx, args river.JobArgs, opts *river.InsertOpts) (*rivertype.JobInsertResult, error) + InsertManyTx(ctx context.Context, tx pgx.Tx, params []river.InsertManyParams) ([]*rivertype.JobInsertResult, error) } // Registrar is what each domain implements to contribute its jobs to the shared diff --git a/internal/outboundsend/jobs.go b/internal/outboundsend/jobs.go index 8636ba6d9..7648e366d 100644 --- a/internal/outboundsend/jobs.go +++ b/internal/outboundsend/jobs.go @@ -133,3 +133,45 @@ func (j *Jobs) enqueueSendTx(ctx context.Context, tx pgx.Tx, messageID string, a } return res.Job.ID, nil } + +// EnqueueBatchTx enqueues N outbound_send jobs in one round-trip WITHIN the +// caller's transaction — the batch-send analog of EnqueueSendTx. Returns +// the enqueued river_job ids in the same order as messageIDs, so the +// caller can stamp messages.send_job_id per row without additional lookup. +// +// Same outbox semantics as EnqueueSendTx: the batch's messages inserts and +// these jobs commit together, so no `accepted` batch child can exist +// without its job (and vice versa). Uses river.InsertManyTx for one +// round-trip regardless of N, which is why the batches accept-tx budget +// (docs/design/batch-send.md §12 load-smoke, <100ms at N=100) is +// realistic — the N grows the batch INSERT and the job INSERT but not the +// round-trip count. +// +// An empty messageIDs slice is a no-op — returns (nil, nil). Callers +// generally guard against this at the accept-tx site (an all-suppressed +// batch skips the enqueue entirely) but the defence is here too so the +// call is safe to make unconditionally. +func (j *Jobs) EnqueueBatchTx(ctx context.Context, tx pgx.Tx, messageIDs []string) ([]int64, error) { + if len(messageIDs) == 0 { + return nil, nil + } + params := make([]river.InsertManyParams, len(messageIDs)) + for i, id := range messageIDs { + params[i] = river.InsertManyParams{ + Args: OutboundSendArgs{MessageID: id}, + InsertOpts: &river.InsertOpts{ + Queue: jobs.QueueOutbound, + MaxAttempts: MaxSendAttempts, + }, + } + } + results, err := j.enq.InsertManyTx(ctx, tx, params) + if err != nil { + return nil, err + } + ids := make([]int64, len(results)) + for i, r := range results { + ids[i] = r.Job.ID + } + return ids, nil +} diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go index d23adba80..8c08a5984 100644 --- a/internal/ratelimit/ratelimit.go +++ b/internal/ratelimit/ratelimit.go @@ -82,6 +82,67 @@ func (l *Limiter) AllowWithRetryAfter(key string) (bool, time.Duration) { return true, 0 } +// AllowN behaves like AllowWithRetryAfter but atomically reserves n slots +// against the window. Returns (true, 0) when all n slots fit; (false, +// retryAfter) when the reservation would overflow the window, without +// recording any of the n hits. Used by batch-send accept-tx to charge N +// against a per-agent throughput limit (docs/design/batch-send.md §4.2, +// §14 Q4). n == 1 is behaviourally identical to AllowWithRetryAfter. +// n == 0 is a no-op — returns (true, 0) without touching state. n < 0 is +// treated as n == 0. +// +// Semantics: all-or-nothing on the reservation. A batch of 100 against a +// window with 50 slots left is rejected outright — batch send never +// consumes some but not all of its reservation, because a partial reserve +// would silently degrade throughput accounting for the caller. +func (l *Limiter) AllowN(key string, n int) (bool, time.Duration) { + if n <= 0 { + return true, 0 + } + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + cutoff := now.Add(-l.window) + + // Prune expired entries in place (same shape as AllowWithRetryAfter). + valid := l.buckets[key][:0] + for _, t := range l.buckets[key] { + if t.After(cutoff) { + valid = append(valid, t) + } + } + + if len(valid)+n > l.max { + l.buckets[key] = valid + // If the bucket has no in-window hits yet, no oldest to age out — + // the caller's n itself exceeds l.max. Retry-after in that case + // clamps to the full window: no waiting can make an unbounded + // reservation fit. + var retryAfter time.Duration + if len(valid) == 0 { + retryAfter = l.window + } else { + retryAfter = valid[0].Add(l.window).Sub(now) + } + if retryAfter < time.Second { + retryAfter = time.Second + } + if retryAfter%time.Second != 0 { + retryAfter = retryAfter.Truncate(time.Second) + time.Second + } + return false, retryAfter + } + + // Record n hits at the same timestamp — they all consumed one slot + // concurrently at accept-tx time, so they share `now`. + for i := 0; i < n; i++ { + valid = append(valid, now) + } + l.buckets[key] = valid + return true, 0 +} + // AllowSnapshot behaves like AllowWithRetryAfter but also returns the IETF // RateLimit header values: the window quota (limit), the remaining quota after // this request, and the seconds until the window resets (when the oldest diff --git a/internal/ratelimit/ratelimit_test.go b/internal/ratelimit/ratelimit_test.go index de18bfe3f..f147d0bc5 100644 --- a/internal/ratelimit/ratelimit_test.go +++ b/internal/ratelimit/ratelimit_test.go @@ -161,3 +161,66 @@ func TestCleanupLoopKeepsActiveEntries(t *testing.T) { t.Errorf("expected active bucket to remain, got %d buckets", count) } } + +func TestAllowN_ReservesAllOrNone(t *testing.T) { + l := New(1*time.Second, 10) + // Batch of 5 in an empty bucket → allowed. + if ok, _ := l.AllowN("k", 5); !ok { + t.Fatalf("expected AllowN(5) to succeed on empty bucket") + } + // Bucket now has 5. Another batch of 5 → allowed (fills to 10). + if ok, _ := l.AllowN("k", 5); !ok { + t.Fatalf("expected AllowN(5) to succeed when bucket has 5") + } + // Bucket is now full. Any batch >= 1 → denied without recording. + if ok, _ := l.AllowN("k", 1); ok { + t.Fatal("expected AllowN(1) to deny on full bucket") + } + // The denied attempt above must not have consumed a slot — retrying + // as a single Allow (in the same window) should also deny. + if l.Allow("k") { + t.Fatal("denied AllowN must not consume a slot") + } +} + +func TestAllowN_AllOrNothingOnOverflow(t *testing.T) { + l := New(1*time.Second, 10) + // Use 8 slots. + if ok, _ := l.AllowN("k", 8); !ok { + t.Fatalf("expected AllowN(8) to succeed") + } + // Now request 5 — bucket has 8, request would overflow to 13 > 10. + // Must deny WITHOUT recording any of the 5. + if ok, retry := l.AllowN("k", 5); ok || retry < time.Second { + t.Fatalf("expected AllowN(5) to deny with >=1s retryAfter, got ok=%v retry=%v", ok, retry) + } + // The 2 remaining slots must still be available for smaller reservations. + if ok, _ := l.AllowN("k", 2); !ok { + t.Fatalf("expected AllowN(2) to succeed on remaining capacity") + } +} + +func TestAllowN_ZeroIsNoOp(t *testing.T) { + l := New(1*time.Second, 1) + if ok, retry := l.AllowN("k", 0); !ok || retry != 0 { + t.Errorf("AllowN(0) should be no-op, got ok=%v retry=%v", ok, retry) + } + // The one slot must still be available afterwards. + if !l.Allow("k") { + t.Fatal("AllowN(0) must not consume the slot") + } +} + +func TestAllowN_NExceedsMaxCleanRetry(t *testing.T) { + l := New(1*time.Second, 10) + // Request 100 against a max-10 window → cannot ever succeed. Still + // deny cleanly (no negative reservation, no panic) with retryAfter + // clamped to at least 1s. + ok, retry := l.AllowN("k", 100) + if ok { + t.Fatal("expected AllowN(100) with max=10 to deny") + } + if retry < time.Second { + t.Errorf("expected retry >= 1s, got %v", retry) + } +} diff --git a/internal/webhookdelivery/worker_test.go b/internal/webhookdelivery/worker_test.go index 5bb84e5bb..cc2dfb2c3 100644 --- a/internal/webhookdelivery/worker_test.go +++ b/internal/webhookdelivery/worker_test.go @@ -150,6 +150,13 @@ func (f *fakeEnq) InsertTx(_ context.Context, _ pgx.Tx, _ river.JobArgs, _ *rive return &rivertype.JobInsertResult{Job: &rivertype.JobRow{ID: f.n}}, nil } +// InsertManyTx satisfies the widened jobs.Enqueuer interface. This test's +// fake only exercises single-job enqueue paths, so bulk-insert is not +// implemented here — a call would indicate a test-wiring mistake. +func (f *fakeEnq) InsertManyTx(_ context.Context, _ pgx.Tx, _ []river.InsertManyParams) ([]*rivertype.JobInsertResult, error) { + panic("fakeEnq.InsertManyTx: not implemented in this test suite") +} + // TestReconcilePending: the one-shot migration enqueues a job + stamps job_id for // every pending row with no job, and a re-run is idempotent (no double-enqueue). // Uses a REAL River client: the reconciler also rescues rows whose stamped job is diff --git a/internal/webhookpub/fanout_worker_integration_test.go b/internal/webhookpub/fanout_worker_integration_test.go index e2b42e754..a63eca580 100644 --- a/internal/webhookpub/fanout_worker_integration_test.go +++ b/internal/webhookpub/fanout_worker_integration_test.go @@ -34,6 +34,12 @@ func (f *fakeFanOutEnq) InsertTx(_ context.Context, _ pgx.Tx, args river.JobArgs return f.record(args) } +// InsertManyTx satisfies the widened jobs.Enqueuer interface. The fanout +// worker enqueues single jobs, so bulk-insert isn't exercised here. +func (f *fakeFanOutEnq) InsertManyTx(_ context.Context, _ pgx.Tx, _ []river.InsertManyParams) ([]*rivertype.JobInsertResult, error) { + panic("fakeFanOutEnq.InsertManyTx: not implemented in this test suite") +} + func (f *fakeFanOutEnq) record(args river.JobArgs) (*rivertype.JobInsertResult, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/sdks/python/src/e2a/v1/generated/api/messages_api.py b/sdks/python/src/e2a/v1/generated/api/messages_api.py index a93cf4e42..b8389651d 100644 --- a/sdks/python/src/e2a/v1/generated/api/messages_api.py +++ b/sdks/python/src/e2a/v1/generated/api/messages_api.py @@ -28,6 +28,8 @@ from e2a.v1.generated.models.page_message_lifecycle_transition import PageMessageLifecycleTransition from e2a.v1.generated.models.page_message_summary_view import PageMessageSummaryView from e2a.v1.generated.models.reply_request import ReplyRequest +from e2a.v1.generated.models.send_batch_request import SendBatchRequest +from e2a.v1.generated.models.send_batch_response import SendBatchResponse from e2a.v1.generated.models.send_email_request import SendEmailRequest from e2a.v1.generated.models.send_result_view import SendResultView from e2a.v1.generated.models.update_message_request import UpdateMessageRequest @@ -3044,6 +3046,334 @@ def _restore_message_serialize( + @validate_call + async def send_batch( + self, + email: StrictStr, + send_batch_request: SendBatchRequest, + idempotency_key: Annotated[Optional[StrictStr], Field(description="Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch).")] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> SendBatchResponse: + """Send a batch of up to 100 emails + + Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. + + :param email: (required) + :type email: str + :param send_batch_request: (required) + :type send_batch_request: SendBatchRequest + :param idempotency_key: Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch). + :type idempotency_key: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._send_batch_serialize( + email=email, + send_batch_request=send_batch_request, + idempotency_key=idempotency_key, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "SendBatchResponse", + '202': "SendBatchResponse", + '400': "ErrorEnvelope", + '402': "LimitExceededEnvelope", + '403': "ErrorEnvelope", + '409': "ErrorEnvelope", + '413': "ErrorEnvelope", + '422': "ErrorEnvelope", + '429': "RateLimitedEnvelope", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def send_batch_with_http_info( + self, + email: StrictStr, + send_batch_request: SendBatchRequest, + idempotency_key: Annotated[Optional[StrictStr], Field(description="Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch).")] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[SendBatchResponse]: + """Send a batch of up to 100 emails + + Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. + + :param email: (required) + :type email: str + :param send_batch_request: (required) + :type send_batch_request: SendBatchRequest + :param idempotency_key: Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch). + :type idempotency_key: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._send_batch_serialize( + email=email, + send_batch_request=send_batch_request, + idempotency_key=idempotency_key, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "SendBatchResponse", + '202': "SendBatchResponse", + '400': "ErrorEnvelope", + '402': "LimitExceededEnvelope", + '403': "ErrorEnvelope", + '409': "ErrorEnvelope", + '413': "ErrorEnvelope", + '422': "ErrorEnvelope", + '429': "RateLimitedEnvelope", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def send_batch_without_preload_content( + self, + email: StrictStr, + send_batch_request: SendBatchRequest, + idempotency_key: Annotated[Optional[StrictStr], Field(description="Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch).")] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Send a batch of up to 100 emails + + Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. + + :param email: (required) + :type email: str + :param send_batch_request: (required) + :type send_batch_request: SendBatchRequest + :param idempotency_key: Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch). + :type idempotency_key: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._send_batch_serialize( + email=email, + send_batch_request=send_batch_request, + idempotency_key=idempotency_key, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "SendBatchResponse", + '202': "SendBatchResponse", + '400': "ErrorEnvelope", + '402': "LimitExceededEnvelope", + '403': "ErrorEnvelope", + '409': "ErrorEnvelope", + '413': "ErrorEnvelope", + '422': "ErrorEnvelope", + '429': "RateLimitedEnvelope", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _send_batch_serialize( + self, + email, + send_batch_request, + idempotency_key, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if email is not None: + _path_params['email'] = email + # process the query parameters + # process the header parameters + if idempotency_key is not None: + _header_params['Idempotency-Key'] = idempotency_key + # process the form parameters + # process the body parameter + if send_batch_request is not None: + _body_params = send_batch_request + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + 'bearer' + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/v1/agents/{email}/batches', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def send_message( self, diff --git a/sdks/python/src/e2a/v1/generated/models/batch_message.py b/sdks/python/src/e2a/v1/generated/models/batch_message.py new file mode 100644 index 000000000..5d0f74d5b --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/batch_message.py @@ -0,0 +1,131 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from e2a.v1.generated.models.attachment import Attachment +from typing import Optional, Set +from typing_extensions import Self + +class BatchMessage(BaseModel): + """ + BatchMessage + """ # noqa: E501 + attachments: Optional[List[Attachment]] = Field(default=None, description="Per-item attachments. Single attachment ≤ 10 MiB, item combined ≤ 25 MiB. Additionally the SUM across all batch items must be ≤ 25 MiB (docs/design/batch-send.md §14 Q15).") + bcc: Optional[List[Annotated[str, Field(strict=True, max_length=320)]]] = Field(default=None, description="Bcc recipients for this item.") + cc: Optional[List[Annotated[str, Field(strict=True, max_length=320)]]] = Field(default=None, description="Cc recipients for this item.") + conversation_id: Optional[Annotated[str, Field(strict=True, max_length=200)]] = Field(default=None, description="Caller-assigned conversation (thread) id. Auto-minted per item if omitted.") + html: Optional[Annotated[str, Field(strict=True, max_length=1048576)]] = Field(default=None, description="HTML body.") + reply_to: Optional[Annotated[str, Field(strict=True, max_length=320)]] = Field(default=None, description="Per-item Reply-To override. If empty, the batch-level reply_to default (if set) applies.") + subject: Optional[Annotated[str, Field(strict=True, max_length=2000)]] = Field(default=None, description="Literal subject. Required unless a template reference is used. Same caps as single-send.") + template_alias: Optional[StrictStr] = Field(default=None, description="Human-handle alternative to template_id.") + template_data: Optional[Dict[str, Any]] = Field(default=None, description="Per-item template data. Populated freely across items — this is what makes native mail-merge possible without a server-side templating loop (docs/design/batch-send.md §0.5).") + template_id: Optional[StrictStr] = Field(default=None, description="Send using a stored template. Mutually exclusive with template_alias and with literal subject/text/html on this item.") + text: Optional[Annotated[str, Field(strict=True, max_length=1048576)]] = Field(default=None, description="Plain-text body.") + to: List[Annotated[str, Field(strict=True, max_length=320)]] = Field(description="Primary recipients for this item. Same per-item cap as single-send (50 combined across to+cc+bcc). Each item's envelope is independent — item i's cc/bcc are not visible in item j.") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["attachments", "bcc", "cc", "conversation_id", "html", "reply_to", "subject", "template_alias", "template_data", "template_id", "text", "to"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of BatchMessage from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of each item in attachments (list) + _items = [] + if self.attachments: + for _item_attachments in self.attachments: + if _item_attachments: + _items.append(_item_attachments.to_dict()) + _dict['attachments'] = _items + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of BatchMessage from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "attachments": [Attachment.from_dict(_item) for _item in obj["attachments"]] if obj.get("attachments") is not None else None, + "bcc": obj.get("bcc"), + "cc": obj.get("cc"), + "conversation_id": obj.get("conversation_id"), + "html": obj.get("html"), + "reply_to": obj.get("reply_to"), + "subject": obj.get("subject"), + "template_alias": obj.get("template_alias"), + "template_data": obj.get("template_data"), + "template_id": obj.get("template_id"), + "text": obj.get("text"), + "to": obj.get("to") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/python/src/e2a/v1/generated/models/batch_result.py b/sdks/python/src/e2a/v1/generated/models/batch_result.py new file mode 100644 index 000000000..b55216ad4 --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/batch_result.py @@ -0,0 +1,106 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from e2a.v1.generated.models.batch_suppressed_result import BatchSuppressedResult +from typing import Optional, Set +from typing_extensions import Self + +class BatchResult(BaseModel): + """ + BatchResult + """ # noqa: E501 + message_id: Optional[StrictStr] = Field(default=None, description="Minted message id when the item was accepted (delivery_status='accepted' persisted and River outbound_send job enqueued). Absent when Suppressed is present.") + suppressed: Optional[BatchSuppressedResult] = Field(default=None, description="Present when the item was dropped by the suppression-list filter (docs/design/batch-send.md §2.2). No message row exists for a suppressed slot; the caller can un-suppress via DELETE /v1/account/suppressions/{address} and resubmit.") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["message_id", "suppressed"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of BatchResult from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of suppressed + if self.suppressed: + _dict['suppressed'] = self.suppressed.to_dict() + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of BatchResult from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "message_id": obj.get("message_id"), + "suppressed": BatchSuppressedResult.from_dict(obj["suppressed"]) if obj.get("suppressed") is not None else None + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/python/src/e2a/v1/generated/models/batch_suppressed_result.py b/sdks/python/src/e2a/v1/generated/models/batch_suppressed_result.py new file mode 100644 index 000000000..123166f45 --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/batch_suppressed_result.py @@ -0,0 +1,102 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self + +class BatchSuppressedResult(BaseModel): + """ + BatchSuppressedResult + """ # noqa: E501 + address: StrictStr = Field(description="The recipient address in this item's to list that triggered the drop. If multiple addresses in the same item are suppressed, only the first is surfaced (dropping the whole item is enough signal).") + reason: StrictStr = Field(description="The suppression-list category from suppressions.source. Known values: bounce, complaint, manual. Open set — treat as string and tolerate unknown values.") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["address", "reason"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of BatchSuppressedResult from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of BatchSuppressedResult from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "address": obj.get("address"), + "reason": obj.get("reason") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/python/src/e2a/v1/generated/models/send_batch_request.py b/sdks/python/src/e2a/v1/generated/models/send_batch_request.py new file mode 100644 index 000000000..7dcc9b400 --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/send_batch_request.py @@ -0,0 +1,111 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from e2a.v1.generated.models.batch_message import BatchMessage +from typing import Optional, Set +from typing_extensions import Self + +class SendBatchRequest(BaseModel): + """ + SendBatchRequest + """ # noqa: E501 + messages: Annotated[List[BatchMessage], Field(min_length=1, max_length=100)] = Field(description="1..100 BatchMessage items. Each item is a self-contained near-clone of SendEmailRequest minus from — its own to/cc/bcc/content/attachments/template/reply_to. Ordering is significant: results[] in the response is positionally aligned with this array.") + reply_to: Optional[Annotated[str, Field(strict=True, max_length=320)]] = Field(default=None, description="Optional batch-level Reply-To default. Applied to any item that leaves reply_to empty; a per-item value always wins. This is the ONLY batch-level default in MVP; every other field is per-item (docs/design/batch-send.md §1.2).") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["messages", "reply_to"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of SendBatchRequest from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of each item in messages (list) + _items = [] + if self.messages: + for _item_messages in self.messages: + if _item_messages: + _items.append(_item_messages.to_dict()) + _dict['messages'] = _items + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of SendBatchRequest from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "messages": [BatchMessage.from_dict(_item) for _item in obj["messages"]] if obj.get("messages") is not None else None, + "reply_to": obj.get("reply_to") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/python/src/e2a/v1/generated/models/send_batch_response.py b/sdks/python/src/e2a/v1/generated/models/send_batch_response.py new file mode 100644 index 000000000..fa591306d --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/send_batch_response.py @@ -0,0 +1,114 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List +from e2a.v1.generated.models.batch_result import BatchResult +from typing import Optional, Set +from typing_extensions import Self + +class SendBatchResponse(BaseModel): + """ + SendBatchResponse + """ # noqa: E501 + accepted: StrictInt = Field(description="Count of results[] slots that are {message_id}. Redundant with results[] but convenient for logging + zero-check.") + batch_id: StrictStr = Field(description="Durable id for this batch (bat_). Use GET /v1/batches/{batch_id} to retrieve the header + status rollup.") + results: List[BatchResult] = Field(description="One slot per request item, positionally aligned. Each slot is either {message_id} (accepted) or {suppressed:{address,reason}} (dropped by the suppression filter).") + suppressed_count: StrictInt = Field(description="Count of results[] slots that are {suppressed}. Zero when no per-item drops occurred.") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["accepted", "batch_id", "results", "suppressed_count"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of SendBatchResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of each item in results (list) + _items = [] + if self.results: + for _item_results in self.results: + if _item_results: + _items.append(_item_results.to_dict()) + _dict['results'] = _items + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of SendBatchResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "accepted": obj.get("accepted"), + "batch_id": obj.get("batch_id"), + "results": [BatchResult.from_dict(_item) for _item in obj["results"]] if obj.get("results") is not None else None, + "suppressed_count": obj.get("suppressed_count") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/typescript/src/v1/generated/models/BatchMessage.ts b/sdks/typescript/src/v1/generated/models/BatchMessage.ts new file mode 100644 index 000000000..5b00a79c7 --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/BatchMessage.ts @@ -0,0 +1,150 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { Attachment } from '../models/Attachment.js'; +import { HttpFile } from '../http/http.js'; + +export class BatchMessage { + /** + * Per-item attachments. Single attachment ≤ 10 MiB, item combined ≤ 25 MiB. Additionally the SUM across all batch items must be ≤ 25 MiB (docs/design/batch-send.md §14 Q15). + */ + 'attachments'?: Array; + /** + * Bcc recipients for this item. + */ + 'bcc'?: Array; + /** + * Cc recipients for this item. + */ + 'cc'?: Array; + /** + * Caller-assigned conversation (thread) id. Auto-minted per item if omitted. + */ + 'conversationId'?: string; + /** + * HTML body. + */ + 'html'?: string; + /** + * Per-item Reply-To override. If empty, the batch-level reply_to default (if set) applies. + */ + 'replyTo'?: string; + /** + * Literal subject. Required unless a template reference is used. Same caps as single-send. + */ + 'subject'?: string; + /** + * Human-handle alternative to template_id. + */ + 'templateAlias'?: string; + /** + * Per-item template data. Populated freely across items — this is what makes native mail-merge possible without a server-side templating loop (docs/design/batch-send.md §0.5). + */ + 'templateData'?: { [key: string]: any; }; + /** + * Send using a stored template. Mutually exclusive with template_alias and with literal subject/text/html on this item. + */ + 'templateId'?: string; + /** + * Plain-text body. + */ + 'text'?: string; + /** + * Primary recipients for this item. Same per-item cap as single-send (50 combined across to+cc+bcc). Each item\'s envelope is independent — item i\'s cc/bcc are not visible in item j. + */ + 'to': Array; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "attachments", + "baseName": "attachments", + "type": "Array", + "format": "" + }, + { + "name": "bcc", + "baseName": "bcc", + "type": "Array", + "format": "" + }, + { + "name": "cc", + "baseName": "cc", + "type": "Array", + "format": "" + }, + { + "name": "conversationId", + "baseName": "conversation_id", + "type": "string", + "format": "" + }, + { + "name": "html", + "baseName": "html", + "type": "string", + "format": "" + }, + { + "name": "replyTo", + "baseName": "reply_to", + "type": "string", + "format": "" + }, + { + "name": "subject", + "baseName": "subject", + "type": "string", + "format": "" + }, + { + "name": "templateAlias", + "baseName": "template_alias", + "type": "string", + "format": "" + }, + { + "name": "templateData", + "baseName": "template_data", + "type": "{ [key: string]: any; }", + "format": "" + }, + { + "name": "templateId", + "baseName": "template_id", + "type": "string", + "format": "" + }, + { + "name": "text", + "baseName": "text", + "type": "string", + "format": "" + }, + { + "name": "to", + "baseName": "to", + "type": "Array", + "format": "" + } ]; + + static getAttributeTypeMap() { + return BatchMessage.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/sdks/typescript/src/v1/generated/models/BatchResult.ts b/sdks/typescript/src/v1/generated/models/BatchResult.ts new file mode 100644 index 000000000..118ad685e --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/BatchResult.ts @@ -0,0 +1,50 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { BatchSuppressedResult } from '../models/BatchSuppressedResult.js'; +import { HttpFile } from '../http/http.js'; + +export class BatchResult { + /** + * Minted message id when the item was accepted (delivery_status=\'accepted\' persisted and River outbound_send job enqueued). Absent when Suppressed is present. + */ + 'messageId'?: string; + /** + * Present when the item was dropped by the suppression-list filter (docs/design/batch-send.md §2.2). No message row exists for a suppressed slot; the caller can un-suppress via DELETE /v1/account/suppressions/{address} and resubmit. + */ + 'suppressed'?: BatchSuppressedResult; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "messageId", + "baseName": "message_id", + "type": "string", + "format": "" + }, + { + "name": "suppressed", + "baseName": "suppressed", + "type": "BatchSuppressedResult", + "format": "" + } ]; + + static getAttributeTypeMap() { + return BatchResult.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/sdks/typescript/src/v1/generated/models/BatchSuppressedResult.ts b/sdks/typescript/src/v1/generated/models/BatchSuppressedResult.ts new file mode 100644 index 000000000..4a12dcbf5 --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/BatchSuppressedResult.ts @@ -0,0 +1,49 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http.js'; + +export class BatchSuppressedResult { + /** + * The recipient address in this item\'s to list that triggered the drop. If multiple addresses in the same item are suppressed, only the first is surfaced (dropping the whole item is enough signal). + */ + 'address': string; + /** + * The suppression-list category from suppressions.source. Known values: bounce, complaint, manual. Open set — treat as string and tolerate unknown values. + */ + 'reason': string; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "address", + "baseName": "address", + "type": "string", + "format": "" + }, + { + "name": "reason", + "baseName": "reason", + "type": "string", + "format": "" + } ]; + + static getAttributeTypeMap() { + return BatchSuppressedResult.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/sdks/typescript/src/v1/generated/models/SendBatchRequest.ts b/sdks/typescript/src/v1/generated/models/SendBatchRequest.ts new file mode 100644 index 000000000..c7babc99c --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/SendBatchRequest.ts @@ -0,0 +1,50 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { BatchMessage } from '../models/BatchMessage.js'; +import { HttpFile } from '../http/http.js'; + +export class SendBatchRequest { + /** + * 1..100 BatchMessage items. Each item is a self-contained near-clone of SendEmailRequest minus from — its own to/cc/bcc/content/attachments/template/reply_to. Ordering is significant: results[] in the response is positionally aligned with this array. + */ + 'messages': Array; + /** + * Optional batch-level Reply-To default. Applied to any item that leaves reply_to empty; a per-item value always wins. This is the ONLY batch-level default in MVP; every other field is per-item (docs/design/batch-send.md §1.2). + */ + 'replyTo'?: string; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "messages", + "baseName": "messages", + "type": "Array", + "format": "" + }, + { + "name": "replyTo", + "baseName": "reply_to", + "type": "string", + "format": "" + } ]; + + static getAttributeTypeMap() { + return SendBatchRequest.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/sdks/typescript/src/v1/generated/models/SendBatchResponse.ts b/sdks/typescript/src/v1/generated/models/SendBatchResponse.ts new file mode 100644 index 000000000..1f3a77d58 --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/SendBatchResponse.ts @@ -0,0 +1,70 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { BatchResult } from '../models/BatchResult.js'; +import { HttpFile } from '../http/http.js'; + +export class SendBatchResponse { + /** + * Count of results[] slots that are {message_id}. Redundant with results[] but convenient for logging + zero-check. + */ + 'accepted': number; + /** + * Durable id for this batch (bat_). Use GET /v1/batches/{batch_id} to retrieve the header + status rollup. + */ + 'batchId': string; + /** + * One slot per request item, positionally aligned. Each slot is either {message_id} (accepted) or {suppressed:{address,reason}} (dropped by the suppression filter). + */ + 'results': Array; + /** + * Count of results[] slots that are {suppressed}. Zero when no per-item drops occurred. + */ + 'suppressedCount': number; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "accepted", + "baseName": "accepted", + "type": "number", + "format": "int64" + }, + { + "name": "batchId", + "baseName": "batch_id", + "type": "string", + "format": "" + }, + { + "name": "results", + "baseName": "results", + "type": "Array", + "format": "" + }, + { + "name": "suppressedCount", + "baseName": "suppressed_count", + "type": "number", + "format": "int64" + } ]; + + static getAttributeTypeMap() { + return SendBatchResponse.attributeTypeMap; + } + + public constructor() { + } +} From 5d10b2c8563223d2e629fb942e78c6c49d14f133 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Mon, 20 Jul 2026 13:42:47 -0700 Subject: [PATCH 05/17] =?UTF-8?q?feat(batch-send):=20observability=20?= =?UTF-8?q?=E2=80=94=20getBatch,=20listMessages=20filter,=20batch=5Fid=20e?= =?UTF-8?q?vents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three read/correlation surfaces from docs/design/batch-send.md §7: GET /v1/batches/{batch_id} (getBatch): - BatchView = header (requested/accepted counts + the suppressed[] drop list captured at accept) + a live status_rollup computed on read from the child messages. Account-scoped: a batch owned by another user returns 404 (does not leak existence). Wired via Deps.GetBatch + Deps.BatchStatusRollup (apiserver → store.GetBatch / BatchStatusRollupByID); nil returns 501. listMessages ?batch_id= filter (§7.2): - New batch_id query param threaded through ListMessagesInput → messagesCursor (new "bt" tag, omitempty → backward-compatible with existing cursors) → MessageListFilter → GetMessagesByAgent WHERE m.batch_id = $n. Cursor identity check + encode updated so a continuation can't silently change the filter. batch_id on webhook events (§7.3): - The design doc assumed async-send-contract §4.4 had reserved this field; it had not, so it is added from scratch. identity.Message gains a BatchID field; the two outbound settle paths (MarkOutboundSentTx, ResolveOutboundProviderAcceptedTx) and the failure path (MarkOutboundFailedTx) now COALESCE(m.batch_id,'') into their RETURNING/Scan; EmailSentData + EmailFailedData gain an omitempty batch_id; buildEmailSentEventFromRow / buildEmailFailedEventFromRow populate it. Single sends leave it empty → omitted. Only the async outbound path is touched (batch never flows through self-send / HITL / test-send), so the golden event fixtures stay byte-identical (omitempty). Tests: - httpapi/batch_test.go: getBatch happy path (header + suppressed + rollup) and 404 for missing AND foreign-owned batch. - identity/batches_test.go: GetMessagesByAgent batch_id filter returns only the batch's children, not sibling single-sends. api/openapi.yaml + SDK bases regenerated. Golden event fixtures pass unchanged. Serialized suite green across httpapi/agent/identity/ eventpayload/outboundsend. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Le Xiao --- internal/agent/outbound_async.go | 2 + internal/apiserver/apiserver.go | 2 + internal/eventpayload/payloads.go | 25 ++-- internal/httpapi/batch.go | 115 ++++++++++++++++ internal/httpapi/batch_test.go | 91 +++++++++++++ internal/httpapi/httpapi.go | 10 +- internal/httpapi/messages.go | 8 +- internal/identity/batches_test.go | 65 +++++++++ internal/identity/delivery_store.go | 12 +- internal/identity/store.go | 14 ++ .../models/batch_status_rollup_view.py | 114 ++++++++++++++++ .../generated/models/batch_suppressed_item.py | 104 +++++++++++++++ .../src/e2a/v1/generated/models/batch_view.py | 124 ++++++++++++++++++ .../generated/models/BatchStatusRollupView.ts | 85 ++++++++++++ .../generated/models/BatchSuppressedItem.ts | 50 +++++++ .../src/v1/generated/models/BatchView.ts | 98 ++++++++++++++ .../v1/generated/models/EmailFailedData.ts | 7 + .../src/v1/generated/models/EmailSentData.ts | 7 + .../src/v1/generated/models/Message.ts | 12 ++ 19 files changed, 928 insertions(+), 17 deletions(-) create mode 100644 sdks/python/src/e2a/v1/generated/models/batch_status_rollup_view.py create mode 100644 sdks/python/src/e2a/v1/generated/models/batch_suppressed_item.py create mode 100644 sdks/python/src/e2a/v1/generated/models/batch_view.py create mode 100644 sdks/typescript/src/v1/generated/models/BatchStatusRollupView.ts create mode 100644 sdks/typescript/src/v1/generated/models/BatchSuppressedItem.ts create mode 100644 sdks/typescript/src/v1/generated/models/BatchView.ts diff --git a/internal/agent/outbound_async.go b/internal/agent/outbound_async.go index b7da21a5d..04a6e6910 100644 --- a/internal/agent/outbound_async.go +++ b/internal/agent/outbound_async.go @@ -490,6 +490,7 @@ func buildEmailSentEventFromRow(info *identity.OutboundSentInfo, providerMessage BCC: m.BCC, Subject: m.Subject, MessageType: m.Type, + BatchID: m.BatchID, LifecycleTransitions: transitions, } return webhookpub.Event{ @@ -519,6 +520,7 @@ func buildEmailFailedEventFromRow(info *identity.OutboundSentInfo, detail string BCC: m.BCC, Subject: m.Subject, MessageType: m.Type, + BatchID: m.BatchID, Reason: detail, LifecycleTransitions: transitions, } diff --git a/internal/apiserver/apiserver.go b/internal/apiserver/apiserver.go index 7c51a35bb..2854b7d65 100644 --- a/internal/apiserver/apiserver.go +++ b/internal/apiserver/apiserver.go @@ -193,6 +193,8 @@ func BuildDeps(p Params) httpapi.Deps { Idempotency: p.Idempotency, DeliverOutbound: p.API.DeliverOutbound, DeliverBatch: p.API.DeliverBatch, + GetBatch: p.Store.GetBatch, + BatchStatusRollup: p.Store.BatchStatusRollupByID, SendTest: p.API.SendTestCore, PollSendOutcome: p.Store.GetSendOutcome, ApprovePending: p.API.ApprovePendingCore, diff --git a/internal/eventpayload/payloads.go b/internal/eventpayload/payloads.go index ecd7b74a2..241176d94 100644 --- a/internal/eventpayload/payloads.go +++ b/internal/eventpayload/payloads.go @@ -108,14 +108,19 @@ type EmailSentData struct { // from the e2a message_id, and the correlation key for the async // delivered/bounced/complained feedback events. Omitted for providerless // local loopback delivery. - ProviderMessageID string `json:"provider_message_id,omitempty"` - Method string `json:"method" doc:"Transport used for the send. Open set; tolerate unknown values. Known values: smtp, loopback."` - From string `json:"from"` - To []string `json:"to" nullable:"false"` - CC []string `json:"cc,omitempty" nullable:"false"` - BCC []string `json:"bcc,omitempty" nullable:"false"` - Subject string `json:"subject"` - MessageType string `json:"message_type" doc:"Send kind. Open set; tolerate unknown values. Known values: send, reply, forward."` + ProviderMessageID string `json:"provider_message_id,omitempty"` + Method string `json:"method" doc:"Transport used for the send. Open set; tolerate unknown values. Known values: smtp, loopback."` + From string `json:"from"` + To []string `json:"to" nullable:"false"` + CC []string `json:"cc,omitempty" nullable:"false"` + BCC []string `json:"bcc,omitempty" nullable:"false"` + Subject string `json:"subject"` + MessageType string `json:"message_type" doc:"Send kind. Open set; tolerate unknown values. Known values: send, reply, forward."` + // BatchID correlates this send to the batch it was submitted under + // (POST /v1/agents/{email}/batches). Omitted for single sends. Lets a + // subscriber group per-recipient delivery outcomes back to the batch + // call (docs/design/batch-send.md §7.3). + BatchID string `json:"batch_id,omitempty"` LifecycleTransitions []messagelifecycle.MessageLifecycleTransition `json:"lifecycle_transitions,omitempty" nullable:"false"` } @@ -135,6 +140,10 @@ type EmailFailedData struct { BCC []string `json:"bcc,omitempty" nullable:"false"` Subject string `json:"subject"` MessageType string `json:"message_type" doc:"Send kind. Open set; tolerate unknown values. Known values: send, reply, forward."` + // BatchID correlates this failure to the batch it was submitted under + // (POST /v1/agents/{email}/batches). Omitted for single sends + // (docs/design/batch-send.md §7.3). + BatchID string `json:"batch_id,omitempty"` // Reason is the human-readable terminal failure diagnostic (e.g. the SMTP // response of a permanent reject). Reason string `json:"reason"` diff --git a/internal/httpapi/batch.go b/internal/httpapi/batch.go index efd6a00c4..0f95a0612 100644 --- a/internal/httpapi/batch.go +++ b/internal/httpapi/batch.go @@ -147,6 +147,121 @@ func (s *Server) registerSendBatch() { "default": s.errorEnvelopeResponse(), }, }, s.handleSendBatch) + + huma.Register(s.API, huma.Operation{ + OperationID: "getBatch", + Method: http.MethodGet, + Path: "/v1/batches/{batch_id}", + Summary: "Get a batch's header and delivery-status rollup", + Tags: []string{"messages"}, + Description: "Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch's child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found.", + Security: []map[string][]string{{"bearer": {}}}, + Responses: map[string]*huma.Response{ + "200": s.jsonResponse(reflect.TypeOf(BatchView{}), "BatchView", "The batch header + per-status rollup."), + "404": s.errorEnvelopeResponse(), + "default": s.errorEnvelopeResponse(), + }, + }, s.handleGetBatch) +} + +// BatchIDParam is the path input for GET /v1/batches/{batch_id}. +type BatchIDParam struct { + BatchID string `path:"batch_id" doc:"The batch id, e.g. bat_abc123."` +} + +// BatchStatusRollupView is the per-delivery-status count of a batch's child +// messages. Statuses with no rows read as zero. Mirrors the message-lifecycle +// vocabulary (accepted → sending → sent → delivered | deferred | bounced | +// complained | failed). +type BatchStatusRollupView struct { + Accepted int `json:"accepted"` + Sending int `json:"sending"` + Sent int `json:"sent"` + Delivered int `json:"delivered"` + Deferred int `json:"deferred"` + Bounced int `json:"bounced"` + Complained int `json:"complained"` + Failed int `json:"failed"` +} + +// BatchView is the GET /v1/batches/{batch_id} response body. +type BatchView struct { + BatchID string `json:"batch_id"` + AgentID string `json:"agent_id" doc:"The sending agent's address."` + Requested int `json:"requested" doc:"Number of items in the original request (accepted + suppressed)."` + Accepted int `json:"accepted" doc:"Number of items durably accepted (each has a message_id + delivery pipeline entry)."` + Suppressed []BatchSuppressedItem `json:"suppressed" nullable:"false" doc:"Items dropped by the suppression filter at accept time. Empty when none were dropped."` + CreatedAt string `json:"created_at" doc:"RFC3339 timestamp of when the batch was accepted."` + StatusRollup BatchStatusRollupView `json:"status_rollup" doc:"Live per-delivery-status count of the batch's child messages, computed on read."` +} + +// BatchSuppressedItem is one dropped-item record in a BatchView. item_index is +// the position in the original request's messages[] array. +type BatchSuppressedItem struct { + ItemIndex int `json:"item_index"` + Address string `json:"address"` + Reason string `json:"reason"` +} + +// batchViewOutput bridges the handler to Huma. +type batchViewOutput struct { + Body BatchView +} + +// handleGetBatch implements GET /v1/batches/{batch_id} (§7.1). Ownership is +// enforced by comparing the batch's user_id to the caller and conflating a +// foreign or missing batch to 404 (the resolveOwnedAgent convention). +func (s *Server) handleGetBatch(ctx context.Context, in *BatchIDParam) (*batchViewOutput, error) { + user, uerr := s.requireUser(ctx) + if uerr != nil { + return nil, uerr + } + if s.deps.GetBatch == nil || s.deps.BatchStatusRollup == nil { + return nil, NewError(http.StatusNotImplemented, "not_implemented", "batch send is not enabled on this deployment") + } + batch, err := s.deps.GetBatch(ctx, in.BatchID) + if err != nil { + return nil, NewError(http.StatusInternalServerError, "internal_error", "failed to load batch") + } + // Foreign or missing batch → 404 (do not leak existence across accounts). + if batch == nil || batch.UserID != user.ID { + return nil, NewError(http.StatusNotFound, "not_found", "batch not found") + } + rollup, err := s.deps.BatchStatusRollup(ctx, in.BatchID) + if err != nil { + return nil, NewError(http.StatusInternalServerError, "internal_error", "failed to compute batch rollup") + } + return &batchViewOutput{Body: batchViewFrom(batch, rollup)}, nil +} + +// batchViewFrom maps the store types into the wire BatchView. +func batchViewFrom(b *identity.Batch, r *identity.BatchStatusRollup) BatchView { + suppressed, _ := b.DecodeSuppressed() + items := make([]BatchSuppressedItem, 0, len(suppressed)) + for _, s := range suppressed { + items = append(items, BatchSuppressedItem{ItemIndex: s.ItemIndex, Address: s.Address, Reason: s.Reason}) + } + view := BatchView{ + BatchID: b.BatchID, + AgentID: b.AgentID, + Requested: b.Requested, + Accepted: b.Accepted, + Suppressed: items, + CreatedAt: b.CreatedAt.UTC().Format("2006-01-02T15:04:05.999999999Z07:00"), + } + if r != nil { + view.StatusRollup = BatchStatusRollupView{ + Accepted: r.Accepted, + Sending: r.Sending, + Sent: r.Sent, + Delivered: r.Delivered, + Deferred: r.Deferred, + Bounced: r.Bounced, + Complained: r.Complained, + Failed: r.Failed, + } + } + return view } // --------------------------------------------------------------------------- diff --git a/internal/httpapi/batch_test.go b/internal/httpapi/batch_test.go index 1163311ad..8187d958a 100644 --- a/internal/httpapi/batch_test.go +++ b/internal/httpapi/batch_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/tokencanopy/e2a/internal/agent" "github.com/tokencanopy/e2a/internal/identity" @@ -379,6 +380,96 @@ func TestSendBatch_ReplyToDefault(t *testing.T) { } } +// --- getBatch --- + +func TestGetBatch_HappyPath(t *testing.T) { + created := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) + deps := Deps{ + Authenticator: func(r *http.Request) (*identity.User, error) { return &identity.User{ID: "u_1"}, nil }, + GetBatch: func(ctx context.Context, batchID string) (*identity.Batch, error) { + if batchID != "bat_x" { + return nil, nil + } + return &identity.Batch{ + BatchID: "bat_x", UserID: "u_1", AgentID: "bot@acme.com", + Requested: 3, Accepted: 2, CreatedAt: created, + SuppressedJSON: []byte(`[{"item_index":1,"address":"b@x.com","reason":"bounce"}]`), + }, nil + }, + BatchStatusRollup: func(ctx context.Context, batchID string) (*identity.BatchStatusRollup, error) { + return &identity.BatchStatusRollup{Accepted: 1, Sent: 1}, nil + }, + } + srv := httptest.NewServer(New(deps)) + t.Cleanup(srv.Close) + + req, _ := http.NewRequest("GET", srv.URL+"/v1/batches/bat_x", nil) + req.Header.Set("Authorization", "Bearer good") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + if out["batch_id"] != "bat_x" { + t.Errorf("batch_id = %v", out["batch_id"]) + } + if out["requested"].(float64) != 3 || out["accepted"].(float64) != 2 { + t.Errorf("counts wrong: %v", out) + } + supp, _ := out["suppressed"].([]any) + if len(supp) != 1 { + t.Fatalf("suppressed len = %d, want 1", len(supp)) + } + rollup, _ := out["status_rollup"].(map[string]any) + if rollup["sent"].(float64) != 1 || rollup["accepted"].(float64) != 1 { + t.Errorf("rollup = %v", rollup) + } +} + +func TestGetBatch_NotFoundAndForeign(t *testing.T) { + deps := Deps{ + Authenticator: func(r *http.Request) (*identity.User, error) { return &identity.User{ID: "u_1"}, nil }, + GetBatch: func(ctx context.Context, batchID string) (*identity.Batch, error) { + switch batchID { + case "bat_missing": + return nil, nil + case "bat_foreign": + return &identity.Batch{BatchID: "bat_foreign", UserID: "u_OTHER"}, nil + } + return nil, nil + }, + BatchStatusRollup: func(ctx context.Context, batchID string) (*identity.BatchStatusRollup, error) { + return &identity.BatchStatusRollup{}, nil + }, + } + srv := httptest.NewServer(New(deps)) + t.Cleanup(srv.Close) + + for _, id := range []string{"bat_missing", "bat_foreign"} { + req, _ := http.NewRequest("GET", srv.URL+"/v1/batches/"+id, nil) + req.Header.Set("Authorization", "Bearer good") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("%s: status = %d, want 404 (foreign must not leak existence)", id, resp.StatusCode) + } + errObj, _ := out["error"].(map[string]any) + if errObj["code"] != "not_found" { + t.Errorf("%s: code = %v, want not_found", id, errObj["code"]) + } + } +} + // sanity: exercise stripBase64Whitespace directly. func TestStripBase64Whitespace(t *testing.T) { in := "aGVs\r\nbG8g\t d29ybGQ=" diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index c923aec72..9feba5440 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -238,7 +238,15 @@ type Deps struct { // nil-DeliverOutbound behavior of single-send). Wired in apiserver // via agent.API.DeliverBatch. DeliverBatch func(ctx context.Context, user *identity.User, ag *identity.AgentIdentity, items []outbound.SendRequest, idemCompleteTx agent.BatchAcceptIdemCompleter) (*agent.BatchAcceptResult, *agent.OutboundError) - SendTest func(ctx context.Context, ag *identity.AgentIdentity) (*agent.OutboundResult, *agent.OutboundError) + // GetBatch loads a batch header by id (nil on miss). The handler + // enforces ownership by comparing batch.UserID to the caller and + // conflating a foreign/missing batch to 404. BatchStatusRollup + // computes the per-delivery-status count over the batch's child + // messages. Both back GET /v1/batches/{batch_id} (§7.1). Optional — + // nil returns 501 not_implemented. + GetBatch func(ctx context.Context, batchID string) (*identity.Batch, error) + BatchStatusRollup func(ctx context.Context, batchID string) (*identity.BatchStatusRollup, error) + SendTest func(ctx context.Context, ag *identity.AgentIdentity) (*agent.OutboundResult, *agent.OutboundError) // PollSendOutcome reads an async send's current delivery_status for wait=sent. // Optional — nil disables the wait valve (accepted is returned immediately). PollSendOutcome func(ctx context.Context, messageID string) (identity.SendOutcome, error) diff --git a/internal/httpapi/messages.go b/internal/httpapi/messages.go index 0b64fea46..bfe221d62 100644 --- a/internal/httpapi/messages.go +++ b/internal/httpapi/messages.go @@ -382,6 +382,7 @@ type ListMessagesInput struct { From string `query:"from" doc:"Case-insensitive substring match on sender."` SubjectContains string `query:"subject_contains" doc:"Case-insensitive substring match on subject."` ConversationID string `query:"conversation_id"` + BatchID string `query:"batch_id" doc:"Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123."` Labels []string `query:"labels" doc:"Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label."` Since string `query:"since" doc:"RFC3339; created_at >= since."` Until string `query:"until" doc:"RFC3339; created_at < until."` @@ -408,6 +409,7 @@ type messagesCursor struct { From string `json:"f,omitempty"` SubjectContains string `json:"sc,omitempty"` ConversationID string `json:"cv,omitempty"` + BatchID string `json:"bt,omitempty"` Since string `json:"sn,omitempty"` Until string `json:"un,omitempty"` Labels []string `json:"lb,omitempty"` @@ -757,7 +759,7 @@ func (s *Server) handleListMessages(ctx context.Context, in *ListMessagesInput) } if cur.AgentID != ag.ID || cur.Status != status || cur.Direction != direction || cur.Sort != sort || cur.From != in.From || cur.SubjectContains != in.SubjectContains || - cur.ConversationID != in.ConversationID || + cur.ConversationID != in.ConversationID || cur.BatchID != in.BatchID || cur.Since != rfc3339OrEmpty(since) || cur.Until != rfc3339OrEmpty(until) || cur.Filter != in.Filter || cur.Deleted != in.Deleted || @@ -786,6 +788,7 @@ func (s *Server) handleListMessages(ctx context.Context, in *ListMessagesInput) From: in.From, SubjectContains: in.SubjectContains, ConversationID: in.ConversationID, + BatchID: in.BatchID, Since: since, Until: until, Labels: labelsFilter, @@ -813,7 +816,8 @@ func (s *Server) handleListMessages(ctx context.Context, in *ListMessagesInput) CreatedAt: last.CreatedAt, ID: last.ID, Status: status, Direction: direction, AgentID: ag.ID, Sort: sort, From: in.From, SubjectContains: in.SubjectContains, ConversationID: in.ConversationID, - Since: rfc3339OrEmpty(since), Until: rfc3339OrEmpty(until), Labels: labelsFilter, + BatchID: in.BatchID, + Since: rfc3339OrEmpty(since), Until: rfc3339OrEmpty(until), Labels: labelsFilter, Filter: in.Filter, Deleted: in.Deleted, }) diff --git a/internal/identity/batches_test.go b/internal/identity/batches_test.go index eec700e88..5c0c262f2 100644 --- a/internal/identity/batches_test.go +++ b/internal/identity/batches_test.go @@ -299,6 +299,71 @@ func TestCreateOutboundMessagesTx_MissingAgentIDFails(t *testing.T) { } } +// TestGetMessagesByAgent_BatchIDFilter verifies the batch_id list filter +// (docs/design/batch-send.md §7.2): listing an agent's messages filtered by +// batch_id returns only that batch's children. +func TestGetMessagesByAgent_BatchIDFilter(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + userID, agentID := batchTestSetup(t, store, "batch-listfilter") + + batchID := identity.NewBatchID() + err := store.WithTx(ctx, func(tx pgx.Tx) error { + if err := store.CreateBatchTx(ctx, tx, &identity.Batch{ + BatchID: batchID, UserID: userID, AgentID: agentID, Requested: 2, Accepted: 2, + }); err != nil { + return err + } + // 2 messages in the batch... + if _, err := store.CreateOutboundMessagesTx(ctx, tx, []identity.OutboundMessageInput{ + {AgentID: agentID, ToRecipients: []string{"in1@x.com"}, Subject: "in batch 1", MsgType: "send", Method: "smtp", DeliveryStatus: "accepted", BatchID: batchID}, + {AgentID: agentID, ToRecipients: []string{"in2@x.com"}, Subject: "in batch 2", MsgType: "send", Method: "smtp", DeliveryStatus: "accepted", BatchID: batchID}, + }); err != nil { + return err + } + return nil + }) + if err != nil { + t.Fatalf("setup batch: %v", err) + } + // ...and 1 message NOT in the batch (single-send, batch_id empty). + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + _, e := store.CreateOutboundMessageTx(ctx, tx, agentID, + []string{"solo@x.com"}, nil, nil, "single send", "send", "smtp", "", "", nil, "accepted", "", "") + return e + }); err != nil { + t.Fatalf("single send: %v", err) + } + + // Filter by batch_id → only the 2 batch children. + filtered, err := store.GetMessagesByAgent(ctx, identity.MessageListFilter{ + AgentID: agentID, Direction: "outbound", BatchID: batchID, Limit: 100, + }) + if err != nil { + t.Fatalf("list filtered: %v", err) + } + if len(filtered) != 2 { + t.Fatalf("batch_id filter returned %d messages, want 2", len(filtered)) + } + for _, m := range filtered { + if m.Recipient == "solo@x.com" { + t.Errorf("single-send leaked into batch_id filter") + } + } + + // No filter → all 3. + all, err := store.GetMessagesByAgent(ctx, identity.MessageListFilter{ + AgentID: agentID, Direction: "outbound", Limit: 100, + }) + if err != nil { + t.Fatalf("list all: %v", err) + } + if len(all) != 3 { + t.Errorf("unfiltered returned %d, want 3", len(all)) + } +} + func TestNewBatchID_HasExpectedPrefix(t *testing.T) { id := identity.NewBatchID() if len(id) < 5 || id[:4] != "bat_" { diff --git a/internal/identity/delivery_store.go b/internal/identity/delivery_store.go index 7fff5d715..6ef01b7c0 100644 --- a/internal/identity/delivery_store.go +++ b/internal/identity/delivery_store.go @@ -818,9 +818,9 @@ func (s *Store) MarkOutboundSentTx(ctx context.Context, tx pgx.Tx, messageID, pr WHERE m.id = $1 AND m.direction = 'outbound' AND m.agent_id = a.id AND m.delivery_status = 'sending' - RETURNING m.agent_id, m.subject, m.message_type, m.method, m.conversation_id, m.sender, m.to_recipients, m.cc, m.bcc`, + RETURNING m.agent_id, m.subject, m.message_type, m.method, m.conversation_id, m.sender, m.to_recipients, m.cc, m.bcc, COALESCE(m.batch_id, '')`, messageID, providerMessageID, rfcMessageIDKey, - ).Scan(&m.AgentID, &m.Subject, &m.Type, &m.Method, &m.ConversationID, &m.Sender, &m.ToRecipients, &m.CC, &m.BCC) + ).Scan(&m.AgentID, &m.Subject, &m.Type, &m.Method, &m.ConversationID, &m.Sender, &m.ToRecipients, &m.CC, &m.BCC, &m.BatchID) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } @@ -900,10 +900,10 @@ func (s *Store) ResolveOutboundProviderAcceptedTx(ctx context.Context, tx pgx.Tx (m.delivery_status='failed' AND COALESCE(m.delivery_failure_source,'local')='local')) AND m.provider_accepted_at IS NOT NULL RETURNING m.agent_id, m.subject, m.message_type, m.method, m.conversation_id, m.sender, - m.to_recipients, m.cc, m.bcc, COALESCE(m.provider_message_id, ''),m.provider_accepted_at`, + m.to_recipients, m.cc, m.bcc, COALESCE(m.provider_message_id, ''), COALESCE(m.batch_id, ''), m.provider_accepted_at`, messageID, ).Scan(&m.AgentID, &m.Subject, &m.Type, &m.Method, &m.ConversationID, &m.Sender, - &m.ToRecipients, &m.CC, &m.BCC, &providerMessageID, &providerAcceptedAt) + &m.ToRecipients, &m.CC, &m.BCC, &providerMessageID, &m.BatchID, &providerAcceptedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, "", nil } @@ -990,10 +990,10 @@ func (s *Store) MarkOutboundFailedTx(ctx context.Context, tx pgx.Tx, messageID, AND m.delivery_status IN ('accepted', 'sending') AND m.provider_accepted_at IS NULL RETURNING m.agent_id, m.subject, m.message_type, m.method, m.conversation_id, m.sender, m.to_recipients, m.cc, m.bcc, - COALESCE(m.delivery_detail, '')`, + COALESCE(m.delivery_detail, ''), COALESCE(m.batch_id, '')`, messageID, nullIfEmpty(detail), string(source), ).Scan(&m.AgentID, &m.Subject, &m.Type, &m.Method, &m.ConversationID, &m.Sender, &m.ToRecipients, &m.CC, &m.BCC, - &m.DeliveryDetail) + &m.DeliveryDetail, &m.BatchID) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } diff --git a/internal/identity/store.go b/internal/identity/store.go index 641134358..dcd77e0bb 100644 --- a/internal/identity/store.go +++ b/internal/identity/store.go @@ -420,6 +420,15 @@ type Message struct { // Reply-To points at a different mailbox. Outbound-irrelevant. ReplyTo []string `json:"reply_to,omitempty"` + // BatchID links an outbound message to the batches row it was created + // under (POST /v1/agents/{email}/batches). Empty for single-sends and + // every inbound row. Populated by the read paths that need it (the + // delivery-feedback UPDATE...RETURNING that builds email.sent / + // email.failed events, so batch children carry batch_id on those + // events — docs/design/batch-send.md §7.3). Source: messages.batch_id + // (migration 067). + BatchID string `json:"batch_id,omitempty"` + // Labels are user-applied string tags (`urgent`, `follow-up`, …). // Always lowercase, charset `[a-z0-9:_-]+`, ≤ 64 chars per label, // capped at 100 per message. Empty slice means no labels — the DB @@ -4144,6 +4153,7 @@ type MessageListFilter struct { From string SubjectContains string ConversationID string // exact match + BatchID string // exact match; child messages of a batch send (migration 067) Since time.Time // created_at >= Since Until time.Time // created_at < Until // Labels filters rows where ALL given labels are present on the @@ -4266,6 +4276,10 @@ func (s *Store) GetMessagesByAgent(ctx context.Context, f MessageListFilter) ([] query += fmt.Sprintf(` AND m.conversation_id = $%d`, len(args)+1) args = append(args, f.ConversationID) } + if f.BatchID != "" { + query += fmt.Sprintf(` AND m.batch_id = $%d`, len(args)+1) + args = append(args, f.BatchID) + } if !f.Since.IsZero() { query += fmt.Sprintf(` AND m.created_at >= $%d`, len(args)+1) args = append(args, f.Since) diff --git a/sdks/python/src/e2a/v1/generated/models/batch_status_rollup_view.py b/sdks/python/src/e2a/v1/generated/models/batch_status_rollup_view.py new file mode 100644 index 000000000..014215c29 --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/batch_status_rollup_view.py @@ -0,0 +1,114 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, StrictInt +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self + +class BatchStatusRollupView(BaseModel): + """ + BatchStatusRollupView + """ # noqa: E501 + accepted: StrictInt + bounced: StrictInt + complained: StrictInt + deferred: StrictInt + delivered: StrictInt + failed: StrictInt + sending: StrictInt + sent: StrictInt + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["accepted", "bounced", "complained", "deferred", "delivered", "failed", "sending", "sent"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of BatchStatusRollupView from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of BatchStatusRollupView from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "accepted": obj.get("accepted"), + "bounced": obj.get("bounced"), + "complained": obj.get("complained"), + "deferred": obj.get("deferred"), + "delivered": obj.get("delivered"), + "failed": obj.get("failed"), + "sending": obj.get("sending"), + "sent": obj.get("sent") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/python/src/e2a/v1/generated/models/batch_suppressed_item.py b/sdks/python/src/e2a/v1/generated/models/batch_suppressed_item.py new file mode 100644 index 000000000..d8e3f1c09 --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/batch_suppressed_item.py @@ -0,0 +1,104 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self + +class BatchSuppressedItem(BaseModel): + """ + BatchSuppressedItem + """ # noqa: E501 + address: StrictStr + item_index: StrictInt + reason: StrictStr + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["address", "item_index", "reason"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of BatchSuppressedItem from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of BatchSuppressedItem from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "address": obj.get("address"), + "item_index": obj.get("item_index"), + "reason": obj.get("reason") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/python/src/e2a/v1/generated/models/batch_view.py b/sdks/python/src/e2a/v1/generated/models/batch_view.py new file mode 100644 index 000000000..cf7ed1d08 --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/batch_view.py @@ -0,0 +1,124 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List +from e2a.v1.generated.models.batch_status_rollup_view import BatchStatusRollupView +from e2a.v1.generated.models.batch_suppressed_item import BatchSuppressedItem +from typing import Optional, Set +from typing_extensions import Self + +class BatchView(BaseModel): + """ + BatchView + """ # noqa: E501 + accepted: StrictInt = Field(description="Number of items durably accepted (each has a message_id + delivery pipeline entry).") + agent_id: StrictStr = Field(description="The sending agent's address.") + batch_id: StrictStr + created_at: StrictStr = Field(description="RFC3339 timestamp of when the batch was accepted.") + requested: StrictInt = Field(description="Number of items in the original request (accepted + suppressed).") + status_rollup: BatchStatusRollupView = Field(description="Live per-delivery-status count of the batch's child messages, computed on read.") + suppressed: List[BatchSuppressedItem] = Field(description="Items dropped by the suppression filter at accept time. Empty when none were dropped.") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["accepted", "agent_id", "batch_id", "created_at", "requested", "status_rollup", "suppressed"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of BatchView from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of status_rollup + if self.status_rollup: + _dict['status_rollup'] = self.status_rollup.to_dict() + # override the default output from pydantic by calling `to_dict()` of each item in suppressed (list) + _items = [] + if self.suppressed: + for _item_suppressed in self.suppressed: + if _item_suppressed: + _items.append(_item_suppressed.to_dict()) + _dict['suppressed'] = _items + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of BatchView from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "accepted": obj.get("accepted"), + "agent_id": obj.get("agent_id"), + "batch_id": obj.get("batch_id"), + "created_at": obj.get("created_at"), + "requested": obj.get("requested"), + "status_rollup": BatchStatusRollupView.from_dict(obj["status_rollup"]) if obj.get("status_rollup") is not None else None, + "suppressed": [BatchSuppressedItem.from_dict(_item) for _item in obj["suppressed"]] if obj.get("suppressed") is not None else None + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/typescript/src/v1/generated/models/BatchStatusRollupView.ts b/sdks/typescript/src/v1/generated/models/BatchStatusRollupView.ts new file mode 100644 index 000000000..8b2a50f05 --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/BatchStatusRollupView.ts @@ -0,0 +1,85 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http.js'; + +export class BatchStatusRollupView { + 'accepted': number; + 'bounced': number; + 'complained': number; + 'deferred': number; + 'delivered': number; + 'failed': number; + 'sending': number; + 'sent': number; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "accepted", + "baseName": "accepted", + "type": "number", + "format": "int64" + }, + { + "name": "bounced", + "baseName": "bounced", + "type": "number", + "format": "int64" + }, + { + "name": "complained", + "baseName": "complained", + "type": "number", + "format": "int64" + }, + { + "name": "deferred", + "baseName": "deferred", + "type": "number", + "format": "int64" + }, + { + "name": "delivered", + "baseName": "delivered", + "type": "number", + "format": "int64" + }, + { + "name": "failed", + "baseName": "failed", + "type": "number", + "format": "int64" + }, + { + "name": "sending", + "baseName": "sending", + "type": "number", + "format": "int64" + }, + { + "name": "sent", + "baseName": "sent", + "type": "number", + "format": "int64" + } ]; + + static getAttributeTypeMap() { + return BatchStatusRollupView.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/sdks/typescript/src/v1/generated/models/BatchSuppressedItem.ts b/sdks/typescript/src/v1/generated/models/BatchSuppressedItem.ts new file mode 100644 index 000000000..3cd1bf17b --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/BatchSuppressedItem.ts @@ -0,0 +1,50 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http.js'; + +export class BatchSuppressedItem { + 'address': string; + 'itemIndex': number; + 'reason': string; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "address", + "baseName": "address", + "type": "string", + "format": "" + }, + { + "name": "itemIndex", + "baseName": "item_index", + "type": "number", + "format": "int64" + }, + { + "name": "reason", + "baseName": "reason", + "type": "string", + "format": "" + } ]; + + static getAttributeTypeMap() { + return BatchSuppressedItem.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/sdks/typescript/src/v1/generated/models/BatchView.ts b/sdks/typescript/src/v1/generated/models/BatchView.ts new file mode 100644 index 000000000..905102e7e --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/BatchView.ts @@ -0,0 +1,98 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { BatchStatusRollupView } from '../models/BatchStatusRollupView.js'; +import { BatchSuppressedItem } from '../models/BatchSuppressedItem.js'; +import { HttpFile } from '../http/http.js'; + +export class BatchView { + /** + * Number of items durably accepted (each has a message_id + delivery pipeline entry). + */ + 'accepted': number; + /** + * The sending agent\'s address. + */ + 'agentId': string; + 'batchId': string; + /** + * RFC3339 timestamp of when the batch was accepted. + */ + 'createdAt': string; + /** + * Number of items in the original request (accepted + suppressed). + */ + 'requested': number; + /** + * Live per-delivery-status count of the batch\'s child messages, computed on read. + */ + 'statusRollup': BatchStatusRollupView; + /** + * Items dropped by the suppression filter at accept time. Empty when none were dropped. + */ + 'suppressed': Array; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "accepted", + "baseName": "accepted", + "type": "number", + "format": "int64" + }, + { + "name": "agentId", + "baseName": "agent_id", + "type": "string", + "format": "" + }, + { + "name": "batchId", + "baseName": "batch_id", + "type": "string", + "format": "" + }, + { + "name": "createdAt", + "baseName": "created_at", + "type": "string", + "format": "" + }, + { + "name": "requested", + "baseName": "requested", + "type": "number", + "format": "int64" + }, + { + "name": "statusRollup", + "baseName": "status_rollup", + "type": "BatchStatusRollupView", + "format": "" + }, + { + "name": "suppressed", + "baseName": "suppressed", + "type": "Array", + "format": "" + } ]; + + static getAttributeTypeMap() { + return BatchView.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/sdks/typescript/src/v1/generated/models/EmailFailedData.ts b/sdks/typescript/src/v1/generated/models/EmailFailedData.ts index 98f068acf..a6a7ed100 100644 --- a/sdks/typescript/src/v1/generated/models/EmailFailedData.ts +++ b/sdks/typescript/src/v1/generated/models/EmailFailedData.ts @@ -15,6 +15,7 @@ import { HttpFile } from '../http/http.js'; export class EmailFailedData { 'agentEmail': string; + 'batchId'?: string; 'bcc'?: Array; 'cc'?: Array; 'conversationId'?: string; @@ -50,6 +51,12 @@ export class EmailFailedData { "type": "string", "format": "" }, + { + "name": "batchId", + "baseName": "batch_id", + "type": "string", + "format": "" + }, { "name": "bcc", "baseName": "bcc", diff --git a/sdks/typescript/src/v1/generated/models/EmailSentData.ts b/sdks/typescript/src/v1/generated/models/EmailSentData.ts index 79fd86b2e..96790189c 100644 --- a/sdks/typescript/src/v1/generated/models/EmailSentData.ts +++ b/sdks/typescript/src/v1/generated/models/EmailSentData.ts @@ -15,6 +15,7 @@ import { HttpFile } from '../http/http.js'; export class EmailSentData { 'agentEmail': string; + 'batchId'?: string; 'bcc'?: Array; 'cc'?: Array; 'conversationId'?: string; @@ -48,6 +49,12 @@ export class EmailSentData { "type": "string", "format": "" }, + { + "name": "batchId", + "baseName": "batch_id", + "type": "string", + "format": "" + }, { "name": "bcc", "baseName": "bcc", diff --git a/sdks/typescript/src/v1/generated/models/Message.ts b/sdks/typescript/src/v1/generated/models/Message.ts index 130567178..2bb02010f 100644 --- a/sdks/typescript/src/v1/generated/models/Message.ts +++ b/sdks/typescript/src/v1/generated/models/Message.ts @@ -18,10 +18,16 @@ export class Message { 'agentEmail': string; 'approvalExpiresAt'?: Date; 'attachments'?: Array | null; +<<<<<<< HEAD /** * Inbound SMTP authentication evidence. Only dmarc.status=pass authenticates the RFC 5322 From domain; even a pass does not authenticate the mailbox local part, a person, or message content. Null means there was no authenticating inbound SMTP peer, as with outbound or providerless loopback delivery. */ 'authentication': Authentication | null; +======= + 'auth'?: AuthVerdict; + 'authHeaders'?: { [key: string]: string; }; + 'batchId'?: string; +>>>>>>> 7ef81607 (feat(batch-send): observability — getBatch, listMessages filter, batch_id events) 'bcc'?: Array | null; 'cc'?: Array | null; 'conversationId'?: string; @@ -113,6 +119,12 @@ export class Message { "type": "Authentication", "format": "" }, + { + "name": "batchId", + "baseName": "batch_id", + "type": "string", + "format": "" + }, { "name": "bcc", "baseName": "bcc", From 1517455008a3de7ece6cd288578dcc31eb5c4142 Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Tue, 21 Jul 2026 20:26:56 -0700 Subject: [PATCH 06/17] fix(sdk): resolve generated batch conflicts --- sdks/python/src/e2a/v1/client.py | 2 ++ sdks/python/tests/test_v1_client.py | 8 ++++++++ sdks/typescript/src/v1/client.ts | 2 ++ sdks/typescript/src/v1/generated/models/Message.ts | 5 ----- sdks/typescript/test/v1/client.test.ts | 11 +++++++++++ 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/sdks/python/src/e2a/v1/client.py b/sdks/python/src/e2a/v1/client.py index 8f48bb270..51448a07b 100644 --- a/sdks/python/src/e2a/v1/client.py +++ b/sdks/python/src/e2a/v1/client.py @@ -498,6 +498,7 @@ def list( from_: Optional[str] = None, subject_contains: Optional[str] = None, conversation_id: Optional[str] = None, + batch_id: Optional[str] = None, labels: Optional[List[str]] = None, since: Optional[str] = None, until: Optional[str] = None, @@ -519,6 +520,7 @@ async def fetch(cursor: Optional[str]) -> Page: from_=from_, subject_contains=subject_contains, conversation_id=conversation_id, + batch_id=batch_id, labels=labels, since=since, until=until, diff --git a/sdks/python/tests/test_v1_client.py b/sdks/python/tests/test_v1_client.py index 4b707499c..2902c0b33 100644 --- a/sdks/python/tests/test_v1_client.py +++ b/sdks/python/tests/test_v1_client.py @@ -1047,6 +1047,14 @@ async def test_messages_list_deleted_lists_trash(httpx_mock): assert httpx_mock.get_requests()[-1].url.params["deleted"] == "true" +@pytest.mark.anyio +async def test_messages_list_batch_id_filter(httpx_mock): + httpx_mock.add_response(json={"items": [], "next_cursor": None}) + async with _client() as c: + await c.messages.list("bot@test.dev", batch_id="bat_123").page() + assert httpx_mock.get_requests()[-1].url.params["batch_id"] == "bat_123" + + @pytest.mark.anyio async def test_messages_delete_soft_by_default(httpx_mock): httpx_mock.add_response(json={"deleted": True, "id": "msg_1"}) diff --git a/sdks/typescript/src/v1/client.ts b/sdks/typescript/src/v1/client.ts index de793ca5a..ba3850e30 100644 --- a/sdks/typescript/src/v1/client.ts +++ b/sdks/typescript/src/v1/client.ts @@ -360,6 +360,7 @@ export interface ListMessagesParams { from_?: string; subjectContains?: string; conversationId?: string; + batchId?: string; labels?: string[]; since?: string; until?: string; @@ -385,6 +386,7 @@ class MessagesResource { params.from_, params.subjectContains, params.conversationId, + params.batchId, params.labels, params.since, params.until, diff --git a/sdks/typescript/src/v1/generated/models/Message.ts b/sdks/typescript/src/v1/generated/models/Message.ts index 2bb02010f..ce50d8f98 100644 --- a/sdks/typescript/src/v1/generated/models/Message.ts +++ b/sdks/typescript/src/v1/generated/models/Message.ts @@ -18,16 +18,11 @@ export class Message { 'agentEmail': string; 'approvalExpiresAt'?: Date; 'attachments'?: Array | null; -<<<<<<< HEAD /** * Inbound SMTP authentication evidence. Only dmarc.status=pass authenticates the RFC 5322 From domain; even a pass does not authenticate the mailbox local part, a person, or message content. Null means there was no authenticating inbound SMTP peer, as with outbound or providerless loopback delivery. */ 'authentication': Authentication | null; -======= - 'auth'?: AuthVerdict; - 'authHeaders'?: { [key: string]: string; }; 'batchId'?: string; ->>>>>>> 7ef81607 (feat(batch-send): observability — getBatch, listMessages filter, batch_id events) 'bcc'?: Array | null; 'cc'?: Array | null; 'conversationId'?: string; diff --git a/sdks/typescript/test/v1/client.test.ts b/sdks/typescript/test/v1/client.test.ts index 1ccdf554e..3ea74e019 100644 --- a/sdks/typescript/test/v1/client.test.ts +++ b/sdks/typescript/test/v1/client.test.ts @@ -552,6 +552,8 @@ describe("E2AClient", () => { expect(url.searchParams.get("filter")).toBe("label:urgent"); expect(url.searchParams.has("q")).toBe(false); expect(url.searchParams.get("deleted")).toBe("true"); + // batch_id (our addition) sits between conversation_id and labels in the + // generated positional signature, so filter is now the 15th arg. expect(listMessagesSpy).toHaveBeenCalledWith( "bot@test.dev", "all", @@ -565,11 +567,20 @@ describe("E2AClient", () => { undefined, undefined, undefined, + undefined, true, "label:urgent", ); }); + it("messages.list exposes batchId as the wire batch_id query", async () => { + globalThis.fetch = mockFetch(200, { items: [], next_cursor: null }); + + await client.messages.list("bot@test.dev", { batchId: "bat_123" }).page(); + + expect(new URL(lastCall().url).searchParams.get("batch_id")).toBe("bat_123"); + }); + it("messages.list({ deleted: true }) lists the trash", async () => { globalThis.fetch = mockFetch(200, { items: [], next_cursor: null }); await client.messages.list("bot@test.dev", { deleted: true }).toArray({ limit: 10 }); From a0cb797d267d365b5775b54fa62d558827b8385d Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Sun, 26 Jul 2026 12:38:27 -0700 Subject: [PATCH 07/17] fix(batch-send): address PR #627 review (findings 1-10) --- docs/api.md | 2 + docs/design/batch-send.md | 17 +-- internal/agent/batch.go | 106 ++++++++++---- internal/agent/batch_test.go | 135 +++++++++++++++++- internal/delivery/consumer.go | 9 ++ internal/delivery/consumer_test.go | 72 +++++++++- internal/eventpayload/golden_test.go | 3 + internal/eventpayload/payloads.go | 29 ++-- internal/httpapi/batch.go | 95 ++++++++---- internal/httpapi/batch_test.go | 9 +- internal/httpapi/response_enum_stance_test.go | 1 + internal/httpapi/stability.go | 8 ++ internal/httpapi/stability_test.go | 27 +++- internal/identity/agent_suppressions.go | 41 ++++++ internal/identity/batches.go | 4 +- internal/identity/delivery_store.go | 47 +----- internal/identity/store.go | 4 +- internal/limits/enforcer.go | 15 +- internal/limits/limits.go | 6 + internal/webhookdelivery/worker_extra_test.go | 3 + .../{067_batches.sql => 095_batches.sql} | 2 +- 21 files changed, 514 insertions(+), 121 deletions(-) rename migrations/{067_batches.sql => 095_batches.sql} (99%) diff --git a/docs/api.md b/docs/api.md index 9713c5033..5f4ee9c74 100644 --- a/docs/api.md +++ b/docs/api.md @@ -408,6 +408,7 @@ every `/v1` operation not listed here is covered by the GA freeze. | `getAccountMetrics` | `GET /v1/metrics` | Delivery metrics | | `getAgentMetrics` | `GET /v1/agents/{email}/metrics` | Delivery metrics | | `getAgentProtection` | `GET /v1/agents/{email}/protection` | Protection config | +| `getBatch` | `GET /v1/batches/{batch_id}` | Batch send | | `getContact` | `GET /v1/contacts/{address}` | Contacts | | `getEngagement` | `GET /v1/agents/{email}/contacts/{address}` | Contacts | | `getMessageLifecycle` | `GET /v1/agents/{email}/messages/{id}/lifecycle` | Message lifecycle | @@ -423,6 +424,7 @@ every `/v1` operation not listed here is covered by the GA freeze. | `listTemplates` | `GET /v1/templates` | Templates | | `putAgentProtection` | `PUT /v1/agents/{email}/protection` | Protection config | | `rejectReview` | `POST /v1/reviews/{id}/reject` | Reviews | +| `sendBatch` | `POST /v1/agents/{email}/batches` | Batch send | | `updateContact` | `PATCH /v1/contacts/{address}` | Contacts | | `updateTemplate` | `PATCH /v1/templates/{id}` | Templates | | `upsertEngagement` | `PUT /v1/agents/{email}/contacts/{address}` | Contacts | diff --git a/docs/design/batch-send.md b/docs/design/batch-send.md index 115c11b7c..67cea95bd 100644 --- a/docs/design/batch-send.md +++ b/docs/design/batch-send.md @@ -76,10 +76,10 @@ The sending agent (`from` of every message) is the path agent — no `from` in t { "batch_id": "bat_...", "results": [ - { "message_id": "msg_..." }, - { "message_id": "msg_..." }, - { "suppressed": { "address": "spammy@example.com", "reason": "hard_bounce" } }, - { "message_id": "msg_..." } + { "status": "accepted", "message_id": "msg_..." }, + { "status": "accepted", "message_id": "msg_..." }, + { "status": "suppressed", "suppressed": { "address": "spammy@example.com", "reason": "hard_bounce" } }, + { "status": "accepted", "message_id": "msg_..." } // ... length == len(request.messages) ], "accepted": 98, @@ -88,9 +88,9 @@ The sending agent (`from` of every message) is the path agent — no `from` in t ``` - `batch_id` — durable id, format `bat_<26-char base32 lower>` (same alphabet as `msg_` per project convention). -- `results[]` — **positionally aligned with `request.messages`**. Each slot is a discriminated union: - - **Accepted item**: `{ "message_id": "msg_..." }` — a `messages` row was inserted and a River `outbound_send` job enqueued. - - **Suppressed item**: `{ "suppressed": { "address": "...", "reason": "..." } }` — the (first) recipient address in this item hit the suppression list; no `messages` row exists. `reason` is the suppression-list category (`hard_bounce` / `complaint` / `unsubscribe` / `manual`) as recorded in the `suppressions` table. +- `results[]` — **positionally aligned with `request.messages`**. Each slot is a discriminated union keyed by a required `status` field (`accepted` | `suppressed`), so a client branches on `status` rather than probing which optional field is present: + - **Accepted item** (`status: "accepted"`): `{ "message_id": "msg_..." }` — a `messages` row was inserted and a River `outbound_send` job enqueued. + - **Suppressed item** (`status: "suppressed"`): `{ "suppressed": { "address": "...", "reason": "..." } }` — the (first) recipient address in this item hit the suppression list; no `messages` row exists. `reason` is the suppression-list category (`hard_bounce` / `complaint` / `unsubscribe` / `manual`) as recorded in the `suppressions` table. This shape preserves per-item correlation (the i-th request item produced `results[i]`) while making the caller's success/skip branching explicit. - `accepted` — count of `results[]` entries whose shape is `{ message_id }`. Redundant but useful for logs and metrics. - `suppressed_count` — count of `results[]` entries whose shape is `{ suppressed }`. Also redundant; useful for zero-check without walking `results[]`. @@ -133,6 +133,7 @@ Validation runs **per item** for content-related checks, but any single failure - Same recipient address appears in the `to` set of two different items → 400 `duplicate_recipient` (**not silently deduplicated** — silent dedup would send N-k messages when the caller asked for N and there's no way to know whether the duplicate was intentional; see §14 Q11). Only cross-item duplicates in `to` are checked; duplicates in cc/bcc across items are allowed (matches how real callers cc a common address across items). - Any item exceeds per-item attachment caps (single attachment > 10 MiB, item combined > 25 MiB) → 413 `payload_too_large` with `details.scope = "item"`, `details.item_index` - **Batch-level combined attachment bytes across all items > 25 MiB** → 413 `payload_too_large` with `details.scope = "batch"`, `details.computed_batch_bytes`, `details.max_batch_bytes` (§14 Q15) +- **Aggregate raw request body > 60 MiB** → 413 `payload_too_large` (`maxBatchRequestBodyBytes`, enforced by Huma before handler dispatch). This is a DOCUMENTED aggregate ceiling on the whole base-64/JSON-encoded body, deliberately stricter than the per-item body caps summed over 100 items: the schema permits ~hundreds of MiB in theory, but buffering that per request is an unacceptable memory/DoS surface, so the aggregate is capped and published in the operation description. Callers size against it and split an oversized batch across calls. (Documented per mentor review — the ceiling was previously undocumented, silently rejecting some schema-valid requests.) - Sending domain not verified → 400 `domain_not_verified` - Screening produces a **block** verdict for any item — either that item's content trips a content-scan block, or any recipient in that item's envelope is not in the outbound `allowlist`/`domain` gate under `action=block` → 403 `blocked_by_policy` with `details.item_index`, `details.reason` (§14 Q14 all-or-nothing; block-only agents still reach batch, HITL agents were already refused at §5.1) - Adding N sends would exceed rate limit or plan quota → 429 `rate_limited` / 402 `limit_exceeded` (see §4) @@ -190,7 +191,7 @@ CREATE INDEX batches_user_created_at_idx ON batches (user_id, created_at DESC) CREATE INDEX batches_agent_created_at_idx ON batches (agent_id, created_at DESC); ``` -**Type corrections from the initial draft.** The initial draft referenced `accounts(account_id)` and `agents(agent_id)` with `UUID` FKs. Neither exists in this codebase — the actual ownership chain is `users.id` (TEXT, `usr_...`) → `agent_identities.id` (TEXT, `agt_...`); there is no `accounts` table (the word appears only in the /v1 `AccountView` API resource, which is a projection over users + limits + usage). The schema above uses the real column shapes and reference targets; migration `067_batches.sql` embeds this SQL. +**Type corrections from the initial draft.** The initial draft referenced `accounts(account_id)` and `agents(agent_id)` with `UUID` FKs. Neither exists in this codebase — the actual ownership chain is `users.id` (TEXT, `usr_...`) → `agent_identities.id` (TEXT, `agt_...`); there is no `accounts` table (the word appears only in the /v1 `AccountView` API resource, which is a projection over users + limits + usage). The schema above uses the real column shapes and reference targets; migration `095_batches.sql` embeds this SQL. Rationale for storing `suppressed_json` on the batch row (not per-message): a suppressed item produces NO `messages` row, so there is no other durable place to record the drop. The batch row is the only place that remembers "item i was in your request but we skipped it because address X hit the suppression list." diff --git a/internal/agent/batch.go b/internal/agent/batch.go index 07793f124..50ff6560f 100644 --- a/internal/agent/batch.go +++ b/internal/agent/batch.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/limits" "github.com/tokencanopy/e2a/internal/outbound" ) @@ -125,7 +126,7 @@ func (a *API) DeliverBatch( // of all `to` addresses, partition items into keep[] and suppressed[]. // §2.2: an item is dropped as a whole if ANY of its recipients hits // the suppression list. - suppressionByAddr, err := a.suppressionLookupForBatch(ctx, user.ID, items) + suppressionByAddr, err := a.suppressionLookupForBatch(ctx, user.ID, agent.ID, items) if err != nil { // Fail-open on suppression store errors (matches // checkSuppression's send-time posture) — a suppression store @@ -197,11 +198,51 @@ func (a *API) DeliverBatch( } } + // Account message/storage quota (parity with single-send's + // EnforceMessageSend, which runs after the rate check). Charge every + // ACCEPTED item: len(items) is the post-suppression keep count, so + // suppressed items — dropped, never sent — don't count against the cap + // (§4.3). The count-aware CheckMessageSendN blocks when current month + // usage + N would exceed the cap, rather than only checking for one free + // slot. Enforced inside the idempotency wrapper, like single send. + if a.enforcer != nil && len(items) > 0 { + if err := a.enforcer.CheckMessageSendN(ctx, user.ID, len(items)); err != nil { + if le, ok := limits.IsLimitExceeded(err); ok { + det := map[string]any{ + "resource": le.Resource, + "limit": int64(le.Limit), + "current": int64(le.Current), + } + if le.Limits.PlanCode != "" { + det["plan_code"] = le.Limits.PlanCode + } + if le.Limits.UpgradeURL != "" { + det["upgrade_url"] = le.Limits.UpgradeURL + } + return nil, &OutboundError{ + Status: http.StatusPaymentRequired, + Code: "limit_exceeded", + Msg: le.Error(), + Details: det, + } + } + log.Printf("[api] batch quota check failed: agent=%s error=%v", agent.Domain, err) + return nil, &OutboundError{Status: http.StatusInternalServerError, Code: "internal_error", Msg: "limits check unavailable"} + } + } + // Step 8 & 9 (§9): Per-item compose. Templates were already resolved // at the handler layer (matching single-send's prepare()); Compose // runs recipient normalize + DKIM sign + MIME assembly here. composed := make([]composedBatchItem, len(items)) for i, item := range items { + // Resolve the thread id per item exactly like single send + // (DeliverOutbound → resolveOutboundConversationID): an explicit + // caller id wins, otherwise mint a fresh anchor. Batch items are cold + // sends (no referenced message), so an omitted id is minted here rather + // than copied through empty to persistence — resolved before compose so + // the X-E2A-Conversation-Id header and the stored row share one value. + item.ConversationID = resolveOutboundConversationID(item.ConversationID, "send", nil) comp, cerr := a.sender.ComposeForAccept(agent, item) if cerr != nil { if outbound.IsValidationError(cerr) { @@ -239,6 +280,18 @@ func (a *API) DeliverBatch( Items: make([]BatchAcceptItem, originalCount), } + // Populate the suppressed slots BEFORE the accept-tx runs. idemCompleteTx + // serializes `result` inside the tx (both the all-suppressed early return + // and the normal path below), so the suppressed slots must already be set + // or a keyed replay returns empty message_id entries miscounted as accepted + // instead of the original suppression results. Accepted slots are filled + // inside the tx (before the same idemCompleteTx call). + for _, drop := range suppressedItems { + result.Items[drop.ItemIndex] = BatchAcceptItem{ + Suppressed: &SuppressedInfo{Address: drop.Address, Reason: drop.Reason}, + } + } + if txErr := a.store.WithTx(ctx, func(tx pgx.Tx) error { if err := a.store.CreateBatchTx(ctx, tx, &identity.Batch{ BatchID: batchID, @@ -318,14 +371,6 @@ func (a *API) DeliverBatch( return nil, &OutboundError{Status: http.StatusInternalServerError, Code: "internal_error", Msg: "failed to accept batch for send"} } - // Fill in suppressed slots on result.Items (accepted slots were - // filled inside the tx). - for _, drop := range suppressedItems { - result.Items[drop.ItemIndex] = BatchAcceptItem{ - Suppressed: &SuppressedInfo{Address: drop.Address, Reason: drop.Reason}, - } - } - log.Printf("[batch:%s] agent=%s accepted=%d suppressed=%d", batchID, agent.EmailAddress(), len(composed), len(suppressedItems)) return result, nil } @@ -349,30 +394,34 @@ type suppressedDrop struct { Reason string `json:"reason"` } -// suppressionLookupForBatch queries the suppression list ONCE for the -// union of every batch item's `to` addresses and returns a map of -// address → source. On error the caller is expected to fail-open (see -// DeliverBatch's use-site). -func (a *API) suppressionLookupForBatch(ctx context.Context, userID string, items []outbound.SendRequest) (map[string]string, error) { +// suppressionLookupForBatch queries the suppression list ONCE for the union of +// every batch item's To/CC/BCC addresses and returns a map of address → source. +// It uses the same effective-suppression semantics as single send (account +// suppressions ∪ this agent's agent_suppressions), so the batch response never +// reports an item accepted that the worker would later refuse. On error the +// caller is expected to fail-open (see DeliverBatch's use-site). +func (a *API) suppressionLookupForBatch(ctx context.Context, userID, agentID string, items []outbound.SendRequest) (map[string]string, error) { all := make([]string, 0) seen := map[string]struct{}{} for _, item := range items { - for _, addr := range item.To { - if _, ok := seen[addr]; ok { - continue + for _, addrs := range [][]string{item.To, item.CC, item.BCC} { + for _, addr := range addrs { + if _, ok := seen[addr]; ok { + continue + } + seen[addr] = struct{}{} + all = append(all, addr) } - seen[addr] = struct{}{} - all = append(all, addr) } } if len(all) == 0 { return map[string]string{}, nil } - return a.store.SuppressedAddressesWithSource(ctx, userID, all) + return a.store.EffectiveSuppressionsWithSource(ctx, userID, agentID, all) } // partitionSuppressed splits items into keep[] (unaffected) and dropped[] -// (any `to` address in the suppression map). Returns: +// (any To/CC/BCC address in the suppression map). Returns: // - keep: the subset of items that proceed // - keepIndexes: original positions in the input, positionally aligned // with keep (keep[i] came from items[keepIndexes[i]]) @@ -392,15 +441,20 @@ func partitionSuppressed(items []outbound.SendRequest, suppressionByAddr map[str return keep, keepIndexes, nil } for i, item := range items { - var hit string - for _, addr := range item.To { - if _, ok := suppressionByAddr[identity.NormalizeEmail(addr)]; ok { - hit = addr + var hit, hitReason string + for _, addrs := range [][]string{item.To, item.CC, item.BCC} { + for _, addr := range addrs { + if reason, ok := suppressionByAddr[identity.NormalizeMailboxAddress(addr)]; ok { + hit, hitReason = addr, reason + break + } + } + if hit != "" { break } } if hit != "" { - reason := suppressionByAddr[identity.NormalizeEmail(hit)] + reason := hitReason if reason == "" { reason = "manual" } diff --git a/internal/agent/batch_test.go b/internal/agent/batch_test.go index a52d4fe24..42094faae 100644 --- a/internal/agent/batch_test.go +++ b/internal/agent/batch_test.go @@ -3,10 +3,12 @@ package agent_test import ( "context" "encoding/json" + "strings" "testing" "github.com/jackc/pgx/v5" + "github.com/tokencanopy/e2a/internal/agent" "github.com/tokencanopy/e2a/internal/identity" "github.com/tokencanopy/e2a/internal/outbound" ) @@ -196,6 +198,137 @@ func TestDeliverBatch_AllSuppressed(t *testing.T) { } } +// TestDeliverBatch_ConversationIDResolvedPerItem: each item runs through the +// same conversation-id resolution as single send — an omitted id is minted (a +// fresh "conv_" anchor persisted to the row, never left empty), a caller- +// provided id is preserved verbatim. +func TestDeliverBatch_ConversationIDResolvedPerItem(t *testing.T) { + api, store, _, _ := setupAsyncAPI(t) + ctx := context.Background() + user, ag := selfAgent(t, store, "batchconv") + + const callerID = "conv_caller_supplied" + items := []outbound.SendRequest{ + {From: ag.EmailAddress(), To: []string{"alice@gmail.com"}, Subject: "omitted", Body: "a"}, + {From: ag.EmailAddress(), To: []string{"bob@gmail.com"}, Subject: "provided", Body: "b", ConversationID: callerID}, + } + res, oerr := api.DeliverBatch(ctx, user, ag, items, nil) + if oerr != nil { + t.Fatalf("DeliverBatch: %+v", oerr) + } + if len(res.Items) != 2 || res.Items[0].MessageID == "" || res.Items[1].MessageID == "" { + t.Fatalf("expected 2 accepted items: %+v", res.Items) + } + + readConvID := func(msgID string) string { + var cid string + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, `SELECT conversation_id FROM messages WHERE id=$1`, msgID).Scan(&cid) + }); err != nil { + t.Fatalf("read conversation_id for %s: %v", msgID, err) + } + return cid + } + + // Omitted → minted anchor (non-empty, "conv_" prefixed), not left empty. + if got := readConvID(res.Items[0].MessageID); got == "" || !strings.HasPrefix(got, "conv_") { + t.Errorf("omitted item conversation_id = %q, want a minted conv_ id", got) + } + // Caller-provided → preserved verbatim. + if got := readConvID(res.Items[1].MessageID); got != callerID { + t.Errorf("provided item conversation_id = %q, want %q", got, callerID) + } +} + +// TestDeliverBatch_IdempotencySerializesSuppressedSlots_Mixed: the idempotency +// completer runs INSIDE the accept-tx and serializes result.Items into the +// cached replay body. This regression-guards the ordering bug where suppressed +// slots were filled only AFTER the tx — a keyed replay would then have returned +// empty message_id entries (miscounted as accepted) for the dropped items +// instead of their suppression results. The spy completer captures exactly what +// idempotency would serialize; both accepted and suppressed slots must be set. +func TestDeliverBatch_IdempotencySerializesSuppressedSlots_Mixed(t *testing.T) { + api, store, _, _ := setupAsyncAPI(t) + ctx := context.Background() + user, ag := selfAgent(t, store, "batchidemmix") + + if _, err := store.AddSuppression(ctx, user.ID, "bounced@gmail.com", "hard bounce", "bounce", ""); err != nil { + t.Fatalf("AddSuppression: %v", err) + } + + items := []outbound.SendRequest{ + {From: ag.EmailAddress(), To: []string{"good1@gmail.com"}, Subject: "a", Body: "a"}, + {From: ag.EmailAddress(), To: []string{"bounced@gmail.com"}, Subject: "b", Body: "b"}, + {From: ag.EmailAddress(), To: []string{"good2@gmail.com"}, Subject: "c", Body: "c"}, + } + + var serialized []agent.BatchAcceptItem + spy := func(ctx context.Context, tx pgx.Tx, result *agent.BatchAcceptResult) error { + // Snapshot the slots as idempotency sees them at serialization time. + serialized = append([]agent.BatchAcceptItem(nil), result.Items...) + return nil + } + + if _, oerr := api.DeliverBatch(ctx, user, ag, items, spy); oerr != nil { + t.Fatalf("DeliverBatch: %+v", oerr) + } + if len(serialized) != 3 { + t.Fatalf("serialized items = %d, want 3", len(serialized)) + } + if serialized[1].Suppressed == nil { + t.Error("item 1 serialized without suppression → a replay would report it accepted with an empty message_id") + } + if serialized[1].MessageID != "" { + t.Errorf("suppressed item 1 has message_id %q, want empty", serialized[1].MessageID) + } + if serialized[0].MessageID == "" || serialized[2].MessageID == "" { + t.Error("accepted slots (0,2) not populated when idempotency serialized the result") + } +} + +// TestDeliverBatch_IdempotencySerializesSuppressedSlots_AllSuppressed: the +// all-suppressed path takes the early return inside the tx and still completes +// idempotency; every slot must be serialized as suppressed (never an empty +// accepted slot) so a replay of an all-suppressed batch is faithful. +func TestDeliverBatch_IdempotencySerializesSuppressedSlots_AllSuppressed(t *testing.T) { + api, store, _, _ := setupAsyncAPI(t) + ctx := context.Background() + user, ag := selfAgent(t, store, "batchidemall") + + if _, err := store.AddSuppression(ctx, user.ID, "a@gmail.com", "", "complaint", ""); err != nil { + t.Fatalf("AddSuppression a: %v", err) + } + if _, err := store.AddSuppression(ctx, user.ID, "b@gmail.com", "", "manual", ""); err != nil { + t.Fatalf("AddSuppression b: %v", err) + } + + items := []outbound.SendRequest{ + {From: ag.EmailAddress(), To: []string{"a@gmail.com"}, Subject: "a", Body: "a"}, + {From: ag.EmailAddress(), To: []string{"b@gmail.com"}, Subject: "b", Body: "b"}, + } + + var serialized []agent.BatchAcceptItem + spy := func(ctx context.Context, tx pgx.Tx, result *agent.BatchAcceptResult) error { + serialized = append([]agent.BatchAcceptItem(nil), result.Items...) + return nil + } + + if _, oerr := api.DeliverBatch(ctx, user, ag, items, spy); oerr != nil { + t.Fatalf("DeliverBatch: %+v", oerr) + } + if len(serialized) != 2 { + t.Fatalf("serialized items = %d, want 2", len(serialized)) + } + for i, item := range serialized { + if item.Suppressed == nil { + t.Errorf("item %d serialized without suppression → replay would report it accepted", i) + } + if item.MessageID != "" { + t.Errorf("suppressed item %d has message_id %q, want empty", i, item.MessageID) + } + } +} + // TestDeliverBatch_HITLAgentRefused: an agent with an outbound review gate is // refused batch send outright (§5.1). No batch/message rows are created. func TestDeliverBatch_HITLAgentRefused(t *testing.T) { @@ -204,7 +337,7 @@ func TestDeliverBatch_HITLAgentRefused(t *testing.T) { user, ag := selfAgent(t, store, "batchhitl") // Turn on an outbound review gate — makes agentUsesHITL true. - if err := store.UpdateAgentProtection(ctx, ag.ID, user.ID, identity.ProtectionConfig{ + if _, err := store.UpdateAgentProtection(ctx, ag.ID, user.ID, identity.ProtectionConfig{ InboundGatePolicy: identity.OutboundPolicyOpen, InboundGateAction: "flag", InboundScanSensitivity: "off", diff --git a/internal/delivery/consumer.go b/internal/delivery/consumer.go index b131a0b5c..21ce4a333 100644 --- a/internal/delivery/consumer.go +++ b/internal/delivery/consumer.go @@ -30,6 +30,11 @@ type CorrelatedMessage struct { To []string CC []string BCC []string + // BatchID is the batch this message was submitted under, empty for single + // sends. Propagated onto the delivery-feedback event payloads so batch + // correlation survives past provider acceptance (email.delivered/bounced/ + // complained), matching email.sent/email.failed. + BatchID string } // Store is the narrow persistence surface the consumer needs. *identity.Store @@ -411,6 +416,7 @@ func deliveryEventData(evType string, m *CorrelatedMessage, ev *Event, r Recipie SMTPDetail: r.Detail, BounceType: bounceType, BounceSubType: ev.BounceSubType, + BatchID: m.BatchID, LifecycleTransitions: transitions, } case EventEmailComplained: @@ -421,6 +427,7 @@ func deliveryEventData(evType string, m *CorrelatedMessage, ev *Event, r Recipie DeliveredTo: r.Address, Subject: m.Subject, SMTPDetail: r.Detail, + BatchID: m.BatchID, LifecycleTransitions: transitions, } default: // EventEmailDelivered @@ -431,6 +438,7 @@ func deliveryEventData(evType string, m *CorrelatedMessage, ev *Event, r Recipie DeliveredTo: r.Address, Subject: m.Subject, SMTPDetail: r.Detail, + BatchID: m.BatchID, LifecycleTransitions: transitions, } } @@ -456,6 +464,7 @@ func failedEventData(m *CorrelatedMessage, reason string, transitions ...message BCC: m.BCC, Subject: m.Subject, MessageType: m.MessageType, + BatchID: m.BatchID, Reason: reason, ReasonCode: string(messagelifecycle.ReasonSubmissionProviderRejected), Retryable: &retryable, diff --git a/internal/delivery/consumer_test.go b/internal/delivery/consumer_test.go index 557f4015f..e942b68da 100644 --- a/internal/delivery/consumer_test.go +++ b/internal/delivery/consumer_test.go @@ -113,6 +113,75 @@ func recordingFirer() (Firer, *[]firedEvent) { return f, &events } +// payloadBatchID extracts batch_id from any of the outbound event payloads +// that carry it, so the propagation test can assert one field across the four +// feedback outcomes without a type switch at each call site. +func payloadBatchID(data any) (string, bool) { + switch d := data.(type) { + case eventpayload.EmailDeliveredData: + return d.BatchID, true + case eventpayload.EmailBouncedData: + return d.BatchID, true + case eventpayload.EmailComplainedData: + return d.BatchID, true + case eventpayload.EmailFailedData: + return d.BatchID, true + } + return "", false +} + +// TestConsumerPropagatesBatchID pins that batch correlation survives PAST +// provider acceptance: every delivery-feedback event for a batch-originated +// message carries the same batch_id as email.sent/email.failed, so a subscriber +// can group delivered/bounced/complained/failed outcomes back to the batch +// call. The empty-BatchID (single-send) case omits it — locked by the golden +// .min.json fixtures. +func TestConsumerPropagatesBatchID(t *testing.T) { + const batchID = "bat_feedbackcorrelation0001" + cases := []struct { + name string + ev *Event + wantType string + }{ + {"delivered", &Event{ProviderEventID: "sns-test", OccurredAt: testFeedbackOccurredAt, Kind: KindDelivery, SESMessageID: "ses-b", + Recipients: []RecipientOutcome{{Address: "a@x.com", Status: StatusDelivered}}}, EventEmailDelivered}, + {"bounced", &Event{ProviderEventID: "sns-test", OccurredAt: testFeedbackOccurredAt, Kind: KindBounce, SESMessageID: "ses-b", BounceType: "permanent", + Recipients: []RecipientOutcome{{Address: "a@x.com", Status: StatusBounced}}}, EventEmailBounced}, + {"complained", &Event{ProviderEventID: "sns-test", OccurredAt: testFeedbackOccurredAt, Kind: KindComplaint, SESMessageID: "ses-b", + Recipients: []RecipientOutcome{{Address: "a@x.com", Status: StatusComplained}}}, EventEmailComplained}, + {"reject->failed", &Event{ProviderEventID: "sns-test", OccurredAt: testFeedbackOccurredAt, Kind: KindReject, SESMessageID: "ses-b", + Recipients: []RecipientOutcome{{Address: "a@x.com", Status: StatusFailed, Detail: "Bad content"}}}, EventEmailFailed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := newFakeConsumerStore() + store.corr["ses-b"] = &CorrelatedMessage{MessageID: "msg_b", UserID: "u_b", AgentID: "bot@x.com", BatchID: batchID} + fire, events := recordingFirer() + c := NewConsumer(store, fire) + if err := c.Process(context.Background(), tc.ev); err != nil { + t.Fatal(err) + } + var found bool + for _, e := range *events { + if e.eventType != tc.wantType { + continue + } + got, ok := payloadBatchID(e.data) + if !ok { + t.Fatalf("%s payload is not a batch_id-carrying type: %T", tc.wantType, e.data) + } + if got != batchID { + t.Errorf("%s batch_id = %q, want %q propagated from the correlated message", tc.wantType, got, batchID) + } + found = true + } + if !found { + t.Fatalf("no %s event fired", tc.wantType) + } + }) + } +} + func TestConsumerProcess(t *testing.T) { t.Run("uncorrelated message is a no-op ack", func(t *testing.T) { store := newFakeConsumerStore() @@ -539,12 +608,13 @@ func TestConsumerGoldenPayloads(t *testing.T) { userID = "user_7a6b5c4d" agent = "support@agents.example.com" subject = "Re: Order #1234 delayed" + batchID = "bat_9c8b7a6d5e4f3a2b1c0d9e8f" fixture = "../eventpayload/testdata/" ) fireGolden := func(sesEvent *Event) *[]firedEvent { t.Helper() store := newFakeConsumerStore() - store.corr["ses-golden"] = &CorrelatedMessage{MessageID: msgID, UserID: userID, AgentID: agent, Subject: subject} + store.corr["ses-golden"] = &CorrelatedMessage{MessageID: msgID, UserID: userID, AgentID: agent, Subject: subject, BatchID: batchID} fire, events := recordingFirer() c := NewConsumer(store, fire) if err := c.Process(context.Background(), sesEvent); err != nil { diff --git a/internal/eventpayload/golden_test.go b/internal/eventpayload/golden_test.go index b42101061..fc73e61b3 100644 --- a/internal/eventpayload/golden_test.go +++ b/internal/eventpayload/golden_test.go @@ -171,6 +171,7 @@ func canonicalEvents() []struct { Direction: "outbound", DeliveredTo: "alice@customer.example.com", Subject: "Re: Order #1234 delayed", + BatchID: "bat_9c8b7a6d5e4f3a2b1c0d9e8f", LifecycleTransitions: []messagelifecycle.MessageLifecycleTransition{deliveredTransition}, // smtp_detail omitted: SES Delivery notifications carry no // per-recipient diagnostic. @@ -192,6 +193,7 @@ func canonicalEvents() []struct { SMTPDetail: "550 5.1.1 no such user", BounceType: "permanent", BounceSubType: "General", + BatchID: "bat_9c8b7a6d5e4f3a2b1c0d9e8f", LifecycleTransitions: []messagelifecycle.MessageLifecycleTransition{bouncedTransition}, }, }, @@ -209,6 +211,7 @@ func canonicalEvents() []struct { DeliveredTo: "carol@customer.example.com", Subject: "Re: Order #1234 delayed", SMTPDetail: "abuse", + BatchID: "bat_9c8b7a6d5e4f3a2b1c0d9e8f", LifecycleTransitions: []messagelifecycle.MessageLifecycleTransition{complainedTransition}, }, }, diff --git a/internal/eventpayload/payloads.go b/internal/eventpayload/payloads.go index 241176d94..50aa789fa 100644 --- a/internal/eventpayload/payloads.go +++ b/internal/eventpayload/payloads.go @@ -173,7 +173,12 @@ type EmailDeliveredData struct { Subject string `json:"subject,omitempty"` // SMTPDetail is the provider diagnostic string (e.g. the remote SMTP // response), when the feedback notification carried one. - SMTPDetail string `json:"smtp_detail,omitempty"` + SMTPDetail string `json:"smtp_detail,omitempty"` + // BatchID correlates this delivery outcome to the batch the message was + // submitted under (POST /v1/agents/{email}/batches), matching email.sent / + // email.failed so correlation survives past provider acceptance. Omitted + // for single sends (docs/design/batch-send.md §7.3). + BatchID string `json:"batch_id,omitempty"` LifecycleTransitions []messagelifecycle.MessageLifecycleTransition `json:"lifecycle_transitions,omitempty" nullable:"false"` } @@ -199,7 +204,11 @@ type EmailBouncedData struct { BounceType string `json:"bounce_type" enum:"permanent,transient,undetermined"` // BounceSubType is the raw SES bounceSubType (e.g. General, NoEmail, // MailboxFull), when present. - BounceSubType string `json:"bounce_sub_type,omitempty"` + BounceSubType string `json:"bounce_sub_type,omitempty"` + // BatchID correlates this bounce to the batch the message was submitted + // under (POST /v1/agents/{email}/batches), matching email.sent / + // email.failed. Omitted for single sends (docs/design/batch-send.md §7.3). + BatchID string `json:"batch_id,omitempty"` LifecycleTransitions []messagelifecycle.MessageLifecycleTransition `json:"lifecycle_transitions,omitempty" nullable:"false"` } @@ -208,12 +217,16 @@ type EmailBouncedData struct { // Same shape as EmailDeliveredData; SMTPDetail carries the complaint // feedback type when present. type EmailComplainedData struct { - MessageID string `json:"message_id"` - AgentEmail string `json:"agent_email"` - Direction string `json:"direction" doc:"Always \"outbound\" on this event."` - DeliveredTo string `json:"delivered_to" doc:"The one recipient address this per-recipient outcome is about."` - Subject string `json:"subject,omitempty"` - SMTPDetail string `json:"smtp_detail,omitempty"` + MessageID string `json:"message_id"` + AgentEmail string `json:"agent_email"` + Direction string `json:"direction" doc:"Always \"outbound\" on this event."` + DeliveredTo string `json:"delivered_to" doc:"The one recipient address this per-recipient outcome is about."` + Subject string `json:"subject,omitempty"` + SMTPDetail string `json:"smtp_detail,omitempty"` + // BatchID correlates this complaint to the batch the message was submitted + // under (POST /v1/agents/{email}/batches), matching email.sent / + // email.failed. Omitted for single sends (docs/design/batch-send.md §7.3). + BatchID string `json:"batch_id,omitempty"` LifecycleTransitions []messagelifecycle.MessageLifecycleTransition `json:"lifecycle_transitions,omitempty" nullable:"false"` } diff --git a/internal/httpapi/batch.go b/internal/httpapi/batch.go index 0f95a0612..9a1567898 100644 --- a/internal/httpapi/batch.go +++ b/internal/httpapi/batch.go @@ -50,12 +50,27 @@ type SendBatchRequest struct { ReplyTo string `json:"reply_to,omitempty" maxLength:"320" doc:"Optional batch-level Reply-To default. Applied to any item that leaves reply_to empty; a per-item value always wins. This is the ONLY batch-level default in MVP; every other field is per-item (docs/design/batch-send.md §1.2)."` } -// BatchResult is one slot in the results[] array. Discriminated: -// - Accepted item: MessageID non-empty, Suppressed absent. -// - Suppressed item: MessageID empty, Suppressed populated. +// Batch result slot discriminator values. status is always populated so a +// client branches on one required field rather than inferring the slot shape +// from which optional field happens to be present (mentor review — the schema +// otherwise permits neither/both). +const ( + batchResultAccepted = "accepted" + batchResultSuppressed = "suppressed" +) + +// BatchResult is one slot in the results[] array, discriminated by status: +// - status "accepted": MessageID set, Suppressed absent. +// - status "suppressed": Suppressed set, MessageID absent. type BatchResult struct { - MessageID string `json:"message_id,omitempty" doc:"Minted message id when the item was accepted (delivery_status='accepted' persisted and River outbound_send job enqueued). Absent when Suppressed is present."` - Suppressed *BatchSuppressedResult `json:"suppressed,omitempty" doc:"Present when the item was dropped by the suppression-list filter (docs/design/batch-send.md §2.2). No message row exists for a suppressed slot; the caller can un-suppress via DELETE /v1/account/suppressions/{address} and resubmit."` + // Status is deliberately a CLOSED enum despite being response-side: every + // slot is by construction exactly one of accepted|suppressed — a binary + // invariant of the discriminated union, not an evolving vocabulary (same + // rationale as MessageView.direction). Allowlisted in + // closedResponseEnumAllowlist (response_enum_stance_test.go). + Status string `json:"status" enum:"accepted,suppressed" doc:"Slot discriminator, always present: \"accepted\" (message_id is set) or \"suppressed\" (suppressed is set). Branch on this rather than inferring the slot shape from which optional field is populated."` + MessageID string `json:"message_id,omitempty" doc:"Minted message id when the item was accepted (delivery_status='accepted' persisted and River outbound_send job enqueued). Present iff status is \"accepted\"."` + Suppressed *BatchSuppressedResult `json:"suppressed,omitempty" doc:"Present iff status is \"suppressed\": the item was dropped by the suppression-list filter (docs/design/batch-send.md §2.2). No message row exists for a suppressed slot; the caller can un-suppress via DELETE /v1/account/suppressions/{address} and resubmit."` } // BatchSuppressedResult describes one dropped item — the first @@ -64,8 +79,8 @@ type BatchResult struct { // (the durable record stored in batches.suppressed_json) minus the // item_index which is implicit in results[]'s position. type BatchSuppressedResult struct { - Address string `json:"address" doc:"The recipient address in this item's to list that triggered the drop. If multiple addresses in the same item are suppressed, only the first is surfaced (dropping the whole item is enough signal)."` - Reason string `json:"reason" doc:"The suppression-list category from suppressions.source. Known values: bounce, complaint, manual. Open set — treat as string and tolerate unknown values."` + Address string `json:"address" doc:"The recipient address in this item's to/cc/bcc envelope that triggered the drop. If multiple addresses in the same item are suppressed, only the first is surfaced (dropping the whole item is enough signal)."` + Reason string `json:"reason" doc:"The suppression category. Known values: bounce, complaint, manual (account-level, from suppressions.source), unsubscribe (agent-level, from agent_suppressions.source). Open set — treat as string and tolerate unknown values."` } // SendBatchResponse is the 202 body of POST /v1/agents/{email}/batches. @@ -74,7 +89,7 @@ type BatchSuppressedResult struct { // without walking Results. type SendBatchResponse struct { BatchID string `json:"batch_id" doc:"Durable id for this batch (bat_). Use GET /v1/batches/{batch_id} to retrieve the header + status rollup."` - Results []BatchResult `json:"results" nullable:"false" doc:"One slot per request item, positionally aligned. Each slot is either {message_id} (accepted) or {suppressed:{address,reason}} (dropped by the suppression filter)."` + Results []BatchResult `json:"results" nullable:"false" doc:"One slot per request item, positionally aligned. Each slot carries a status discriminator: status=\"accepted\" → {message_id}; status=\"suppressed\" → {suppressed:{address,reason}} (dropped by the suppression filter)."` Accepted int `json:"accepted" doc:"Count of results[] slots that are {message_id}. Redundant with results[] but convenient for logging + zero-check."` SuppressedCount int `json:"suppressed_count" doc:"Count of results[] slots that are {suppressed}. Zero when no per-item drops occurred."` } @@ -104,14 +119,18 @@ type sendBatchOutput struct { // validateOutboundBody / validateAttachments). const maxBatchAttachmentBytes = 25 * 1024 * 1024 // 25 MiB -// maxBatchRequestBodyBytes bounds the raw HTTP request body Huma will -// accept before returning 413 payload_too_large. Sized to comfortably -// fit 100 items at the per-item body caps (subject 2 KiB + text 1 MiB + -// html 1 MiB) plus the batch attachment ceiling (25 MiB) + overhead. -// Base-64 attachment encoding is 4/3 the byte size, so 25 MiB of -// attachments occupy ~33.3 MiB on the wire; add ~5 MiB per item × 100 = -// 500 MiB overhead theoretical max, but real batches don't put max -// bodies on every item — settle for 60 MiB. +// maxBatchRequestBodyBytes is the DOCUMENTED aggregate request-body ceiling: +// Huma rejects a raw HTTP body larger than this with 413 payload_too_large. +// +// It is deliberately an aggregate constraint SEPARATE from — and stricter in +// aggregate than — the per-item schema bounds. The schema permits up to 100 +// items each with independently bounded text (1 MiB) + html (1 MiB) + a 25 MiB +// batch-wide attachment ceiling, whose theoretical sum (~hundreds of MiB once +// base-64 and JSON escaping are counted) would force us to buffer an +// implausibly large request in memory. Rather than accept that DoS surface, we +// cap the aggregate and PUBLISH the cap in the operation description (and the +// 413 response) so callers can size requests predictably and split an +// oversized batch. Keep this constant in sync with that documented number. const maxBatchRequestBodyBytes = 60 * 1024 * 1024 // 60 MiB // batchAccepted202 returns the 202 response schema declaration for @@ -122,17 +141,23 @@ func (s *Server) batchAccepted202() *huma.Response { } // registerSendBatch is called from the API constructor to wire the +// batchSendBetaDescription is the shared beta-status sentence stamped on both +// batch operations' descriptions, so the whole batch-send surface stays outside +// the /v1 stability freeze (mentor review — keep the MVP evolvable). +const batchSendBetaDescription = "Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable." + // sendBatch operation. Kept as a method-on-Server to match the other // per-endpoint register* helpers in outbound.go. func (s *Server) registerSendBatch() { - huma.Register(s.API, huma.Operation{ + registerOp(s.API, huma.Operation{ OperationID: "sendBatch", Method: http.MethodPost, Path: "/v1/agents/{email}/batches", - Summary: "Send a batch of up to 100 emails", + Summary: "Send a batch of up to 100 emails (beta)", Tags: []string{"messages"}, - Description: "Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract.\n\nMVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant.", + Description: "Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract.\n\nMVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant.\n\nAggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item's content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls.\n\n" + batchSendBetaDescription, Security: []map[string][]string{{"bearer": {}}}, + Extensions: beta(), MaxBodyBytes: maxBatchRequestBodyBytes, Responses: map[string]*huma.Response{ "202": s.batchAccepted202(), @@ -148,14 +173,15 @@ func (s *Server) registerSendBatch() { }, }, s.handleSendBatch) - huma.Register(s.API, huma.Operation{ + registerOp(s.API, huma.Operation{ OperationID: "getBatch", Method: http.MethodGet, Path: "/v1/batches/{batch_id}", - Summary: "Get a batch's header and delivery-status rollup", + Summary: "Get a batch's header and delivery-status rollup (beta)", Tags: []string{"messages"}, - Description: "Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch's child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found.", + Description: "Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch's child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found.\n\n" + batchSendBetaDescription, Security: []map[string][]string{{"bearer": {}}}, + Extensions: beta(), Responses: map[string]*huma.Response{ "200": s.jsonResponse(reflect.TypeOf(BatchView{}), "BatchView", "The batch header + per-status rollup."), "404": s.errorEnvelopeResponse(), @@ -313,6 +339,14 @@ func (s *Server) handleSendBatch(ctx context.Context, in *sendBatchInput) (*send // delegates to agent.API.DeliverBatch for the accept-tx. Extracted from // handleSendBatch so the idempotency wrapper can invoke it as a closure. func (s *Server) deliverBatch(ctx context.Context, user *identity.User, ag *identity.AgentIdentity, body SendBatchRequest, idemKey string) (int, SendBatchResponse, error) { + // Domain verification (parity with single send). The sending agent is + // shared across the whole batch, so check once — an unverified domain + // fails the entire batch with 403 domain_not_verified, exactly like + // single send refuses a single unverified send. + if !ag.DomainVerified { + return 0, SendBatchResponse{}, NewError(http.StatusForbidden, "domain_not_verified", "agent domain must be verified before sending") + } + items := make([]outbound.SendRequest, len(body.Messages)) for i := range body.Messages { bm := body.Messages[i] @@ -344,6 +378,19 @@ func (s *Server) deliverBatch(ctx context.Context, user *identity.User, ag *iden return 0, SendBatchResponse{}, envelopeWithItemIndex(env, i) } + // Per-item attachment + composed-size checks (parity with single + // send's deliver()). validateAttachments enforces the per-item + // attachment contract (single ≤ 10 MiB, item combined ≤ 25 MiB, + // decodable base64); composedMessageSizeError enforces the composed + // ceiling HERE so an oversized item returns the documented 413 with + // item_index rather than a generic 500 raised later at compose time. + if env := validateAttachments(asSend.Attachments); env != nil { + return 0, SendBatchResponse{}, envelopeWithItemIndex(env, i) + } + if env := composedMessageSizeError(asSend.Subject, asSend.Body, asSend.HTMLBody, asSend.Attachments); env != nil { + return 0, SendBatchResponse{}, envelopeWithItemIndex(env, i) + } + items[i] = outbound.SendRequest{ From: ag.EmailAddress(), To: asSend.To, @@ -407,14 +454,14 @@ func sendBatchResponseFromAcceptResult(r *agent.BatchAcceptResult) SendBatchResp resp := SendBatchResponse{BatchID: r.BatchID, Results: make([]BatchResult, len(r.Items))} for i, item := range r.Items { if item.Suppressed != nil { - resp.Results[i] = BatchResult{Suppressed: &BatchSuppressedResult{ + resp.Results[i] = BatchResult{Status: batchResultSuppressed, Suppressed: &BatchSuppressedResult{ Address: item.Suppressed.Address, Reason: item.Suppressed.Reason, }} resp.SuppressedCount++ continue } - resp.Results[i] = BatchResult{MessageID: item.MessageID} + resp.Results[i] = BatchResult{Status: batchResultAccepted, MessageID: item.MessageID} resp.Accepted++ } return resp diff --git a/internal/httpapi/batch_test.go b/internal/httpapi/batch_test.go index 8187d958a..9475ed6d2 100644 --- a/internal/httpapi/batch_test.go +++ b/internal/httpapi/batch_test.go @@ -155,14 +155,17 @@ func TestSendBatch_BatchAttachmentSumExceeded(t *testing.T) { }))) t.Cleanup(srv.Close) - // Two items, each with a ~15 MiB attachment → 30 MiB total > 25 MiB cap. - // Each item is individually under the per-item 25 MiB cap. - blob := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("A"), 15*1024*1024)) + // Three items, each with a 9 MiB attachment → 27 MiB total > 25 MiB batch + // cap. Each item is individually valid (9 MiB < the 10 MiB single-attachment + // cap AND < the 25 MiB per-item combined cap), so per-item validateAttachments + // passes and the failure is specifically the batch-wide SUM cap. + blob := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("A"), 9*1024*1024)) att := []map[string]any{{"filename": "big.bin", "content_type": "application/octet-stream", "data": blob}} status, out := postBatch(t, srv, map[string]any{ "messages": []map[string]any{ {"to": []string{"a@x.com"}, "subject": "a", "text": "a", "attachments": att}, {"to": []string{"b@x.com"}, "subject": "b", "text": "b", "attachments": att}, + {"to": []string{"c@x.com"}, "subject": "c", "text": "c", "attachments": att}, }, }, "") if status != http.StatusRequestEntityTooLarge { diff --git a/internal/httpapi/response_enum_stance_test.go b/internal/httpapi/response_enum_stance_test.go index 1940eaafa..5692fda2b 100644 --- a/internal/httpapi/response_enum_stance_test.go +++ b/internal/httpapi/response_enum_stance_test.go @@ -31,6 +31,7 @@ import ( // state which of the three categories the field falls into, and record it in a // comment on the struct tag. var closedResponseEnumAllowlist = map[string]string{ + "BatchResult.status": "binary invariant of the discriminated union (every slot is exactly accepted or suppressed)", "EmailBouncedData.bounce_type": "normalized exhaustive classification (undetermined is the guaranteed catch-all)", "MessageSummaryView.direction": "binary invariant of the model", "MessageView.direction": "binary invariant of the model", diff --git a/internal/httpapi/stability.go b/internal/httpapi/stability.go index 022ec2258..844af6c38 100644 --- a/internal/httpapi/stability.go +++ b/internal/httpapi/stability.go @@ -244,6 +244,14 @@ func (s *Server) applyEvolutionStance() { for _, schema := range []string{"HoldReasonView", "ProtectionFindingView", "ThreatCategoryView"} { markSchema(schemas, schema, extStabilityLevel, stabilityBeta) } + // Batch send is a beta feature. Its own operations (sendBatch, getBatch) + // declare beta at registration, so their exclusive schemas inherit the + // marker above. The batch_id correlation field, however, rides on the STABLE + // outbound event payloads — mark it beta per-field so the parent event + // schemas stay stable while batch remains evolvable (mentor review). + for _, schema := range []string{"EmailSentData", "EmailFailedData", "EmailDeliveredData", "EmailBouncedData", "EmailComplainedData"} { + markProperty(schemas, schema, "batch_id", extStabilityLevel, stabilityBeta) + } // ErrorBody.code is a stable open discriminator; only the outbound // gate-policy value remains experimental. markProperty(schemas, "ErrorBody", "code", extExperimentalValues, []string{"blocked_by_policy"}) diff --git a/internal/httpapi/stability_test.go b/internal/httpapi/stability_test.go index dfdb3dd0f..9318f37e8 100644 --- a/internal/httpapi/stability_test.go +++ b/internal/httpapi/stability_test.go @@ -25,6 +25,7 @@ var betaOperationIDs = []string{ "getAccountMetrics", "getAgentMetrics", "getAgentProtection", + "getBatch", "getContact", "getEngagement", "importContacts", @@ -40,6 +41,7 @@ var betaOperationIDs = []string{ "listTemplates", "putAgentProtection", "rejectReview", + "sendBatch", "updateContact", "updateTemplate", "upsertEngagement", @@ -328,6 +330,16 @@ func TestSpecBetaMarkers(t *testing.T) { if desc, _ := lifecycleOp["description"].(string); !strings.Contains(desc, "Beta: message lifecycle") { t.Errorf("getMessageLifecycle description must describe its beta status, got %q", desc) } + const batchBetaSentence = "Beta: the batch-send surface" + for _, id := range []string{"sendBatch", "getBatch"} { + op := opFor(id) + if summary, _ := op["summary"].(string); !strings.Contains(summary, "(beta)") { + t.Errorf("%s summary must visibly say (beta), got %q", id, summary) + } + if desc, _ := op["description"].(string); !strings.Contains(desc, batchBetaSentence) { + t.Errorf("%s description must contain the batch beta sentence, got %q", id, desc) + } + } for _, id := range []string{"sendMessage", "replyToMessage", "forwardMessage", "listSuppressions", "deleteSuppression", "createAgent", "listMessages", "createWebhook", "listEvents", "deleteMessage", "restoreMessage", "restoreAgent", "deleteAgent"} { if got := opExt(id, "x-stability"); got != nil { t.Errorf("%s is stable GA surface and must NOT carry x-stability, got %v", id, got) @@ -365,7 +377,10 @@ func TestSpecBetaMarkers(t *testing.T) { } return sc[extension] } - for _, name := range []string{"AgentSuppressionView", "CreateAgentSuppressionRequest", "PageAgentSuppressionView", "UnsubscribeOptions", "TemplateView", "CreateTemplateRequest", "StarterTemplateView", "ProtectionConfigView", "ProtectionConfigRequest", "ReviewView", "PageReviewView", "ApproveRequest", "RejectRequest", "RejectResultView", "HoldReasonView", "ProtectionFindingView", "ThreatCategoryView", "MessageLifecycleTransition", "PageMessageLifecycleTransition"} { + for _, name := range []string{"AgentSuppressionView", "CreateAgentSuppressionRequest", "PageAgentSuppressionView", "UnsubscribeOptions", "TemplateView", "CreateTemplateRequest", "StarterTemplateView", "ProtectionConfigView", "ProtectionConfigRequest", "ReviewView", "PageReviewView", "ApproveRequest", "RejectRequest", "RejectResultView", "HoldReasonView", "ProtectionFindingView", "ThreatCategoryView", "MessageLifecycleTransition", "PageMessageLifecycleTransition", + // Batch send is beta: its exclusive schemas inherit the marker from the + // beta sendBatch/getBatch operations (they must never enter the /v1 freeze). + "SendBatchRequest", "SendBatchResponse", "BatchMessage", "BatchResult", "BatchSuppressedResult", "BatchView", "BatchStatusRollupView", "BatchSuppressedItem"} { if got := schemaExt(name, "x-stability"); got != nil { t.Errorf("schema %s must not carry duplicate x-stability alias, got %v", name, got) } @@ -436,6 +451,16 @@ func TestSpecBetaMarkers(t *testing.T) { } } + // Batch send is beta: the batch_id correlation field rides on the STABLE + // outbound event payloads, so it carries a per-field beta marker while the + // parent event schemas stay stable. + for _, schema := range []string{"EmailSentData", "EmailFailedData", "EmailDeliveredData", "EmailBouncedData", "EmailComplainedData"} { + property, _ := schemaProps(t, doc, schema)["batch_id"].(map[string]any) + if property == nil || property["x-stability-level"] != "beta" { + t.Errorf("%s.batch_id must carry canonical x-stability-level: beta", schema) + } + } + // The error discriminator remains stable; only the gate-policy value is // experimental. errorCode, _ := schemaProps(t, doc, "ErrorBody")["code"].(map[string]any) diff --git a/internal/identity/agent_suppressions.go b/internal/identity/agent_suppressions.go index 223a02f97..b70892e23 100644 --- a/internal/identity/agent_suppressions.go +++ b/internal/identity/agent_suppressions.go @@ -191,6 +191,47 @@ func (s *Store) EffectiveSuppressions(ctx context.Context, userID, agentID strin return out, rows.Err() } +// EffectiveSuppressionsWithSource is EffectiveSuppressions plus the source +// category for each hit, so the batch response can report a dropped item's +// reason (bounce / complaint / manual / unsubscribe). Same union (account +// suppressions ∪ exact-agent agent_suppressions) and same normalization as the +// single-send check. When an address is suppressed at BOTH levels the account +// row wins (ORDER BY pri; account rows carry pri 0, agent rows pri 1). Empty +// input → empty map (not nil), so callers can iterate without a nil-check. +func (s *Store) EffectiveSuppressionsWithSource(ctx context.Context, userID, agentID string, addresses []string) (map[string]string, error) { + out := map[string]string{} + if len(addresses) == 0 { + return out, nil + } + normalized := make([]string, 0, len(addresses)) + for _, address := range addresses { + normalized = append(normalized, NormalizeMailboxAddress(address)) + } + rows, err := s.pool.Query(ctx, + `SELECT address, source, 0 AS pri FROM suppressions + WHERE user_id = $1 AND address = ANY($2) + UNION ALL + SELECT address, source, 1 AS pri FROM agent_suppressions + WHERE user_id = $1 AND agent_id = $3 AND address = ANY($2) + ORDER BY pri`, + userID, normalized, NormalizeEmail(agentID)) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var address, source string + var pri int + if err := rows.Scan(&address, &source, &pri); err != nil { + return nil, err + } + if _, ok := out[address]; !ok { + out[address] = source + } + } + return out, rows.Err() +} + // PutUnsubscribeToken idempotently records a token hash's exact scope. func (s *Store) PutUnsubscribeToken(ctx context.Context, tokenHash []byte, userID, agentID, address string) error { _, err := s.pool.Exec(ctx, diff --git a/internal/identity/batches.go b/internal/identity/batches.go index 05b74a74a..f4b57f591 100644 --- a/internal/identity/batches.go +++ b/internal/identity/batches.go @@ -75,7 +75,7 @@ type BatchStatusRollup struct { // for one message. Mirrors CreateOutboundMessageTx's positional args as a // struct so a batch of N doesn't need a 14-arg loop body. When BatchID is // non-empty the resulting messages row is linked back to that batch header -// via the FK added in migration 067. See docs/design/batch-send.md §9 +// via the FK added in migration 095. See docs/design/batch-send.md §9 // (accept-tx step 10.b). type OutboundMessageInput struct { AgentID string @@ -163,7 +163,7 @@ func (s *Store) GetBatch(ctx context.Context, batchID string) (*Batch, error) { // BatchStatusRollupByID computes the delivery-status histogram over the // batch's child messages rows in one grouped query. Not cached — batch // observation is a rare, poll-after-send operation and at ≤100 rows per -// batch the query is cheap (the partial index in migration 067 makes the +// batch the query is cheap (the partial index in migration 095 makes the // WHERE batch_id = $1 scan a bounded lookup). See docs/design/batch-send.md // §7.1. // diff --git a/internal/identity/delivery_store.go b/internal/identity/delivery_store.go index 6ef01b7c0..7b2807c4d 100644 --- a/internal/identity/delivery_store.go +++ b/internal/identity/delivery_store.go @@ -47,7 +47,7 @@ func (s *Store) CorrelateBySESMessageID(ctx context.Context, sesMessageID string `SELECT m.id, a.user_id, m.agent_id, COALESCE(m.subject, ''), COALESCE(m.conversation_id, ''), COALESCE(m.method, ''), COALESCE(m.message_type, ''), COALESCE(m.sender, ''), - m.to_recipients, m.cc, m.bcc + m.to_recipients, m.cc, m.bcc, COALESCE(m.batch_id, '') FROM messages m JOIN agent_identities a ON a.id = m.agent_id WHERE m.direction = 'outbound' @@ -58,7 +58,7 @@ func (s *Store) CorrelateBySESMessageID(ctx context.Context, sesMessageID string sesMessageID, ).Scan(&m.MessageID, &m.UserID, &m.AgentID, &m.Subject, &m.ConversationID, &m.Method, &m.MessageType, &m.From, - &m.To, &m.CC, &m.BCC) + &m.To, &m.CC, &m.BCC, &m.BatchID) if errors.Is(err, pgx.ErrNoRows) { return nil, false, nil } @@ -84,14 +84,14 @@ func (s *Store) CorrelateByE2AMessageID(ctx context.Context, e2aMessageID string `SELECT m.id, a.user_id, m.agent_id, COALESCE(m.subject, ''), COALESCE(m.conversation_id, ''), COALESCE(m.method, ''), COALESCE(m.message_type, ''), COALESCE(m.sender, ''), - m.to_recipients, m.cc, m.bcc + m.to_recipients, m.cc, m.bcc, COALESCE(m.batch_id, '') FROM messages m JOIN agent_identities a ON a.id = m.agent_id WHERE m.id = $1 AND m.direction = 'outbound'`, e2aMessageID, ).Scan(&m.MessageID, &m.UserID, &m.AgentID, &m.Subject, &m.ConversationID, &m.Method, &m.MessageType, &m.From, - &m.To, &m.CC, &m.BCC) + &m.To, &m.CC, &m.BCC, &m.BatchID) if errors.Is(err, pgx.ErrNoRows) { return nil, false, nil } @@ -1076,45 +1076,6 @@ func (s *Store) SuppressedAddresses(ctx context.Context, userID string, addrs [] return out, rows.Err() } -// SuppressedAddressesWithSource returns address→source for every address in -// addrs that is suppressed for the user. Used by batch-send accept-tx to -// populate the per-item `suppressed` slot in the response with the reason -// category (bounce / complaint / manual), per -// docs/design/batch-send.md §1.3. -// -// The `source` values come straight from suppressions.source (see -// migrations/031_delivery_feedback.sql). Empty input → empty map (not nil, -// so callers can iterate without a nil-check). -func (s *Store) SuppressedAddressesWithSource(ctx context.Context, userID string, addrs []string) (map[string]string, error) { - out := map[string]string{} - if len(addrs) == 0 { - return out, nil - } - norm := make([]string, 0, len(addrs)) - for _, a := range addrs { - norm = append(norm, NormalizeEmail(a)) - } - rows, err := s.pool.Query(ctx, - `SELECT address, source FROM suppressions WHERE user_id = $1 AND address = ANY($2)`, - userID, norm, - ) - if err != nil { - return nil, err - } - defer rows.Close() - for rows.Next() { - var address, source string - if err := rows.Scan(&address, &source); err != nil { - return nil, err - } - out[address] = source - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - // ListSuppressions returns the user's suppression list, newest first. // ListSuppressions returns one page of the user's suppressed addresses, // newest first, keyset-paginated on (created_at, address). The caller passes diff --git a/internal/identity/store.go b/internal/identity/store.go index dcd77e0bb..75e791640 100644 --- a/internal/identity/store.go +++ b/internal/identity/store.go @@ -426,7 +426,7 @@ type Message struct { // delivery-feedback UPDATE...RETURNING that builds email.sent / // email.failed events, so batch children carry batch_id on those // events — docs/design/batch-send.md §7.3). Source: messages.batch_id - // (migration 067). + // (migration 095). BatchID string `json:"batch_id,omitempty"` // Labels are user-applied string tags (`urgent`, `follow-up`, …). @@ -4153,7 +4153,7 @@ type MessageListFilter struct { From string SubjectContains string ConversationID string // exact match - BatchID string // exact match; child messages of a batch send (migration 067) + BatchID string // exact match; child messages of a batch send (migration 095) Since time.Time // created_at >= Since Until time.Time // created_at < Until // Labels filters rows where ALL given labels are present on the diff --git a/internal/limits/enforcer.go b/internal/limits/enforcer.go index d8e2b9388..9baa8c26a 100644 --- a/internal/limits/enforcer.go +++ b/internal/limits/enforcer.go @@ -224,6 +224,19 @@ func (e *DBEnforcer) CheckDomainCreate(ctx context.Context, userID string) error // the user sees "messages_month" as the reason, which is the easier one // to explain ("you sent N this month"). func (e *DBEnforcer) CheckMessageSend(ctx context.Context, userID string) error { + return e.CheckMessageSendN(ctx, userID, 1) +} + +// CheckMessageSendN is the count-aware form of CheckMessageSend: it blocks when +// accepting n more messages this month would exceed the month-flow cap (i.e. +// current count + n > MaxMessagesMonth). Batch send passes n = the number of +// accepted items so the cap accounts for every item, not just one free slot. +// n = 1 is exactly CheckMessageSend (count+1 > cap ⟺ count >= cap). The storage +// stock cap is a point-in-time check and is unaffected by n. +func (e *DBEnforcer) CheckMessageSendN(ctx context.Context, userID string, n int) error { + if n < 1 { + n = 1 + } lim, err := e.Get(ctx, userID) if err != nil { return err @@ -232,7 +245,7 @@ func (e *DBEnforcer) CheckMessageSend(ctx context.Context, userID string) error if err != nil { return err } - if msgCount >= lim.MaxMessagesMonth { + if msgCount+n > lim.MaxMessagesMonth { return &LimitExceededError{ Resource: "messages_month", Limit: lim.MaxMessagesMonth, diff --git a/internal/limits/limits.go b/internal/limits/limits.go index 17e68331c..eaa45c977 100644 --- a/internal/limits/limits.go +++ b/internal/limits/limits.go @@ -72,6 +72,12 @@ type Enforcer interface { // month against MaxMessagesMonth. CheckMessageSend(ctx context.Context, userID string) error + // CheckMessageSendN is the count-aware form of CheckMessageSend: nil if + // the user may accept n more messages this calendar month, or + // *LimitExceededError if current count + n would exceed the cap. Used by + // batch send to charge every accepted item. CheckMessageSend is n=1. + CheckMessageSendN(ctx context.Context, userID string, n int) error + // Invalidate evicts the user's cached Limits so the next Get/Check // re-reads from the database. Called by the limits-invalidate HTTP // endpoint when an external writer (e.g. billing sidecar) has just diff --git a/internal/webhookdelivery/worker_extra_test.go b/internal/webhookdelivery/worker_extra_test.go index faf80e31a..37f6b031e 100644 --- a/internal/webhookdelivery/worker_extra_test.go +++ b/internal/webhookdelivery/worker_extra_test.go @@ -41,6 +41,9 @@ func (f failEnq) Insert(_ context.Context, _ river.JobArgs, _ *river.InsertOpts) func (f failEnq) InsertTx(_ context.Context, _ pgx.Tx, _ river.JobArgs, _ *river.InsertOpts) (*rivertype.JobInsertResult, error) { return nil, f.err } +func (f failEnq) InsertManyTx(_ context.Context, _ pgx.Tx, _ []river.InsertManyParams) ([]*rivertype.JobInsertResult, error) { + return nil, f.err +} // TestDeliverWorker_MissingDeliveryRowIsNoop: the delivery row can disappear // between enqueue and execution (webhook deleted, cascade) — Work treats a gone diff --git a/migrations/067_batches.sql b/migrations/095_batches.sql similarity index 99% rename from migrations/067_batches.sql rename to migrations/095_batches.sql index d53e931c9..00414affa 100644 --- a/migrations/067_batches.sql +++ b/migrations/095_batches.sql @@ -1,4 +1,4 @@ --- 067_batches.sql +-- 095_batches.sql -- -- Storage for the batch-send feature — the primitive that lets one API call -- fan out N independent messages, each with its own message_id/state/retry From e4669f8a45ba0ff8f02bd07d0db7ca4fb81e9559 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Sun, 26 Jul 2026 15:02:09 -0700 Subject: [PATCH 08/17] chore(batch-send): regenerate OpenAPI spec + SDKs for review fixes Regenerates api/openapi.yaml and the TS/Python SDK bases from the handler changes in the preceding review-fix commit: batch_id on the delivery-feedback payloads, beta stability markers across the batch surface, the BatchResult status discriminator, and the restored inbound_mx error codes. Co-Authored-By: Claude Opus 4.8 --- api/openapi.yaml | 493 +++++++++++++++++- .../eventpayload/testdata/email.bounced.json | 1 + .../testdata/email.complained.json | 1 + .../testdata/email.delivered.json | 1 + sdks/python/src/e2a/v1/generated/__init__.py | 20 + .../src/e2a/v1/generated/api/messages_api.py | 294 ++++++++++- .../src/e2a/v1/generated/models/__init__.py | 10 + .../e2a/v1/generated/models/batch_result.py | 10 +- .../models/batch_suppressed_result.py | 4 +- .../v1/generated/models/email_bounced_data.py | 4 +- .../generated/models/email_complained_data.py | 4 +- .../generated/models/email_delivered_data.py | 4 +- .../v1/generated/models/email_failed_data.py | 4 +- .../v1/generated/models/email_sent_data.py | 4 +- .../src/e2a/v1/generated/models/error_body.py | 2 +- .../src/e2a/v1/generated/models/message.py | 4 +- .../generated/models/send_batch_response.py | 2 +- .../src/v1/generated/.openapi-generator/FILES | 10 + .../src/v1/generated/apis/MessagesApi.ts | 296 ++++++++--- .../src/v1/generated/models/BatchResult.ts | 20 +- .../generated/models/BatchSuppressedResult.ts | 4 +- .../v1/generated/models/EmailBouncedData.ts | 7 + .../generated/models/EmailComplainedData.ts | 7 + .../v1/generated/models/EmailDeliveredData.ts | 7 + .../src/v1/generated/models/ErrorBody.ts | 2 +- .../v1/generated/models/ObjectSerializer.ts | 33 +- .../v1/generated/models/SendBatchResponse.ts | 2 +- .../typescript/src/v1/generated/models/all.ts | 10 + .../src/v1/generated/types/ObjectParamAPI.ts | 186 +++---- .../src/v1/generated/types/ObservableAPI.ts | 161 +++--- .../src/v1/generated/types/PromiseAPI.ts | 131 +++-- 31 files changed, 1386 insertions(+), 352 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 2acd6b084..c9b9d2125 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -582,6 +582,190 @@ components: - dkim - dmarc type: object + BatchMessage: + additionalProperties: false + properties: + attachments: + description: Per-item attachments. Single attachment ≤ 10 MiB, item combined ≤ 25 MiB. Additionally the SUM across all batch items must be ≤ 25 MiB (docs/design/batch-send.md §14 Q15). + items: + $ref: "#/components/schemas/Attachment" + type: array + bcc: + description: Bcc recipients for this item. + items: + maxLength: 320 + type: string + type: array + cc: + description: Cc recipients for this item. + items: + maxLength: 320 + type: string + type: array + conversation_id: + description: Caller-assigned conversation (thread) id. Auto-minted per item if omitted. + maxLength: 200 + type: string + html: + description: HTML body. + maxLength: 1048576 + type: string + reply_to: + description: Per-item Reply-To override. If empty, the batch-level reply_to default (if set) applies. + maxLength: 320 + type: string + subject: + description: Literal subject. Required unless a template reference is used. Same caps as single-send. + maxLength: 2000 + type: string + template_alias: + description: Human-handle alternative to template_id. + type: string + template_data: + additionalProperties: {} + description: Per-item template data. Populated freely across items — this is what makes native mail-merge possible without a server-side templating loop (docs/design/batch-send.md §0.5). + type: object + template_id: + description: Send using a stored template. Mutually exclusive with template_alias and with literal subject/text/html on this item. + type: string + text: + description: Plain-text body. + maxLength: 1048576 + type: string + to: + description: Primary recipients for this item. Same per-item cap as single-send (50 combined across to+cc+bcc). Each item's envelope is independent — item i's cc/bcc are not visible in item j. + items: + maxLength: 320 + type: string + type: array + required: + - to + type: object + x-stability-level: beta + BatchResult: + additionalProperties: true + properties: + message_id: + description: Minted message id when the item was accepted (delivery_status='accepted' persisted and River outbound_send job enqueued). Present iff status is "accepted". + type: string + status: + description: "Slot discriminator, always present: \"accepted\" (message_id is set) or \"suppressed\" (suppressed is set). Branch on this rather than inferring the slot shape from which optional field is populated." + enum: + - accepted + - suppressed + type: string + suppressed: + $ref: "#/components/schemas/BatchSuppressedResult" + description: "Present iff status is \"suppressed\": the item was dropped by the suppression-list filter (docs/design/batch-send.md §2.2). No message row exists for a suppressed slot; the caller can un-suppress via DELETE /v1/account/suppressions/{address} and resubmit." + required: + - status + type: object + x-stability-level: beta + BatchStatusRollupView: + additionalProperties: true + properties: + accepted: + format: int64 + type: integer + bounced: + format: int64 + type: integer + complained: + format: int64 + type: integer + deferred: + format: int64 + type: integer + delivered: + format: int64 + type: integer + failed: + format: int64 + type: integer + sending: + format: int64 + type: integer + sent: + format: int64 + type: integer + required: + - accepted + - sending + - sent + - delivered + - deferred + - bounced + - complained + - failed + type: object + x-stability-level: beta + BatchSuppressedItem: + additionalProperties: true + properties: + address: + type: string + item_index: + format: int64 + type: integer + reason: + type: string + required: + - item_index + - address + - reason + type: object + x-stability-level: beta + BatchSuppressedResult: + additionalProperties: true + properties: + address: + description: The recipient address in this item's to/cc/bcc envelope that triggered the drop. If multiple addresses in the same item are suppressed, only the first is surfaced (dropping the whole item is enough signal). + type: string + reason: + description: "The suppression category. Known values: bounce, complaint, manual (account-level, from suppressions.source), unsubscribe (agent-level, from agent_suppressions.source). Open set — treat as string and tolerate unknown values." + type: string + required: + - address + - reason + type: object + x-stability-level: beta + BatchView: + additionalProperties: true + properties: + accepted: + description: Number of items durably accepted (each has a message_id + delivery pipeline entry). + format: int64 + type: integer + agent_id: + description: The sending agent's address. + type: string + batch_id: + type: string + created_at: + description: RFC3339 timestamp of when the batch was accepted. + type: string + requested: + description: Number of items in the original request (accepted + suppressed). + format: int64 + type: integer + status_rollup: + $ref: "#/components/schemas/BatchStatusRollupView" + description: Live per-delivery-status count of the batch's child messages, computed on read. + suppressed: + description: Items dropped by the suppression filter at accept time. Empty when none were dropped. + items: + $ref: "#/components/schemas/BatchSuppressedItem" + type: array + required: + - batch_id + - agent_id + - requested + - accepted + - suppressed + - created_at + - status_rollup + type: object + x-stability-level: beta ContactDueContact: additionalProperties: true properties: @@ -1672,11 +1856,30 @@ components: - sending_status - sending_ramp type: object + DuplicateRecipientDetails: + additionalProperties: true + properties: + address: + description: The recipient address that appears in more than one item's to. + type: string + item_indices: + description: Positions in the request's messages[] where the address appears in to. Length ≥ 2. + items: + format: int64 + type: integer + type: array + required: + - address + - item_indices + type: object EmailBouncedData: additionalProperties: true properties: agent_email: type: string + batch_id: + type: string + x-stability-level: beta bounce_sub_type: type: string bounce_type: @@ -1714,6 +1917,9 @@ components: properties: agent_email: type: string + batch_id: + type: string + x-stability-level: beta delivered_to: description: The one recipient address this per-recipient outcome is about. type: string @@ -1742,6 +1948,9 @@ components: properties: agent_email: type: string + batch_id: + type: string + x-stability-level: beta delivered_to: description: The one recipient address this per-recipient outcome is about. type: string @@ -1770,6 +1979,9 @@ components: properties: agent_email: type: string + batch_id: + type: string + x-stability-level: beta bcc: items: type: string @@ -1907,6 +2119,9 @@ components: properties: agent_email: type: string + batch_id: + type: string + x-stability-level: beta bcc: items: type: string @@ -1973,7 +2188,7 @@ components: additionalProperties: true properties: code: - description: "Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status." + description: "Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, batch_hitl_unsupported, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, too_many_messages, duplicate_recipient, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), batch_hitl_unsupported (403, batch send refused for HITL-enabled agent — use single-send per recipient or disable HITL). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, too_many_messages (batch: cap on messages[]), duplicate_recipient (batch: same address in to across items), template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status." type: string x-e2a-error-contracts: address_in_trash: @@ -2001,6 +2216,11 @@ components: retryable: false statuses: - 413 + batch_hitl_unsupported: + family: auth + retryable: false + statuses: + - 403 blocked_by_policy: family: auth retryable: false @@ -2042,6 +2262,12 @@ components: retryable: false statuses: - 409 + duplicate_recipient: + details_schema: "#/components/schemas/DuplicateRecipientDetails" + family: validation + retryable: false + statuses: + - 400 engagement_not_found: family: not_found retryable: false @@ -2263,6 +2489,12 @@ components: retryable: false statuses: - 400 + too_many_messages: + details_schema: "#/components/schemas/TooManyMessagesDetails" + family: validation + retryable: false + statuses: + - 400 too_many_recipients: details_schema: "#/components/schemas/TooManyRecipientsDetails" family: validation @@ -2301,11 +2533,13 @@ components: description: Optional structured context, polymorphic by code. Treat it as an open object keyed off code; unknown codes and fields must be preserved. type: object x-e2a-error-details-schemas: + duplicate_recipient: "#/components/schemas/DuplicateRecipientDetails" invalid_request: "#/components/schemas/ValidationErrorDetails" limit_exceeded: "#/components/schemas/LimitExceededDetails" limits_unavailable: "#/components/schemas/RetryAfterDetails" payload_too_large: "#/components/schemas/PayloadTooLargeDetails" rate_limited: "#/components/schemas/RateLimitedDetails" + too_many_messages: "#/components/schemas/TooManyMessagesDetails" too_many_recipients: "#/components/schemas/TooManyRecipientsDetails" message: description: Human-readable explanation. Not for branching — use code. @@ -2651,6 +2885,8 @@ components: - object - "null" description: Inbound SMTP authentication evidence. Only dmarc.status=pass authenticates the RFC 5322 From domain; even a pass does not authenticate the mailbox local part, a person, or message content. Null means there was no authenticating inbound SMTP peer, as with outbound or providerless loopback delivery. + batch_id: + type: string bcc: items: type: string @@ -3624,13 +3860,17 @@ components: filename: description: Attachment filename when scope is attachment. type: string + item_index: + description: "For batch-send: the offending item's index in messages[]. Absent for single-send responses and for batch-wide scope violations." + format: int64 + type: integer max_bytes: description: Maximum bytes accepted for this scope. format: int64 minimum: 1 type: integer scope: - description: "Which byte budget was exceeded. Open set: new values may be added over time, so treat these as strings and tolerate unknown values. Known values: composed_message, attachment, attachments_total, request_body." + description: "Which byte budget was exceeded. Open set: new values may be added over time, so treat these as strings and tolerate unknown values. Known values: composed_message, attachment, attachments_total, request_body, batch (batch-send total attachment bytes across all items)." type: string required: - scope @@ -4173,6 +4413,50 @@ components: - domain - aligned type: object + SendBatchRequest: + additionalProperties: false + properties: + messages: + description: "1..100 BatchMessage items. Each item is a self-contained near-clone of SendEmailRequest minus from — its own to/cc/bcc/content/attachments/template/reply_to. Ordering is significant: results[] in the response is positionally aligned with this array." + items: + $ref: "#/components/schemas/BatchMessage" + maxItems: 100 + minItems: 1 + type: array + reply_to: + description: Optional batch-level Reply-To default. Applied to any item that leaves reply_to empty; a per-item value always wins. This is the ONLY batch-level default in MVP; every other field is per-item (docs/design/batch-send.md §1.2). + maxLength: 320 + type: string + required: + - messages + type: object + x-stability-level: beta + SendBatchResponse: + additionalProperties: true + properties: + accepted: + description: Count of results[] slots that are {message_id}. Redundant with results[] but convenient for logging + zero-check. + format: int64 + type: integer + batch_id: + description: Durable id for this batch (bat_). Use GET /v1/batches/{batch_id} to retrieve the header + status rollup. + type: string + results: + description: "One slot per request item, positionally aligned. Each slot carries a status discriminator: status=\"accepted\" → {message_id}; status=\"suppressed\" → {suppressed:{address,reason}} (dropped by the suppression filter)." + items: + $ref: "#/components/schemas/BatchResult" + type: array + suppressed_count: + description: Count of results[] slots that are {suppressed}. Zero when no per-item drops occurred. + format: int64 + type: integer + required: + - batch_id + - results + - accepted + - suppressed_count + type: object + x-stability-level: beta SendEmailRequest: additionalProperties: false properties: @@ -4567,6 +4851,23 @@ components: - name type: object x-stability-level: beta + TooManyMessagesDetails: + additionalProperties: true + properties: + max_messages: + description: Maximum BatchMessage items per batch request. + format: int64 + minimum: 1 + type: integer + provided: + description: Item count supplied by the caller. + format: int64 + minimum: 1 + type: integer + required: + - max_messages + - provided + type: object TooManyRecipientsDetails: additionalProperties: true properties: @@ -5764,6 +6065,134 @@ paths: summary: Update an agent tags: - agents + /v1/agents/{email}/batches: + post: + description: |- + Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract. + + MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. + + Aggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item's content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls. + + Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + operationId: sendBatch + parameters: + - in: path + name: email + required: true + schema: + type: string + - description: "Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch)." + in: header + name: Idempotency-Key + schema: + description: "Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch)." + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SendBatchRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/SendBatchResponse" + description: OK + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + "202": + content: + application/json: + schema: + $ref: "#/components/schemas/SendBatchResponse" + description: Batch accepted — durably persisted, with up to len(request.messages) River outbound_send jobs enqueued. results[] is positionally aligned with request.messages; each slot is either {message_id} (accepted) or {suppressed} (dropped by the suppression filter — the request itself is not an error). An all-suppressed batch (every slot suppressed) is also a 202; accepted=0. + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Bad Request — request-shape/validation failure. error.code includes invalid_request, invalid_recipient, too_many_messages, duplicate_recipient, invalid_attachment (undecodable base64). Per-item errors carry details.item_index identifying the offending messages[] index; batch-wide errors omit it. + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + "402": + content: + application/json: + schema: + $ref: "#/components/schemas/LimitExceededEnvelope" + description: Payment required — a per-account resource cap was hit (code limit_exceeded). error.details.resource is the AccountView usage/limits field stem (agents, domains, messages_month, storage_bytes), so the client can key it to usage. / limits.max_. This is a QUOTA (stock/flow) cap — distinct from a 429 rate_limited (throughput). A retry alone will not clear it; surface a quota/upgrade path. + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Error — the standard envelope; branch on error.code. + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: "Conflict — code idempotency_in_flight: a request with this Idempotency-Key is still executing. Retry-able: wait for the first request to finish, then retry with the SAME key and byte-identical body — the retry replays the first request's response instead of re-executing the side effect." + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + "413": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: "Payload Too Large — error.code = payload_too_large. An attachment exceeds 10 MiB decoded; combined attachments exceed 25 MiB decoded; or the composed message exceeds 10 MiB (10485760 bytes), measured as subject + text + html + decoded attachment bytes. error.details uses PayloadTooLargeDetails: {scope, actual_bytes, max_bytes, filename?}; scope identifies composed_message, attachment, attachments_total, or request_body." + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: "Unprocessable — branch on error.code. idempotency_key_reuse: this Idempotency-Key was already used with a DIFFERENT request body (the dedup hash covers the route + the raw body bytes) — do NOT retry as-is; a legitimate retry must resend the byte-identical body, and a genuinely new request needs a fresh key. invalid_request: a semantic validation failure in the request body." + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + "429": + content: + application/json: + schema: + $ref: "#/components/schemas/RateLimitedEnvelope" + description: "Too Many Requests — a request-RATE / throughput limit was hit (code rate_limited). This is distinct from a 402 limit_exceeded (a QUOTA cap): a 429 is transient and retry-able — wait error.details.retry_after_seconds (mirrored on the Retry-After header), then the same request succeeds. Branch on the HTTP status: 429 → back off and retry; 402 → surface a quota/upgrade path." + headers: + Retry-After: + $ref: "#/components/headers/RetryAfter" + X-Request-Id: + $ref: "#/components/headers/XRequestID" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Error — the standard envelope; branch on error.code. + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + security: + - bearer: [] + summary: Send a batch of up to 100 emails (beta) + tags: + - messages + x-stability-level: beta /v1/agents/{email}/contacts: get: description: "Lists the contacts this agent is working, with the reply and delivery facts e2a derives from real message activity. Combine replied=false, next_action_before and last_outbound_before to get everyone due for a follow-up in one request. Agent-scoped credentials may read their own agent. Beta: the outreach surface may change before it is declared stable." @@ -6213,6 +6642,13 @@ paths: name: conversation_id schema: type: string + - description: Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123. + explode: false + in: query + name: batch_id + schema: + description: Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123. + type: string - description: Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. explode: false in: query @@ -7358,6 +7794,55 @@ paths: summary: Send a test email to the agent's own address tags: - agents + /v1/batches/{batch_id}: + get: + description: |- + Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch's child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found. + + Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + operationId: getBatch + parameters: + - description: The batch id, e.g. bat_abc123. + in: path + name: batch_id + required: true + schema: + description: The batch id, e.g. bat_abc123. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/BatchView" + description: The batch header + per-status rollup. + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Error — the standard envelope; branch on error.code. + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Error — the standard envelope; branch on error.code. + headers: + X-Request-Id: + $ref: "#/components/headers/XRequestID" + security: + - bearer: [] + summary: Get a batch's header and delivery-status rollup (beta) + tags: + - messages + x-stability-level: beta /v1/contacts: get: description: "Lists the people this account corresponds with, newest first. Account-scoped credentials only. Beta: the contacts surface may change before it is declared stable." @@ -9125,6 +9610,8 @@ x-e2a-standalone-schema-refs: $ref: "#/components/schemas/DomainSendingVerifiedData" DomainSuppressionAddedData: $ref: "#/components/schemas/DomainSuppressionAddedData" + DuplicateRecipientDetails: + $ref: "#/components/schemas/DuplicateRecipientDetails" EmailBouncedData: $ref: "#/components/schemas/EmailBouncedData" EmailComplainedData: @@ -9147,6 +9634,8 @@ x-e2a-standalone-schema-refs: $ref: "#/components/schemas/RateLimitedDetails" RetryAfterDetails: $ref: "#/components/schemas/RetryAfterDetails" + TooManyMessagesDetails: + $ref: "#/components/schemas/TooManyMessagesDetails" TooManyRecipientsDetails: $ref: "#/components/schemas/TooManyRecipientsDetails" ValidationErrorDetails: diff --git a/internal/eventpayload/testdata/email.bounced.json b/internal/eventpayload/testdata/email.bounced.json index 7ee42c0ad..fdf9c27d7 100644 --- a/internal/eventpayload/testdata/email.bounced.json +++ b/internal/eventpayload/testdata/email.bounced.json @@ -12,6 +12,7 @@ "smtp_detail": "550 5.1.1 no such user", "bounce_type": "permanent", "bounce_sub_type": "General", + "batch_id": "bat_9c8b7a6d5e4f3a2b1c0d9e8f", "lifecycle_transitions": [ { "id": "mlt_bounced", diff --git a/internal/eventpayload/testdata/email.complained.json b/internal/eventpayload/testdata/email.complained.json index bc162c542..60b5c45ee 100644 --- a/internal/eventpayload/testdata/email.complained.json +++ b/internal/eventpayload/testdata/email.complained.json @@ -10,6 +10,7 @@ "delivered_to": "carol@customer.example.com", "subject": "Re: Order #1234 delayed", "smtp_detail": "abuse", + "batch_id": "bat_9c8b7a6d5e4f3a2b1c0d9e8f", "lifecycle_transitions": [ { "id": "mlt_complained", diff --git a/internal/eventpayload/testdata/email.delivered.json b/internal/eventpayload/testdata/email.delivered.json index 2772f9fa6..c3b1b92ab 100644 --- a/internal/eventpayload/testdata/email.delivered.json +++ b/internal/eventpayload/testdata/email.delivered.json @@ -9,6 +9,7 @@ "direction": "outbound", "delivered_to": "alice@customer.example.com", "subject": "Re: Order #1234 delayed", + "batch_id": "bat_9c8b7a6d5e4f3a2b1c0d9e8f", "lifecycle_transitions": [ { "id": "mlt_delivered", diff --git a/sdks/python/src/e2a/v1/generated/__init__.py b/sdks/python/src/e2a/v1/generated/__init__.py index b0a30eb28..ccf437442 100644 --- a/sdks/python/src/e2a/v1/generated/__init__.py +++ b/sdks/python/src/e2a/v1/generated/__init__.py @@ -54,6 +54,12 @@ "AttachmentMetaView", "AttachmentView", "Authentication", + "BatchMessage", + "BatchResult", + "BatchStatusRollupView", + "BatchSuppressedItem", + "BatchSuppressedResult", + "BatchView", "ContactDueContact", "ContactDueData", "ContactEngagementView", @@ -93,6 +99,7 @@ "DomainSendingVerifiedData", "DomainSuppressionAddedData", "DomainView", + "DuplicateRecipientDetails", "EmailBouncedData", "EmailComplainedData", "EmailDeliveredData", @@ -168,6 +175,8 @@ "ReviewView", "RotateSecretResponse", "SPFResult", + "SendBatchRequest", + "SendBatchResponse", "SendEmailRequest", "SendResultView", "SendingRampView", @@ -182,6 +191,7 @@ "TestWebhookRequest", "TestWebhookResponse", "ThreatCategoryView", + "TooManyMessagesDetails", "TooManyRecipientsDetails", "UnsubscribeOptions", "UpdateAgentRequest", @@ -247,6 +257,12 @@ from e2a.v1.generated.models.attachment_meta_view import AttachmentMetaView as AttachmentMetaView from e2a.v1.generated.models.attachment_view import AttachmentView as AttachmentView from e2a.v1.generated.models.authentication import Authentication as Authentication +from e2a.v1.generated.models.batch_message import BatchMessage as BatchMessage +from e2a.v1.generated.models.batch_result import BatchResult as BatchResult +from e2a.v1.generated.models.batch_status_rollup_view import BatchStatusRollupView as BatchStatusRollupView +from e2a.v1.generated.models.batch_suppressed_item import BatchSuppressedItem as BatchSuppressedItem +from e2a.v1.generated.models.batch_suppressed_result import BatchSuppressedResult as BatchSuppressedResult +from e2a.v1.generated.models.batch_view import BatchView as BatchView from e2a.v1.generated.models.contact_due_contact import ContactDueContact as ContactDueContact from e2a.v1.generated.models.contact_due_data import ContactDueData as ContactDueData from e2a.v1.generated.models.contact_engagement_view import ContactEngagementView as ContactEngagementView @@ -286,6 +302,7 @@ from e2a.v1.generated.models.domain_sending_verified_data import DomainSendingVerifiedData as DomainSendingVerifiedData from e2a.v1.generated.models.domain_suppression_added_data import DomainSuppressionAddedData as DomainSuppressionAddedData from e2a.v1.generated.models.domain_view import DomainView as DomainView +from e2a.v1.generated.models.duplicate_recipient_details import DuplicateRecipientDetails as DuplicateRecipientDetails from e2a.v1.generated.models.email_bounced_data import EmailBouncedData as EmailBouncedData from e2a.v1.generated.models.email_complained_data import EmailComplainedData as EmailComplainedData from e2a.v1.generated.models.email_delivered_data import EmailDeliveredData as EmailDeliveredData @@ -361,6 +378,8 @@ from e2a.v1.generated.models.review_view import ReviewView as ReviewView from e2a.v1.generated.models.rotate_secret_response import RotateSecretResponse as RotateSecretResponse from e2a.v1.generated.models.spf_result import SPFResult as SPFResult +from e2a.v1.generated.models.send_batch_request import SendBatchRequest as SendBatchRequest +from e2a.v1.generated.models.send_batch_response import SendBatchResponse as SendBatchResponse from e2a.v1.generated.models.send_email_request import SendEmailRequest as SendEmailRequest from e2a.v1.generated.models.send_result_view import SendResultView as SendResultView from e2a.v1.generated.models.sending_ramp_view import SendingRampView as SendingRampView @@ -375,6 +394,7 @@ from e2a.v1.generated.models.test_webhook_request import TestWebhookRequest as TestWebhookRequest from e2a.v1.generated.models.test_webhook_response import TestWebhookResponse as TestWebhookResponse from e2a.v1.generated.models.threat_category_view import ThreatCategoryView as ThreatCategoryView +from e2a.v1.generated.models.too_many_messages_details import TooManyMessagesDetails as TooManyMessagesDetails from e2a.v1.generated.models.too_many_recipients_details import TooManyRecipientsDetails as TooManyRecipientsDetails from e2a.v1.generated.models.unsubscribe_options import UnsubscribeOptions as UnsubscribeOptions from e2a.v1.generated.models.update_agent_request import UpdateAgentRequest as UpdateAgentRequest diff --git a/sdks/python/src/e2a/v1/generated/api/messages_api.py b/sdks/python/src/e2a/v1/generated/api/messages_api.py index b8389651d..791149a85 100644 --- a/sdks/python/src/e2a/v1/generated/api/messages_api.py +++ b/sdks/python/src/e2a/v1/generated/api/messages_api.py @@ -22,6 +22,7 @@ from typing_extensions import Annotated from e2a.v1.generated.models.agent_metrics_view import AgentMetricsView from e2a.v1.generated.models.attachment_view import AttachmentView +from e2a.v1.generated.models.batch_view import BatchView from e2a.v1.generated.models.delete_message_result import DeleteMessageResult from e2a.v1.generated.models.forward_request import ForwardRequest from e2a.v1.generated.models.message_view import MessageView @@ -1344,6 +1345,270 @@ def _get_attachment_serialize( + @validate_call + async def get_batch( + self, + batch_id: Annotated[StrictStr, Field(description="The batch id, e.g. bat_abc123.")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> BatchView: + """Get a batch's header and delivery-status rollup (beta) + + Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch's child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + + :param batch_id: The batch id, e.g. bat_abc123. (required) + :type batch_id: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_batch_serialize( + batch_id=batch_id, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "BatchView", + '404': "ErrorEnvelope", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def get_batch_with_http_info( + self, + batch_id: Annotated[StrictStr, Field(description="The batch id, e.g. bat_abc123.")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[BatchView]: + """Get a batch's header and delivery-status rollup (beta) + + Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch's child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + + :param batch_id: The batch id, e.g. bat_abc123. (required) + :type batch_id: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_batch_serialize( + batch_id=batch_id, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "BatchView", + '404': "ErrorEnvelope", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def get_batch_without_preload_content( + self, + batch_id: Annotated[StrictStr, Field(description="The batch id, e.g. bat_abc123.")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Get a batch's header and delivery-status rollup (beta) + + Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch's child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + + :param batch_id: The batch id, e.g. bat_abc123. (required) + :type batch_id: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_batch_serialize( + batch_id=batch_id, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "BatchView", + '404': "ErrorEnvelope", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_batch_serialize( + self, + batch_id, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if batch_id is not None: + _path_params['batch_id'] = batch_id + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + 'bearer' + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/v1/batches/{batch_id}', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def get_message( self, @@ -1940,6 +2205,7 @@ async def list_messages( from_: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on sender.")] = None, subject_contains: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on subject.")] = None, conversation_id: Optional[StrictStr] = None, + batch_id: Annotated[Optional[StrictStr], Field(description="Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123.")] = None, labels: Annotated[Optional[List[StrictStr]], Field(description="Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label.")] = None, since: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at >= since.")] = None, until: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at < until.")] = None, @@ -1978,6 +2244,8 @@ async def list_messages( :type subject_contains: str :param conversation_id: :type conversation_id: str + :param batch_id: Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123. + :type batch_id: str :param labels: Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. :type labels: List[str] :param since: RFC3339; created_at >= since. @@ -2022,6 +2290,7 @@ async def list_messages( from_=from_, subject_contains=subject_contains, conversation_id=conversation_id, + batch_id=batch_id, labels=labels, since=since, until=until, @@ -2059,6 +2328,7 @@ async def list_messages_with_http_info( from_: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on sender.")] = None, subject_contains: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on subject.")] = None, conversation_id: Optional[StrictStr] = None, + batch_id: Annotated[Optional[StrictStr], Field(description="Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123.")] = None, labels: Annotated[Optional[List[StrictStr]], Field(description="Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label.")] = None, since: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at >= since.")] = None, until: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at < until.")] = None, @@ -2097,6 +2367,8 @@ async def list_messages_with_http_info( :type subject_contains: str :param conversation_id: :type conversation_id: str + :param batch_id: Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123. + :type batch_id: str :param labels: Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. :type labels: List[str] :param since: RFC3339; created_at >= since. @@ -2141,6 +2413,7 @@ async def list_messages_with_http_info( from_=from_, subject_contains=subject_contains, conversation_id=conversation_id, + batch_id=batch_id, labels=labels, since=since, until=until, @@ -2178,6 +2451,7 @@ async def list_messages_without_preload_content( from_: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on sender.")] = None, subject_contains: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on subject.")] = None, conversation_id: Optional[StrictStr] = None, + batch_id: Annotated[Optional[StrictStr], Field(description="Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123.")] = None, labels: Annotated[Optional[List[StrictStr]], Field(description="Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label.")] = None, since: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at >= since.")] = None, until: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at < until.")] = None, @@ -2216,6 +2490,8 @@ async def list_messages_without_preload_content( :type subject_contains: str :param conversation_id: :type conversation_id: str + :param batch_id: Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123. + :type batch_id: str :param labels: Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. :type labels: List[str] :param since: RFC3339; created_at >= since. @@ -2260,6 +2536,7 @@ async def list_messages_without_preload_content( from_=from_, subject_contains=subject_contains, conversation_id=conversation_id, + batch_id=batch_id, labels=labels, since=since, until=until, @@ -2292,6 +2569,7 @@ def _list_messages_serialize( from_, subject_contains, conversation_id, + batch_id, labels, since, until, @@ -2348,6 +2626,10 @@ def _list_messages_serialize( _query_params.append(('conversation_id', conversation_id)) + if batch_id is not None: + + _query_params.append(('batch_id', batch_id)) + if labels is not None: _query_params.append(('labels', labels)) @@ -3065,9 +3347,9 @@ async def send_batch( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> SendBatchResponse: - """Send a batch of up to 100 emails + """Send a batch of up to 100 emails (beta) - Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. + Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. Aggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item's content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. :param email: (required) :type email: str @@ -3148,9 +3430,9 @@ async def send_batch_with_http_info( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> ApiResponse[SendBatchResponse]: - """Send a batch of up to 100 emails + """Send a batch of up to 100 emails (beta) - Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. + Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. Aggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item's content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. :param email: (required) :type email: str @@ -3231,9 +3513,9 @@ async def send_batch_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Send a batch of up to 100 emails + """Send a batch of up to 100 emails (beta) - Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. + Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. Aggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item's content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. :param email: (required) :type email: str diff --git a/sdks/python/src/e2a/v1/generated/models/__init__.py b/sdks/python/src/e2a/v1/generated/models/__init__.py index d9a69c783..2f4369703 100644 --- a/sdks/python/src/e2a/v1/generated/models/__init__.py +++ b/sdks/python/src/e2a/v1/generated/models/__init__.py @@ -29,6 +29,12 @@ from e2a.v1.generated.models.attachment_meta_view import AttachmentMetaView from e2a.v1.generated.models.attachment_view import AttachmentView from e2a.v1.generated.models.authentication import Authentication +from e2a.v1.generated.models.batch_message import BatchMessage +from e2a.v1.generated.models.batch_result import BatchResult +from e2a.v1.generated.models.batch_status_rollup_view import BatchStatusRollupView +from e2a.v1.generated.models.batch_suppressed_item import BatchSuppressedItem +from e2a.v1.generated.models.batch_suppressed_result import BatchSuppressedResult +from e2a.v1.generated.models.batch_view import BatchView from e2a.v1.generated.models.contact_due_contact import ContactDueContact from e2a.v1.generated.models.contact_due_data import ContactDueData from e2a.v1.generated.models.contact_engagement_view import ContactEngagementView @@ -68,6 +74,7 @@ from e2a.v1.generated.models.domain_sending_verified_data import DomainSendingVerifiedData from e2a.v1.generated.models.domain_suppression_added_data import DomainSuppressionAddedData from e2a.v1.generated.models.domain_view import DomainView +from e2a.v1.generated.models.duplicate_recipient_details import DuplicateRecipientDetails from e2a.v1.generated.models.email_bounced_data import EmailBouncedData from e2a.v1.generated.models.email_complained_data import EmailComplainedData from e2a.v1.generated.models.email_delivered_data import EmailDeliveredData @@ -143,6 +150,8 @@ from e2a.v1.generated.models.review_view import ReviewView from e2a.v1.generated.models.rotate_secret_response import RotateSecretResponse from e2a.v1.generated.models.spf_result import SPFResult +from e2a.v1.generated.models.send_batch_request import SendBatchRequest +from e2a.v1.generated.models.send_batch_response import SendBatchResponse from e2a.v1.generated.models.send_email_request import SendEmailRequest from e2a.v1.generated.models.send_result_view import SendResultView from e2a.v1.generated.models.sending_ramp_view import SendingRampView @@ -157,6 +166,7 @@ from e2a.v1.generated.models.test_webhook_request import TestWebhookRequest from e2a.v1.generated.models.test_webhook_response import TestWebhookResponse from e2a.v1.generated.models.threat_category_view import ThreatCategoryView +from e2a.v1.generated.models.too_many_messages_details import TooManyMessagesDetails from e2a.v1.generated.models.too_many_recipients_details import TooManyRecipientsDetails from e2a.v1.generated.models.unsubscribe_options import UnsubscribeOptions from e2a.v1.generated.models.update_agent_request import UpdateAgentRequest diff --git a/sdks/python/src/e2a/v1/generated/models/batch_result.py b/sdks/python/src/e2a/v1/generated/models/batch_result.py index b55216ad4..d77f4541e 100644 --- a/sdks/python/src/e2a/v1/generated/models/batch_result.py +++ b/sdks/python/src/e2a/v1/generated/models/batch_result.py @@ -17,7 +17,7 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictStr +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator from typing import Any, ClassVar, Dict, List, Optional from e2a.v1.generated.models.batch_suppressed_result import BatchSuppressedResult from typing import Optional, Set @@ -27,10 +27,11 @@ class BatchResult(BaseModel): """ BatchResult """ # noqa: E501 - message_id: Optional[StrictStr] = Field(default=None, description="Minted message id when the item was accepted (delivery_status='accepted' persisted and River outbound_send job enqueued). Absent when Suppressed is present.") - suppressed: Optional[BatchSuppressedResult] = Field(default=None, description="Present when the item was dropped by the suppression-list filter (docs/design/batch-send.md §2.2). No message row exists for a suppressed slot; the caller can un-suppress via DELETE /v1/account/suppressions/{address} and resubmit.") + message_id: Optional[StrictStr] = Field(default=None, description="Minted message id when the item was accepted (delivery_status='accepted' persisted and River outbound_send job enqueued). Present iff status is \"accepted\".") + status: StrictStr = Field(description="Slot discriminator, always present: \"accepted\" (message_id is set) or \"suppressed\" (suppressed is set). Branch on this rather than inferring the slot shape from which optional field is populated.") + suppressed: Optional[BatchSuppressedResult] = Field(default=None, description="Present iff status is \"suppressed\": the item was dropped by the suppression-list filter (docs/design/batch-send.md §2.2). No message row exists for a suppressed slot; the caller can un-suppress via DELETE /v1/account/suppressions/{address} and resubmit.") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["message_id", "suppressed"] + __properties: ClassVar[List[str]] = ["message_id", "status", "suppressed"] model_config = ConfigDict( populate_by_name=True, @@ -94,6 +95,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "message_id": obj.get("message_id"), + "status": obj.get("status"), "suppressed": BatchSuppressedResult.from_dict(obj["suppressed"]) if obj.get("suppressed") is not None else None }) # store additional fields in additional_properties diff --git a/sdks/python/src/e2a/v1/generated/models/batch_suppressed_result.py b/sdks/python/src/e2a/v1/generated/models/batch_suppressed_result.py index 123166f45..5b916c9c1 100644 --- a/sdks/python/src/e2a/v1/generated/models/batch_suppressed_result.py +++ b/sdks/python/src/e2a/v1/generated/models/batch_suppressed_result.py @@ -26,8 +26,8 @@ class BatchSuppressedResult(BaseModel): """ BatchSuppressedResult """ # noqa: E501 - address: StrictStr = Field(description="The recipient address in this item's to list that triggered the drop. If multiple addresses in the same item are suppressed, only the first is surfaced (dropping the whole item is enough signal).") - reason: StrictStr = Field(description="The suppression-list category from suppressions.source. Known values: bounce, complaint, manual. Open set — treat as string and tolerate unknown values.") + address: StrictStr = Field(description="The recipient address in this item's to/cc/bcc envelope that triggered the drop. If multiple addresses in the same item are suppressed, only the first is surfaced (dropping the whole item is enough signal).") + reason: StrictStr = Field(description="The suppression category. Known values: bounce, complaint, manual (account-level, from suppressions.source), unsubscribe (agent-level, from agent_suppressions.source). Open set — treat as string and tolerate unknown values.") additional_properties: Dict[str, Any] = {} __properties: ClassVar[List[str]] = ["address", "reason"] diff --git a/sdks/python/src/e2a/v1/generated/models/email_bounced_data.py b/sdks/python/src/e2a/v1/generated/models/email_bounced_data.py index a2df1224c..5c28b37f8 100644 --- a/sdks/python/src/e2a/v1/generated/models/email_bounced_data.py +++ b/sdks/python/src/e2a/v1/generated/models/email_bounced_data.py @@ -28,6 +28,7 @@ class EmailBouncedData(BaseModel): EmailBouncedData """ # noqa: E501 agent_email: StrictStr + batch_id: Optional[StrictStr] = None bounce_sub_type: Optional[StrictStr] = None bounce_type: StrictStr delivered_to: StrictStr = Field(description="The one recipient address this per-recipient outcome is about.") @@ -37,7 +38,7 @@ class EmailBouncedData(BaseModel): smtp_detail: Optional[StrictStr] = None subject: Optional[StrictStr] = None additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["agent_email", "bounce_sub_type", "bounce_type", "delivered_to", "direction", "lifecycle_transitions", "message_id", "smtp_detail", "subject"] + __properties: ClassVar[List[str]] = ["agent_email", "batch_id", "bounce_sub_type", "bounce_type", "delivered_to", "direction", "lifecycle_transitions", "message_id", "smtp_detail", "subject"] model_config = ConfigDict( populate_by_name=True, @@ -105,6 +106,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "agent_email": obj.get("agent_email"), + "batch_id": obj.get("batch_id"), "bounce_sub_type": obj.get("bounce_sub_type"), "bounce_type": obj.get("bounce_type"), "delivered_to": obj.get("delivered_to"), diff --git a/sdks/python/src/e2a/v1/generated/models/email_complained_data.py b/sdks/python/src/e2a/v1/generated/models/email_complained_data.py index 17520e830..f82658926 100644 --- a/sdks/python/src/e2a/v1/generated/models/email_complained_data.py +++ b/sdks/python/src/e2a/v1/generated/models/email_complained_data.py @@ -28,6 +28,7 @@ class EmailComplainedData(BaseModel): EmailComplainedData """ # noqa: E501 agent_email: StrictStr + batch_id: Optional[StrictStr] = None delivered_to: StrictStr = Field(description="The one recipient address this per-recipient outcome is about.") direction: StrictStr = Field(description="Always \"outbound\" on this event.") lifecycle_transitions: Optional[List[MessageLifecycleTransition]] = None @@ -35,7 +36,7 @@ class EmailComplainedData(BaseModel): smtp_detail: Optional[StrictStr] = None subject: Optional[StrictStr] = None additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["agent_email", "delivered_to", "direction", "lifecycle_transitions", "message_id", "smtp_detail", "subject"] + __properties: ClassVar[List[str]] = ["agent_email", "batch_id", "delivered_to", "direction", "lifecycle_transitions", "message_id", "smtp_detail", "subject"] model_config = ConfigDict( populate_by_name=True, @@ -103,6 +104,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "agent_email": obj.get("agent_email"), + "batch_id": obj.get("batch_id"), "delivered_to": obj.get("delivered_to"), "direction": obj.get("direction"), "lifecycle_transitions": [MessageLifecycleTransition.from_dict(_item) for _item in obj["lifecycle_transitions"]] if obj.get("lifecycle_transitions") is not None else None, diff --git a/sdks/python/src/e2a/v1/generated/models/email_delivered_data.py b/sdks/python/src/e2a/v1/generated/models/email_delivered_data.py index f0ecec0fc..2fbe35c48 100644 --- a/sdks/python/src/e2a/v1/generated/models/email_delivered_data.py +++ b/sdks/python/src/e2a/v1/generated/models/email_delivered_data.py @@ -28,6 +28,7 @@ class EmailDeliveredData(BaseModel): EmailDeliveredData """ # noqa: E501 agent_email: StrictStr + batch_id: Optional[StrictStr] = None delivered_to: StrictStr = Field(description="The one recipient address this per-recipient outcome is about.") direction: StrictStr = Field(description="Always \"outbound\" on this event.") lifecycle_transitions: Optional[List[MessageLifecycleTransition]] = None @@ -35,7 +36,7 @@ class EmailDeliveredData(BaseModel): smtp_detail: Optional[StrictStr] = None subject: Optional[StrictStr] = None additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["agent_email", "delivered_to", "direction", "lifecycle_transitions", "message_id", "smtp_detail", "subject"] + __properties: ClassVar[List[str]] = ["agent_email", "batch_id", "delivered_to", "direction", "lifecycle_transitions", "message_id", "smtp_detail", "subject"] model_config = ConfigDict( populate_by_name=True, @@ -103,6 +104,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "agent_email": obj.get("agent_email"), + "batch_id": obj.get("batch_id"), "delivered_to": obj.get("delivered_to"), "direction": obj.get("direction"), "lifecycle_transitions": [MessageLifecycleTransition.from_dict(_item) for _item in obj["lifecycle_transitions"]] if obj.get("lifecycle_transitions") is not None else None, diff --git a/sdks/python/src/e2a/v1/generated/models/email_failed_data.py b/sdks/python/src/e2a/v1/generated/models/email_failed_data.py index 5410d5097..c61b5dc11 100644 --- a/sdks/python/src/e2a/v1/generated/models/email_failed_data.py +++ b/sdks/python/src/e2a/v1/generated/models/email_failed_data.py @@ -28,6 +28,7 @@ class EmailFailedData(BaseModel): EmailFailedData """ # noqa: E501 agent_email: StrictStr + batch_id: Optional[StrictStr] = None bcc: Optional[List[StrictStr]] = None cc: Optional[List[StrictStr]] = None conversation_id: Optional[StrictStr] = None @@ -43,7 +44,7 @@ class EmailFailedData(BaseModel): subject: StrictStr to: List[StrictStr] additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["agent_email", "bcc", "cc", "conversation_id", "direction", "from", "lifecycle_transitions", "message_id", "message_type", "method", "reason", "reason_code", "retryable", "subject", "to"] + __properties: ClassVar[List[str]] = ["agent_email", "batch_id", "bcc", "cc", "conversation_id", "direction", "from", "lifecycle_transitions", "message_id", "message_type", "method", "reason", "reason_code", "retryable", "subject", "to"] model_config = ConfigDict( populate_by_name=True, @@ -111,6 +112,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "agent_email": obj.get("agent_email"), + "batch_id": obj.get("batch_id"), "bcc": obj.get("bcc"), "cc": obj.get("cc"), "conversation_id": obj.get("conversation_id"), diff --git a/sdks/python/src/e2a/v1/generated/models/email_sent_data.py b/sdks/python/src/e2a/v1/generated/models/email_sent_data.py index abdd03427..ffa55d831 100644 --- a/sdks/python/src/e2a/v1/generated/models/email_sent_data.py +++ b/sdks/python/src/e2a/v1/generated/models/email_sent_data.py @@ -28,6 +28,7 @@ class EmailSentData(BaseModel): EmailSentData """ # noqa: E501 agent_email: StrictStr + batch_id: Optional[StrictStr] = None bcc: Optional[List[StrictStr]] = None cc: Optional[List[StrictStr]] = None conversation_id: Optional[StrictStr] = None @@ -41,7 +42,7 @@ class EmailSentData(BaseModel): subject: StrictStr to: List[StrictStr] additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["agent_email", "bcc", "cc", "conversation_id", "direction", "from", "lifecycle_transitions", "message_id", "message_type", "method", "provider_message_id", "subject", "to"] + __properties: ClassVar[List[str]] = ["agent_email", "batch_id", "bcc", "cc", "conversation_id", "direction", "from", "lifecycle_transitions", "message_id", "message_type", "method", "provider_message_id", "subject", "to"] model_config = ConfigDict( populate_by_name=True, @@ -109,6 +110,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "agent_email": obj.get("agent_email"), + "batch_id": obj.get("batch_id"), "bcc": obj.get("bcc"), "cc": obj.get("cc"), "conversation_id": obj.get("conversation_id"), diff --git a/sdks/python/src/e2a/v1/generated/models/error_body.py b/sdks/python/src/e2a/v1/generated/models/error_body.py index 92bdd8ef0..4cc5c0823 100644 --- a/sdks/python/src/e2a/v1/generated/models/error_body.py +++ b/sdks/python/src/e2a/v1/generated/models/error_body.py @@ -26,7 +26,7 @@ class ErrorBody(BaseModel): """ ErrorBody """ # noqa: E501 - code: StrictStr = Field(description="Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status.") + code: StrictStr = Field(description="Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, batch_hitl_unsupported, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, too_many_messages, duplicate_recipient, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), batch_hitl_unsupported (403, batch send refused for HITL-enabled agent — use single-send per recipient or disable HITL). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, too_many_messages (batch: cap on messages[]), duplicate_recipient (batch: same address in to across items), template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status.") details: Optional[Dict[str, Any]] = Field(default=None, description="Optional structured context, polymorphic by code. Treat it as an open object keyed off code; unknown codes and fields must be preserved.") message: StrictStr = Field(description="Human-readable explanation. Not for branching — use code.") request_id: StrictStr = Field(description="Echoes the X-Request-Id response header so a failing call is greppable in logs.") diff --git a/sdks/python/src/e2a/v1/generated/models/message.py b/sdks/python/src/e2a/v1/generated/models/message.py index 16caf737a..5ee863873 100644 --- a/sdks/python/src/e2a/v1/generated/models/message.py +++ b/sdks/python/src/e2a/v1/generated/models/message.py @@ -33,6 +33,7 @@ class Message(BaseModel): approval_expires_at: Optional[datetime] = None attachments: Optional[List[AttachmentMetaView]] = None authentication: Optional[Authentication] = Field(description="Inbound SMTP authentication evidence. Only dmarc.status=pass authenticates the RFC 5322 From domain; even a pass does not authenticate the mailbox local part, a person, or message content. Null means there was no authenticating inbound SMTP peer, as with outbound or providerless loopback delivery.") + batch_id: Optional[StrictStr] = None bcc: Optional[List[StrictStr]] = None cc: Optional[List[StrictStr]] = None conversation_id: Optional[StrictStr] = None @@ -77,7 +78,7 @@ class Message(BaseModel): webhook_error: Optional[StrictStr] = None webhook_status: Optional[StrictStr] = None additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["agent_email", "approval_expires_at", "attachments", "authentication", "bcc", "cc", "conversation_id", "created_at", "deleted_at", "delivered_to", "delivery_detail", "delivery_status", "direction", "edited", "email_message_id", "envelope_from", "expires_at", "flag_reason", "flagged", "header_from", "html", "id", "labels", "method", "provider_message_id", "raw_message", "read_status", "rejection_reason", "reply_to", "review_reason", "reviewed_at", "reviewed_by_name", "reviewed_by_user_id", "scan_action", "scan_score", "scheduled_at", "sent_as", "size_bytes", "status", "subject", "text", "to", "type", "verified_domain", "webhook_attempts", "webhook_error", "webhook_status"] + __properties: ClassVar[List[str]] = ["agent_email", "approval_expires_at", "attachments", "authentication", "batch_id", "bcc", "cc", "conversation_id", "created_at", "deleted_at", "delivered_to", "delivery_detail", "delivery_status", "direction", "edited", "email_message_id", "envelope_from", "expires_at", "flag_reason", "flagged", "header_from", "html", "id", "labels", "method", "provider_message_id", "raw_message", "read_status", "rejection_reason", "reply_to", "review_reason", "reviewed_at", "reviewed_by_name", "reviewed_by_user_id", "scan_action", "scan_score", "scheduled_at", "sent_as", "size_bytes", "status", "subject", "text", "to", "type", "verified_domain", "webhook_attempts", "webhook_error", "webhook_status"] model_config = ConfigDict( populate_by_name=True, @@ -206,6 +207,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "approval_expires_at": obj.get("approval_expires_at"), "attachments": [AttachmentMetaView.from_dict(_item) for _item in obj["attachments"]] if obj.get("attachments") is not None else None, "authentication": Authentication.from_dict(obj["authentication"]) if obj.get("authentication") is not None else None, + "batch_id": obj.get("batch_id"), "bcc": obj.get("bcc"), "cc": obj.get("cc"), "conversation_id": obj.get("conversation_id"), diff --git a/sdks/python/src/e2a/v1/generated/models/send_batch_response.py b/sdks/python/src/e2a/v1/generated/models/send_batch_response.py index fa591306d..b78f53117 100644 --- a/sdks/python/src/e2a/v1/generated/models/send_batch_response.py +++ b/sdks/python/src/e2a/v1/generated/models/send_batch_response.py @@ -29,7 +29,7 @@ class SendBatchResponse(BaseModel): """ # noqa: E501 accepted: StrictInt = Field(description="Count of results[] slots that are {message_id}. Redundant with results[] but convenient for logging + zero-check.") batch_id: StrictStr = Field(description="Durable id for this batch (bat_). Use GET /v1/batches/{batch_id} to retrieve the header + status rollup.") - results: List[BatchResult] = Field(description="One slot per request item, positionally aligned. Each slot is either {message_id} (accepted) or {suppressed:{address,reason}} (dropped by the suppression filter).") + results: List[BatchResult] = Field(description="One slot per request item, positionally aligned. Each slot carries a status discriminator: status=\"accepted\" → {message_id}; status=\"suppressed\" → {suppressed:{address,reason}} (dropped by the suppression filter).") suppressed_count: StrictInt = Field(description="Count of results[] slots that are {suppressed}. Zero when no per-item drops occurred.") additional_properties: Dict[str, Any] = {} __properties: ClassVar[List[str]] = ["accepted", "batch_id", "results", "suppressed_count"] diff --git a/sdks/typescript/src/v1/generated/.openapi-generator/FILES b/sdks/typescript/src/v1/generated/.openapi-generator/FILES index 6af4091ee..81a346d99 100644 --- a/sdks/typescript/src/v1/generated/.openapi-generator/FILES +++ b/sdks/typescript/src/v1/generated/.openapi-generator/FILES @@ -33,6 +33,12 @@ models/Attachment.ts models/AttachmentMetaView.ts models/AttachmentView.ts models/Authentication.ts +models/BatchMessage.ts +models/BatchResult.ts +models/BatchStatusRollupView.ts +models/BatchSuppressedItem.ts +models/BatchSuppressedResult.ts +models/BatchView.ts models/ContactDueContact.ts models/ContactDueData.ts models/ContactEngagementView.ts @@ -72,6 +78,7 @@ models/DomainSendingFailedData.ts models/DomainSendingVerifiedData.ts models/DomainSuppressionAddedData.ts models/DomainView.ts +models/DuplicateRecipientDetails.ts models/EmailBouncedData.ts models/EmailComplainedData.ts models/EmailDeliveredData.ts @@ -148,6 +155,8 @@ models/RetryAfterDetails.ts models/ReviewView.ts models/RotateSecretResponse.ts models/SPFResult.ts +models/SendBatchRequest.ts +models/SendBatchResponse.ts models/SendEmailRequest.ts models/SendResultView.ts models/SendingRampView.ts @@ -162,6 +171,7 @@ models/TemplateView.ts models/TestWebhookRequest.ts models/TestWebhookResponse.ts models/ThreatCategoryView.ts +models/TooManyMessagesDetails.ts models/TooManyRecipientsDetails.ts models/UnsubscribeOptions.ts models/UpdateAgentRequest.ts diff --git a/sdks/typescript/src/v1/generated/apis/MessagesApi.ts b/sdks/typescript/src/v1/generated/apis/MessagesApi.ts index d0bb1d055..7c4fb70d9 100644 --- a/sdks/typescript/src/v1/generated/apis/MessagesApi.ts +++ b/sdks/typescript/src/v1/generated/apis/MessagesApi.ts @@ -8,8 +8,8 @@ import {canConsumeForm, isCodeInRange} from '../util.js'; import {SecurityAuthentication} from '../auth/auth.js'; -import { AgentMetricsView } from '../models/AgentMetricsView.js'; import { AttachmentView } from '../models/AttachmentView.js'; +import { BatchView } from '../models/BatchView.js'; import { DeleteMessageResult } from '../models/DeleteMessageResult.js'; import { ErrorEnvelope } from '../models/ErrorEnvelope.js'; import { ForwardRequest } from '../models/ForwardRequest.js'; @@ -19,6 +19,8 @@ import { PageMessageLifecycleTransition } from '../models/PageMessageLifecycleTr import { PageMessageSummaryView } from '../models/PageMessageSummaryView.js'; import { RateLimitedEnvelope } from '../models/RateLimitedEnvelope.js'; import { ReplyRequest } from '../models/ReplyRequest.js'; +import { SendBatchRequest } from '../models/SendBatchRequest.js'; +import { SendBatchResponse } from '../models/SendBatchResponse.js'; import { SendEmailRequest } from '../models/SendEmailRequest.js'; import { SendResultView } from '../models/SendResultView.js'; import { UpdateMessageRequest } from '../models/UpdateMessageRequest.js'; @@ -166,39 +168,48 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { } /** - * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. - * Get an agent\'s delivery metrics (beta) + * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. + * Get an attachment (metadata + short-lived download URL) * @param email - * @param start Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. - * @param end Exclusive end of the cohort window (RFC 3339). Defaults to now. + * @param id + * @param index + * @param inline When true, also include the bytes as base64 in \'data\' — ONLY for attachments <= 256 KB; larger inline requests are rejected (413). Default false (use download_url). */ - public async getAgentMetrics(email: string, start?: Date, end?: Date, _options?: Configuration): Promise { + public async getAttachment(email: string, id: string, index: number, inline?: boolean, _options?: Configuration): Promise { let _config = _options || this.configuration; // verify required parameter 'email' is not null or undefined if (email === null || email === undefined) { - throw new RequiredError("MessagesApi", "getAgentMetrics", "email"); + throw new RequiredError("MessagesApi", "getAttachment", "email"); } + // verify required parameter 'id' is not null or undefined + if (id === null || id === undefined) { + throw new RequiredError("MessagesApi", "getAttachment", "id"); + } + + + // verify required parameter 'index' is not null or undefined + if (index === null || index === undefined) { + throw new RequiredError("MessagesApi", "getAttachment", "index"); + } + // Path Params - const localVarPath = '/v1/agents/{email}/metrics' - .replace('{' + 'email' + '}', encodeURIComponent(String(email))); + const localVarPath = '/v1/agents/{email}/messages/{id}/attachments/{index}' + .replace('{' + 'email' + '}', encodeURIComponent(String(email))) + .replace('{' + 'id' + '}', encodeURIComponent(String(id))) + .replace('{' + 'index' + '}', encodeURIComponent(String(index))); // Make Request Context const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET); requestContext.setHeaderParam("Accept", "application/json, */*;q=0.8") // Query Params - if (start !== undefined) { - requestContext.setQueryParam("start", ObjectSerializer.serialize(start, "Date", "date-time")); - } - - // Query Params - if (end !== undefined) { - requestContext.setQueryParam("end", ObjectSerializer.serialize(end, "Date", "date-time")); + if (inline !== undefined) { + requestContext.setQueryParam("inline", ObjectSerializer.serialize(inline, "boolean", "")); } @@ -218,50 +229,27 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { } /** - * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. - * Get an attachment (metadata + short-lived download URL) - * @param email - * @param id - * @param index - * @param inline When true, also include the bytes as base64 in \'data\' — ONLY for attachments <= 256 KB; larger inline requests are rejected (413). Default false (use download_url). + * Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch\'s child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Get a batch\'s header and delivery-status rollup (beta) + * @param batchId The batch id, e.g. bat_abc123. */ - public async getAttachment(email: string, id: string, index: number, inline?: boolean, _options?: Configuration): Promise { + public async getBatch(batchId: string, _options?: Configuration): Promise { let _config = _options || this.configuration; - // verify required parameter 'email' is not null or undefined - if (email === null || email === undefined) { - throw new RequiredError("MessagesApi", "getAttachment", "email"); - } - - - // verify required parameter 'id' is not null or undefined - if (id === null || id === undefined) { - throw new RequiredError("MessagesApi", "getAttachment", "id"); - } - - - // verify required parameter 'index' is not null or undefined - if (index === null || index === undefined) { - throw new RequiredError("MessagesApi", "getAttachment", "index"); + // verify required parameter 'batchId' is not null or undefined + if (batchId === null || batchId === undefined) { + throw new RequiredError("MessagesApi", "getBatch", "batchId"); } - // Path Params - const localVarPath = '/v1/agents/{email}/messages/{id}/attachments/{index}' - .replace('{' + 'email' + '}', encodeURIComponent(String(email))) - .replace('{' + 'id' + '}', encodeURIComponent(String(id))) - .replace('{' + 'index' + '}', encodeURIComponent(String(index))); + const localVarPath = '/v1/batches/{batch_id}' + .replace('{' + 'batch_id' + '}', encodeURIComponent(String(batchId))); // Make Request Context const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET); requestContext.setHeaderParam("Accept", "application/json, */*;q=0.8") - // Query Params - if (inline !== undefined) { - requestContext.setQueryParam("inline", ObjectSerializer.serialize(inline, "boolean", "")); - } - let authMethod: SecurityAuthentication | undefined; // Apply auth methods @@ -394,15 +382,15 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { * @param from_ Case-insensitive substring match on sender. * @param subjectContains Case-insensitive substring match on subject. * @param conversationId + * @param batchId Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123. * @param labels Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. * @param since RFC3339; created_at >= since. * @param until RFC3339; created_at < until. * @param cursor * @param limit * @param deleted List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). - * @param filter Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public async listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: Configuration): Promise { + public async listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: Configuration): Promise { let _config = _options || this.configuration; // verify required parameter 'email' is not null or undefined @@ -462,6 +450,11 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { requestContext.setQueryParam("conversation_id", ObjectSerializer.serialize(conversationId, "string", "")); } + // Query Params + if (batchId !== undefined) { + requestContext.setQueryParam("batch_id", ObjectSerializer.serialize(batchId, "string", "")); + } + // Query Params if (labels !== undefined) { requestContext.setQueryParam("labels", ObjectSerializer.serialize(labels, "Array", "")); @@ -492,11 +485,6 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { requestContext.setQueryParam("deleted", ObjectSerializer.serialize(deleted, "boolean", "")); } - // Query Params - if (filter !== undefined) { - requestContext.setQueryParam("filter", ObjectSerializer.serialize(filter, "string", "")); - } - let authMethod: SecurityAuthentication | undefined; // Apply auth methods @@ -635,6 +623,67 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { return requestContext; } + /** + * Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account\'s suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. Aggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item\'s content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Send a batch of up to 100 emails (beta) + * @param email + * @param sendBatchRequest + * @param idempotencyKey Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch). + */ + public async sendBatch(email: string, sendBatchRequest: SendBatchRequest, idempotencyKey?: string, _options?: Configuration): Promise { + let _config = _options || this.configuration; + + // verify required parameter 'email' is not null or undefined + if (email === null || email === undefined) { + throw new RequiredError("MessagesApi", "sendBatch", "email"); + } + + + // verify required parameter 'sendBatchRequest' is not null or undefined + if (sendBatchRequest === null || sendBatchRequest === undefined) { + throw new RequiredError("MessagesApi", "sendBatch", "sendBatchRequest"); + } + + + + // Path Params + const localVarPath = '/v1/agents/{email}/batches' + .replace('{' + 'email' + '}', encodeURIComponent(String(email))); + + // Make Request Context + const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.POST); + requestContext.setHeaderParam("Accept", "application/json, */*;q=0.8") + + // Header Params + requestContext.setHeaderParam("Idempotency-Key", ObjectSerializer.serialize(idempotencyKey, "string", "")); + + + // Body Params + const contentType = ObjectSerializer.getPreferredMediaType([ + "application/json" + ]); + requestContext.setHeaderParam("Content-Type", contentType); + const serializedBody = ObjectSerializer.stringify( + ObjectSerializer.serialize(sendBatchRequest, "SendBatchRequest", ""), + contentType + ); + requestContext.setBody(serializedBody); + + let authMethod: SecurityAuthentication | undefined; + // Apply auth methods + authMethod = _config.authMethods["bearer"] + if (authMethod?.applySecurityAuthentication) { + await authMethod?.applySecurityAuthentication(requestContext); + } + + const defaultAuth: SecurityAuthentication | undefined = _config?.authMethods?.default + if (defaultAuth?.applySecurityAuthentication) { + await defaultAuth?.applySecurityAuthentication(requestContext); + } + + return requestContext; + } + /** * Beta: scheduled sending may change before it is declared stable. Send a new email from the agent named in the path (a new thread). The sender is the path agent — `reply`/`forward` are their own sub-resources. A future send_at returns 202 + status=scheduled; an HITL hold returns 202 + status=pending_review. Both are successful durable acceptance and must not be re-sent. Honors Idempotency-Key. Attachment limits: at most 10 attachments, each ≤ 10 MiB decoded, ≤ 25 MiB decoded combined (over-count → 400 invalid_request; over-size → 413 payload_too_large). Composed-message ceiling: 10 MiB (10485760 bytes), measured as subject + text + html + decoded attachment bytes; exceeding it returns 413 payload_too_large. Two capacity limits apply and are permanently distinct — branch on the HTTP status: 402 limit_exceeded is a QUOTA (monthly-message / storage stock-or-flow cap; a retry will not clear it — surface an upgrade path), 429 rate_limited is a throughput/request-RATE cap (transient; back off Retry-After seconds and retry). * Send a new email @@ -896,32 +945,39 @@ export class MessagesApiResponseProcessor { * Unwraps the actual response sent by the server from the response context and deserializes the response content * to the expected objects * - * @params response Response returned by the server for a request to getAgentMetrics + * @params response Response returned by the server for a request to getAttachment * @throws ApiException if the response code was not in [200, 299] */ - public async getAgentMetricsWithHttpInfo(response: ResponseContext): Promise> { + public async getAttachmentWithHttpInfo(response: ResponseContext): Promise> { const contentType = ObjectSerializer.normalizeMediaType(response.headers["content-type"]); if (isCodeInRange("200", response.httpStatusCode)) { - const body: AgentMetricsView = ObjectSerializer.deserialize( + const body: AttachmentView = ObjectSerializer.deserialize( ObjectSerializer.parse(await response.body.text(), contentType), - "AgentMetricsView", "" - ) as AgentMetricsView; + "AttachmentView", "" + ) as AttachmentView; return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); } + if (isCodeInRange("413", response.httpStatusCode)) { + const body: ErrorEnvelope = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "ErrorEnvelope", "" + ) as ErrorEnvelope; + throw new ApiException(response.httpStatusCode, "Payload Too Large — code attachment_too_large: `?inline=true` was requested for an attachment over the 256 KB inline cap. Fetch the bytes via download_url instead; the metadata (and this error) tells you the size.", body, response.headers); + } if (isCodeInRange("0", response.httpStatusCode)) { const body: ErrorEnvelope = ObjectSerializer.deserialize( ObjectSerializer.parse(await response.body.text(), contentType), "ErrorEnvelope", "" ) as ErrorEnvelope; - throw new ApiException(response.httpStatusCode, "Error", body, response.headers); + throw new ApiException(response.httpStatusCode, "Error — the standard envelope; branch on error.code.", body, response.headers); } // Work around for missing responses in specification, e.g. for petstore.yaml if (response.httpStatusCode >= 200 && response.httpStatusCode <= 299) { - const body: AgentMetricsView = ObjectSerializer.deserialize( + const body: AttachmentView = ObjectSerializer.deserialize( ObjectSerializer.parse(await response.body.text(), contentType), - "AgentMetricsView", "" - ) as AgentMetricsView; + "AttachmentView", "" + ) as AttachmentView; return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); } @@ -932,24 +988,24 @@ export class MessagesApiResponseProcessor { * Unwraps the actual response sent by the server from the response context and deserializes the response content * to the expected objects * - * @params response Response returned by the server for a request to getAttachment + * @params response Response returned by the server for a request to getBatch * @throws ApiException if the response code was not in [200, 299] */ - public async getAttachmentWithHttpInfo(response: ResponseContext): Promise> { + public async getBatchWithHttpInfo(response: ResponseContext): Promise> { const contentType = ObjectSerializer.normalizeMediaType(response.headers["content-type"]); if (isCodeInRange("200", response.httpStatusCode)) { - const body: AttachmentView = ObjectSerializer.deserialize( + const body: BatchView = ObjectSerializer.deserialize( ObjectSerializer.parse(await response.body.text(), contentType), - "AttachmentView", "" - ) as AttachmentView; + "BatchView", "" + ) as BatchView; return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); } - if (isCodeInRange("413", response.httpStatusCode)) { + if (isCodeInRange("404", response.httpStatusCode)) { const body: ErrorEnvelope = ObjectSerializer.deserialize( ObjectSerializer.parse(await response.body.text(), contentType), "ErrorEnvelope", "" ) as ErrorEnvelope; - throw new ApiException(response.httpStatusCode, "Payload Too Large — code attachment_too_large: `?inline=true` was requested for an attachment over the 256 KB inline cap. Fetch the bytes via download_url instead; the metadata (and this error) tells you the size.", body, response.headers); + throw new ApiException(response.httpStatusCode, "Error — the standard envelope; branch on error.code.", body, response.headers); } if (isCodeInRange("0", response.httpStatusCode)) { const body: ErrorEnvelope = ObjectSerializer.deserialize( @@ -961,10 +1017,10 @@ export class MessagesApiResponseProcessor { // Work around for missing responses in specification, e.g. for petstore.yaml if (response.httpStatusCode >= 200 && response.httpStatusCode <= 299) { - const body: AttachmentView = ObjectSerializer.deserialize( + const body: BatchView = ObjectSerializer.deserialize( ObjectSerializer.parse(await response.body.text(), contentType), - "AttachmentView", "" - ) as AttachmentView; + "BatchView", "" + ) as BatchView; return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); } @@ -1200,6 +1256,98 @@ export class MessagesApiResponseProcessor { throw new ApiException(response.httpStatusCode, "Unknown API Status Code!", await response.getBodyAsAny(), response.headers); } + /** + * Unwraps the actual response sent by the server from the response context and deserializes the response content + * to the expected objects + * + * @params response Response returned by the server for a request to sendBatch + * @throws ApiException if the response code was not in [200, 299] + */ + public async sendBatchWithHttpInfo(response: ResponseContext): Promise> { + const contentType = ObjectSerializer.normalizeMediaType(response.headers["content-type"]); + if (isCodeInRange("200", response.httpStatusCode)) { + const body: SendBatchResponse = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "SendBatchResponse", "" + ) as SendBatchResponse; + return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); + } + if (isCodeInRange("202", response.httpStatusCode)) { + const body: SendBatchResponse = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "SendBatchResponse", "" + ) as SendBatchResponse; + return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); + } + if (isCodeInRange("400", response.httpStatusCode)) { + const body: ErrorEnvelope = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "ErrorEnvelope", "" + ) as ErrorEnvelope; + throw new ApiException(response.httpStatusCode, "Bad Request — request-shape/validation failure. error.code includes invalid_request, invalid_recipient, too_many_messages, duplicate_recipient, invalid_attachment (undecodable base64). Per-item errors carry details.item_index identifying the offending messages[] index; batch-wide errors omit it.", body, response.headers); + } + if (isCodeInRange("402", response.httpStatusCode)) { + const body: LimitExceededEnvelope = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "LimitExceededEnvelope", "" + ) as LimitExceededEnvelope; + throw new ApiException(response.httpStatusCode, "Payment required — a per-account resource cap was hit (code limit_exceeded). error.details.resource is the AccountView usage/limits field stem (agents, domains, messages_month, storage_bytes), so the client can key it to usage.<resource> / limits.max_<resource>. This is a QUOTA (stock/flow) cap — distinct from a 429 rate_limited (throughput). A retry alone will not clear it; surface a quota/upgrade path.", body, response.headers); + } + if (isCodeInRange("403", response.httpStatusCode)) { + const body: ErrorEnvelope = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "ErrorEnvelope", "" + ) as ErrorEnvelope; + throw new ApiException(response.httpStatusCode, "Error — the standard envelope; branch on error.code.", body, response.headers); + } + if (isCodeInRange("409", response.httpStatusCode)) { + const body: ErrorEnvelope = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "ErrorEnvelope", "" + ) as ErrorEnvelope; + throw new ApiException(response.httpStatusCode, "Conflict — code idempotency_in_flight: a request with this Idempotency-Key is still executing. Retry-able: wait for the first request to finish, then retry with the SAME key and byte-identical body — the retry replays the first request\'s response instead of re-executing the side effect.", body, response.headers); + } + if (isCodeInRange("413", response.httpStatusCode)) { + const body: ErrorEnvelope = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "ErrorEnvelope", "" + ) as ErrorEnvelope; + throw new ApiException(response.httpStatusCode, "Payload Too Large — error.code = payload_too_large. An attachment exceeds 10 MiB decoded; combined attachments exceed 25 MiB decoded; or the composed message exceeds 10 MiB (10485760 bytes), measured as subject + text + html + decoded attachment bytes. error.details uses PayloadTooLargeDetails: {scope, actual_bytes, max_bytes, filename?}; scope identifies composed_message, attachment, attachments_total, or request_body.", body, response.headers); + } + if (isCodeInRange("422", response.httpStatusCode)) { + const body: ErrorEnvelope = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "ErrorEnvelope", "" + ) as ErrorEnvelope; + throw new ApiException(response.httpStatusCode, "Unprocessable — branch on error.code. idempotency_key_reuse: this Idempotency-Key was already used with a DIFFERENT request body (the dedup hash covers the route + the raw body bytes) — do NOT retry as-is; a legitimate retry must resend the byte-identical body, and a genuinely new request needs a fresh key. invalid_request: a semantic validation failure in the request body.", body, response.headers); + } + if (isCodeInRange("429", response.httpStatusCode)) { + const body: RateLimitedEnvelope = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "RateLimitedEnvelope", "" + ) as RateLimitedEnvelope; + throw new ApiException(response.httpStatusCode, "Too Many Requests — a request-RATE / throughput limit was hit (code rate_limited). This is distinct from a 402 limit_exceeded (a QUOTA cap): a 429 is transient and retry-able — wait error.details.retry_after_seconds (mirrored on the Retry-After header), then the same request succeeds. Branch on the HTTP status: 429 → back off and retry; 402 → surface a quota/upgrade path.", body, response.headers); + } + if (isCodeInRange("0", response.httpStatusCode)) { + const body: ErrorEnvelope = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "ErrorEnvelope", "" + ) as ErrorEnvelope; + throw new ApiException(response.httpStatusCode, "Error — the standard envelope; branch on error.code.", body, response.headers); + } + + // Work around for missing responses in specification, e.g. for petstore.yaml + if (response.httpStatusCode >= 200 && response.httpStatusCode <= 299) { + const body: SendBatchResponse = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "SendBatchResponse", "" + ) as SendBatchResponse; + return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); + } + + throw new ApiException(response.httpStatusCode, "Unknown API Status Code!", await response.getBodyAsAny(), response.headers); + } + /** * Unwraps the actual response sent by the server from the response context and deserializes the response content * to the expected objects diff --git a/sdks/typescript/src/v1/generated/models/BatchResult.ts b/sdks/typescript/src/v1/generated/models/BatchResult.ts index 118ad685e..dd1af9ef0 100644 --- a/sdks/typescript/src/v1/generated/models/BatchResult.ts +++ b/sdks/typescript/src/v1/generated/models/BatchResult.ts @@ -15,11 +15,15 @@ import { HttpFile } from '../http/http.js'; export class BatchResult { /** - * Minted message id when the item was accepted (delivery_status=\'accepted\' persisted and River outbound_send job enqueued). Absent when Suppressed is present. + * Minted message id when the item was accepted (delivery_status=\'accepted\' persisted and River outbound_send job enqueued). Present iff status is \"accepted\". */ 'messageId'?: string; /** - * Present when the item was dropped by the suppression-list filter (docs/design/batch-send.md §2.2). No message row exists for a suppressed slot; the caller can un-suppress via DELETE /v1/account/suppressions/{address} and resubmit. + * Slot discriminator, always present: \"accepted\" (message_id is set) or \"suppressed\" (suppressed is set). Branch on this rather than inferring the slot shape from which optional field is populated. + */ + 'status': BatchResultStatusEnum; + /** + * Present iff status is \"suppressed\": the item was dropped by the suppression-list filter (docs/design/batch-send.md §2.2). No message row exists for a suppressed slot; the caller can un-suppress via DELETE /v1/account/suppressions/{address} and resubmit. */ 'suppressed'?: BatchSuppressedResult; @@ -34,6 +38,12 @@ export class BatchResult { "type": "string", "format": "" }, + { + "name": "status", + "baseName": "status", + "type": "BatchResultStatusEnum", + "format": "" + }, { "name": "suppressed", "baseName": "suppressed", @@ -48,3 +58,9 @@ export class BatchResult { public constructor() { } } + +export enum BatchResultStatusEnum { + Accepted = 'accepted', + Suppressed = 'suppressed' +} + diff --git a/sdks/typescript/src/v1/generated/models/BatchSuppressedResult.ts b/sdks/typescript/src/v1/generated/models/BatchSuppressedResult.ts index 4a12dcbf5..6c096641b 100644 --- a/sdks/typescript/src/v1/generated/models/BatchSuppressedResult.ts +++ b/sdks/typescript/src/v1/generated/models/BatchSuppressedResult.ts @@ -14,11 +14,11 @@ import { HttpFile } from '../http/http.js'; export class BatchSuppressedResult { /** - * The recipient address in this item\'s to list that triggered the drop. If multiple addresses in the same item are suppressed, only the first is surfaced (dropping the whole item is enough signal). + * The recipient address in this item\'s to/cc/bcc envelope that triggered the drop. If multiple addresses in the same item are suppressed, only the first is surfaced (dropping the whole item is enough signal). */ 'address': string; /** - * The suppression-list category from suppressions.source. Known values: bounce, complaint, manual. Open set — treat as string and tolerate unknown values. + * The suppression category. Known values: bounce, complaint, manual (account-level, from suppressions.source), unsubscribe (agent-level, from agent_suppressions.source). Open set — treat as string and tolerate unknown values. */ 'reason': string; diff --git a/sdks/typescript/src/v1/generated/models/EmailBouncedData.ts b/sdks/typescript/src/v1/generated/models/EmailBouncedData.ts index bf9b9bd91..f847b696a 100644 --- a/sdks/typescript/src/v1/generated/models/EmailBouncedData.ts +++ b/sdks/typescript/src/v1/generated/models/EmailBouncedData.ts @@ -15,6 +15,7 @@ import { HttpFile } from '../http/http.js'; export class EmailBouncedData { 'agentEmail': string; + 'batchId'?: string; 'bounceSubType'?: string; 'bounceType': EmailBouncedDataBounceTypeEnum; /** @@ -41,6 +42,12 @@ export class EmailBouncedData { "type": "string", "format": "" }, + { + "name": "batchId", + "baseName": "batch_id", + "type": "string", + "format": "" + }, { "name": "bounceSubType", "baseName": "bounce_sub_type", diff --git a/sdks/typescript/src/v1/generated/models/EmailComplainedData.ts b/sdks/typescript/src/v1/generated/models/EmailComplainedData.ts index 823e7263d..8ad89133c 100644 --- a/sdks/typescript/src/v1/generated/models/EmailComplainedData.ts +++ b/sdks/typescript/src/v1/generated/models/EmailComplainedData.ts @@ -15,6 +15,7 @@ import { HttpFile } from '../http/http.js'; export class EmailComplainedData { 'agentEmail': string; + 'batchId'?: string; /** * The one recipient address this per-recipient outcome is about. */ @@ -39,6 +40,12 @@ export class EmailComplainedData { "type": "string", "format": "" }, + { + "name": "batchId", + "baseName": "batch_id", + "type": "string", + "format": "" + }, { "name": "deliveredTo", "baseName": "delivered_to", diff --git a/sdks/typescript/src/v1/generated/models/EmailDeliveredData.ts b/sdks/typescript/src/v1/generated/models/EmailDeliveredData.ts index ec742b431..7a6456448 100644 --- a/sdks/typescript/src/v1/generated/models/EmailDeliveredData.ts +++ b/sdks/typescript/src/v1/generated/models/EmailDeliveredData.ts @@ -15,6 +15,7 @@ import { HttpFile } from '../http/http.js'; export class EmailDeliveredData { 'agentEmail': string; + 'batchId'?: string; /** * The one recipient address this per-recipient outcome is about. */ @@ -39,6 +40,12 @@ export class EmailDeliveredData { "type": "string", "format": "" }, + { + "name": "batchId", + "baseName": "batch_id", + "type": "string", + "format": "" + }, { "name": "deliveredTo", "baseName": "delivered_to", diff --git a/sdks/typescript/src/v1/generated/models/ErrorBody.ts b/sdks/typescript/src/v1/generated/models/ErrorBody.ts index 9a32c74b3..223dba459 100644 --- a/sdks/typescript/src/v1/generated/models/ErrorBody.ts +++ b/sdks/typescript/src/v1/generated/models/ErrorBody.ts @@ -14,7 +14,7 @@ import { HttpFile } from '../http/http.js'; export class ErrorBody { /** - * Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status. + * Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, batch_hitl_unsupported, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, too_many_messages, duplicate_recipient, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, send_in_progress, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), batch_hitl_unsupported (403, batch send refused for HITL-enabled agent — use single-send per recipient or disable HITL). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, too_many_messages (batch: cap on messages[]), duplicate_recipient (batch: same address in to across items), template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), send_in_progress (409), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status. */ 'code': string; /** diff --git a/sdks/typescript/src/v1/generated/models/ObjectSerializer.ts b/sdks/typescript/src/v1/generated/models/ObjectSerializer.ts index 75cdc8ff6..8934acb70 100644 --- a/sdks/typescript/src/v1/generated/models/ObjectSerializer.ts +++ b/sdks/typescript/src/v1/generated/models/ObjectSerializer.ts @@ -14,6 +14,12 @@ export * from '../models/Attachment.js'; export * from '../models/AttachmentMetaView.js'; export * from '../models/AttachmentView.js'; export * from '../models/Authentication.js'; +export * from '../models/BatchMessage.js'; +export * from '../models/BatchResult.js'; +export * from '../models/BatchStatusRollupView.js'; +export * from '../models/BatchSuppressedItem.js'; +export * from '../models/BatchSuppressedResult.js'; +export * from '../models/BatchView.js'; export * from '../models/ContactDueContact.js'; export * from '../models/ContactDueData.js'; export * from '../models/ContactEngagementView.js'; @@ -53,6 +59,7 @@ export * from '../models/DomainSendingFailedData.js'; export * from '../models/DomainSendingVerifiedData.js'; export * from '../models/DomainSuppressionAddedData.js'; export * from '../models/DomainView.js'; +export * from '../models/DuplicateRecipientDetails.js'; export * from '../models/EmailBouncedData.js'; export * from '../models/EmailComplainedData.js'; export * from '../models/EmailDeliveredData.js'; @@ -128,6 +135,8 @@ export * from '../models/RetryAfterDetails.js'; export * from '../models/ReviewView.js'; export * from '../models/RotateSecretResponse.js'; export * from '../models/SPFResult.js'; +export * from '../models/SendBatchRequest.js'; +export * from '../models/SendBatchResponse.js'; export * from '../models/SendEmailRequest.js'; export * from '../models/SendResultView.js'; export * from '../models/SendingRampView.js'; @@ -142,6 +151,7 @@ export * from '../models/TemplateView.js'; export * from '../models/TestWebhookRequest.js'; export * from '../models/TestWebhookResponse.js'; export * from '../models/ThreatCategoryView.js'; +export * from '../models/TooManyMessagesDetails.js'; export * from '../models/TooManyRecipientsDetails.js'; export * from '../models/UnsubscribeOptions.js'; export * from '../models/UpdateAgentRequest.js'; @@ -181,6 +191,12 @@ import { Attachment } from '../models/Attachment.js'; import { AttachmentMetaView } from '../models/AttachmentMetaView.js'; import { AttachmentView } from '../models/AttachmentView.js'; import { Authentication } from '../models/Authentication.js'; +import { BatchMessage } from '../models/BatchMessage.js'; +import { BatchResult , BatchResultStatusEnum } from '../models/BatchResult.js'; +import { BatchStatusRollupView } from '../models/BatchStatusRollupView.js'; +import { BatchSuppressedItem } from '../models/BatchSuppressedItem.js'; +import { BatchSuppressedResult } from '../models/BatchSuppressedResult.js'; +import { BatchView } from '../models/BatchView.js'; import { ContactDueContact } from '../models/ContactDueContact.js'; import { ContactDueData } from '../models/ContactDueData.js'; import { ContactEngagementView } from '../models/ContactEngagementView.js'; @@ -220,7 +236,8 @@ import { DomainSendingFailedData } from '../models/DomainSendingFailedData.js'; import { DomainSendingVerifiedData } from '../models/DomainSendingVerifiedData.js'; import { DomainSuppressionAddedData } from '../models/DomainSuppressionAddedData.js'; import { DomainView } from '../models/DomainView.js'; -import { EmailBouncedData , EmailBouncedDataBounceTypeEnum } from '../models/EmailBouncedData.js'; +import { DuplicateRecipientDetails } from '../models/DuplicateRecipientDetails.js'; +import { EmailBouncedData , EmailBouncedDataBounceTypeEnum } from '../models/EmailBouncedData.js'; import { EmailComplainedData } from '../models/EmailComplainedData.js'; import { EmailDeliveredData } from '../models/EmailDeliveredData.js'; import { EmailFailedData } from '../models/EmailFailedData.js'; @@ -294,6 +311,8 @@ import { RetryAfterDetails } from '../models/RetryAfterDetails.js'; import { ReviewView } from '../models/ReviewView.js'; import { RotateSecretResponse } from '../models/RotateSecretResponse.js'; import { SPFResult } from '../models/SPFResult.js'; +import { SendBatchRequest } from '../models/SendBatchRequest.js'; +import { SendBatchResponse } from '../models/SendBatchResponse.js'; import { SendEmailRequest } from '../models/SendEmailRequest.js'; import { SendResultView } from '../models/SendResultView.js'; import { SendingRampView } from '../models/SendingRampView.js'; @@ -308,6 +327,7 @@ import { TemplateView } from '../models/TemplateView.js'; import { TestWebhookRequest , TestWebhookRequestTypeEnum } from '../models/TestWebhookRequest.js'; import { TestWebhookResponse } from '../models/TestWebhookResponse.js'; import { ThreatCategoryView } from '../models/ThreatCategoryView.js'; +import { TooManyMessagesDetails } from '../models/TooManyMessagesDetails.js'; import { TooManyRecipientsDetails } from '../models/TooManyRecipientsDetails.js'; import { UnsubscribeOptions, UnsubscribeOptionsModeEnum } from '../models/UnsubscribeOptions.js'; import { UpdateAgentRequest } from '../models/UpdateAgentRequest.js'; @@ -344,6 +364,7 @@ let primitives = [ ]; let enumsMap: Set = new Set([ + "BatchResultStatusEnum", "CreateAPIKeyRequestScopeEnum", "CreateWebhookRequestEventsEnum", "DKIMResultStatusEnum", @@ -388,6 +409,12 @@ let typeMap: {[index: string]: any} = { "AttachmentMetaView": AttachmentMetaView, "AttachmentView": AttachmentView, "Authentication": Authentication, + "BatchMessage": BatchMessage, + "BatchResult": BatchResult, + "BatchStatusRollupView": BatchStatusRollupView, + "BatchSuppressedItem": BatchSuppressedItem, + "BatchSuppressedResult": BatchSuppressedResult, + "BatchView": BatchView, "ContactDueContact": ContactDueContact, "ContactDueData": ContactDueData, "ContactEngagementView": ContactEngagementView, @@ -427,6 +454,7 @@ let typeMap: {[index: string]: any} = { "DomainSendingVerifiedData": DomainSendingVerifiedData, "DomainSuppressionAddedData": DomainSuppressionAddedData, "DomainView": DomainView, + "DuplicateRecipientDetails": DuplicateRecipientDetails, "EmailBouncedData": EmailBouncedData, "EmailComplainedData": EmailComplainedData, "EmailDeliveredData": EmailDeliveredData, @@ -501,6 +529,8 @@ let typeMap: {[index: string]: any} = { "ReviewView": ReviewView, "RotateSecretResponse": RotateSecretResponse, "SPFResult": SPFResult, + "SendBatchRequest": SendBatchRequest, + "SendBatchResponse": SendBatchResponse, "SendEmailRequest": SendEmailRequest, "SendResultView": SendResultView, "SendingRampView": SendingRampView, @@ -515,6 +545,7 @@ let typeMap: {[index: string]: any} = { "TestWebhookRequest": TestWebhookRequest, "TestWebhookResponse": TestWebhookResponse, "ThreatCategoryView": ThreatCategoryView, + "TooManyMessagesDetails": TooManyMessagesDetails, "TooManyRecipientsDetails": TooManyRecipientsDetails, "UnsubscribeOptions": UnsubscribeOptions, "UpdateAgentRequest": UpdateAgentRequest, diff --git a/sdks/typescript/src/v1/generated/models/SendBatchResponse.ts b/sdks/typescript/src/v1/generated/models/SendBatchResponse.ts index 1f3a77d58..836ed0cad 100644 --- a/sdks/typescript/src/v1/generated/models/SendBatchResponse.ts +++ b/sdks/typescript/src/v1/generated/models/SendBatchResponse.ts @@ -23,7 +23,7 @@ export class SendBatchResponse { */ 'batchId': string; /** - * One slot per request item, positionally aligned. Each slot is either {message_id} (accepted) or {suppressed:{address,reason}} (dropped by the suppression filter). + * One slot per request item, positionally aligned. Each slot carries a status discriminator: status=\"accepted\" → {message_id}; status=\"suppressed\" → {suppressed:{address,reason}} (dropped by the suppression filter). */ 'results': Array; /** diff --git a/sdks/typescript/src/v1/generated/models/all.ts b/sdks/typescript/src/v1/generated/models/all.ts index 6ed989926..29fc78b76 100644 --- a/sdks/typescript/src/v1/generated/models/all.ts +++ b/sdks/typescript/src/v1/generated/models/all.ts @@ -14,6 +14,12 @@ export * from '../models/Attachment.js' export * from '../models/AttachmentMetaView.js' export * from '../models/AttachmentView.js' export * from '../models/Authentication.js' +export * from '../models/BatchMessage.js' +export * from '../models/BatchResult.js' +export * from '../models/BatchStatusRollupView.js' +export * from '../models/BatchSuppressedItem.js' +export * from '../models/BatchSuppressedResult.js' +export * from '../models/BatchView.js' export * from '../models/ContactDueContact.js' export * from '../models/ContactDueData.js' export * from '../models/ContactEngagementView.js' @@ -53,6 +59,7 @@ export * from '../models/DomainSendingFailedData.js' export * from '../models/DomainSendingVerifiedData.js' export * from '../models/DomainSuppressionAddedData.js' export * from '../models/DomainView.js' +export * from '../models/DuplicateRecipientDetails.js' export * from '../models/EmailBouncedData.js' export * from '../models/EmailComplainedData.js' export * from '../models/EmailDeliveredData.js' @@ -128,6 +135,8 @@ export * from '../models/RetryAfterDetails.js' export * from '../models/ReviewView.js' export * from '../models/RotateSecretResponse.js' export * from '../models/SPFResult.js' +export * from '../models/SendBatchRequest.js' +export * from '../models/SendBatchResponse.js' export * from '../models/SendEmailRequest.js' export * from '../models/SendResultView.js' export * from '../models/SendingRampView.js' @@ -142,6 +151,7 @@ export * from '../models/TemplateView.js' export * from '../models/TestWebhookRequest.js' export * from '../models/TestWebhookResponse.js' export * from '../models/ThreatCategoryView.js' +export * from '../models/TooManyMessagesDetails.js' export * from '../models/TooManyRecipientsDetails.js' export * from '../models/UnsubscribeOptions.js' export * from '../models/UpdateAgentRequest.js' diff --git a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts index 20a704c87..0a110840e 100644 --- a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts @@ -4,12 +4,9 @@ import type { Middleware } from '../middleware.js'; import { APIKeyExportEntry } from '../models/APIKeyExportEntry.js'; import { APIKeyView } from '../models/APIKeyView.js'; -import { AccountMetricsView } from '../models/AccountMetricsView.js'; import { AccountUserView } from '../models/AccountUserView.js'; import { AccountView } from '../models/AccountView.js'; import { AgentIdentity } from '../models/AgentIdentity.js'; -import { AgentMetricsGroupView } from '../models/AgentMetricsGroupView.js'; -import { AgentMetricsView } from '../models/AgentMetricsView.js'; import { AgentSuppressionAddedData } from '../models/AgentSuppressionAddedData.js'; import { AgentSuppressionView } from '../models/AgentSuppressionView.js'; import { AgentView } from '../models/AgentView.js'; @@ -17,6 +14,12 @@ import { ApproveRequest } from '../models/ApproveRequest.js'; import { Attachment } from '../models/Attachment.js'; import { AttachmentMetaView } from '../models/AttachmentMetaView.js'; import { AttachmentView } from '../models/AttachmentView.js'; +import { BatchMessage } from '../models/BatchMessage.js'; +import { BatchResult } from '../models/BatchResult.js'; +import { BatchStatusRollupView } from '../models/BatchStatusRollupView.js'; +import { BatchSuppressedItem } from '../models/BatchSuppressedItem.js'; +import { BatchSuppressedResult } from '../models/BatchSuppressedResult.js'; +import { BatchView } from '../models/BatchView.js'; import { ContactDueContact } from '../models/ContactDueContact.js'; import { ContactDueData } from '../models/ContactDueData.js'; import { ContactEngagementView } from '../models/ContactEngagementView.js'; @@ -54,6 +57,7 @@ import { DomainSendingFailedData } from '../models/DomainSendingFailedData.js'; import { DomainSendingVerifiedData } from '../models/DomainSendingVerifiedData.js'; import { DomainSuppressionAddedData } from '../models/DomainSuppressionAddedData.js'; import { DomainView } from '../models/DomainView.js'; +import { DuplicateRecipientDetails } from '../models/DuplicateRecipientDetails.js'; import { EmailBouncedData } from '../models/EmailBouncedData.js'; import { EmailComplainedData } from '../models/EmailComplainedData.js'; import { EmailDeliveredData } from '../models/EmailDeliveredData.js'; @@ -67,7 +71,6 @@ import { EventEnvelope } from '../models/EventEnvelope.js'; import { EventView } from '../models/EventView.js'; import { FieldError } from '../models/FieldError.js'; import { ForwardRequest } from '../models/ForwardRequest.js'; -import { ForwardRequestReplyTo } from '../models/ForwardRequestReplyTo.js'; import { HoldReasonView } from '../models/HoldReasonView.js'; import { ImportContactsRequest } from '../models/ImportContactsRequest.js'; import { LimitExceededDetails } from '../models/LimitExceededDetails.js'; @@ -81,9 +84,6 @@ import { MessageLifecycleTransition } from '../models/MessageLifecycleTransition import { MessageParsedView } from '../models/MessageParsedView.js'; import { MessageSummaryView } from '../models/MessageSummaryView.js'; import { MessageView } from '../models/MessageView.js'; -import { MetricsCounterView } from '../models/MetricsCounterView.js'; -import { MetricsRatesView } from '../models/MetricsRatesView.js'; -import { MetricsSummaryView } from '../models/MetricsSummaryView.js'; import { OAuthConnectionEntry } from '../models/OAuthConnectionEntry.js'; import { PageAPIKeyView } from '../models/PageAPIKeyView.js'; import { PageAgentSuppressionView } from '../models/PageAgentSuppressionView.js'; @@ -129,6 +129,8 @@ import { RetryAfterDetails } from '../models/RetryAfterDetails.js'; import { ReviewView } from '../models/ReviewView.js'; import { RotateSecretResponse } from '../models/RotateSecretResponse.js'; import { SPFResult } from '../models/SPFResult.js'; +import { SendBatchRequest } from '../models/SendBatchRequest.js'; +import { SendBatchResponse } from '../models/SendBatchResponse.js'; import { SendEmailRequest } from '../models/SendEmailRequest.js'; import { SendResultView } from '../models/SendResultView.js'; import { StarterTemplateDetailView } from '../models/StarterTemplateDetailView.js'; @@ -142,6 +144,7 @@ import { TemplateView } from '../models/TemplateView.js'; import { TestWebhookRequest } from '../models/TestWebhookRequest.js'; import { TestWebhookResponse } from '../models/TestWebhookResponse.js'; import { ThreatCategoryView } from '../models/ThreatCategoryView.js'; +import { TooManyMessagesDetails } from '../models/TooManyMessagesDetails.js'; import { TooManyRecipientsDetails } from '../models/TooManyRecipientsDetails.js'; import { UnsubscribeOptions } from '../models/UnsubscribeOptions.js'; import { UpdateAgentRequest } from '../models/UpdateAgentRequest.js'; @@ -159,10 +162,8 @@ import { ValidateTemplateResponse } from '../models/ValidateTemplateResponse.js' import { ValidationErrorDetails } from '../models/ValidationErrorDetails.js'; import { VerifyDomainView } from '../models/VerifyDomainView.js'; import { WebhookDeliveryView } from '../models/WebhookDeliveryView.js'; -import { WebhookEndpointMetricsView } from '../models/WebhookEndpointMetricsView.js'; import { WebhookFiltersRequest } from '../models/WebhookFiltersRequest.js'; import { WebhookFiltersView } from '../models/WebhookFiltersView.js'; -import { WebhookMetricsView } from '../models/WebhookMetricsView.js'; import { WebhookView } from '../models/WebhookView.js'; import { ObservableAccountApi } from "./ObservableAPI.js"; @@ -234,30 +235,6 @@ export interface AccountApiExportAccountRequest { export interface AccountApiGetAccountRequest { } -export interface AccountApiGetAccountMetricsRequest { - /** - * Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. - * Defaults to: undefined - * @type Date - * @memberof AccountApigetAccountMetrics - */ - start?: Date - /** - * Exclusive end of the cohort window (RFC 3339). Defaults to now. - * Defaults to: undefined - * @type Date - * @memberof AccountApigetAccountMetrics - */ - end?: Date - /** - * Set to \'agent\' to also receive a per-agent breakdown. Omit for account totals only, which is the cheaper read. - * Defaults to: undefined - * @type 'agent' - * @memberof AccountApigetAccountMetrics - */ - groupBy?: 'agent' -} - export interface AccountApiListApiKeysRequest { /** * Opaque pagination cursor from a previous response\'s next_cursor. Continuation requests must not change the other filters. @@ -411,24 +388,6 @@ export class ObjectAccountApi { return this.api.getAccount( options).toPromise(); } - /** - * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. - * Get account-wide delivery metrics (beta) - * @param param the request object - */ - public getAccountMetricsWithHttpInfo(param: AccountApiGetAccountMetricsRequest = {}, options?: ConfigurationOptions): Promise> { - return this.api.getAccountMetricsWithHttpInfo(param.start, param.end, param.groupBy, options).toPromise(); - } - - /** - * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. - * Get account-wide delivery metrics (beta) - * @param param the request object - */ - public getAccountMetrics(param: AccountApiGetAccountMetricsRequest = {}, options?: ConfigurationOptions): Promise { - return this.api.getAccountMetrics(param.start, param.end, param.groupBy, options).toPromise(); - } - /** * API keys for the account (metadata only — secrets are shown once, at creation). Account scope only: an agent-scoped credential cannot manage keys. * List API keys @@ -1870,30 +1829,6 @@ export interface MessagesApiForwardMessageRequest { wait?: string } -export interface MessagesApiGetAgentMetricsRequest { - /** - * - * Defaults to: undefined - * @type string - * @memberof MessagesApigetAgentMetrics - */ - email: string - /** - * Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. - * Defaults to: undefined - * @type Date - * @memberof MessagesApigetAgentMetrics - */ - start?: Date - /** - * Exclusive end of the cohort window (RFC 3339). Defaults to now. - * Defaults to: undefined - * @type Date - * @memberof MessagesApigetAgentMetrics - */ - end?: Date -} - export interface MessagesApiGetAttachmentRequest { /** * @@ -1926,6 +1861,16 @@ export interface MessagesApiGetAttachmentRequest { inline?: boolean } +export interface MessagesApiGetBatchRequest { + /** + * The batch id, e.g. bat_abc123. + * Defaults to: undefined + * @type string + * @memberof MessagesApigetBatch + */ + batchId: string +} + export interface MessagesApiGetMessageRequest { /** * The agent\'s full email address. @@ -2026,6 +1971,13 @@ export interface MessagesApiListMessagesRequest { * @memberof MessagesApilistMessages */ conversationId?: string + /** + * Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123. + * Defaults to: undefined + * @type string + * @memberof MessagesApilistMessages + */ + batchId?: string /** * Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. * Defaults to: undefined @@ -2070,13 +2022,6 @@ export interface MessagesApiListMessagesRequest { * @memberof MessagesApilistMessages */ deleted?: boolean - /** - * Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. - * Defaults to: undefined - * @type string - * @memberof MessagesApilistMessages - */ - filter?: string } export interface MessagesApiReplyToMessageRequest { @@ -2133,6 +2078,29 @@ export interface MessagesApiRestoreMessageRequest { id: string } +export interface MessagesApiSendBatchRequest { + /** + * + * Defaults to: undefined + * @type string + * @memberof MessagesApisendBatch + */ + email: string + /** + * + * @type SendBatchRequest + * @memberof MessagesApisendBatch + */ + sendBatchRequest: SendBatchRequest + /** + * Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch). + * Defaults to: undefined + * @type string + * @memberof MessagesApisendBatch + */ + idempotencyKey?: string +} + export interface MessagesApiSendMessageRequest { /** * @@ -2230,39 +2198,39 @@ export class ObjectMessagesApi { } /** - * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. - * Get an agent\'s delivery metrics (beta) + * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. + * Get an attachment (metadata + short-lived download URL) * @param param the request object */ - public getAgentMetricsWithHttpInfo(param: MessagesApiGetAgentMetricsRequest, options?: ConfigurationOptions): Promise> { - return this.api.getAgentMetricsWithHttpInfo(param.email, param.start, param.end, options).toPromise(); + public getAttachmentWithHttpInfo(param: MessagesApiGetAttachmentRequest, options?: ConfigurationOptions): Promise> { + return this.api.getAttachmentWithHttpInfo(param.email, param.id, param.index, param.inline, options).toPromise(); } /** - * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. - * Get an agent\'s delivery metrics (beta) + * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. + * Get an attachment (metadata + short-lived download URL) * @param param the request object */ - public getAgentMetrics(param: MessagesApiGetAgentMetricsRequest, options?: ConfigurationOptions): Promise { - return this.api.getAgentMetrics(param.email, param.start, param.end, options).toPromise(); + public getAttachment(param: MessagesApiGetAttachmentRequest, options?: ConfigurationOptions): Promise { + return this.api.getAttachment(param.email, param.id, param.index, param.inline, options).toPromise(); } /** - * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. - * Get an attachment (metadata + short-lived download URL) + * Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch\'s child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Get a batch\'s header and delivery-status rollup (beta) * @param param the request object */ - public getAttachmentWithHttpInfo(param: MessagesApiGetAttachmentRequest, options?: ConfigurationOptions): Promise> { - return this.api.getAttachmentWithHttpInfo(param.email, param.id, param.index, param.inline, options).toPromise(); + public getBatchWithHttpInfo(param: MessagesApiGetBatchRequest, options?: ConfigurationOptions): Promise> { + return this.api.getBatchWithHttpInfo(param.batchId, options).toPromise(); } /** - * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. - * Get an attachment (metadata + short-lived download URL) + * Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch\'s child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Get a batch\'s header and delivery-status rollup (beta) * @param param the request object */ - public getAttachment(param: MessagesApiGetAttachmentRequest, options?: ConfigurationOptions): Promise { - return this.api.getAttachment(param.email, param.id, param.index, param.inline, options).toPromise(); + public getBatch(param: MessagesApiGetBatchRequest, options?: ConfigurationOptions): Promise { + return this.api.getBatch(param.batchId, options).toPromise(); } /** @@ -2307,7 +2275,7 @@ export class ObjectMessagesApi { * @param param the request object */ public listMessagesWithHttpInfo(param: MessagesApiListMessagesRequest, options?: ConfigurationOptions): Promise> { - return this.api.listMessagesWithHttpInfo(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, param.filter, options).toPromise(); + return this.api.listMessagesWithHttpInfo(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.batchId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, options).toPromise(); } /** @@ -2316,7 +2284,7 @@ export class ObjectMessagesApi { * @param param the request object */ public listMessages(param: MessagesApiListMessagesRequest, options?: ConfigurationOptions): Promise { - return this.api.listMessages(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, param.filter, options).toPromise(); + return this.api.listMessages(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.batchId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, options).toPromise(); } /** @@ -2355,6 +2323,24 @@ export class ObjectMessagesApi { return this.api.restoreMessage(param.email, param.id, options).toPromise(); } + /** + * Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account\'s suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. Aggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item\'s content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Send a batch of up to 100 emails (beta) + * @param param the request object + */ + public sendBatchWithHttpInfo(param: MessagesApiSendBatchRequest, options?: ConfigurationOptions): Promise> { + return this.api.sendBatchWithHttpInfo(param.email, param.sendBatchRequest, param.idempotencyKey, options).toPromise(); + } + + /** + * Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account\'s suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. Aggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item\'s content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Send a batch of up to 100 emails (beta) + * @param param the request object + */ + public sendBatch(param: MessagesApiSendBatchRequest, options?: ConfigurationOptions): Promise { + return this.api.sendBatch(param.email, param.sendBatchRequest, param.idempotencyKey, options).toPromise(); + } + /** * Beta: scheduled sending may change before it is declared stable. Send a new email from the agent named in the path (a new thread). The sender is the path agent — `reply`/`forward` are their own sub-resources. A future send_at returns 202 + status=scheduled; an HITL hold returns 202 + status=pending_review. Both are successful durable acceptance and must not be re-sent. Honors Idempotency-Key. Attachment limits: at most 10 attachments, each ≤ 10 MiB decoded, ≤ 25 MiB decoded combined (over-count → 400 invalid_request; over-size → 413 payload_too_large). Composed-message ceiling: 10 MiB (10485760 bytes), measured as subject + text + html + decoded attachment bytes; exceeding it returns 413 payload_too_large. Two capacity limits apply and are permanently distinct — branch on the HTTP status: 402 limit_exceeded is a QUOTA (monthly-message / storage stock-or-flow cap; a retry will not clear it — surface an upgrade path), 429 rate_limited is a throughput/request-RATE cap (transient; back off Retry-After seconds and retry). * Send a new email diff --git a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts index 5752ca306..0afd3e2c0 100644 --- a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts @@ -5,12 +5,9 @@ import { Observable, of, from } from '../rxjsStub.js'; import {mergeMap, map} from '../rxjsStub.js'; import { APIKeyExportEntry } from '../models/APIKeyExportEntry.js'; import { APIKeyView } from '../models/APIKeyView.js'; -import { AccountMetricsView } from '../models/AccountMetricsView.js'; import { AccountUserView } from '../models/AccountUserView.js'; import { AccountView } from '../models/AccountView.js'; import { AgentIdentity } from '../models/AgentIdentity.js'; -import { AgentMetricsGroupView } from '../models/AgentMetricsGroupView.js'; -import { AgentMetricsView } from '../models/AgentMetricsView.js'; import { AgentSuppressionAddedData } from '../models/AgentSuppressionAddedData.js'; import { AgentSuppressionView } from '../models/AgentSuppressionView.js'; import { AgentView } from '../models/AgentView.js'; @@ -18,6 +15,12 @@ import { ApproveRequest } from '../models/ApproveRequest.js'; import { Attachment } from '../models/Attachment.js'; import { AttachmentMetaView } from '../models/AttachmentMetaView.js'; import { AttachmentView } from '../models/AttachmentView.js'; +import { BatchMessage } from '../models/BatchMessage.js'; +import { BatchResult } from '../models/BatchResult.js'; +import { BatchStatusRollupView } from '../models/BatchStatusRollupView.js'; +import { BatchSuppressedItem } from '../models/BatchSuppressedItem.js'; +import { BatchSuppressedResult } from '../models/BatchSuppressedResult.js'; +import { BatchView } from '../models/BatchView.js'; import { ContactDueContact } from '../models/ContactDueContact.js'; import { ContactDueData } from '../models/ContactDueData.js'; import { ContactEngagementView } from '../models/ContactEngagementView.js'; @@ -55,6 +58,7 @@ import { DomainSendingFailedData } from '../models/DomainSendingFailedData.js'; import { DomainSendingVerifiedData } from '../models/DomainSendingVerifiedData.js'; import { DomainSuppressionAddedData } from '../models/DomainSuppressionAddedData.js'; import { DomainView } from '../models/DomainView.js'; +import { DuplicateRecipientDetails } from '../models/DuplicateRecipientDetails.js'; import { EmailBouncedData } from '../models/EmailBouncedData.js'; import { EmailComplainedData } from '../models/EmailComplainedData.js'; import { EmailDeliveredData } from '../models/EmailDeliveredData.js'; @@ -68,7 +72,6 @@ import { EventEnvelope } from '../models/EventEnvelope.js'; import { EventView } from '../models/EventView.js'; import { FieldError } from '../models/FieldError.js'; import { ForwardRequest } from '../models/ForwardRequest.js'; -import { ForwardRequestReplyTo } from '../models/ForwardRequestReplyTo.js'; import { HoldReasonView } from '../models/HoldReasonView.js'; import { ImportContactsRequest } from '../models/ImportContactsRequest.js'; import { LimitExceededDetails } from '../models/LimitExceededDetails.js'; @@ -82,9 +85,6 @@ import { MessageLifecycleTransition } from '../models/MessageLifecycleTransition import { MessageParsedView } from '../models/MessageParsedView.js'; import { MessageSummaryView } from '../models/MessageSummaryView.js'; import { MessageView } from '../models/MessageView.js'; -import { MetricsCounterView } from '../models/MetricsCounterView.js'; -import { MetricsRatesView } from '../models/MetricsRatesView.js'; -import { MetricsSummaryView } from '../models/MetricsSummaryView.js'; import { OAuthConnectionEntry } from '../models/OAuthConnectionEntry.js'; import { PageAPIKeyView } from '../models/PageAPIKeyView.js'; import { PageAgentSuppressionView } from '../models/PageAgentSuppressionView.js'; @@ -130,6 +130,8 @@ import { RetryAfterDetails } from '../models/RetryAfterDetails.js'; import { ReviewView } from '../models/ReviewView.js'; import { RotateSecretResponse } from '../models/RotateSecretResponse.js'; import { SPFResult } from '../models/SPFResult.js'; +import { SendBatchRequest } from '../models/SendBatchRequest.js'; +import { SendBatchResponse } from '../models/SendBatchResponse.js'; import { SendEmailRequest } from '../models/SendEmailRequest.js'; import { SendResultView } from '../models/SendResultView.js'; import { StarterTemplateDetailView } from '../models/StarterTemplateDetailView.js'; @@ -143,6 +145,7 @@ import { TemplateView } from '../models/TemplateView.js'; import { TestWebhookRequest } from '../models/TestWebhookRequest.js'; import { TestWebhookResponse } from '../models/TestWebhookResponse.js'; import { ThreatCategoryView } from '../models/ThreatCategoryView.js'; +import { TooManyMessagesDetails } from '../models/TooManyMessagesDetails.js'; import { TooManyRecipientsDetails } from '../models/TooManyRecipientsDetails.js'; import { UnsubscribeOptions } from '../models/UnsubscribeOptions.js'; import { UpdateAgentRequest } from '../models/UpdateAgentRequest.js'; @@ -160,10 +163,8 @@ import { ValidateTemplateResponse } from '../models/ValidateTemplateResponse.js' import { ValidationErrorDetails } from '../models/ValidationErrorDetails.js'; import { VerifyDomainView } from '../models/VerifyDomainView.js'; import { WebhookDeliveryView } from '../models/WebhookDeliveryView.js'; -import { WebhookEndpointMetricsView } from '../models/WebhookEndpointMetricsView.js'; import { WebhookFiltersRequest } from '../models/WebhookFiltersRequest.js'; import { WebhookFiltersView } from '../models/WebhookFiltersView.js'; -import { WebhookMetricsView } from '../models/WebhookMetricsView.js'; import { WebhookView } from '../models/WebhookView.js'; import { AccountApiRequestFactory, AccountApiResponseProcessor} from "../apis/AccountApi.js"; @@ -388,44 +389,6 @@ export class ObservableAccountApi { return this.getAccountWithHttpInfo(_options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } - /** - * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. - * Get account-wide delivery metrics (beta) - * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. - * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. - * @param [groupBy] Set to \'agent\' to also receive a per-agent breakdown. Omit for account totals only, which is the cheaper read. - */ - public getAccountMetricsWithHttpInfo(start?: Date, end?: Date, groupBy?: 'agent', _options?: ConfigurationOptions): Observable> { - const _config = mergeConfiguration(this.configuration, _options); - - const requestContextPromise = this.requestFactory.getAccountMetrics(start, end, groupBy, _config); - // build promise chain - let middlewarePreObservable = from(requestContextPromise); - for (const middleware of _config.middleware) { - middlewarePreObservable = middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => middleware.pre(ctx))); - } - - return middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => _config.httpApi.send(ctx))). - pipe(mergeMap((response: ResponseContext) => { - let middlewarePostObservable = of(response); - for (const middleware of _config.middleware.reverse()) { - middlewarePostObservable = middlewarePostObservable.pipe(mergeMap((rsp: ResponseContext) => middleware.post(rsp))); - } - return middlewarePostObservable.pipe(map((rsp: ResponseContext) => this.responseProcessor.getAccountMetricsWithHttpInfo(rsp))); - })); - } - - /** - * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. - * Get account-wide delivery metrics (beta) - * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. - * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. - * @param [groupBy] Set to \'agent\' to also receive a per-agent breakdown. Omit for account totals only, which is the cheaper read. - */ - public getAccountMetrics(start?: Date, end?: Date, groupBy?: 'agent', _options?: ConfigurationOptions): Observable { - return this.getAccountMetricsWithHttpInfo(start, end, groupBy, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); - } - /** * API keys for the account (metadata only — secrets are shown once, at creation). Account scope only: an agent-scoped credential cannot manage keys. * List API keys @@ -1905,16 +1868,17 @@ export class ObservableMessagesApi { } /** - * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. - * Get an agent\'s delivery metrics (beta) + * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. + * Get an attachment (metadata + short-lived download URL) * @param email - * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. - * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. + * @param id + * @param index + * @param [inline] When true, also include the bytes as base64 in \'data\' — ONLY for attachments <= 256 KB; larger inline requests are rejected (413). Default false (use download_url). */ - public getAgentMetricsWithHttpInfo(email: string, start?: Date, end?: Date, _options?: ConfigurationOptions): Observable> { + public getAttachmentWithHttpInfo(email: string, id: string, index: number, inline?: boolean, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); - const requestContextPromise = this.requestFactory.getAgentMetrics(email, start, end, _config); + const requestContextPromise = this.requestFactory.getAttachment(email, id, index, inline, _config); // build promise chain let middlewarePreObservable = from(requestContextPromise); for (const middleware of _config.middleware) { @@ -1927,21 +1891,10 @@ export class ObservableMessagesApi { for (const middleware of _config.middleware.reverse()) { middlewarePostObservable = middlewarePostObservable.pipe(mergeMap((rsp: ResponseContext) => middleware.post(rsp))); } - return middlewarePostObservable.pipe(map((rsp: ResponseContext) => this.responseProcessor.getAgentMetricsWithHttpInfo(rsp))); + return middlewarePostObservable.pipe(map((rsp: ResponseContext) => this.responseProcessor.getAttachmentWithHttpInfo(rsp))); })); } - /** - * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. - * Get an agent\'s delivery metrics (beta) - * @param email - * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. - * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. - */ - public getAgentMetrics(email: string, start?: Date, end?: Date, _options?: ConfigurationOptions): Observable { - return this.getAgentMetricsWithHttpInfo(email, start, end, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); - } - /** * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. * Get an attachment (metadata + short-lived download URL) @@ -1950,10 +1903,19 @@ export class ObservableMessagesApi { * @param index * @param [inline] When true, also include the bytes as base64 in \'data\' — ONLY for attachments <= 256 KB; larger inline requests are rejected (413). Default false (use download_url). */ - public getAttachmentWithHttpInfo(email: string, id: string, index: number, inline?: boolean, _options?: ConfigurationOptions): Observable> { + public getAttachment(email: string, id: string, index: number, inline?: boolean, _options?: ConfigurationOptions): Observable { + return this.getAttachmentWithHttpInfo(email, id, index, inline, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + } + + /** + * Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch\'s child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Get a batch\'s header and delivery-status rollup (beta) + * @param batchId The batch id, e.g. bat_abc123. + */ + public getBatchWithHttpInfo(batchId: string, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); - const requestContextPromise = this.requestFactory.getAttachment(email, id, index, inline, _config); + const requestContextPromise = this.requestFactory.getBatch(batchId, _config); // build promise chain let middlewarePreObservable = from(requestContextPromise); for (const middleware of _config.middleware) { @@ -1966,20 +1928,17 @@ export class ObservableMessagesApi { for (const middleware of _config.middleware.reverse()) { middlewarePostObservable = middlewarePostObservable.pipe(mergeMap((rsp: ResponseContext) => middleware.post(rsp))); } - return middlewarePostObservable.pipe(map((rsp: ResponseContext) => this.responseProcessor.getAttachmentWithHttpInfo(rsp))); + return middlewarePostObservable.pipe(map((rsp: ResponseContext) => this.responseProcessor.getBatchWithHttpInfo(rsp))); })); } /** - * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. - * Get an attachment (metadata + short-lived download URL) - * @param email - * @param id - * @param index - * @param [inline] When true, also include the bytes as base64 in \'data\' — ONLY for attachments <= 256 KB; larger inline requests are rejected (413). Default false (use download_url). + * Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch\'s child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Get a batch\'s header and delivery-status rollup (beta) + * @param batchId The batch id, e.g. bat_abc123. */ - public getAttachment(email: string, id: string, index: number, inline?: boolean, _options?: ConfigurationOptions): Observable { - return this.getAttachmentWithHttpInfo(email, id, index, inline, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + public getBatch(batchId: string, _options?: ConfigurationOptions): Observable { + return this.getBatchWithHttpInfo(batchId, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } /** @@ -2068,18 +2027,18 @@ export class ObservableMessagesApi { * @param [from_] Case-insensitive substring match on sender. * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] + * @param [batchId] Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123. * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). - * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: ConfigurationOptions): Observable> { + public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); - const requestContextPromise = this.requestFactory.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, filter, _config); + const requestContextPromise = this.requestFactory.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, _config); // build promise chain let middlewarePreObservable = from(requestContextPromise); for (const middleware of _config.middleware) { @@ -2106,16 +2065,16 @@ export class ObservableMessagesApi { * @param [from_] Case-insensitive substring match on sender. * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] + * @param [batchId] Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123. * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). - * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: ConfigurationOptions): Observable { - return this.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, filter, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: ConfigurationOptions): Observable { + return this.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } /** @@ -2196,6 +2155,44 @@ export class ObservableMessagesApi { return this.restoreMessageWithHttpInfo(email, id, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } + /** + * Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account\'s suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. Aggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item\'s content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Send a batch of up to 100 emails (beta) + * @param email + * @param sendBatchRequest + * @param [idempotencyKey] Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch). + */ + public sendBatchWithHttpInfo(email: string, sendBatchRequest: SendBatchRequest, idempotencyKey?: string, _options?: ConfigurationOptions): Observable> { + const _config = mergeConfiguration(this.configuration, _options); + + const requestContextPromise = this.requestFactory.sendBatch(email, sendBatchRequest, idempotencyKey, _config); + // build promise chain + let middlewarePreObservable = from(requestContextPromise); + for (const middleware of _config.middleware) { + middlewarePreObservable = middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => middleware.pre(ctx))); + } + + return middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => _config.httpApi.send(ctx))). + pipe(mergeMap((response: ResponseContext) => { + let middlewarePostObservable = of(response); + for (const middleware of _config.middleware.reverse()) { + middlewarePostObservable = middlewarePostObservable.pipe(mergeMap((rsp: ResponseContext) => middleware.post(rsp))); + } + return middlewarePostObservable.pipe(map((rsp: ResponseContext) => this.responseProcessor.sendBatchWithHttpInfo(rsp))); + })); + } + + /** + * Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account\'s suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. Aggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item\'s content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Send a batch of up to 100 emails (beta) + * @param email + * @param sendBatchRequest + * @param [idempotencyKey] Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch). + */ + public sendBatch(email: string, sendBatchRequest: SendBatchRequest, idempotencyKey?: string, _options?: ConfigurationOptions): Observable { + return this.sendBatchWithHttpInfo(email, sendBatchRequest, idempotencyKey, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + } + /** * Beta: scheduled sending may change before it is declared stable. Send a new email from the agent named in the path (a new thread). The sender is the path agent — `reply`/`forward` are their own sub-resources. A future send_at returns 202 + status=scheduled; an HITL hold returns 202 + status=pending_review. Both are successful durable acceptance and must not be re-sent. Honors Idempotency-Key. Attachment limits: at most 10 attachments, each ≤ 10 MiB decoded, ≤ 25 MiB decoded combined (over-count → 400 invalid_request; over-size → 413 payload_too_large). Composed-message ceiling: 10 MiB (10485760 bytes), measured as subject + text + html + decoded attachment bytes; exceeding it returns 413 payload_too_large. Two capacity limits apply and are permanently distinct — branch on the HTTP status: 402 limit_exceeded is a QUOTA (monthly-message / storage stock-or-flow cap; a retry will not clear it — surface an upgrade path), 429 rate_limited is a throughput/request-RATE cap (transient; back off Retry-After seconds and retry). * Send a new email diff --git a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts index 1d70f4f68..428495146 100644 --- a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts +++ b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts @@ -4,12 +4,9 @@ import { PromiseMiddleware, Middleware, PromiseMiddlewareWrapper } from '../midd import { APIKeyExportEntry } from '../models/APIKeyExportEntry.js'; import { APIKeyView } from '../models/APIKeyView.js'; -import { AccountMetricsView } from '../models/AccountMetricsView.js'; import { AccountUserView } from '../models/AccountUserView.js'; import { AccountView } from '../models/AccountView.js'; import { AgentIdentity } from '../models/AgentIdentity.js'; -import { AgentMetricsGroupView } from '../models/AgentMetricsGroupView.js'; -import { AgentMetricsView } from '../models/AgentMetricsView.js'; import { AgentSuppressionAddedData } from '../models/AgentSuppressionAddedData.js'; import { AgentSuppressionView } from '../models/AgentSuppressionView.js'; import { AgentView } from '../models/AgentView.js'; @@ -17,6 +14,12 @@ import { ApproveRequest } from '../models/ApproveRequest.js'; import { Attachment } from '../models/Attachment.js'; import { AttachmentMetaView } from '../models/AttachmentMetaView.js'; import { AttachmentView } from '../models/AttachmentView.js'; +import { BatchMessage } from '../models/BatchMessage.js'; +import { BatchResult } from '../models/BatchResult.js'; +import { BatchStatusRollupView } from '../models/BatchStatusRollupView.js'; +import { BatchSuppressedItem } from '../models/BatchSuppressedItem.js'; +import { BatchSuppressedResult } from '../models/BatchSuppressedResult.js'; +import { BatchView } from '../models/BatchView.js'; import { ContactDueContact } from '../models/ContactDueContact.js'; import { ContactDueData } from '../models/ContactDueData.js'; import { ContactEngagementView } from '../models/ContactEngagementView.js'; @@ -54,6 +57,7 @@ import { DomainSendingFailedData } from '../models/DomainSendingFailedData.js'; import { DomainSendingVerifiedData } from '../models/DomainSendingVerifiedData.js'; import { DomainSuppressionAddedData } from '../models/DomainSuppressionAddedData.js'; import { DomainView } from '../models/DomainView.js'; +import { DuplicateRecipientDetails } from '../models/DuplicateRecipientDetails.js'; import { EmailBouncedData } from '../models/EmailBouncedData.js'; import { EmailComplainedData } from '../models/EmailComplainedData.js'; import { EmailDeliveredData } from '../models/EmailDeliveredData.js'; @@ -67,7 +71,6 @@ import { EventEnvelope } from '../models/EventEnvelope.js'; import { EventView } from '../models/EventView.js'; import { FieldError } from '../models/FieldError.js'; import { ForwardRequest } from '../models/ForwardRequest.js'; -import { ForwardRequestReplyTo } from '../models/ForwardRequestReplyTo.js'; import { HoldReasonView } from '../models/HoldReasonView.js'; import { ImportContactsRequest } from '../models/ImportContactsRequest.js'; import { LimitExceededDetails } from '../models/LimitExceededDetails.js'; @@ -81,9 +84,6 @@ import { MessageLifecycleTransition } from '../models/MessageLifecycleTransition import { MessageParsedView } from '../models/MessageParsedView.js'; import { MessageSummaryView } from '../models/MessageSummaryView.js'; import { MessageView } from '../models/MessageView.js'; -import { MetricsCounterView } from '../models/MetricsCounterView.js'; -import { MetricsRatesView } from '../models/MetricsRatesView.js'; -import { MetricsSummaryView } from '../models/MetricsSummaryView.js'; import { OAuthConnectionEntry } from '../models/OAuthConnectionEntry.js'; import { PageAPIKeyView } from '../models/PageAPIKeyView.js'; import { PageAgentSuppressionView } from '../models/PageAgentSuppressionView.js'; @@ -129,6 +129,8 @@ import { RetryAfterDetails } from '../models/RetryAfterDetails.js'; import { ReviewView } from '../models/ReviewView.js'; import { RotateSecretResponse } from '../models/RotateSecretResponse.js'; import { SPFResult } from '../models/SPFResult.js'; +import { SendBatchRequest } from '../models/SendBatchRequest.js'; +import { SendBatchResponse } from '../models/SendBatchResponse.js'; import { SendEmailRequest } from '../models/SendEmailRequest.js'; import { SendResultView } from '../models/SendResultView.js'; import { StarterTemplateDetailView } from '../models/StarterTemplateDetailView.js'; @@ -142,6 +144,7 @@ import { TemplateView } from '../models/TemplateView.js'; import { TestWebhookRequest } from '../models/TestWebhookRequest.js'; import { TestWebhookResponse } from '../models/TestWebhookResponse.js'; import { ThreatCategoryView } from '../models/ThreatCategoryView.js'; +import { TooManyMessagesDetails } from '../models/TooManyMessagesDetails.js'; import { TooManyRecipientsDetails } from '../models/TooManyRecipientsDetails.js'; import { UnsubscribeOptions } from '../models/UnsubscribeOptions.js'; import { UpdateAgentRequest } from '../models/UpdateAgentRequest.js'; @@ -159,10 +162,8 @@ import { ValidateTemplateResponse } from '../models/ValidateTemplateResponse.js' import { ValidationErrorDetails } from '../models/ValidationErrorDetails.js'; import { VerifyDomainView } from '../models/VerifyDomainView.js'; import { WebhookDeliveryView } from '../models/WebhookDeliveryView.js'; -import { WebhookEndpointMetricsView } from '../models/WebhookEndpointMetricsView.js'; import { WebhookFiltersRequest } from '../models/WebhookFiltersRequest.js'; import { WebhookFiltersView } from '../models/WebhookFiltersView.js'; -import { WebhookMetricsView } from '../models/WebhookMetricsView.js'; import { WebhookView } from '../models/WebhookView.js'; import { ObservableAccountApi } from './ObservableAPI.js'; @@ -312,32 +313,6 @@ export class PromiseAccountApi { return result.toPromise(); } - /** - * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. - * Get account-wide delivery metrics (beta) - * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. - * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. - * @param [groupBy] Set to \'agent\' to also receive a per-agent breakdown. Omit for account totals only, which is the cheaper read. - */ - public getAccountMetricsWithHttpInfo(start?: Date, end?: Date, groupBy?: 'agent', _options?: PromiseConfigurationOptions): Promise> { - const observableOptions = wrapOptions(_options); - const result = this.api.getAccountMetricsWithHttpInfo(start, end, groupBy, observableOptions); - return result.toPromise(); - } - - /** - * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. - * Get account-wide delivery metrics (beta) - * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. - * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. - * @param [groupBy] Set to \'agent\' to also receive a per-agent breakdown. Omit for account totals only, which is the cheaper read. - */ - public getAccountMetrics(start?: Date, end?: Date, groupBy?: 'agent', _options?: PromiseConfigurationOptions): Promise { - const observableOptions = wrapOptions(_options); - const result = this.api.getAccountMetrics(start, end, groupBy, observableOptions); - return result.toPromise(); - } - /** * API keys for the account (metadata only — secrets are shown once, at creation). Account scope only: an agent-scoped credential cannot manage keys. * List API keys @@ -1378,32 +1353,6 @@ export class PromiseMessagesApi { return result.toPromise(); } - /** - * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. - * Get an agent\'s delivery metrics (beta) - * @param email - * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. - * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. - */ - public getAgentMetricsWithHttpInfo(email: string, start?: Date, end?: Date, _options?: PromiseConfigurationOptions): Promise> { - const observableOptions = wrapOptions(_options); - const result = this.api.getAgentMetricsWithHttpInfo(email, start, end, observableOptions); - return result.toPromise(); - } - - /** - * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. - * Get an agent\'s delivery metrics (beta) - * @param email - * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. - * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. - */ - public getAgentMetrics(email: string, start?: Date, end?: Date, _options?: PromiseConfigurationOptions): Promise { - const observableOptions = wrapOptions(_options); - const result = this.api.getAgentMetrics(email, start, end, observableOptions); - return result.toPromise(); - } - /** * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. * Get an attachment (metadata + short-lived download URL) @@ -1432,6 +1381,28 @@ export class PromiseMessagesApi { return result.toPromise(); } + /** + * Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch\'s child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Get a batch\'s header and delivery-status rollup (beta) + * @param batchId The batch id, e.g. bat_abc123. + */ + public getBatchWithHttpInfo(batchId: string, _options?: PromiseConfigurationOptions): Promise> { + const observableOptions = wrapOptions(_options); + const result = this.api.getBatchWithHttpInfo(batchId, observableOptions); + return result.toPromise(); + } + + /** + * Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch\'s child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Get a batch\'s header and delivery-status rollup (beta) + * @param batchId The batch id, e.g. bat_abc123. + */ + public getBatch(batchId: string, _options?: PromiseConfigurationOptions): Promise { + const observableOptions = wrapOptions(_options); + const result = this.api.getBatch(batchId, observableOptions); + return result.toPromise(); + } + /** * Fetch a single message (inbound or outbound) by id, scoped to an agent the caller owns. A trashed message remains readable by this direct GET and includes deleted_at until it is permanently purged (30 days after deletion by default, deployment-configurable); ordinary lists, conversations, reply targets, and forward targets exclude it. Includes the raw message and canonical inbound authentication evidence. Fetching an unread inbound message marks it read as a side effect. * Get a message @@ -1494,17 +1465,17 @@ export class PromiseMessagesApi { * @param [from_] Case-insensitive substring match on sender. * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] + * @param [batchId] Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123. * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). - * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: PromiseConfigurationOptions): Promise> { + public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: PromiseConfigurationOptions): Promise> { const observableOptions = wrapOptions(_options); - const result = this.api.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, filter, observableOptions); + const result = this.api.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, observableOptions); return result.toPromise(); } @@ -1518,17 +1489,17 @@ export class PromiseMessagesApi { * @param [from_] Case-insensitive substring match on sender. * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] + * @param [batchId] Filter to the child messages of a batch send (docs/design/batch-send.md §7.2). Outbound only; pair with direction=outbound. Exact match on the batch id, e.g. bat_abc123. * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). - * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: PromiseConfigurationOptions): Promise { + public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: PromiseConfigurationOptions): Promise { const observableOptions = wrapOptions(_options); - const result = this.api.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, filter, observableOptions); + const result = this.api.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, observableOptions); return result.toPromise(); } @@ -1586,6 +1557,32 @@ export class PromiseMessagesApi { return result.toPromise(); } + /** + * Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account\'s suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. Aggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item\'s content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Send a batch of up to 100 emails (beta) + * @param email + * @param sendBatchRequest + * @param [idempotencyKey] Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch). + */ + public sendBatchWithHttpInfo(email: string, sendBatchRequest: SendBatchRequest, idempotencyKey?: string, _options?: PromiseConfigurationOptions): Promise> { + const observableOptions = wrapOptions(_options); + const result = this.api.sendBatchWithHttpInfo(email, sendBatchRequest, idempotencyKey, observableOptions); + return result.toPromise(); + } + + /** + * Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account\'s suppression list). See docs/design/batch-send.md for the full contract. MVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant. Aggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item\'s content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls. Beta: the batch-send surface (both operations, the batch schemas, and the batch_id fields on stable event payloads) may change before it is declared stable. + * Send a batch of up to 100 emails (beta) + * @param email + * @param sendBatchRequest + * @param [idempotencyKey] Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch). + */ + public sendBatch(email: string, sendBatchRequest: SendBatchRequest, idempotencyKey?: string, _options?: PromiseConfigurationOptions): Promise { + const observableOptions = wrapOptions(_options); + const result = this.api.sendBatch(email, sendBatchRequest, idempotencyKey, observableOptions); + return result.toPromise(); + } + /** * Beta: scheduled sending may change before it is declared stable. Send a new email from the agent named in the path (a new thread). The sender is the path agent — `reply`/`forward` are their own sub-resources. A future send_at returns 202 + status=scheduled; an HITL hold returns 202 + status=pending_review. Both are successful durable acceptance and must not be re-sent. Honors Idempotency-Key. Attachment limits: at most 10 attachments, each ≤ 10 MiB decoded, ≤ 25 MiB decoded combined (over-count → 400 invalid_request; over-size → 413 payload_too_large). Composed-message ceiling: 10 MiB (10485760 bytes), measured as subject + text + html + decoded attachment bytes; exceeding it returns 413 payload_too_large. Two capacity limits apply and are permanently distinct — branch on the HTTP status: 402 limit_exceeded is a QUOTA (monthly-message / storage stock-or-flow cap; a retry will not clear it — surface an upgrade path), 429 rate_limited is a throughput/request-RATE cap (transient; back off Retry-After seconds and retry). * Send a new email From 121d8aea2c424257379768b8a0e58b72964d4341 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Tue, 4 Aug 2026 13:39:46 -0700 Subject: [PATCH 09/17] feat(sdk): add ergonomic sendBatch/getBatch to the TS client The generated MessagesApi already exposes sendBatch/getBatch, but the hand-written ergonomic MessagesResource did not wrap them, so the CLI and MCP server (both consumers of the ergonomic layer) had no batch entry point. Adds client.messages.sendBatch(email, body, opts) and client.messages.getBatch(batchId), mirroring send()/get(). The listMessages batchId filter was already wired. Co-Authored-By: Claude Opus 4.8 --- sdks/typescript/src/v1/client.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/sdks/typescript/src/v1/client.ts b/sdks/typescript/src/v1/client.ts index ba3850e30..0a97c8f2e 100644 --- a/sdks/typescript/src/v1/client.ts +++ b/sdks/typescript/src/v1/client.ts @@ -36,6 +36,9 @@ import type { AttachmentView, MessageSummaryView, SendEmailRequest, + SendBatchRequest, + SendBatchResponse, + BatchView, ReplyRequest, ForwardRequest, ApproveRequest, @@ -476,6 +479,25 @@ class MessagesResource { forward(email: string, messageId: string, body: ForwardInput, opts: SendOptions = {}): Promise { return call(() => this.api.forwardMessage(email, messageId, body as ForwardRequest, opts.idempotencyKey, opts.wait)); } + /** + * Beta: send a batch of up to 100 messages in one call. Each item is an + * independent message with its own recipients/state; the response `results` + * are positionally aligned to `messages` (each slot is accepted with a + * message_id, or suppressed). Always async (202) — there is no `wait`. See + * docs/design/batch-send.md. The batch surface may change before stable. + */ + sendBatch(email: string, body: SendBatchRequest, opts: SendOptions = {}): Promise { + return call(() => this.api.sendBatch(email, body, opts.idempotencyKey)); + } + /** + * Beta: fetch a batch's header (counts + suppressed items at accept time) + * plus a live delivery-status rollup of its child messages. Account-scoped: + * a batch owned by another account returns 404. For per-recipient detail use + * `list(email, { batchId })`. + */ + getBatch(batchId: string): Promise { + return call(() => this.api.getBatch(batchId)); + } // Approve/reject a held message live on the account-scoped review queue — // `client.reviews.approve(id, body)` / `client.reviews.reject(id, body)`. The // deprecated per-inbox messages.approve/reject was removed in the pre-GA From f6b66db0f2d3f15859d5f60773d63289d92401f3 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Tue, 4 Aug 2026 13:43:52 -0700 Subject: [PATCH 10/17] feat(cli): add batch send/get commands Adds `e2a batch send --file ` (reads a SendBatchRequest JSON body, or stdin) and `e2a batch get `, mirroring the single send/messages-get commands over the new ergonomic SDK methods. Batch is always async (no --wait); suppressed items are surfaced per-item and in a stderr summary but do not fail the batch. getBatch is account-scoped (no --agent). Registered in the dispatcher + USAGE. Co-Authored-By: Claude Opus 4.8 --- cli/src/bin/e2a.ts | 29 ++++++++++ cli/src/commands/batch.ts | 112 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 cli/src/commands/batch.ts diff --git a/cli/src/bin/e2a.ts b/cli/src/bin/e2a.ts index a873c703f..f8f2c83c7 100644 --- a/cli/src/bin/e2a.ts +++ b/cli/src/bin/e2a.ts @@ -18,6 +18,7 @@ import { listen } from "../commands/listen.js"; import { whoami } from "../commands/whoami.js"; import { doctor } from "../commands/doctor.js"; import { send, reply } from "../commands/send.js"; +import { batchSend, batchGet } from "../commands/batch.js"; import { messagesList, messagesGet, messagesLifecycle } from "../commands/messages.js"; import { agentsList, agentsCreate, agentsGet } from "../commands/agents.js"; import { protectionGet, protectionSet } from "../commands/protection.js"; @@ -125,6 +126,12 @@ Usage: --agent Sending inbox (or config agent_email / E2A_AGENT_EMAIL) --json Print the full send result as JSON e2a reply [options] Reply in-thread (same body options as send) + e2a batch send [options] Send a batch of up to 100 messages (beta) + --file JSON batch body ({"messages":[…]}); - reads stdin + --agent Sending inbox (or config agent_email / E2A_AGENT_EMAIL) + --idempotency-key Stable key so a retried invocation can't double-send + --json Print the full batch result as JSON + e2a batch get [--json] Show a batch's header + delivery-status rollup (beta) e2a messages list [options] List messages, oldest first --direction inbound|outbound|all --since Messages created AT or after this timestamp @@ -696,6 +703,28 @@ async function main() { json: hasFlag(args, "--json"), }); break; + case "batch": { + const sub = args[0]; + const rest = args.slice(1); + if (sub === "send") { + checkFlags(rest, ["--file", "--agent", "--idempotency-key", "--json"]); + getPositionals(rest, 0, "usage: e2a batch send --file [options]"); + await batchSend({ + file: getFlagChecked(rest, "--file"), + agent: getFlagChecked(rest, "--agent"), + idempotencyKey: getFlagChecked(rest, "--idempotency-key"), + json: hasFlag(rest, "--json"), + }); + } else if (sub === "get") { + checkFlags(rest, ["--json"]); + const [batchId] = getPositionals(rest, 1, "usage: e2a batch get [--json]"); + await batchGet(batchId, { json: hasFlag(rest, "--json") }); + } else { + process.stderr.write("Usage: e2a batch [send --file |get ]\n"); + process.exit(EXIT.USAGE); + } + break; + } case "messages": { const sub = args[0]; const rest = args.slice(1); diff --git a/cli/src/commands/batch.ts b/cli/src/commands/batch.ts new file mode 100644 index 000000000..383e75b05 --- /dev/null +++ b/cli/src/commands/batch.ts @@ -0,0 +1,112 @@ +import { readFileSync } from "node:fs"; +import type { SendBatchRequest, SendBatchResponse, BatchView } from "@e2a/sdk/v1"; +import { createClient, requireAgentEmail } from "../sdk.js"; +import { EXIT, fail } from "../exit.js"; + +export interface BatchSendOptions { + file?: string; + agent?: string; + json?: boolean; + idempotencyKey?: string; +} + +export interface BatchGetOptions { + json?: boolean; +} + +export const BATCH_SEND_USAGE = + "usage: e2a batch send --file [--agent ] [--idempotency-key ] [--json]"; +export const BATCH_GET_USAGE = "usage: e2a batch get [--json]"; + +/** + * Read the batch request body from a JSON file (or stdin with `-`). The file + * is the SendBatchRequest shape: a JSON object with a non-empty `messages` + * array, each item a message (to, subject, text/html, cc, bcc, attachments, + * conversationId, replyTo, template*). Field names are the SDK's camelCase + * form, mirroring what `e2a send` accepts per item. See + * docs/design/batch-send.md for the caps (≤100 items, 60 MiB aggregate). + */ +function readBatchRequest(file: string | undefined): SendBatchRequest { + if (!file) return fail(EXIT.USAGE, BATCH_SEND_USAGE); + let raw: string; + try { + // fd 0 is stdin, so `--file -` pipes a batch in from a generator script. + raw = file === "-" ? readFileSync(0, "utf-8") : readFileSync(file, "utf-8"); + } catch { + return fail(EXIT.USAGE, `--file not found or unreadable: ${file}`); + } + let body: unknown; + try { + body = JSON.parse(raw); + } catch (e) { + return fail(EXIT.USAGE, `--file is not valid JSON: ${(e as Error).message}`); + } + const messages = (body as { messages?: unknown } | null)?.messages; + if (typeof body !== "object" || body === null || !Array.isArray(messages) || messages.length === 0) { + return fail( + EXIT.USAGE, + `--file must be a JSON object with a non-empty "messages" array (see docs/design/batch-send.md)`, + ); + } + return body as SendBatchRequest; +} + +/** + * Print the batch accept result. A batch is always accepted async (202); the + * per-item results are positionally aligned to the input. Suppressed items are + * a compliance drop, not a send failure, so the batch still exits 0 — the + * per-item lines and the stderr summary surface the drops for a human/script. + */ +function emitBatchSendResult(result: SendBatchResponse, json?: boolean): void { + if (json) { + process.stdout.write(JSON.stringify(result) + "\n"); + return; + } + process.stdout.write(result.batchId + "\n"); + result.results.forEach((r, i) => { + if (r.status === "accepted") { + process.stdout.write(`${i}\taccepted\t${r.messageId ?? ""}\n`); + } else { + process.stdout.write(`${i}\tsuppressed\t${r.suppressed?.address ?? ""}\t${r.suppressed?.reason ?? ""}\n`); + } + }); + process.stderr.write( + `batch ${result.batchId}: accepted=${result.accepted} suppressed=${result.suppressedCount}\n`, + ); +} + +export async function batchSend(opts: BatchSendOptions): Promise { + const body = readBatchRequest(opts.file); + const client = createClient(); + const agentEmail = requireAgentEmail(opts.agent); + const result = await client.messages.sendBatch( + agentEmail, + body, + opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : undefined, + ); + emitBatchSendResult(result, opts.json); +} + +function emitBatchView(v: BatchView): void { + process.stdout.write(`batch ${v.batchId} (agent ${v.agentId}, created ${v.createdAt})\n`); + process.stdout.write(`requested=${v.requested} accepted=${v.accepted} suppressed=${v.suppressed.length}\n`); + const r = v.statusRollup; + process.stdout.write( + `rollup: accepted=${r.accepted} sending=${r.sending} sent=${r.sent} delivered=${r.delivered} ` + + `deferred=${r.deferred} bounced=${r.bounced} complained=${r.complained} failed=${r.failed}\n`, + ); + for (const s of v.suppressed) { + process.stdout.write(`suppressed\t${s.itemIndex}\t${s.address}\t${s.reason}\n`); + } +} + +export async function batchGet(batchId: string | undefined, opts: BatchGetOptions): Promise { + if (!batchId) fail(EXIT.USAGE, BATCH_GET_USAGE); + const client = createClient(); + const view = await client.messages.getBatch(batchId); + if (opts.json) { + process.stdout.write(JSON.stringify(view) + "\n"); + return; + } + emitBatchView(view); +} From 55ac3502e618462aae7e8830022fb96570414eb8 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Tue, 4 Aug 2026 17:03:33 -0700 Subject: [PATCH 11/17] feat(mcp): add send_batch and get_batch tools Exposes the batch surface on the MCP server, mirroring send_message / get_message. Adds McpClient.sendBatch/getBatch wrappers over the new ergonomic SDK methods, the two tools (context-safe snake_case projections of SendBatchResponse / BatchView), and tiers them as runtime (both handlers accept agent scope: sendBatch resolves the owned agent, getBatch uses requireUser). Updates the frozen v1 tool-name baseline (76 -> 78) and the tool-count assertions in the tier/events/ http tests. Co-Authored-By: Claude Opus 4.8 --- mcp/src/client.ts | 25 +++++++ mcp/src/tools/messages.ts | 139 +++++++++++++++++++++++++++++++++++++- mcp/src/tools/tiers.ts | 5 ++ mcp/tests/events.test.ts | 5 +- mcp/tests/http.test.ts | 2 +- mcp/tests/tools.test.ts | 12 ++-- mcp/tool-names.v1.json | 2 + 7 files changed, 180 insertions(+), 10 deletions(-) diff --git a/mcp/src/client.ts b/mcp/src/client.ts index c7137e793..298a28b41 100644 --- a/mcp/src/client.ts +++ b/mcp/src/client.ts @@ -7,6 +7,9 @@ import type { MessageSummaryView, ReviewView, SendResultView, + SendBatchRequest, + SendBatchResponse, + BatchView, RejectResultView, UpdateMessageResultView, ConversationSummaryView, @@ -384,6 +387,28 @@ export class McpClient { return this.sdk.messages.get(this.resolveAddress(explicitAddress), messageId); } + /** + * Beta: send a batch of up to 100 messages in one call. Async (202); the + * response `results` are positionally aligned to `body.messages`. Suppressed + * items are a compliance drop, not a send failure. + */ + sendBatch( + body: SendBatchRequest, + opts: SendOpts = {}, + explicitAddress?: string, + ): Promise { + return this.sdk.messages.sendBatch(this.resolveAddress(explicitAddress), body, opts); + } + + /** + * Beta: fetch a batch header (counts + accept-time suppressions) plus a live + * delivery-status rollup of its child messages. Account-scoped — the batch id + * alone identifies it, no agent address needed. + */ + getBatch(batchId: string): Promise { + return this.sdk.messages.getBatch(batchId); + } + /** Beta: fetch the canonical observations for one message. */ getMessageLifecycle( messageId: string, diff --git a/mcp/src/tools/messages.ts b/mcp/src/tools/messages.ts index f1914c18d..eb04b05f2 100644 --- a/mcp/src/tools/messages.ts +++ b/mcp/src/tools/messages.ts @@ -1,6 +1,12 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpClient, SendOpts } from "../client.js"; -import type { MessageSummaryView, MessageView } from "@e2a/sdk/v1"; +import type { + MessageSummaryView, + MessageView, + SendBatchRequest, + SendBatchResponse, + BatchView, +} from "@e2a/sdk/v1"; import { z } from "zod"; import { runTool, strictInputSchema, paginationInput, emailSelector } from "./util.js"; import { attachmentsArraySchema, type AttachmentInput } from "./attachments.js"; @@ -98,6 +104,51 @@ export function messageSummaryViewForTool(message: MessageSummaryView) { }; } +// SendBatchResponse → context-safe MCP shape. Snake_case wire names; the +// per-item results stay positionally aligned to the request `messages`. +function batchSendResultForTool(r: SendBatchResponse) { + return { + batch_id: r.batchId, + accepted: r.accepted, + suppressed: r.suppressedCount, + results: r.results.map((res) => ({ + status: res.status, + ...(res.messageId !== undefined ? { message_id: res.messageId } : {}), + ...(res.suppressed !== undefined + ? { suppressed: { address: res.suppressed.address, reason: res.suppressed.reason } } + : {}), + })), + }; +} + +// BatchView → context-safe MCP shape (header counts + live delivery rollup + +// accept-time suppressions), snake_case to match the rest of the tool surface. +function batchViewForTool(v: BatchView) { + const r = v.statusRollup; + return { + batch_id: v.batchId, + agent_id: v.agentId, + requested: v.requested, + accepted: v.accepted, + created_at: v.createdAt, + status_rollup: { + accepted: r.accepted, + sending: r.sending, + sent: r.sent, + delivered: r.delivered, + deferred: r.deferred, + bounced: r.bounced, + complained: r.complained, + failed: r.failed, + }, + suppressed: v.suppressed.map((s) => ({ + item_index: s.itemIndex, + address: s.address, + reason: s.reason, + })), + }; +} + // Shared scheduled-send input. A future send_at defers submission to that // instant (status "scheduled"); the tool passes it through as a Date so the SDK // serializes it as a date-time. Same field on send/reply/forward. @@ -600,6 +651,92 @@ export function registerMessageTools(server: McpServer, client: McpClient): void }), ); + server.registerTool( + "send_batch", + { + title: "Send a batch of emails (beta)", + annotations: { destructiveHint: false }, + description: + "Beta: send up to 100 INDEPENDENT emails in one call — each item is its own message with its own recipients and state, NOT one email to many people (use send_message cc/bcc for that). Always asynchronous: returns { batch_id, accepted, suppressed, results[] } where results are positionally aligned to the input `messages`. Each result is either { status: \"accepted\", message_id } or { status: \"suppressed\", suppressed: { address, reason } } — a suppressed item was dropped by the recipient block list (compliance), NOT a failure, and does not stop the rest. **`accepted` is success — do NOT re-send;** reuse `idempotency_key` if you must retry after an ambiguous failure. Terminal delivery arrives later via webhook events or by polling `get_batch` / `get_message`. HITL-enabled agents are refused (403 batch_hitl_unsupported) — use per-recipient send_message for those. Per-item fields (including templates) match send_message. The batch surface may change before it is declared stable.", + inputSchema: strictInputSchema({ + messages: z + .array( + z.object({ + to: z.array(z.string()).describe("Recipient email addresses for this item (one or more)."), + subject: z.string().optional(), + text: z.string().optional(), + html: z.string().optional(), + template_id: z.string().optional(), + template_alias: z.string().optional(), + template_data: z.record(z.string(), z.unknown()).optional(), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + attachments: attachmentsArraySchema, + conversation_id: z.string().optional(), + reply_to: z.string().optional(), + }), + ) + .min(1) + .max(100) + .describe("1 to 100 independent messages; each item uses the same per-item fields as send_message."), + reply_to: z + .string() + .optional() + .describe("Batch-level default Reply-To header for items that don't set their own."), + idempotency_key: z + .string() + .optional() + .describe( + "Stable key so a retried batch can't double-send. When omitted the SDK mints a fresh UUIDv4 per call (network-retry safety only).", + ), + email: emailSelector, + }), + }, + async (args) => + runTool(() => { + const opts: SendOpts = + args.idempotency_key !== undefined ? { idempotencyKey: args.idempotency_key } : {}; + const body: SendBatchRequest = { + messages: args.messages.map((m) => ({ + to: m.to, + ...(m.subject !== undefined ? { subject: m.subject } : {}), + ...(m.text !== undefined ? { text: m.text } : {}), + ...(m.html !== undefined ? { html: m.html } : {}), + ...(m.template_id !== undefined ? { templateId: m.template_id } : {}), + ...(m.template_alias !== undefined ? { templateAlias: m.template_alias } : {}), + ...(m.template_data !== undefined ? { templateData: m.template_data } : {}), + ...(m.cc !== undefined ? { cc: m.cc } : {}), + ...(m.bcc !== undefined ? { bcc: m.bcc } : {}), + ...(mapAttachments(m.attachments) !== undefined + ? { attachments: mapAttachments(m.attachments) } + : {}), + ...(m.conversation_id !== undefined ? { conversationId: m.conversation_id } : {}), + ...(m.reply_to !== undefined ? { replyTo: m.reply_to } : {}), + })), + ...(args.reply_to !== undefined ? { replyTo: args.reply_to } : {}), + }; + return client.sendBatch(body, opts, args.email).then(batchSendResultForTool); + }), + ); + + server.registerTool( + "get_batch", + { + title: "Get a batch (beta)", + annotations: { readOnlyHint: true, idempotentHint: true }, + description: + "Beta: fetch a batch's header (requested/accepted counts + the items dropped by the suppression filter at accept time) plus a LIVE delivery-status rollup of its child messages — poll it after send_batch to watch progress. Account-scoped: the batch_id alone identifies it; a batch owned by another account returns not_found. For per-recipient detail use list_messages filtered by the batch id. The batch surface may change before it is declared stable.", + inputSchema: strictInputSchema({ + batch_id: z.string().describe("The batch id returned by send_batch (bat_…)."), + }), + }, + async (args) => + runTool(async () => { + const v = await client.getBatch(args.batch_id); + return batchViewForTool(v); + }), + ); + server.registerTool( "get_message_lifecycle", { diff --git a/mcp/src/tools/tiers.ts b/mcp/src/tools/tiers.ts index c9c8e35a5..fe0c94166 100644 --- a/mcp/src/tools/tiers.ts +++ b/mcp/src/tools/tiers.ts @@ -31,6 +31,9 @@ export const RUNTIME_TOOLS: ReadonlySet = new Set([ // correct its own behavior without a human. The REST handler pins an // agent-scoped credential to its bound agent, so it can never widen this. "get_agent_metrics", + // Batch read: handleGetBatch uses requireUser (not requireAccountUser), so an + // agent-scoped credential may read its account's batches — runtime tier. + "get_batch", "get_attachment", "get_attachment_data", // Soft delete + restore are both per-agent inbox hygiene: the REST handler @@ -47,6 +50,8 @@ export const RUNTIME_TOOLS: ReadonlySet = new Set([ "get_conversation", "send_message", "send_email", + // send_batch resolves the owned agent like send_message — agent scope can use it. + "send_batch", "reply_to_message", "forward_message", "list_outreach_contacts", diff --git a/mcp/tests/events.test.ts b/mcp/tests/events.test.ts index e9344af41..6dfe817cb 100644 --- a/mcp/tests/events.test.ts +++ b/mcp/tests/events.test.ts @@ -200,8 +200,9 @@ describe("MCP events tools", () => { // full registered set (incl. the 8 beta template tools and the 3 // api-key tools, the 3 trash-lifecycle tools, the beta message-lifecycle // diagnostic tool, contact/outreach and suppression tools, the two - // delivery-metrics tools, and compatibility aliases) is 78 tools. - expect(tools).toHaveLength(78); + // delivery-metrics tools, the two batch tools, and compatibility + // aliases) is 80 tools. + expect(tools).toHaveLength(80); expect(names.has("list_events")).toBe(true); expect(names.has("get_event")).toBe(true); expect(names.has("redeliver_event")).toBe(true); diff --git a/mcp/tests/http.test.ts b/mcp/tests/http.test.ts index ffa4c25e9..3111b3da8 100644 --- a/mcp/tests/http.test.ts +++ b/mcp/tests/http.test.ts @@ -472,7 +472,7 @@ describe("HTTP MCP server", () => { const { client, transport } = await connect(); const names = new Set((await client.listTools()).tools.map((t) => t.name)); - expect(names.size).toBe(21); + expect(names.size).toBe(23); expect(names.has("send_message")).toBe(true); // runtime present expect(names.has("send_email")).toBe(true); // deprecated runtime alias expect(names.has("get_attachment_data")).toBe(true); // deprecated runtime alias diff --git a/mcp/tests/tools.test.ts b/mcp/tests/tools.test.ts index 901dd1d2e..fc7e6ce53 100644 --- a/mcp/tests/tools.test.ts +++ b/mcp/tests/tools.test.ts @@ -551,7 +551,7 @@ describe("e2a MCP server", () => { // account scope sees the full surface; agent scope sees only the runtime tier. it("keeps the frozen v1 tool-name baseline sorted, unique, and callable", async () => { - expect(frozenToolNames).toHaveLength(78); + expect(frozenToolNames).toHaveLength(80); expect(frozenToolNames).toEqual([...new Set(frozenToolNames)].sort()); const accountNames = new Set((await client.listTools()).tools.map((tool) => tool.name)); for (const name of frozenToolNames) { @@ -584,7 +584,7 @@ describe("e2a MCP server", () => { registerMetricsTools(recorder, stub); registerLegacyTools(recorder, stub); - expect(names).toHaveLength(78); + expect(names).toHaveLength(80); // Throws if any registered tool is untiered / double-tiered / phantom. expect(() => assertToolTiersComplete(names)).not.toThrow(); }); @@ -593,15 +593,15 @@ describe("e2a MCP server", () => { expect(toolNamesForScope("bogus")).toBe(RUNTIME_TOOLS); expect(toolNamesForScope("")).toBe(RUNTIME_TOOLS); expect(toolNamesForScope("agent")).toBe(RUNTIME_TOOLS); - expect(RUNTIME_TOOLS.size).toBe(21); + expect(RUNTIME_TOOLS.size).toBe(23); expect(ADMIN_TOOLS.size).toBe(57); - expect(toolNamesForScope("account").size).toBe(78); + expect(toolNamesForScope("account").size).toBe(80); }); it("account scope exposes all 78 canonical and compatibility tools", async () => { const acct = await connect(makeStubClient({ scope: "account" })); const { tools } = await acct.listTools(); - expect(tools).toHaveLength(78); + expect(tools).toHaveLength(80); const names = new Set(tools.map((tool) => tool.name)); for (const name of ["list_reviews", "get_review", "approve_review", "reject_review"]) { expect(names.has(name), `account review tool ${name} should be visible`).toBe(true); @@ -611,7 +611,7 @@ describe("e2a MCP server", () => { it("agent scope exposes runtime inbox and outreach tools", async () => { const ag = await connect(makeStubClient({ scope: "agent" })); const names = new Set((await ag.listTools()).tools.map((t) => t.name)); - expect(names.size).toBe(21); + expect(names.size).toBe(23); // Runtime tools present: an agent can send and read its own mailbox, but // account review discovery and decisions stay with the account owner. for (const n of [ diff --git a/mcp/tool-names.v1.json b/mcp/tool-names.v1.json index 121c7510b..80751a8a8 100644 --- a/mcp/tool-names.v1.json +++ b/mcp/tool-names.v1.json @@ -25,6 +25,7 @@ "get_agent_metrics", "get_attachment", "get_attachment_data", + "get_batch", "get_contact", "get_conversation", "get_domain", @@ -64,6 +65,7 @@ "restore_agent", "restore_message", "rotate_webhook_secret", + "send_batch", "send_email", "send_message", "set_outreach_contact", From 559a3938fa2b91da7d6c46af40963acbe4133827 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Sat, 8 Aug 2026 19:48:03 -0700 Subject: [PATCH 12/17] test(batch-send): contract scenario + coverage-gate happy path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-to-end contract coverage for the batch surface (mentor review round 2, item 2 — 'unit tests alone are the #774 failure mode'). - tests/contract/scenarios.yaml: batch_send_accept_and_observe exercises POST /v1/agents/{email}/batches (202 + positionally-aligned results with the status discriminator), GET /v1/batches/{batch_id} (header + live status rollup), and listMessages?batch_id (child-message filter). Runs through the Go runner in make test (store-dependent setup, so the TS/ Python runners skip it, same as the other verify_domain scenarios). - tests/e2e-prod/suites/23-coverage-happy-path.test.ts: exercises sendBatch + getBatch with a 2xx so the operationId coverage gate records them as covered rather than uncovered. Verification: - go test -tags integration ./tests/contract/ \ -run TestScenarios/batch_send_accept_and_observe -count=1 -v => PASS (server log: [batch:bat_...] agent=bot@batch.test.dev accepted=2 suppressed=0). - Full contract suite: go test -tags integration ./tests/contract/ -count=1 => ok (20.1s) — confirms the YAML parses and no other scenario regressed. - e2e-prod: npm run typecheck (tsc --noEmit) => clean. The suite itself runs in CI against the deployed server (needs staging creds; not run locally). - Note: BatchView.agent_id is the internal agent id, not the email, so the scenario asserts its presence, not its value. Co-Authored-By: Claude Opus 4.8 --- tests/contract/scenarios.yaml | 56 +++++++++++++++++++ .../suites/23-coverage-happy-path.test.ts | 37 ++++++++++++ 2 files changed, 93 insertions(+) diff --git a/tests/contract/scenarios.yaml b/tests/contract/scenarios.yaml index 811615c32..4d1280da3 100644 --- a/tests/contract/scenarios.yaml +++ b/tests/contract/scenarios.yaml @@ -563,6 +563,62 @@ scenarios: expect: status: 201 + - name: batch_send_accept_and_observe + description: > + POST /v1/agents/{email}/batches accepts a multi-item batch (202) with a + positionally-aligned results[] carrying the status discriminator; GET + /v1/batches/{batch_id} returns the header + live status rollup; and + listMessages?batch_id filters to the batch's child messages. Exercises the + full read/write batch surface end to end (sendBatch, getBatch, and the + batch_id message filter). + setup: + - register_domain: batch.test.dev + - verify_domain: batch.test.dev + - register_agent: + email: bot@batch.test.dev + steps: + - id: send_batch + action: request + method: POST + path: /v1/agents/{agent_email}/batches + body: + messages: + - to: ["alice@example.com"] + subject: Batch item one + text: hello alice + - to: ["bob@example.com"] + subject: Batch item two + text: hello bob + expect: + status: 202 + body_contains: [batch_id, results, accepted, suppressed_count] + body_match: + accepted: 2 + suppressed_count: 0 + "results.length": 2 + "results[0].status": accepted + "results[1].status": accepted + capture: + batch_id: batch_id + - id: get_batch + action: request + method: GET + path: /v1/batches/{batch_id} + expect: + status: 200 + body_contains: [batch_id, agent_id, status_rollup] + body_match: + requested: 2 + accepted: 2 + - id: list_batch_messages + action: request + method: GET + path: /v1/agents/{agent_email}/messages?direction=outbound&batch_id={batch_id} + expect: + status: 200 + body_match: + "items.length": 2 + - name: ws_notification_shape description: > WebSocket drain-on-reconnect delivers frames in the versioned event diff --git a/tests/e2e-prod/suites/23-coverage-happy-path.test.ts b/tests/e2e-prod/suites/23-coverage-happy-path.test.ts index 55d391d95..c33438c4c 100644 --- a/tests/e2e-prod/suites/23-coverage-happy-path.test.ts +++ b/tests/e2e-prod/suites/23-coverage-happy-path.test.ts @@ -187,6 +187,43 @@ test("approveReview: approve a HITL-held outbound (200 terminal or 202 enqueued) assert.equal(ap.body?.status, ap.status === 202 ? "accepted" : "sent"); }); +test("sendBatch + getBatch: accept a batch and read its header + rollup (2xx)", async () => { + // Batch send + batch read are only exercised here; the coverage gate records + // sendBatch/getBatch on a 2xx, so without this they'd read as UNCOVERED (the + // #774 failure mode). Two independent items to the simulator so both accept. + const email = await freshAgent("covbatch"); + const send = await client.post<{ + batch_id: string; + accepted: number; + suppressed_count: number; + results: Array<{ status: string; message_id?: string }>; + }>(`/v1/agents/${encodeURIComponent(email)}/batches`, { + body: { + messages: [ + { to: [SIMULATOR], subject: uniqueSubject("cov batch 1"), text: "batch coverage item one" }, + { to: [SIMULATOR], subject: uniqueSubject("cov batch 2"), text: "batch coverage item two" }, + ], + }, + expect: [200, 202], + }); + const batchId = send.body!.batch_id; + assert.ok(batchId, "send_batch returned a batch id"); + assert.equal(send.body?.accepted, 2, "both items accepted"); + assert.equal(send.body?.results?.length, 2, "results are positionally aligned to the 2 items"); + assert.equal(send.body?.results?.[0]?.status, "accepted"); + + const view = await client.get<{ + batch_id: string; + requested: number; + accepted: number; + status_rollup: Record; + }>(`/v1/batches/${encodeURIComponent(batchId)}`, { expect: 200 }); + assert.equal(view.body?.batch_id, batchId, "getBatch returns the same batch id"); + assert.equal(view.body?.requested, 2); + assert.equal(view.body?.accepted, 2); + assert.ok(view.body?.status_rollup, "batch view carries a live status rollup"); +}); + after(async () => { await cleanup(client); await writeReport(`./reports/${SUITE}.json`); From 39db5a955833d26930c70e4cef57372439cafb41 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Sat, 8 Aug 2026 20:08:36 -0700 Subject: [PATCH 13/17] feat(web): batch monitor page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only dashboard page for monitoring a batch send — the batch analogue of the per-message delivery status (mentor review round 2, item 3: surface parity). The dashboard doesn't compose batches (agents do, via the API/CLI/MCP); a human opens /batches/detail?id=bat_... to watch a batch's header (requested/accepted/suppressed counts) and its LIVE per-delivery-status rollup (polled every 10s), plus the items dropped by the suppression filter at accept time. - api.ts: getBatch(batchId) + BatchDetail/BatchStatusRollup/ BatchSuppressedItem types (GET /v1/batches/{id}). - swrKeys.ts: batchKey(id). - (app)/batches/detail/page.tsx: mirrors the webhooks detail page — static- export ?id= addressing (no dynamic segments), PageShell + Chip design system, rollup tones matching MessageStatusChip, 404-vs-500 error split. Reachable by batch id; a batches list would need a listBatches API endpoint (deferred in docs/design/batch-send.md), so it's out of scope here. Co-Authored-By: Claude Opus 4.8 --- web/src/app/(app)/batches/detail/page.tsx | 261 ++++++++++++++++++++++ web/src/app/components/onboarding/api.ts | 44 ++++ web/src/lib/swrKeys.ts | 6 + 3 files changed, 311 insertions(+) create mode 100644 web/src/app/(app)/batches/detail/page.tsx diff --git a/web/src/app/(app)/batches/detail/page.tsx b/web/src/app/(app)/batches/detail/page.tsx new file mode 100644 index 000000000..71a903167 --- /dev/null +++ b/web/src/app/(app)/batches/detail/page.tsx @@ -0,0 +1,261 @@ +"use client"; + +// One batch send: how many messages it fanned out, how many were accepted vs +// dropped by the suppression filter, and — the live part — where its child +// messages are in the delivery lifecycle. The dashboard doesn't compose +// batches (agents do, via the API/CLI/MCP); this page is how a human MONITORS +// one, the batch analogue of the per-message delivery status. +// +// Addressed by query param (?id=bat_…) rather than a dynamic route segment: +// the dashboard is a static export (next.config `output: "export"`), so the +// app has no dynamic segments — /batches/[id] would not build. Matches +// /webhooks/detail?id= and /inboxes/messages?email=. + +import { Suspense } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import useSWR from "swr"; +import { Chip, type ChipTone } from "@e2a/ui"; +import { PageShell } from "../../../components/loft/PageShell"; +import { + ApiError, + getBatch, + type BatchStatusRollup, + type BatchSuppressedItem, +} from "../../../components/onboarding/api"; +import { batchKey } from "../../../../lib/swrKeys"; + +// The rollup keys in display order, each with its label + chip tone. Tones +// mirror MessageStatusChip so a batch reads the same as its individual +// messages: green = done well, amber = slow, red = needs attention, blue = in +// flight. +const ROLLUP_STATUSES: ReadonlyArray<{ + key: keyof BatchStatusRollup; + label: string; + tone: ChipTone; +}> = [ + { key: "accepted", label: "Queued", tone: "info" }, + { key: "sending", label: "Sending", tone: "info" }, + { key: "sent", label: "Sent", tone: "success" }, + { key: "delivered", label: "Delivered", tone: "success" }, + { key: "deferred", label: "Delayed", tone: "warn" }, + { key: "bounced", label: "Bounced", tone: "danger" }, + { key: "complained", label: "Complaint", tone: "danger" }, + { key: "failed", label: "Failed", tone: "danger" }, +]; + +export default function BatchDetailPage() { + return ( + + + + ); +} + +function BatchDetailRouter() { + const searchParams = useSearchParams(); + const id = searchParams.get("id") ?? ""; + // Keyed by id so navigating between batches remounts rather than showing the + // previous batch's rollup while a new fetch is in flight. + return ; +} + +function BatchDetailContent({ id }: { id: string }) { + const { data: batch, error, isLoading } = useSWR( + id ? batchKey(id) : null, + () => getBatch(id), + // The rollup is computed on read, so poll while the page is open to show + // delivery progress without a manual refresh. + { refreshInterval: 10_000 }, + ); + + if (!id) { + return ( + + ); + } + + if (isLoading) { + return ( + Batch}> +

+ Loading… +

+
+ ); + } + + // A missing batch and a broken server are different problems: the id comes + // from a user-editable query string, so 404 is an ordinary state — but + // reporting "not found" for a 500 sends the reader to debug the address bar + // instead of the outage. + if (error) { + const notFound = error instanceof ApiError && error.status === 404; + return notFound ? ( + + ) : ( + + ); + } + + if (!batch) { + return ( + + ); + } + + const rollup = batch.status_rollup; + const nonZero = ROLLUP_STATUSES.filter((s) => (rollup[s.key] ?? 0) > 0); + const suppressedCount = batch.suppressed.length; + + return ( + Batch} + subtitle={{batch.batch_id}} + > + + +
+
+ + + {batch.requested} + + + + + {batch.accepted} + + + + 0 ? "var(--warn-strong)" : "var(--fg)" }} + > + {suppressedCount} + + + + + {new Date(batch.created_at).toLocaleString()} + + +
+
+ + {/* Live delivery rollup — the point of the page. */} +
+

+ DELIVERY STATUS +

+ {nonZero.length > 0 ? ( +
+ {nonZero.map((s) => ( + + {s.label} · {rollup[s.key]} + + ))} +
+ ) : ( +

+ No child messages yet — an all-suppressed batch, or delivery hasn't started. +

+ )} +
+ + {suppressedCount > 0 ? ( +
+

+ SUPPRESSED AT ACCEPT ({suppressedCount}) +

+
+ {batch.suppressed.map((s: BatchSuppressedItem) => ( +
+ + {s.address} + + + item {s.item_index} · {s.reason} + +
+ ))} +
+
+ ) : null} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label.toUpperCase()} +
+
{children}
+
+ ); +} + +function BackLink() { + return ( + + Dashboard + + ); +} + +function DetailMessage({ title, body }: { title: string; body: string }) { + return ( + Batch}> + +
+

+ {title} +

+

+ {body} +

+
+
+ ); +} diff --git a/web/src/app/components/onboarding/api.ts b/web/src/app/components/onboarding/api.ts index 61c312c84..cd25c970c 100644 --- a/web/src/app/components/onboarding/api.ts +++ b/web/src/app/components/onboarding/api.ts @@ -472,6 +472,50 @@ export function projectMessageDetail( : { direction: "inbound", data: projectInbound(w) }; } +// ── Batches ────────────────────────────────────────────── +// +// A batch send (POST /v1/agents/{email}/batches, via API/CLI/MCP) fans one +// call out to N independent messages. The dashboard doesn't compose batches — +// it MONITORS them: getBatch returns the header (counts + accept-time +// suppressions) plus a live per-delivery-status rollup of the batch's child +// messages, computed on read, so re-fetching shows progress. + +/** Per-delivery-status count of a batch's child messages (BatchStatusRollupView). */ +export interface BatchStatusRollup { + accepted: number; + sending: number; + sent: number; + delivered: number; + deferred: number; + bounced: number; + complained: number; + failed: number; +} + +/** One item dropped by the suppression filter at accept time. */ +export interface BatchSuppressedItem { + item_index: number; + address: string; + reason: string; +} + +/** GET /v1/batches/{id} response (BatchView). */ +export interface BatchDetail { + batch_id: string; + agent_id: string; + requested: number; + accepted: number; + suppressed: BatchSuppressedItem[]; + created_at: string; + status_rollup: BatchStatusRollup; +} + +// Account-scoped read: GET /v1/batches/{id}. Returns 404 for a batch owned by +// another account or a bad id (surfaced as ApiError with status 404). +export async function getBatch(batchId: string): Promise { + return request(`/v1/batches/${encodeURIComponent(batchId)}`); +} + // ── Message-detail fetchers ────────────────────────────── // // Both return the RAW wire so every per-message SWR entry holds one diff --git a/web/src/lib/swrKeys.ts b/web/src/lib/swrKeys.ts index 46a8b76a3..395defc58 100644 --- a/web/src/lib/swrKeys.ts +++ b/web/src/lib/swrKeys.ts @@ -93,6 +93,12 @@ export const messageDetailKey = (id: string) => export const messageLifecycleKey = (email: string, id: string) => ["message-lifecycle", email, id] as const; +// One batch (GET /v1/batches/{id}), for the batch monitor page. Keyed by the +// batch id alone — batch ids are globally unique and account-scoped, so the id +// is the identity. The status rollup is computed on read, so re-fetching this +// key (e.g. SWR refresh) is how the page shows live delivery progress. +export const batchKey = (id: string) => ["batch", id] as const; + // One webhook subscription (GET /v1/webhooks/{id}), for the detail page. export const webhookKey = (id: string) => ["webhook", id] as const; From b7265c2f03006a77326dd2fdf69c8fb5104c66c8 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Sat, 8 Aug 2026 20:55:09 -0700 Subject: [PATCH 14/17] chore(batch-send): post-rebase adaptations to latest upstream Reconcile the batch feature with upstream changes surfaced by the merge-time rebase (mentor review round 2, item 1): - migration renumbered 095 -> 099 (upstream now runs through 098); refs updated in batches.go, store.go, and the design doc. - batch reply_to adapted to upstream's new ReplyToField type: batch reply_to stays a single wire string, wrapped for the shared validateReplyTo (now returning the normalized address) so the batch path reuses single-send's validation + canonicalization unchanged. - outbound_footer_internal_test.go's fakeFooterEnforcer gains the CheckMessageSendN method our feature added to limits.Enforcer, so the upstream test fake still satisfies the widened interface. Co-Authored-By: Claude Opus 4.8 --- docs/design/batch-send.md | 2 +- .../agent/outbound_footer_internal_test.go | 5 +- internal/httpapi/batch.go | 50 +++++++++++-------- internal/identity/batches.go | 4 +- internal/identity/store.go | 4 +- .../{095_batches.sql => 099_batches.sql} | 2 +- 6 files changed, 39 insertions(+), 28 deletions(-) rename migrations/{095_batches.sql => 099_batches.sql} (99%) diff --git a/docs/design/batch-send.md b/docs/design/batch-send.md index 67cea95bd..8900957c0 100644 --- a/docs/design/batch-send.md +++ b/docs/design/batch-send.md @@ -191,7 +191,7 @@ CREATE INDEX batches_user_created_at_idx ON batches (user_id, created_at DESC) CREATE INDEX batches_agent_created_at_idx ON batches (agent_id, created_at DESC); ``` -**Type corrections from the initial draft.** The initial draft referenced `accounts(account_id)` and `agents(agent_id)` with `UUID` FKs. Neither exists in this codebase — the actual ownership chain is `users.id` (TEXT, `usr_...`) → `agent_identities.id` (TEXT, `agt_...`); there is no `accounts` table (the word appears only in the /v1 `AccountView` API resource, which is a projection over users + limits + usage). The schema above uses the real column shapes and reference targets; migration `095_batches.sql` embeds this SQL. +**Type corrections from the initial draft.** The initial draft referenced `accounts(account_id)` and `agents(agent_id)` with `UUID` FKs. Neither exists in this codebase — the actual ownership chain is `users.id` (TEXT, `usr_...`) → `agent_identities.id` (TEXT, `agt_...`); there is no `accounts` table (the word appears only in the /v1 `AccountView` API resource, which is a projection over users + limits + usage). The schema above uses the real column shapes and reference targets; migration `099_batches.sql` embeds this SQL. Rationale for storing `suppressed_json` on the batch row (not per-message): a suppressed item produces NO `messages` row, so there is no other durable place to record the drop. The batch row is the only place that remembers "item i was in your request but we skipped it because address X hit the suppression list." diff --git a/internal/agent/outbound_footer_internal_test.go b/internal/agent/outbound_footer_internal_test.go index 5348c5239..8cb96db56 100644 --- a/internal/agent/outbound_footer_internal_test.go +++ b/internal/agent/outbound_footer_internal_test.go @@ -25,7 +25,10 @@ func (f *fakeFooterEnforcer) Get(ctx context.Context, userID string) (limits.Lim func (f *fakeFooterEnforcer) CheckAgentCreate(context.Context, string) error { return nil } func (f *fakeFooterEnforcer) CheckDomainCreate(context.Context, string) error { return nil } func (f *fakeFooterEnforcer) CheckMessageSend(context.Context, string) error { return nil } -func (f *fakeFooterEnforcer) Invalidate(string) {} +func (f *fakeFooterEnforcer) CheckMessageSendN(context.Context, string, int) error { + return nil +} +func (f *fakeFooterEnforcer) Invalidate(string) {} // TestResolveOutboundFooterMatrix pins the gating decision: // diff --git a/internal/httpapi/batch.go b/internal/httpapi/batch.go index 9a1567898..fc324fdf9 100644 --- a/internal/httpapi/batch.go +++ b/internal/httpapi/batch.go @@ -98,9 +98,9 @@ type SendBatchResponse struct { // Mirrors createMessageInput's shape (path email + Idempotency-Key header + // RawBody for body-hash idempotency + Body). type sendBatchInput struct { - Address string `path:"email"` - RawBody []byte // populated by Huma for idempotency body-hashing (see runIdempotent) - IdempotencyKey string `header:"Idempotency-Key" doc:"Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch)."` + Address string `path:"email"` + RawBody []byte // populated by Huma for idempotency body-hashing (see runIdempotent) + IdempotencyKey string `header:"Idempotency-Key" doc:"Optional idempotency key for safe retries. Same semantics as single-send: 24h TTL, path+body hash, replay returns the cached 202 verbatim (409 in-flight, 422 mismatch)."` Body SendBatchRequest } @@ -150,12 +150,12 @@ const batchSendBetaDescription = "Beta: the batch-send surface (both operations, // per-endpoint register* helpers in outbound.go. func (s *Server) registerSendBatch() { registerOp(s.API, huma.Operation{ - OperationID: "sendBatch", - Method: http.MethodPost, - Path: "/v1/agents/{email}/batches", - Summary: "Send a batch of up to 100 emails (beta)", - Tags: []string{"messages"}, - Description: "Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract.\n\nMVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant.\n\nAggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item's content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls.\n\n" + batchSendBetaDescription, + OperationID: "sendBatch", + Method: http.MethodPost, + Path: "/v1/agents/{email}/batches", + Summary: "Send a batch of up to 100 emails (beta)", + Tags: []string{"messages"}, + Description: "Fan out N independent emails in one API call. Each `messages[i]` item is a full send request in its own right (to/subject/body/template/attachments/reply_to) — the batch endpoint is essentially single-send in a loop, sharing rate-limit reservation and idempotency across all N items. Response `results[]` is positionally aligned with the input `messages[]`; each slot is either `{message_id}` (accepted) or `{suppressed: {address, reason}}` (dropped because a recipient was on this account's suppression list). See docs/design/batch-send.md for the full contract.\n\nMVP restrictions: HITL-enabled agents are refused with 403 `batch_hitl_unsupported` (§5.1); per-item content override is native (each item carries its own body or template_data); attachments are per-item with a 25 MiB batch-wide combined cap (§14 Q15); rate limits count as N sends (§4.2); duplicate recipients across items are rejected (§14 Q11). All error responses include `details.item_index` (or `details.item_indices`) to identify the offending item where relevant.\n\nAggregate size limit: the whole request body is capped at 60 MiB (payload_too_large 413) — the base-64/JSON-encoded sum of every item's content and attachments. This aggregate ceiling is separate from, and stricter in total than, the per-item body caps: a batch whose items are each schema-valid but whose combined body exceeds 60 MiB is rejected. Size requests against this ceiling and split an oversized batch across multiple calls.\n\n" + batchSendBetaDescription, Security: []map[string][]string{{"bearer": {}}}, Extensions: beta(), MaxBodyBytes: maxBatchRequestBodyBytes, @@ -179,7 +179,7 @@ func (s *Server) registerSendBatch() { Path: "/v1/batches/{batch_id}", Summary: "Get a batch's header and delivery-status rollup (beta)", Tags: []string{"messages"}, - Description: "Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch's child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found.\n\n" + batchSendBetaDescription, + Description: "Returns the batch header (counts + the list of items dropped by the suppression filter at accept time) plus a live rollup of the batch's child messages by delivery status. The rollup is computed on read from the messages table — poll it after a batch send to watch delivery progress. For per-recipient detail beyond the aggregate, use GET /v1/messages?batch_id={batch_id}. Account-scoped: a batch owned by another account returns 404 not_found.\n\n" + batchSendBetaDescription, Security: []map[string][]string{{"bearer": {}}}, Extensions: beta(), Responses: map[string]*huma.Response{ @@ -212,13 +212,13 @@ type BatchStatusRollupView struct { // BatchView is the GET /v1/batches/{batch_id} response body. type BatchView struct { - BatchID string `json:"batch_id"` - AgentID string `json:"agent_id" doc:"The sending agent's address."` - Requested int `json:"requested" doc:"Number of items in the original request (accepted + suppressed)."` - Accepted int `json:"accepted" doc:"Number of items durably accepted (each has a message_id + delivery pipeline entry)."` - Suppressed []BatchSuppressedItem `json:"suppressed" nullable:"false" doc:"Items dropped by the suppression filter at accept time. Empty when none were dropped."` - CreatedAt string `json:"created_at" doc:"RFC3339 timestamp of when the batch was accepted."` - StatusRollup BatchStatusRollupView `json:"status_rollup" doc:"Live per-delivery-status count of the batch's child messages, computed on read."` + BatchID string `json:"batch_id"` + AgentID string `json:"agent_id" doc:"The sending agent's address."` + Requested int `json:"requested" doc:"Number of items in the original request (accepted + suppressed)."` + Accepted int `json:"accepted" doc:"Number of items durably accepted (each has a message_id + delivery pipeline entry)."` + Suppressed []BatchSuppressedItem `json:"suppressed" nullable:"false" doc:"Items dropped by the suppression filter at accept time. Empty when none were dropped."` + CreatedAt string `json:"created_at" doc:"RFC3339 timestamp of when the batch was accepted."` + StatusRollup BatchStatusRollupView `json:"status_rollup" doc:"Live per-delivery-status count of the batch's child messages, computed on read."` } // BatchSuppressedItem is one dropped-item record in a BatchView. item_index is @@ -356,7 +356,7 @@ func (s *Server) deliverBatch(ctx context.Context, user *identity.User, ag *iden To: bm.To, CC: bm.CC, BCC: bm.BCC, Subject: bm.Subject, Body: bm.Body, HTMLBody: bm.HTMLBody, TemplateID: bm.TemplateID, TemplateAlias: bm.TemplateAlias, TemplateData: bm.TemplateData, - ConversationID: bm.ConversationID, ReplyTo: bm.ReplyTo, Attachments: bm.Attachments, + ConversationID: bm.ConversationID, Attachments: bm.Attachments, } if env := validateSendTemplateShape(&asSend); env != nil { return 0, SendBatchResponse{}, envelopeWithItemIndex(env, i) @@ -370,11 +370,19 @@ func (s *Server) deliverBatch(ctx context.Context, user *identity.User, ag *iden // Apply the batch-level reply_to default only when the item // didn't set its own. Per-item ReplyTo always wins (§1.2). - replyTo := asSend.ReplyTo + replyTo := bm.ReplyTo if replyTo == "" { replyTo = body.ReplyTo } - if env := validateReplyTo(replyTo); env != nil { + // Reuse single-send's Reply-To validation + normalization. Batch + // reply_to is a single address string; wrap it in the shared + // ReplyToField input and take the flattened canonical form. + var replyToField ReplyToField + if replyTo != "" { + replyToField = ReplyToField{values: []string{replyTo}} + } + normReplyTo, env := validateReplyTo(replyToField) + if env != nil { return 0, SendBatchResponse{}, envelopeWithItemIndex(env, i) } @@ -400,7 +408,7 @@ func (s *Server) deliverBatch(ctx context.Context, user *identity.User, ag *iden Body: asSend.Body, HTMLBody: asSend.HTMLBody, ConversationID: asSend.ConversationID, - ReplyTo: replyTo, + ReplyTo: normReplyTo, Attachments: asSend.Attachments, } } diff --git a/internal/identity/batches.go b/internal/identity/batches.go index f4b57f591..b94148e3a 100644 --- a/internal/identity/batches.go +++ b/internal/identity/batches.go @@ -75,7 +75,7 @@ type BatchStatusRollup struct { // for one message. Mirrors CreateOutboundMessageTx's positional args as a // struct so a batch of N doesn't need a 14-arg loop body. When BatchID is // non-empty the resulting messages row is linked back to that batch header -// via the FK added in migration 095. See docs/design/batch-send.md §9 +// via the FK added in migration 099. See docs/design/batch-send.md §9 // (accept-tx step 10.b). type OutboundMessageInput struct { AgentID string @@ -163,7 +163,7 @@ func (s *Store) GetBatch(ctx context.Context, batchID string) (*Batch, error) { // BatchStatusRollupByID computes the delivery-status histogram over the // batch's child messages rows in one grouped query. Not cached — batch // observation is a rare, poll-after-send operation and at ≤100 rows per -// batch the query is cheap (the partial index in migration 095 makes the +// batch the query is cheap (the partial index in migration 099 makes the // WHERE batch_id = $1 scan a bounded lookup). See docs/design/batch-send.md // §7.1. // diff --git a/internal/identity/store.go b/internal/identity/store.go index 75e791640..334bc8b05 100644 --- a/internal/identity/store.go +++ b/internal/identity/store.go @@ -426,7 +426,7 @@ type Message struct { // delivery-feedback UPDATE...RETURNING that builds email.sent / // email.failed events, so batch children carry batch_id on those // events — docs/design/batch-send.md §7.3). Source: messages.batch_id - // (migration 095). + // (migration 099). BatchID string `json:"batch_id,omitempty"` // Labels are user-applied string tags (`urgent`, `follow-up`, …). @@ -4153,7 +4153,7 @@ type MessageListFilter struct { From string SubjectContains string ConversationID string // exact match - BatchID string // exact match; child messages of a batch send (migration 095) + BatchID string // exact match; child messages of a batch send (migration 099) Since time.Time // created_at >= Since Until time.Time // created_at < Until // Labels filters rows where ALL given labels are present on the diff --git a/migrations/095_batches.sql b/migrations/099_batches.sql similarity index 99% rename from migrations/095_batches.sql rename to migrations/099_batches.sql index 00414affa..5958cc4d0 100644 --- a/migrations/095_batches.sql +++ b/migrations/099_batches.sql @@ -1,4 +1,4 @@ --- 095_batches.sql +-- 099_batches.sql -- -- Storage for the batch-send feature — the primitive that lets one API call -- fan out N independent messages, each with its own message_id/state/retry From ab4b8b68b7b08290b568a303c5397c868b223195 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Sat, 8 Aug 2026 20:55:19 -0700 Subject: [PATCH 15/17] chore(batch-send): regenerate TS SDK after rebase Regenerate the TS /v1 client base so the generated listMessages carries both our batch_id filter and upstream's new filter param in the correct positional order (from the ListMessagesInput field order). Spec and Python SDK were already consistent post-rebase. Co-Authored-By: Claude Opus 4.8 --- .../src/v1/generated/apis/MessagesApi.ts | 98 ++++++++++++++++- .../src/v1/generated/types/ObjectParamAPI.ts | 104 +++++++++++++++++- .../src/v1/generated/types/ObservableAPI.ts | 95 +++++++++++++++- .../src/v1/generated/types/PromiseAPI.ts | 71 +++++++++++- 4 files changed, 357 insertions(+), 11 deletions(-) diff --git a/sdks/typescript/src/v1/generated/apis/MessagesApi.ts b/sdks/typescript/src/v1/generated/apis/MessagesApi.ts index 7c4fb70d9..880e27af6 100644 --- a/sdks/typescript/src/v1/generated/apis/MessagesApi.ts +++ b/sdks/typescript/src/v1/generated/apis/MessagesApi.ts @@ -8,6 +8,7 @@ import {canConsumeForm, isCodeInRange} from '../util.js'; import {SecurityAuthentication} from '../auth/auth.js'; +import { AgentMetricsView } from '../models/AgentMetricsView.js'; import { AttachmentView } from '../models/AttachmentView.js'; import { BatchView } from '../models/BatchView.js'; import { DeleteMessageResult } from '../models/DeleteMessageResult.js'; @@ -167,6 +168,58 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { return requestContext; } + /** + * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. + * Get an agent\'s delivery metrics (beta) + * @param email + * @param start Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. + * @param end Exclusive end of the cohort window (RFC 3339). Defaults to now. + */ + public async getAgentMetrics(email: string, start?: Date, end?: Date, _options?: Configuration): Promise { + let _config = _options || this.configuration; + + // verify required parameter 'email' is not null or undefined + if (email === null || email === undefined) { + throw new RequiredError("MessagesApi", "getAgentMetrics", "email"); + } + + + + + // Path Params + const localVarPath = '/v1/agents/{email}/metrics' + .replace('{' + 'email' + '}', encodeURIComponent(String(email))); + + // Make Request Context + const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET); + requestContext.setHeaderParam("Accept", "application/json, */*;q=0.8") + + // Query Params + if (start !== undefined) { + requestContext.setQueryParam("start", ObjectSerializer.serialize(start, "Date", "date-time")); + } + + // Query Params + if (end !== undefined) { + requestContext.setQueryParam("end", ObjectSerializer.serialize(end, "Date", "date-time")); + } + + + let authMethod: SecurityAuthentication | undefined; + // Apply auth methods + authMethod = _config.authMethods["bearer"] + if (authMethod?.applySecurityAuthentication) { + await authMethod?.applySecurityAuthentication(requestContext); + } + + const defaultAuth: SecurityAuthentication | undefined = _config?.authMethods?.default + if (defaultAuth?.applySecurityAuthentication) { + await defaultAuth?.applySecurityAuthentication(requestContext); + } + + return requestContext; + } + /** * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. * Get an attachment (metadata + short-lived download URL) @@ -389,8 +442,9 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { * @param cursor * @param limit * @param deleted List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). + * @param filter Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public async listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: Configuration): Promise { + public async listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: Configuration): Promise { let _config = _options || this.configuration; // verify required parameter 'email' is not null or undefined @@ -412,6 +466,7 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { + // Path Params const localVarPath = '/v1/agents/{email}/messages' .replace('{' + 'email' + '}', encodeURIComponent(String(email))); @@ -485,6 +540,11 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { requestContext.setQueryParam("deleted", ObjectSerializer.serialize(deleted, "boolean", "")); } + // Query Params + if (filter !== undefined) { + requestContext.setQueryParam("filter", ObjectSerializer.serialize(filter, "string", "")); + } + let authMethod: SecurityAuthentication | undefined; // Apply auth methods @@ -941,6 +1001,42 @@ export class MessagesApiResponseProcessor { throw new ApiException(response.httpStatusCode, "Unknown API Status Code!", await response.getBodyAsAny(), response.headers); } + /** + * Unwraps the actual response sent by the server from the response context and deserializes the response content + * to the expected objects + * + * @params response Response returned by the server for a request to getAgentMetrics + * @throws ApiException if the response code was not in [200, 299] + */ + public async getAgentMetricsWithHttpInfo(response: ResponseContext): Promise> { + const contentType = ObjectSerializer.normalizeMediaType(response.headers["content-type"]); + if (isCodeInRange("200", response.httpStatusCode)) { + const body: AgentMetricsView = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "AgentMetricsView", "" + ) as AgentMetricsView; + return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); + } + if (isCodeInRange("0", response.httpStatusCode)) { + const body: ErrorEnvelope = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "ErrorEnvelope", "" + ) as ErrorEnvelope; + throw new ApiException(response.httpStatusCode, "Error", body, response.headers); + } + + // Work around for missing responses in specification, e.g. for petstore.yaml + if (response.httpStatusCode >= 200 && response.httpStatusCode <= 299) { + const body: AgentMetricsView = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "AgentMetricsView", "" + ) as AgentMetricsView; + return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); + } + + throw new ApiException(response.httpStatusCode, "Unknown API Status Code!", await response.getBodyAsAny(), response.headers); + } + /** * Unwraps the actual response sent by the server from the response context and deserializes the response content * to the expected objects diff --git a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts index 0a110840e..5f48a0192 100644 --- a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts @@ -4,9 +4,12 @@ import type { Middleware } from '../middleware.js'; import { APIKeyExportEntry } from '../models/APIKeyExportEntry.js'; import { APIKeyView } from '../models/APIKeyView.js'; +import { AccountMetricsView } from '../models/AccountMetricsView.js'; import { AccountUserView } from '../models/AccountUserView.js'; import { AccountView } from '../models/AccountView.js'; import { AgentIdentity } from '../models/AgentIdentity.js'; +import { AgentMetricsGroupView } from '../models/AgentMetricsGroupView.js'; +import { AgentMetricsView } from '../models/AgentMetricsView.js'; import { AgentSuppressionAddedData } from '../models/AgentSuppressionAddedData.js'; import { AgentSuppressionView } from '../models/AgentSuppressionView.js'; import { AgentView } from '../models/AgentView.js'; @@ -71,6 +74,7 @@ import { EventEnvelope } from '../models/EventEnvelope.js'; import { EventView } from '../models/EventView.js'; import { FieldError } from '../models/FieldError.js'; import { ForwardRequest } from '../models/ForwardRequest.js'; +import { ForwardRequestReplyTo } from '../models/ForwardRequestReplyTo.js'; import { HoldReasonView } from '../models/HoldReasonView.js'; import { ImportContactsRequest } from '../models/ImportContactsRequest.js'; import { LimitExceededDetails } from '../models/LimitExceededDetails.js'; @@ -84,6 +88,9 @@ import { MessageLifecycleTransition } from '../models/MessageLifecycleTransition import { MessageParsedView } from '../models/MessageParsedView.js'; import { MessageSummaryView } from '../models/MessageSummaryView.js'; import { MessageView } from '../models/MessageView.js'; +import { MetricsCounterView } from '../models/MetricsCounterView.js'; +import { MetricsRatesView } from '../models/MetricsRatesView.js'; +import { MetricsSummaryView } from '../models/MetricsSummaryView.js'; import { OAuthConnectionEntry } from '../models/OAuthConnectionEntry.js'; import { PageAPIKeyView } from '../models/PageAPIKeyView.js'; import { PageAgentSuppressionView } from '../models/PageAgentSuppressionView.js'; @@ -162,8 +169,10 @@ import { ValidateTemplateResponse } from '../models/ValidateTemplateResponse.js' import { ValidationErrorDetails } from '../models/ValidationErrorDetails.js'; import { VerifyDomainView } from '../models/VerifyDomainView.js'; import { WebhookDeliveryView } from '../models/WebhookDeliveryView.js'; +import { WebhookEndpointMetricsView } from '../models/WebhookEndpointMetricsView.js'; import { WebhookFiltersRequest } from '../models/WebhookFiltersRequest.js'; import { WebhookFiltersView } from '../models/WebhookFiltersView.js'; +import { WebhookMetricsView } from '../models/WebhookMetricsView.js'; import { WebhookView } from '../models/WebhookView.js'; import { ObservableAccountApi } from "./ObservableAPI.js"; @@ -235,6 +244,30 @@ export interface AccountApiExportAccountRequest { export interface AccountApiGetAccountRequest { } +export interface AccountApiGetAccountMetricsRequest { + /** + * Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. + * Defaults to: undefined + * @type Date + * @memberof AccountApigetAccountMetrics + */ + start?: Date + /** + * Exclusive end of the cohort window (RFC 3339). Defaults to now. + * Defaults to: undefined + * @type Date + * @memberof AccountApigetAccountMetrics + */ + end?: Date + /** + * Set to \'agent\' to also receive a per-agent breakdown. Omit for account totals only, which is the cheaper read. + * Defaults to: undefined + * @type 'agent' + * @memberof AccountApigetAccountMetrics + */ + groupBy?: 'agent' +} + export interface AccountApiListApiKeysRequest { /** * Opaque pagination cursor from a previous response\'s next_cursor. Continuation requests must not change the other filters. @@ -388,6 +421,24 @@ export class ObjectAccountApi { return this.api.getAccount( options).toPromise(); } + /** + * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. + * Get account-wide delivery metrics (beta) + * @param param the request object + */ + public getAccountMetricsWithHttpInfo(param: AccountApiGetAccountMetricsRequest = {}, options?: ConfigurationOptions): Promise> { + return this.api.getAccountMetricsWithHttpInfo(param.start, param.end, param.groupBy, options).toPromise(); + } + + /** + * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. + * Get account-wide delivery metrics (beta) + * @param param the request object + */ + public getAccountMetrics(param: AccountApiGetAccountMetricsRequest = {}, options?: ConfigurationOptions): Promise { + return this.api.getAccountMetrics(param.start, param.end, param.groupBy, options).toPromise(); + } + /** * API keys for the account (metadata only — secrets are shown once, at creation). Account scope only: an agent-scoped credential cannot manage keys. * List API keys @@ -1829,6 +1880,30 @@ export interface MessagesApiForwardMessageRequest { wait?: string } +export interface MessagesApiGetAgentMetricsRequest { + /** + * + * Defaults to: undefined + * @type string + * @memberof MessagesApigetAgentMetrics + */ + email: string + /** + * Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. + * Defaults to: undefined + * @type Date + * @memberof MessagesApigetAgentMetrics + */ + start?: Date + /** + * Exclusive end of the cohort window (RFC 3339). Defaults to now. + * Defaults to: undefined + * @type Date + * @memberof MessagesApigetAgentMetrics + */ + end?: Date +} + export interface MessagesApiGetAttachmentRequest { /** * @@ -2022,6 +2097,13 @@ export interface MessagesApiListMessagesRequest { * @memberof MessagesApilistMessages */ deleted?: boolean + /** + * Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. + * Defaults to: undefined + * @type string + * @memberof MessagesApilistMessages + */ + filter?: string } export interface MessagesApiReplyToMessageRequest { @@ -2197,6 +2279,24 @@ export class ObjectMessagesApi { return this.api.forwardMessage(param.email, param.id, param.forwardRequest, param.idempotencyKey, param.wait, options).toPromise(); } + /** + * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. + * Get an agent\'s delivery metrics (beta) + * @param param the request object + */ + public getAgentMetricsWithHttpInfo(param: MessagesApiGetAgentMetricsRequest, options?: ConfigurationOptions): Promise> { + return this.api.getAgentMetricsWithHttpInfo(param.email, param.start, param.end, options).toPromise(); + } + + /** + * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. + * Get an agent\'s delivery metrics (beta) + * @param param the request object + */ + public getAgentMetrics(param: MessagesApiGetAgentMetricsRequest, options?: ConfigurationOptions): Promise { + return this.api.getAgentMetrics(param.email, param.start, param.end, options).toPromise(); + } + /** * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. * Get an attachment (metadata + short-lived download URL) @@ -2275,7 +2375,7 @@ export class ObjectMessagesApi { * @param param the request object */ public listMessagesWithHttpInfo(param: MessagesApiListMessagesRequest, options?: ConfigurationOptions): Promise> { - return this.api.listMessagesWithHttpInfo(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.batchId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, options).toPromise(); + return this.api.listMessagesWithHttpInfo(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.batchId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, param.filter, options).toPromise(); } /** @@ -2284,7 +2384,7 @@ export class ObjectMessagesApi { * @param param the request object */ public listMessages(param: MessagesApiListMessagesRequest, options?: ConfigurationOptions): Promise { - return this.api.listMessages(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.batchId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, options).toPromise(); + return this.api.listMessages(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.batchId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, param.filter, options).toPromise(); } /** diff --git a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts index 0afd3e2c0..6ff456c97 100644 --- a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts @@ -5,9 +5,12 @@ import { Observable, of, from } from '../rxjsStub.js'; import {mergeMap, map} from '../rxjsStub.js'; import { APIKeyExportEntry } from '../models/APIKeyExportEntry.js'; import { APIKeyView } from '../models/APIKeyView.js'; +import { AccountMetricsView } from '../models/AccountMetricsView.js'; import { AccountUserView } from '../models/AccountUserView.js'; import { AccountView } from '../models/AccountView.js'; import { AgentIdentity } from '../models/AgentIdentity.js'; +import { AgentMetricsGroupView } from '../models/AgentMetricsGroupView.js'; +import { AgentMetricsView } from '../models/AgentMetricsView.js'; import { AgentSuppressionAddedData } from '../models/AgentSuppressionAddedData.js'; import { AgentSuppressionView } from '../models/AgentSuppressionView.js'; import { AgentView } from '../models/AgentView.js'; @@ -72,6 +75,7 @@ import { EventEnvelope } from '../models/EventEnvelope.js'; import { EventView } from '../models/EventView.js'; import { FieldError } from '../models/FieldError.js'; import { ForwardRequest } from '../models/ForwardRequest.js'; +import { ForwardRequestReplyTo } from '../models/ForwardRequestReplyTo.js'; import { HoldReasonView } from '../models/HoldReasonView.js'; import { ImportContactsRequest } from '../models/ImportContactsRequest.js'; import { LimitExceededDetails } from '../models/LimitExceededDetails.js'; @@ -85,6 +89,9 @@ import { MessageLifecycleTransition } from '../models/MessageLifecycleTransition import { MessageParsedView } from '../models/MessageParsedView.js'; import { MessageSummaryView } from '../models/MessageSummaryView.js'; import { MessageView } from '../models/MessageView.js'; +import { MetricsCounterView } from '../models/MetricsCounterView.js'; +import { MetricsRatesView } from '../models/MetricsRatesView.js'; +import { MetricsSummaryView } from '../models/MetricsSummaryView.js'; import { OAuthConnectionEntry } from '../models/OAuthConnectionEntry.js'; import { PageAPIKeyView } from '../models/PageAPIKeyView.js'; import { PageAgentSuppressionView } from '../models/PageAgentSuppressionView.js'; @@ -163,8 +170,10 @@ import { ValidateTemplateResponse } from '../models/ValidateTemplateResponse.js' import { ValidationErrorDetails } from '../models/ValidationErrorDetails.js'; import { VerifyDomainView } from '../models/VerifyDomainView.js'; import { WebhookDeliveryView } from '../models/WebhookDeliveryView.js'; +import { WebhookEndpointMetricsView } from '../models/WebhookEndpointMetricsView.js'; import { WebhookFiltersRequest } from '../models/WebhookFiltersRequest.js'; import { WebhookFiltersView } from '../models/WebhookFiltersView.js'; +import { WebhookMetricsView } from '../models/WebhookMetricsView.js'; import { WebhookView } from '../models/WebhookView.js'; import { AccountApiRequestFactory, AccountApiResponseProcessor} from "../apis/AccountApi.js"; @@ -389,6 +398,44 @@ export class ObservableAccountApi { return this.getAccountWithHttpInfo(_options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } + /** + * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. + * Get account-wide delivery metrics (beta) + * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. + * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. + * @param [groupBy] Set to \'agent\' to also receive a per-agent breakdown. Omit for account totals only, which is the cheaper read. + */ + public getAccountMetricsWithHttpInfo(start?: Date, end?: Date, groupBy?: 'agent', _options?: ConfigurationOptions): Observable> { + const _config = mergeConfiguration(this.configuration, _options); + + const requestContextPromise = this.requestFactory.getAccountMetrics(start, end, groupBy, _config); + // build promise chain + let middlewarePreObservable = from(requestContextPromise); + for (const middleware of _config.middleware) { + middlewarePreObservable = middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => middleware.pre(ctx))); + } + + return middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => _config.httpApi.send(ctx))). + pipe(mergeMap((response: ResponseContext) => { + let middlewarePostObservable = of(response); + for (const middleware of _config.middleware.reverse()) { + middlewarePostObservable = middlewarePostObservable.pipe(mergeMap((rsp: ResponseContext) => middleware.post(rsp))); + } + return middlewarePostObservable.pipe(map((rsp: ResponseContext) => this.responseProcessor.getAccountMetricsWithHttpInfo(rsp))); + })); + } + + /** + * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. + * Get account-wide delivery metrics (beta) + * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. + * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. + * @param [groupBy] Set to \'agent\' to also receive a per-agent breakdown. Omit for account totals only, which is the cheaper read. + */ + public getAccountMetrics(start?: Date, end?: Date, groupBy?: 'agent', _options?: ConfigurationOptions): Observable { + return this.getAccountMetricsWithHttpInfo(start, end, groupBy, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + } + /** * API keys for the account (metadata only — secrets are shown once, at creation). Account scope only: an agent-scoped credential cannot manage keys. * List API keys @@ -1867,6 +1914,44 @@ export class ObservableMessagesApi { return this.forwardMessageWithHttpInfo(email, id, forwardRequest, idempotencyKey, wait, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } + /** + * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. + * Get an agent\'s delivery metrics (beta) + * @param email + * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. + * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. + */ + public getAgentMetricsWithHttpInfo(email: string, start?: Date, end?: Date, _options?: ConfigurationOptions): Observable> { + const _config = mergeConfiguration(this.configuration, _options); + + const requestContextPromise = this.requestFactory.getAgentMetrics(email, start, end, _config); + // build promise chain + let middlewarePreObservable = from(requestContextPromise); + for (const middleware of _config.middleware) { + middlewarePreObservable = middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => middleware.pre(ctx))); + } + + return middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => _config.httpApi.send(ctx))). + pipe(mergeMap((response: ResponseContext) => { + let middlewarePostObservable = of(response); + for (const middleware of _config.middleware.reverse()) { + middlewarePostObservable = middlewarePostObservable.pipe(mergeMap((rsp: ResponseContext) => middleware.post(rsp))); + } + return middlewarePostObservable.pipe(map((rsp: ResponseContext) => this.responseProcessor.getAgentMetricsWithHttpInfo(rsp))); + })); + } + + /** + * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. + * Get an agent\'s delivery metrics (beta) + * @param email + * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. + * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. + */ + public getAgentMetrics(email: string, start?: Date, end?: Date, _options?: ConfigurationOptions): Observable { + return this.getAgentMetricsWithHttpInfo(email, start, end, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + } + /** * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. * Get an attachment (metadata + short-lived download URL) @@ -2034,11 +2119,12 @@ export class ObservableMessagesApi { * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). + * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: ConfigurationOptions): Observable> { + public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); - const requestContextPromise = this.requestFactory.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, _config); + const requestContextPromise = this.requestFactory.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, filter, _config); // build promise chain let middlewarePreObservable = from(requestContextPromise); for (const middleware of _config.middleware) { @@ -2072,9 +2158,10 @@ export class ObservableMessagesApi { * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). + * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: ConfigurationOptions): Observable { - return this.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: ConfigurationOptions): Observable { + return this.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, filter, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } /** diff --git a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts index 428495146..415495a22 100644 --- a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts +++ b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts @@ -4,9 +4,12 @@ import { PromiseMiddleware, Middleware, PromiseMiddlewareWrapper } from '../midd import { APIKeyExportEntry } from '../models/APIKeyExportEntry.js'; import { APIKeyView } from '../models/APIKeyView.js'; +import { AccountMetricsView } from '../models/AccountMetricsView.js'; import { AccountUserView } from '../models/AccountUserView.js'; import { AccountView } from '../models/AccountView.js'; import { AgentIdentity } from '../models/AgentIdentity.js'; +import { AgentMetricsGroupView } from '../models/AgentMetricsGroupView.js'; +import { AgentMetricsView } from '../models/AgentMetricsView.js'; import { AgentSuppressionAddedData } from '../models/AgentSuppressionAddedData.js'; import { AgentSuppressionView } from '../models/AgentSuppressionView.js'; import { AgentView } from '../models/AgentView.js'; @@ -71,6 +74,7 @@ import { EventEnvelope } from '../models/EventEnvelope.js'; import { EventView } from '../models/EventView.js'; import { FieldError } from '../models/FieldError.js'; import { ForwardRequest } from '../models/ForwardRequest.js'; +import { ForwardRequestReplyTo } from '../models/ForwardRequestReplyTo.js'; import { HoldReasonView } from '../models/HoldReasonView.js'; import { ImportContactsRequest } from '../models/ImportContactsRequest.js'; import { LimitExceededDetails } from '../models/LimitExceededDetails.js'; @@ -84,6 +88,9 @@ import { MessageLifecycleTransition } from '../models/MessageLifecycleTransition import { MessageParsedView } from '../models/MessageParsedView.js'; import { MessageSummaryView } from '../models/MessageSummaryView.js'; import { MessageView } from '../models/MessageView.js'; +import { MetricsCounterView } from '../models/MetricsCounterView.js'; +import { MetricsRatesView } from '../models/MetricsRatesView.js'; +import { MetricsSummaryView } from '../models/MetricsSummaryView.js'; import { OAuthConnectionEntry } from '../models/OAuthConnectionEntry.js'; import { PageAPIKeyView } from '../models/PageAPIKeyView.js'; import { PageAgentSuppressionView } from '../models/PageAgentSuppressionView.js'; @@ -162,8 +169,10 @@ import { ValidateTemplateResponse } from '../models/ValidateTemplateResponse.js' import { ValidationErrorDetails } from '../models/ValidationErrorDetails.js'; import { VerifyDomainView } from '../models/VerifyDomainView.js'; import { WebhookDeliveryView } from '../models/WebhookDeliveryView.js'; +import { WebhookEndpointMetricsView } from '../models/WebhookEndpointMetricsView.js'; import { WebhookFiltersRequest } from '../models/WebhookFiltersRequest.js'; import { WebhookFiltersView } from '../models/WebhookFiltersView.js'; +import { WebhookMetricsView } from '../models/WebhookMetricsView.js'; import { WebhookView } from '../models/WebhookView.js'; import { ObservableAccountApi } from './ObservableAPI.js'; @@ -313,6 +322,32 @@ export class PromiseAccountApi { return result.toPromise(); } + /** + * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. + * Get account-wide delivery metrics (beta) + * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. + * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. + * @param [groupBy] Set to \'agent\' to also receive a per-agent breakdown. Omit for account totals only, which is the cheaper read. + */ + public getAccountMetricsWithHttpInfo(start?: Date, end?: Date, groupBy?: 'agent', _options?: PromiseConfigurationOptions): Promise> { + const observableOptions = wrapOptions(_options); + const result = this.api.getAccountMetricsWithHttpInfo(start, end, groupBy, observableOptions); + return result.toPromise(); + } + + /** + * Counter metrics across every agent this account owns, aggregated from the canonical message lifecycle ledger, on the same cohort-window and denominator contract as GET /v1/agents/{email}/metrics — so an account total and the per-agent numbers under it can never disagree about what a rate means. Messages are attributed to the window by their own creation time, so bounce and complaint feedback keeps arriving for up to 72 hours and the most recent days should be read as provisional. Account-scoped credentials only; an agent-scoped credential reads its own agent through GET /v1/agents/{email}/metrics instead. Beta: account metrics may change before it is declared stable. + * Get account-wide delivery metrics (beta) + * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. + * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. + * @param [groupBy] Set to \'agent\' to also receive a per-agent breakdown. Omit for account totals only, which is the cheaper read. + */ + public getAccountMetrics(start?: Date, end?: Date, groupBy?: 'agent', _options?: PromiseConfigurationOptions): Promise { + const observableOptions = wrapOptions(_options); + const result = this.api.getAccountMetrics(start, end, groupBy, observableOptions); + return result.toPromise(); + } + /** * API keys for the account (metadata only — secrets are shown once, at creation). Account scope only: an agent-scoped credential cannot manage keys. * List API keys @@ -1353,6 +1388,32 @@ export class PromiseMessagesApi { return result.toPromise(); } + /** + * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. + * Get an agent\'s delivery metrics (beta) + * @param email + * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. + * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. + */ + public getAgentMetricsWithHttpInfo(email: string, start?: Date, end?: Date, _options?: PromiseConfigurationOptions): Promise> { + const observableOptions = wrapOptions(_options); + const result = this.api.getAgentMetricsWithHttpInfo(email, start, end, observableOptions); + return result.toPromise(); + } + + /** + * Counter metrics for one agent over a cohort window, aggregated from the canonical message lifecycle ledger. Messages are attributed to the window by their own creation time, not by when each observation landed, so a rate never mixes numerator and denominator from different populations. The cost of that is a settling period: bounce and complaint feedback arrives for up to 72 hours, so the most recent days keep moving and should be read as provisional. Delivery means recipient-server acceptance and does not claim inbox placement. Beta: agent metrics may change before it is declared stable. + * Get an agent\'s delivery metrics (beta) + * @param email + * @param [start] Inclusive start of the cohort window (RFC 3339). Defaults to 30 days before end. + * @param [end] Exclusive end of the cohort window (RFC 3339). Defaults to now. + */ + public getAgentMetrics(email: string, start?: Date, end?: Date, _options?: PromiseConfigurationOptions): Promise { + const observableOptions = wrapOptions(_options); + const result = this.api.getAgentMetrics(email, start, end, observableOptions); + return result.toPromise(); + } + /** * Returns one attachment\'s metadata plus a short-lived `download_url` (+ `expires_at`) to fetch the bytes out of band — so binary content never streams through an agent\'s context. Pass `?inline=true` to also receive base64 `data` for small attachments (<= 256 KB); larger inline requests are rejected with 413 attachment_too_large. `index` is the 0-based attachment index from the message\'s `attachments[]`. * Get an attachment (metadata + short-lived download URL) @@ -1472,10 +1533,11 @@ export class PromiseMessagesApi { * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). + * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: PromiseConfigurationOptions): Promise> { + public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: PromiseConfigurationOptions): Promise> { const observableOptions = wrapOptions(_options); - const result = this.api.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, observableOptions); + const result = this.api.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, filter, observableOptions); return result.toPromise(); } @@ -1496,10 +1558,11 @@ export class PromiseMessagesApi { * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). + * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: PromiseConfigurationOptions): Promise { + public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, batchId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: PromiseConfigurationOptions): Promise { const observableOptions = wrapOptions(_options); - const result = this.api.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, observableOptions); + const result = this.api.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, batchId, labels, since, until, cursor, limit, deleted, filter, observableOptions); return result.toPromise(); } From 4d68962e8becd3d1e337f0b3a57cbd2e508bd3e5 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Sat, 8 Aug 2026 23:00:41 -0700 Subject: [PATCH 16/17] test(batch-send): satisfy CI surface/coverage gates for batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI gates beyond `make test` flagged four gaps in the batch surface work: - Python SDK parity: add ergonomic messages.send_batch / get_batch to the async MessagesResource (the sync client proxies it), so the SDK-operation-coverage gate sees the ops reachable from BOTH SDKs. - TS contract-client: exercise client.messages.sendBatch + getBatch against the live contract server (self-send loopback), satisfying the per-PR contract-coverage ratchet — the #774 unit-tests-alone gap. - MCP branch coverage: add send_batch / get_batch tool tests (arg mapping + result projections), lifting branch coverage back over the 69% threshold (68.9% -> 71.0%). - Plugin skill guidance: bump the hardcoded MCP tool count 78 -> 80 (23 runtime + 57 admin) after adding the two batch tools. Co-Authored-By: Claude Opus 4.8 --- mcp/tests/tools.test.ts | 73 +++++++++++++++++++ plugins/e2a/skills/e2a/SKILL.md | 2 +- sdks/python/src/e2a/v1/client.py | 37 ++++++++++ .../test/v1/contract-client.test.ts | 37 ++++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) diff --git a/mcp/tests/tools.test.ts b/mcp/tests/tools.test.ts index fc7e6ce53..c30fdb10d 100644 --- a/mcp/tests/tools.test.ts +++ b/mcp/tests/tools.test.ts @@ -85,6 +85,27 @@ function makeStubClient( send: vi.fn(async () => ({ messageId: "msg_sent", status: "sent" })), reply: vi.fn(async () => ({ messageId: "msg_reply", status: "sent" })), forward: vi.fn(async () => ({ messageId: "msg_fwd", status: "sent" })), + sendBatch: vi.fn(async () => ({ + batchId: "bat_stub", + accepted: 1, + suppressedCount: 1, + results: [ + { status: "accepted", messageId: "msg_b1" }, + { status: "suppressed", suppressed: { address: "blocked@example.com", reason: "bounce" } }, + ], + })), + getBatch: vi.fn(async (batchId: string) => ({ + batchId, + agentId: "bot@example.com", + requested: 2, + accepted: 1, + suppressed: [{ itemIndex: 1, address: "blocked@example.com", reason: "bounce" }], + createdAt: new Date("2026-08-09T00:00:00Z"), + statusRollup: { + accepted: 1, sending: 0, sent: 0, delivered: 0, + deferred: 0, bounced: 0, complained: 0, failed: 0, + }, + })), updateMessageLabels: vi.fn(async () => ({ messageId: "msg_in", labels: ["urgent"] })), // Cursor-paginated lists return a Page { items, next_cursor }. listConversations: vi.fn(async () => ({ items: [{ conversationId: "conv_1" }], next_cursor: undefined })), @@ -727,6 +748,58 @@ describe("e2a MCP server", () => { ); }); + it("send_batch maps per-item fields and projects the positional results", async () => { + const res = await client.callTool({ + name: "send_batch", + arguments: { + messages: [ + { to: ["a@example.com"], subject: "one", text: "hi", conversation_id: "conv_x" }, + { to: ["blocked@example.com"], subject: "two", text: "yo" }, + ], + reply_to: "ops@example.com", + idempotency_key: "idem-batch-1", + }, + }); + expect(res.isError ?? false).toBe(false); + + // snake_case per-item input is mapped to the SDK's camelCase body. + expect(stub.sendBatch).toHaveBeenCalledWith( + { + messages: [ + { to: ["a@example.com"], subject: "one", text: "hi", conversationId: "conv_x" }, + { to: ["blocked@example.com"], subject: "two", text: "yo" }, + ], + replyTo: "ops@example.com", + }, + { idempotencyKey: "idem-batch-1" }, + undefined, + ); + + const payload = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(payload.batch_id).toBe("bat_stub"); + expect(payload.accepted).toBe(1); + expect(payload.suppressed).toBe(1); + expect(payload.results).toEqual([ + { status: "accepted", message_id: "msg_b1" }, + { status: "suppressed", suppressed: { address: "blocked@example.com", reason: "bounce" } }, + ]); + }); + + it("get_batch projects the header + delivery rollup by batch id", async () => { + const res = await client.callTool({ name: "get_batch", arguments: { batch_id: "bat_stub" } }); + expect(res.isError ?? false).toBe(false); + expect(stub.getBatch).toHaveBeenCalledWith("bat_stub"); + + const payload = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(payload.batch_id).toBe("bat_stub"); + expect(payload.requested).toBe(2); + expect(payload.accepted).toBe(1); + expect(payload.status_rollup.accepted).toBe(1); + expect(payload.suppressed).toEqual([ + { item_index: 1, address: "blocked@example.com", reason: "bounce" }, + ]); + }); + it("contact tools reject date-only and offsetless timestamps", async () => { // Without an explicit offset the instant is ambiguous: `new Date()` reads a // bare date-time in LOCAL time and a date-only value as UTC midnight. diff --git a/plugins/e2a/skills/e2a/SKILL.md b/plugins/e2a/skills/e2a/SKILL.md index d72505664..f4f533961 100644 --- a/plugins/e2a/skills/e2a/SKILL.md +++ b/plugins/e2a/skills/e2a/SKILL.md @@ -291,5 +291,5 @@ The full, current integration code — SDK install, send / reply / parse, webhoo - Webhook + SDK code: https://e2a.dev/sdk.md - Exact tool signatures: call `tools/list` (authoritative). - OpenAPI contract: https://e2a.dev/v1/openapi.yaml -- The MCP surface is **78 tools** (21 runtime/inbox + 57 admin/setup) spanning agents, messages, attachments, delivery metrics, contacts and outreach, suppressions, domains, events, webhooks, API keys, and templates (beta). The set you see depends on your credential's scope: an agent-scoped credential sees the 21 runtime tools; an account-scoped credential sees all 78. Tool descriptions teach behavior; this skill teaches the mental model. (`create_api_key` mints **agent-scoped** keys only — account-scoped keys come from the dashboard or raw API.) +- The MCP surface is **80 tools** (23 runtime/inbox + 57 admin/setup) spanning agents, messages, attachments, delivery metrics, contacts and outreach, suppressions, domains, events, webhooks, API keys, and templates (beta). The set you see depends on your credential's scope: an agent-scoped credential sees the 23 runtime tools; an account-scoped credential sees all 80. Tool descriptions teach behavior; this skill teaches the mental model. (`create_api_key` mints **agent-scoped** keys only — account-scoped keys come from the dashboard or raw API.) - Plugin homepage / docs index: https://e2a.dev (machine-readable index: https://e2a.dev/llms.txt) diff --git a/sdks/python/src/e2a/v1/client.py b/sdks/python/src/e2a/v1/client.py index 51448a07b..946f35bf0 100644 --- a/sdks/python/src/e2a/v1/client.py +++ b/sdks/python/src/e2a/v1/client.py @@ -74,6 +74,9 @@ RotateSecretResponse, SendEmailRequest, SendResultView, + SendBatchRequest, + SendBatchResponse, + BatchView, StarterTemplateDetailView, StarterTemplateView, SuppressionView, @@ -677,6 +680,40 @@ async def send( idempotency_key, ) + async def send_batch( + self, + email: str, + body: Body, + *, + idempotency_key: Optional[str] = None, + ) -> SendBatchResponse: + """Send a batch of up to 100 independent messages in one call (beta). + + Each item in ``body.messages`` is a self-contained message with its own + recipients and content; ``results`` in the response is positionally + aligned to it. A suppressed item is a compliance drop, not a failure, + and does not stop the rest. Always asynchronous (202); poll + :meth:`get_batch` or the child messages for terminal delivery. HITL- + enabled agents are refused (403 ``batch_hitl_unsupported``) — use + per-recipient :meth:`send` for those. The batch surface is beta and may + change before it is declared stable. + """ + req = _coerce(SendBatchRequest, body) + return await self._c._write_keyed( + lambda h: self._api.send_batch(email, req, _headers=h), + idempotency_key, + ) + + async def get_batch(self, batch_id: str) -> BatchView: + """Fetch a batch header (counts + accept-time suppressions) plus a live + per-delivery-status rollup of its child messages (beta). + + Account-scoped: the batch id alone identifies it, so no agent email is + needed; a batch owned by another account returns 404. The batch surface + is beta and may change before it is declared stable. + """ + return await self._c._read(lambda h: self._api.get_batch(batch_id, _headers=h)) + async def reply( self, email: str, diff --git a/sdks/typescript/test/v1/contract-client.test.ts b/sdks/typescript/test/v1/contract-client.test.ts index 375d23f6e..f8f34c941 100644 --- a/sdks/typescript/test/v1/contract-client.test.ts +++ b/sdks/typescript/test/v1/contract-client.test.ts @@ -52,6 +52,43 @@ describe.skipIf(!baseUrl || !apiKey)("E2AClient contract (high-level)", () => { } }); + it("messages.sendBatch fans out N items and messages.getBatch returns the header + rollup", async () => { + const email = `${slug("sdkc-batch")}@agents.e2a.dev`; + await client.agents.create({ email }); + try { + // Two self-send loopback items — deterministic accepts (no suppression), + // so results are positionally aligned and every slot is "accepted". + const res = await client.messages.sendBatch(email, { + messages: [ + { to: [email], subject: "batch one", text: "self-send loopback 1" }, + { to: [email], subject: "batch two", text: "self-send loopback 2" }, + ], + }); + expect(res.batchId).toMatch(/^bat_/); + expect(res.accepted).toBe(2); + expect(res.suppressedCount).toBe(0); + expect(res.results).toHaveLength(2); + for (const r of res.results) { + expect(r.status).toBe("accepted"); + expect(r.messageId).toMatch(/^msg_/); + } + + // getBatch is account-scoped (no agent email) and reports the header plus + // a live rollup; the two child messages exist from the accept-tx, so the + // rollup counts sum to 2 whatever lifecycle stage they're in. + const view = await client.messages.getBatch(res.batchId); + expect(view.batchId).toBe(res.batchId); + expect(view.requested).toBe(2); + expect(view.accepted).toBe(2); + const r = view.statusRollup; + const total = + r.accepted + r.sending + r.sent + r.delivered + r.deferred + r.bounced + r.complained + r.failed; + expect(total).toBe(2); + } finally { + await client.agents.delete(email, { permanent: true }); + } + }); + it("messages.getMetrics reports null rates before traffic and counts after", async () => { const email = `${slug("sdkc-metrics")}@agents.e2a.dev`; await client.agents.create({ email }); From 67184d4f73faf89f1c4dee63ad26127717f6e2b6 Mon Sep 17 00:00:00 2001 From: Le Xiao Date: Sat, 8 Aug 2026 23:37:15 -0700 Subject: [PATCH 17/17] test(batch-send): fix contract-client batch test to use distinct recipients The batch feature rejects the same recipient across items (duplicate_recipient); the live contract-client test wrongly self-sent both items to the agent's own address. Give item 2 a distinct recipient so both slots come back accepted. Co-Authored-By: Claude Opus 4.8 --- sdks/typescript/test/v1/contract-client.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sdks/typescript/test/v1/contract-client.test.ts b/sdks/typescript/test/v1/contract-client.test.ts index f8f34c941..9eb9fd688 100644 --- a/sdks/typescript/test/v1/contract-client.test.ts +++ b/sdks/typescript/test/v1/contract-client.test.ts @@ -56,12 +56,13 @@ describe.skipIf(!baseUrl || !apiKey)("E2AClient contract (high-level)", () => { const email = `${slug("sdkc-batch")}@agents.e2a.dev`; await client.agents.create({ email }); try { - // Two self-send loopback items — deterministic accepts (no suppression), - // so results are positionally aligned and every slot is "accepted". + // Two items with DISTINCT recipients — the batch rejects the same address + // across items (duplicate_recipient), and neither is suppressed, so both + // slots come back "accepted", positionally aligned to the input. const res = await client.messages.sendBatch(email, { messages: [ { to: [email], subject: "batch one", text: "self-send loopback 1" }, - { to: [email], subject: "batch two", text: "self-send loopback 2" }, + { to: [`${slug("rcpt")}@example.com`], subject: "batch two", text: "external item 2" }, ], }); expect(res.batchId).toMatch(/^bat_/);