Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
faafd98
docs(design): batch send spec and MVP implementation plan
Shanaia0805 Jul 18, 2026
028843f
feat(api): register batch-send error codes + typed details
Shanaia0805 Jul 18, 2026
261b209
feat(batch-send): storage layer — batches table + store methods
Shanaia0805 Jul 20, 2026
7f404d0
feat(batch-send): handler + accept-transaction
Shanaia0805 Jul 20, 2026
5d10b2c
feat(batch-send): observability — getBatch, listMessages filter, batc…
Shanaia0805 Jul 20, 2026
1517455
fix(sdk): resolve generated batch conflicts
jiashuoz Jul 22, 2026
a0cb797
fix(batch-send): address PR #627 review (findings 1-10)
Shanaia0805 Jul 26, 2026
e4669f8
chore(batch-send): regenerate OpenAPI spec + SDKs for review fixes
Shanaia0805 Jul 26, 2026
121d8ae
feat(sdk): add ergonomic sendBatch/getBatch to the TS client
Shanaia0805 Aug 4, 2026
f6b66db
feat(cli): add batch send/get commands
Shanaia0805 Aug 4, 2026
55ac350
feat(mcp): add send_batch and get_batch tools
Shanaia0805 Aug 5, 2026
559a393
test(batch-send): contract scenario + coverage-gate happy path
Shanaia0805 Aug 9, 2026
39db5a9
feat(web): batch monitor page
Shanaia0805 Aug 9, 2026
b7265c2
chore(batch-send): post-rebase adaptations to latest upstream
Shanaia0805 Aug 9, 2026
ab4b8b6
chore(batch-send): regenerate TS SDK after rebase
Shanaia0805 Aug 9, 2026
4d68962
test(batch-send): satisfy CI surface/coverage gates for batch
Shanaia0805 Aug 9, 2026
67184d4
test(batch-send): fix contract-client batch test to use distinct reci…
Shanaia0805 Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/fixtures/errors/batch_hitl_unsupported.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"error":{"code":"batch_hitl_unsupported","message":"batch send is not available for agents with HITL enabled","request_id":"req_fixture"}}
1 change: 1 addition & 0 deletions api/fixtures/errors/duplicate_recipient.json
Original file line number Diff line number Diff line change
@@ -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]}}}
1 change: 1 addition & 0 deletions api/fixtures/errors/too_many_messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"error":{"code":"too_many_messages","message":"too many messages in batch","request_id":"req_fixture","details":{"max_messages":100,"provided":101}}}
493 changes: 491 additions & 2 deletions api/openapi.yaml

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions cli/src/bin/e2a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -125,6 +126,12 @@ Usage:
--agent <email> Sending inbox (or config agent_email / E2A_AGENT_EMAIL)
--json Print the full send result as JSON
e2a reply <message-id> [options] Reply in-thread (same body options as send)
e2a batch send [options] Send a batch of up to 100 messages (beta)
--file <path|-> JSON batch body ({"messages":[…]}); - reads stdin
--agent <email> Sending inbox (or config agent_email / E2A_AGENT_EMAIL)
--idempotency-key <k> Stable key so a retried invocation can't double-send
--json Print the full batch result as JSON
e2a batch get <batch-id> [--json] Show a batch's header + delivery-status rollup (beta)
e2a messages list [options] List messages, oldest first
--direction <d> inbound|outbound|all
--since <ISO> Messages created AT or after this timestamp
Expand Down Expand Up @@ -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 <path|-> [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 <batch-id> [--json]");
await batchGet(batchId, { json: hasFlag(rest, "--json") });
} else {
process.stderr.write("Usage: e2a batch [send --file <path>|get <batch-id>]\n");
process.exit(EXIT.USAGE);
}
break;
}
case "messages": {
const sub = args[0];
const rest = args.slice(1);
Expand Down
112 changes: 112 additions & 0 deletions cli/src/commands/batch.ts
Original file line number Diff line number Diff line change
@@ -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 <path|-> [--agent <inbox>] [--idempotency-key <k>] [--json]";
export const BATCH_GET_USAGE = "usage: e2a batch get <batch-id> [--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<void> {
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<void> {
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);
}
5 changes: 5 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,13 +304,16 @@ 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. |
| `invalid_filter` | 400 | Bad list-filter parameter (messages/conversations/events). |
| `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** | | |
Expand Down Expand Up @@ -405,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 |
Expand All @@ -420,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 |
Expand Down
Loading