feat: batch send - fan out up to 100 independent emails in one API call - #627
feat: batch send - fan out up to 100 independent emails in one API call#627Shanaia0805 wants to merge 8 commits into
Conversation
|
Thanks for putting this together — the batch-send work and design notes are substantial. Before we can review the feature itself, the branch needs to be cleaned up. GitHub currently shows a single commit changing 457 files (+12,959 / -16,897). Most of that is unrelated to batch sending and appears to overwrite current files with an older repository snapshot. For example, the commit changes the CLI version from 2.0.0 back to 1.6.0 and removes recently merged OIDC, request-rate-limit, and sending-ramp code and tests. This is separate from the fact that main has moved forward by a few commits and the PR now reports conflicts. A normal rebase or the GitHub Update branch button may address those conflicts, but it will not remove the unrelated changes already contained in the PR commit. Could you please create a fresh branch from the current main branch, then cherry-pick or reapply only the batch-send-specific commits and force-push the cleaned branch to this PR? The resulting diff should contain the batch endpoint, persistence/migration, generated API and SDK updates, tests, and related documentation, without reverting unrelated repository changes. The PR description mentions seven batch-send commits, but GitHub currently sees only one combined commit. If those original commits are still available locally, replaying them onto a fresh branch from current main is likely the easiest recovery path. Happy to take another look once the diff is cleaned up. |
defb178 to
6cdfe50
Compare
|
Hi @jiashuoz, Thanks for the precise feedback! I've created a fresh branch off the latest The commit history is now clean and linear, and the diff is strictly focused on the batch-send feature without reverting unrelated repository changes. I've also updated the PR description to match the 5 commits. Ready for your review! |
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 <noreply@anthropic.com>
Signed-off-by: Le Xiao <xiaole0805@outlook.com>
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 <noreply@anthropic.com>
Signed-off-by: Le Xiao <xiaole0805@outlook.com>
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_<generated> 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 <noreply@anthropic.com>
Signed-off-by: Le Xiao <xiaole0805@outlook.com>
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 <noreply@anthropic.com>
Signed-off-by: Le Xiao <xiaole0805@outlook.com>
…h_id events
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 <noreply@anthropic.com>
Signed-off-by: Le Xiao <xiaole0805@outlook.com>
6cdfe50 to
2120688
Compare
jiashuoz
left a comment
There was a problem hiding this comment.
I re-reviewed the rebased implementation and verified these findings against the current PR head. The main blockers are behavioral parity with single send, idempotent replay correctness, complete batch correlation, and keeping this new surface explicitly beta. I also reproduced the focused error-vocabulary test failure.
| // 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) { |
There was a problem hiding this comment.
Batch send currently skips protections used by single send: per-item validateAttachments, domain-verification enforcement, and account message/storage quota enforcement. Please reuse those checks here, with quota accounting for every accepted item. The composed-message limit does run later, but an oversized item becomes a generic 500 instead of the documented 413; please preserve the single-send error mapping as well.
| result.Items[originalIndex] = BatchAcceptItem{MessageID: id} | ||
| } | ||
|
|
||
| if idemCompleteTx != nil { |
There was a problem hiding this comment.
The idempotency response is serialized here before suppressed result slots are populated below. A replay can therefore return empty message_id entries counted as accepted rather than the original suppression results. Please populate both accepted and suppressed slots before completing idempotency, and add replay tests for mixed and all-suppressed batches.
| if len(all) == 0 { | ||
| return map[string]string{}, nil | ||
| } | ||
| return a.store.SuppressedAddressesWithSource(ctx, userID, all) |
There was a problem hiding this comment.
This checks only to against account-wide suppressions, while single send checks to, cc, and bcc against effective account- and agent-scoped suppressions. The worker may prevent delivery later, but the batch response has already reported the item as accepted. Please use the same effective-suppression semantics as single send.
| Subject: asSend.Subject, | ||
| Body: asSend.Body, | ||
| HTMLBody: asSend.HTMLBody, | ||
| ConversationID: asSend.ConversationID, |
There was a problem hiding this comment.
The API says a conversation ID is auto-minted when omitted, but this copies an empty value through to persistence. Please run each item through the same conversation-ID resolution used by single send, and cover omitted and caller-provided IDs in tests.
| @@ -0,0 +1,80 @@ | |||
| -- 067_batches.sql | |||
There was a problem hiding this comment.
This number collides with the two existing 067 migrations. main currently runs through 078; please renumber this to the next available migration number at merge time.
| // 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"` |
There was a problem hiding this comment.
email.sent and email.failed now include batch_id, but the later email.delivered, email.bounced, and email.complained payloads do not. That breaks correlation after provider acceptance and contradicts the stated event contract. Please propagate batch_id through the delivery-feedback payloads and add event-schema tests.
| // 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{ |
There was a problem hiding this comment.
Please mark the entire batch-send surface beta: both operations, the batch schemas, and the new batch-related fields on otherwise stable schemas. Add the operations/schemas/fields to the stability tests as appropriate so this feature does not accidentally enter the stable /v1 compatibility contract.
| // 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 |
There was a problem hiding this comment.
The schema permits up to 100 items with independently bounded text and HTML bodies, but this undocumented 60 MiB aggregate ceiling rejects some schema-valid requests with 413. Please either accommodate every schema-valid request or expose and document a compatible aggregate constraint.
| // 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, template_not_found, starter_template_not_found, gone, conflict, 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, 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, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), 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), 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, template_not_found, starter_template_not_found, gone, conflict, 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, limit_exceeded, rate_limited, 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, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate), 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, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), 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-able — the reply/forward target is an outbound message still queued for provider submission; replies cannot thread until the provider assigns the source a Message-ID, and forwards require the source message to have actually been sent; retry once it is sent, or use wait=sent on the original send), 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). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), 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). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status."` |
There was a problem hiding this comment.
The focused TestErrorCodeVocabularyIsDocumented test currently fails because this edit drops inbound_mx_check_failed and inbound_mx_missing from the exact vocabulary. Please restore both existing codes and rerun the internal/httpapi tests.
| // 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 { |
There was a problem hiding this comment.
Non-blocking contract suggestion: this is described as a discriminated union, but both fields are optional, so OpenAPI permits neither or both. Consider an explicit oneOf representation or a stable discriminator such as status: accepted | suppressed.
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 <noreply@anthropic.com>
|
Hi @jiashuoz — thanks for the detailed review. I've pushed the fixes; all 10 items are addressed. Quick rundown: ① single-send parity — batch now runs per-item validateAttachments, domain-verification, and account message/storage quota (charged per accepted item via a new count-aware CheckMessageSendN); oversized items map to 413, not 500. ⑧ (60 MiB): I went with the "expose and document a compatible aggregate constraint" option rather than accommodating every schema-valid request. Raising the ceiling to the theoretical max (~500 MiB) meaningfully widens the DoS surface since the whole body is buffered before processing. The 60 MiB aggregate cap is now documented in the sendBatch operation description and the design doc, and returns payload_too_large (413) with guidance to split oversized batches. Happy to instead encode it as a formal schema-level constraint if you'd prefer it be machine-visible rather than documented. ⑦ (beta scope): Both batch operations, the batch schemas, and the new batch_id fields on the stable event payloads are marked beta (added to the stability tests). The one thing intentionally not marked is the listMessages ?batch_id= query parameter — the stability mechanism is schema-based and query params aren't part of it, and adding an optional query param is additive/non-breaking. Left it as-is to avoid building param-marking machinery, but let me know if you'd like it handled differently. Also caught one thing while regenerating on the latest base: BatchResult.status tripped TestResponseEnumsAreOpenSets since it's a closed response-side enum. I allowlisted it as a binary invariant of the discriminated union (same pattern as MessageView.direction), keeping the discriminator closed. make generate-check and the batch test suites are green. |
Summary
Add a batch-send endpoint that accepts up to 100 independent messages in one request. Each recipient receives their own SMTP-separate message with its own message_id, its own state machine, and its own River retry envelope.
Endpoints
POST /v1/agents/{email}/batches — send a batch (202 Accepted)
GET /v1/batches/{batch_id} — batch header + live delivery-status rollup
GET /v1/messages?batch_id= — list batch child messages
Design Decisions
Resend-shape (each item is a complete independent send request, not SendGrid's shared-content model) — fits e2a's agent-first 1-on-1 conversation positioning
All-or-nothing at accept time — any validation failure rejects the whole batch; zero messages if any 4xx
Per-item suppression skip — suppression-list hits drop individual items without failing the batch (industry standard)
HITL agents refused (MVP) — 403 batch_hitl_unsupported; batch-level review UI deferred to post-MVP
Rate limit counts as N sends — a batch of 100 = 100 against the per-agent throughput cap
Templates reused as-is — each item can reference template_alias + its own template_data for mail-merge
Commits
Test Coverage
14 handler contract tests (every 4xx path + happy path)
5 agent-layer integration tests (including end-to-end worker → delivery → event batch_id correlation)
8 storage tests (CRUD, rollup, batch_id filter)
Note
This branch includes upstream merges from main. The batch-send specific changes are the 7 commits listed above.