# Batch Operations

Source: https://molted.email/docs/batch-operations

Send, simulate, classify, and get recommendations for up to 500 recipients in a single request.

Batch endpoints let you process multiple items in a single API call -- up to 500 per request. Each item is evaluated independently with full policy enforcement.

## Batch send

Send email to up to 500 recipients in one request. Each recipient gets their own policy evaluation and deduplication check.

**CLI**

```bash
molted send batch --template <id> --recipients '<json>' [--mailbox <id>] [--stdin]
```

### Options

| Flag | Type | Required | Description |
| - | - | - | - |
| `--template` | `string` | Yes | Template slug or ID to use for all recipients. |
| `--recipients` | `string` | No | JSON array of recipient objects (required unless using `--stdin`). |
| `--mailbox` | `string` | No | Mailbox ID to send from. |
| `--stdin` | `flag` | No | Read recipients JSON from stdin instead of `--recipients`. |
| `--humanize` / `--no-humanize` | `flag` | No | Batch default: ask for or turn off the [humanizer](https://molted.email/docs/humanizer.md). A recipient with its own `humanize` overrides this. |
| `--humanize-style` | `string` | No | Batch default style: `casual`, `professional`, or `friendly`. A recipient with its own `humanizeStyle` overrides this. |
| `--reply-to` | `string` | No | Batch default [Reply-To](https://molted.email/docs/sending-email.md#reply-to): one plain address where replies go. A recipient with its own `replyTo` overrides this. |

### Recipient format

```json
[
  { "email": "alice@example.com", "dedupeKey": "promo-alice", "payload": { "name": "Alice" } },
  { "email": "bob@example.com", "dedupeKey": "promo-bob", "payload": { "name": "Bob" }, "humanize": true, "humanizeStyle": "casual" }
]
```

| Field | Type | Required | Description |
| - | - | - | - |
| `email` | `string` | Yes | Recipient email address. A plain string in place of the object works too. |
| `dedupeKey` | `string` | No | Unique key to prevent duplicate sends. Omitted, the CLI generates a random one (`batch-...`), so a rerun sends again: pass your own for safe retries. |
| `payload` | `object` | No | Template variables for this recipient. |
| `humanize` | `boolean` | No | Overrides `--humanize`/`--no-humanize` for this recipient only. |
| `humanizeStyle` | `string` | No | Overrides `--humanize-style` for this recipient only. |
| `humanizePreviewId` | `string` | No | This recipient's own [previewed rewrite](https://molted.email/docs/humanizer.md#send-exactly-what-you-previewed) (preview with `--template` and this recipient's `payload`). One preview per recipient: an id sends once. A preview that doesn't fit, or was already used, fails only this recipient. |
| `replyTo` | `string` | No | Overrides `--reply-to` for this recipient only. |

### Example

```bash
molted send batch --template campaign-march \
  --recipients '[{"email":"alice@example.com","dedupeKey":"march-alice","payload":{"name":"Alice"}},{"email":"bob@example.com","dedupeKey":"march-bob","payload":{"name":"Bob"}}]'
```

Using stdin (useful for large recipient lists):

```bash
cat recipients.json | molted send batch --template campaign-march --stdin
```

**API**

```
POST https://mailbox.molted.email/v1/agent/batch/request-send
```

Requires [Bearer authentication](https://molted.email/docs/authentication.md).

### Parameters

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `templateId` | `string` | No | Template slug or ID to use for all recipients (default `_default`). |
| `sends` | `array` | Yes | Array of recipient objects (max 500). |
| `sends[].recipientEmail` | `string` | Yes | Recipient email address. Missing or blank fails only this recipient (`recipient_email_required`). |
| `sends[].dedupeKey` | `string` | Yes | Unique key to prevent duplicate sends. Missing or blank fails only this recipient (`dedupe_key_required`); a key repeated within the batch fails every later occurrence (`duplicate_in_batch`). |
| `sends[].payload` | `object` | No | Template variables for this recipient. |
| `sends[].sendReason` | `string` | No | Annotate why the agent is sending (for audit trails). |
| `sends[].attachments` | `array` | No | Up to 20 `{ "id": "<attachment uuid>" }` refs for this recipient, checked as on the [agent send](https://molted.email/docs/sending-email.md#request-body). |
| `sends[].humanize` | `boolean` | No | Overrides the batch-level `humanize` below for this recipient only. |
| `sends[].humanizeStyle` | `string` | No | Overrides the batch-level `humanizeStyle` below for this recipient only. |
| `sends[].humanizePreviewId` | `string` | No | This recipient's [previewed rewrite](https://molted.email/docs/humanizer.md#send-exactly-what-you-previewed). Per recipient only (there is no batch-level preview id), and each recipient needs its own preview: an id sends once. A preview that doesn't fit gives that recipient `status: "error"` with `reason` `humanize_preview_mismatch`, `humanize_preview_not_found`, `humanize_preview_used`, `humanize_preview_conflict` or `invalid_humanize_preview_id`; the rest of the batch still goes. |
| `sends[].replyTo` | `string` | No | Overrides the batch-level `replyTo` below for this recipient only. |
| `mailboxId` | `string` | No | Mailbox ID to send from. |
| `humanize` | `boolean` | No | Batch default. `false` always tightens (delivered exactly as written). `true` can't turn the humanizer on by itself -- see [Humanizer precedence](https://molted.email/docs/humanizer.md#precedence). |
| `humanizeStyle` | `string` | No | Batch default style: `casual`, `professional`, or `friendly`. |
| `replyTo` | `string` | No | Batch default [Reply-To](https://molted.email/docs/sending-email.md#reply-to): one plain address where replies go. |

### Example

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/batch/request-send \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "campaign-march",
    "sends": [
      {
        "recipientEmail": "alice@example.com",
        "dedupeKey": "march-alice",
        "payload": { "name": "Alice" }
      },
      {
        "recipientEmail": "bob@example.com",
        "dedupeKey": "march-bob",
        "payload": { "name": "Bob" }
      }
    ]
  }'
```

### Response

```json
{
  "batchId": "batch_abc123",
  "total": 2,
  "queued": 1,
  "blocked": 1,
  "scheduled": 0,
  "errors": 0,
  "results": [
    { "recipientEmail": "alice@example.com", "requestId": "req_001", "status": "queued" },
    { "recipientEmail": "bob@example.com", "requestId": "req_002", "status": "blocked", "reason": "suppressed" }
  ]
}
```

| Field | Type | Description |
| - | - | - |
| `batchId` | `string` | Unique identifier for this batch. |
| `total` | `number` | Total number of recipients in the batch. |
| `queued` | `number` | Number of sends that passed policy and are queued for delivery. |
| `blocked` | `number` | Number of sends blocked by the policy engine. |
| `scheduled` | `number` | Number deferred (e.g. domain warmup); see each item's `scheduledAt`. |
| `errors` | `number` | Number that failed validation (duplicate dedupe key in this batch, missing recipient email, missing dedupe key) or hit an unexpected internal error (not a policy block either way). |
| `results` | `array` | Per-recipient results with `status` (`queued`, `scheduled`, `pending_approval`, `blocked`, or `error`) and optional `reason`. **Note:** `queued`/`blocked`/`scheduled`/`errors` above don't count `pending_approval` items -- tally those from `results` yourself if any recipient's mailbox runs below autonomy level 3. |

Each item gets its own result -- some may be queued while others are blocked, scheduled, or held for approval. A `scheduled` item carries its `scheduledAt`. An item for a contact a human has taken over in the sending mailbox is `blocked` with `reason: "thread_taken_over"` and an empty `requestId`. An `error` item's `reason` is `duplicate_in_batch`, `recipient_email_required`, `dedupe_key_required`, `invalid_humanize_style`, one of the preview codes above, or `internal_error`; see [Batch item errors](https://molted.email/docs/errors.md#batch-item-errors-resultsstatus-error).

In MCP the tool is `batch_send`: `template_id`, `sends` (each `to`, `dedupe_key`, and optional `payload`, `send_reason`, `humanize`, `humanize_style`, `humanize_preview_id`, `reply_to`), `mailbox_id`, and the batch-level `humanize`, `humanize_style` and `reply_to`.

## Simulate batch

Dry-run a batch send to check policy decisions without delivering. Use this to verify which recipients would be allowed before committing to a real send.

**CLI**

```bash
molted send simulate-batch --template <id> --recipients '<json>'
```

### Options

| Flag | Type | Required | Description |
| - | - | - | - |
| `--template` | `string` | Yes | Template ID to simulate against. |
| `--recipients` | `string` | Yes | JSON array of email strings or recipient objects. |

The recipients flag accepts either plain email strings or full `{email, dedupeKey, payload}` objects. For simulation, only the email address is used. There is no MCP tool for a batch simulation; MCP `dry_run` checks one recipient.

### Example

```bash
molted send simulate-batch --template campaign-march \
  --recipients '["alice@example.com", "bob@example.com"]'
```

**API**

```
POST https://mailbox.molted.email/v1/agent/simulate-batch
```

Requires [Bearer authentication](https://molted.email/docs/authentication.md).

### Parameters

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `templateId` | `string` | Yes | Template ID to simulate against. |
| `recipientEmails` | `string[]` | Yes | Array of recipient email addresses (max 500). |

Each recipient gets the batch send's own checks (no lease check). No payload is taken, so content is never rendered: this is a policy-only check.

### Example

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/simulate-batch \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "campaign-march",
    "recipientEmails": ["alice@example.com", "bob@example.com"]
  }'
```

### Response

```json
{
  "total": 2,
  "wouldAllow": 1,
  "wouldBlock": 1,
  "results": [
    { "recipientEmail": "alice@example.com", "wouldAllow": true },
    { "recipientEmail": "bob@example.com", "wouldAllow": false, "reason": "cooldown", "cooldownExpiresAt": "2025-03-15T14:30:00Z" }
  ]
}
```

| Field | Type | Description |
| - | - | - |
| `total` | `number` | Total recipients evaluated. |
| `wouldAllow` | `number` | Number that would pass policy (including those that would be held or deferred). |
| `wouldBlock` | `number` | Number that would be blocked. |
| `wouldDefer` | `number` | Number that would be deferred to a later warmup slot. |
| `results[].wouldAllow` | `boolean` | Whether this recipient would pass policy checks. |
| `results[].reason` | `string` | Why the recipient would be blocked (only present when `wouldAllow` is false). |
| `results[].cooldownExpiresAt` | `string` | When the cooldown expires, if applicable. |
| `results[].wouldRequireApproval`, `results[].approvalReason` | `boolean`, `string` | Present when the send would wait for a human (`autonomy_level`, `thread_risk_flagged` or `content_check`). |
| `results[].wouldDefer`, `results[].scheduledFor` | `boolean`, `string` | Present when the send would land on a later day (the warmup projection is spread across the batch). |

## Next-best-action for one contact

Ask what to do next with one contact. The answer comes from the contact's recent sends and inbound mail, their suppression status and the safety verdicts on their latest messages, and it is one of four recommendations.

**CLI**

```bash
molted next-action --contact alice@example.com
```

In MCP the tool is `next_best_action` (`contact_email`).

**API**

```
POST https://mailbox.molted.email/v1/agent/next-best-action
```

Requires [Bearer authentication](https://molted.email/docs/authentication.md).

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `contactEmail` | `string` | Yes | The contact's email address. |

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/next-best-action \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "contactEmail": "alice@example.com" }'
```

```json title="Response"
{
  "recommendation": "nudge",
  "reasoning": "No activity for 9 days. Consider a gentle follow-up.",
  "contactSummary": {
    "email": "alice@example.com",
    "name": "Alice",
    "lastSendAt": "2026-10-16T09:12:00.000Z",
    "lastInboundAt": null,
    "isSuppressed": false,
    "activeIncidents": 0
  }
}
```

| `recommendation` | When |
| - | - |
| `stop` | The contact is suppressed, or one of their latest messages was an opt-out (`platformLabel: "opt_out"`). Do not send. |
| `escalate` | One of their latest messages was flagged by the safety check (any verdict except `clean`). A person should look first. |
| `wait` | You sent to them in the last 24 hours (a send waiting for approval counts) and they have not replied since, or there is no strong signal either way. |
| `nudge` | Nothing has happened for more than 7 days, or there is no prior activity at all. |

The rules are checked in this order: suppressed (`stop`), then a flagged safety verdict (`escalate`), then an opt-out (`stop`), then the 24-hour and 7-day checks. "Latest messages" means the 10 most recent classified messages from that address. `reasoning` is a sentence you can log or show. `contactSummary.name` is `null` when the contact has no saved name, and `activeIncidents` counts the workspace's open or acknowledged incidents, not the contact's. A mailbox-scoped key only sees activity in its own mailboxes: a contact whose activity is all elsewhere reads as if it had none.

## Batch next-best-action

Get recommendations for up to 500 contacts at once, the same answer as above for each (`recommendation` and `reasoning`; no `contactSummary`).

**CLI**

```bash
molted next-action batch --contacts '["alice@example.com", "bob@example.com"]'
# or: cat contacts.json | molted next-action batch --stdin
```

In MCP the tool is `batch_next_action` (`contact_emails`).

**API**

```
POST https://mailbox.molted.email/v1/agent/batch/next-best-action
```

Requires [Bearer authentication](https://molted.email/docs/authentication.md).

### Parameters

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `contactEmails` | `string[]` | Yes | Array of contact email addresses (max 500). |

### Example

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/batch/next-best-action \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contactEmails": ["alice@example.com", "bob@example.com"]
  }'
```

### Response

```json
{
  "results": [
    { "contactEmail": "alice@example.com", "recommendation": "nudge", "reasoning": "No reply in 5 days" },
    { "contactEmail": "bob@example.com", "recommendation": "wait", "reasoning": "Recent send 2 hours ago" }
  ]
}
```

A contact that cannot be looked up is left out of `results` instead of failing the whole batch.

## Batch classify

Classify up to 500 inbound messages at once: each gets its [platform label](https://molted.email/docs/inbound.md#platform-labels), safety verdict and route.

**CLI**

```bash
molted classify batch --emails '[{"subject": "Re: Order", "body": "When does it ship?"}]'
# or: cat emails.json | molted classify batch --stdin
```

In MCP the tool is `batch_classify` (`messages`, each with `subject`, `body_text` and the optional sender fields).

**API**

```
POST https://mailbox.molted.email/v1/agent/batch/classify-intent
```

Requires [Bearer authentication](https://molted.email/docs/authentication.md).

### Parameters

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `messages` | `array` | Yes | Array of message objects to classify (max 500). |
| `messages[].subject` | `string` | No | The email subject line (default empty). |
| `messages[].bodyText` | `string` | No | The plain text body of the email (default empty). |
| `messages[].bodyHtml` | `string` | No | HTML body -- feeds the excessive-links safety signal. |
| `messages[].fromEmail` | `string` | No | Sender address -- loads that sender's tenant safety settings, spam feedback, reputation and allow-list status. Omit when there's no real sender. |
| `messages[].fromName` | `string` | No | From display name, if known. |
| `messages[].replyTo` | `string` | No | Reply-To address, if known. |

### Example

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/batch/classify-intent \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      { "subject": "Re: Partnership proposal", "bodyText": "Sure, let'\''s set up a call next week", "fromEmail": "alice@example.com" },
      { "subject": "Re: Your offer", "bodyText": "Please remove me from your mailing list." }
    ]
  }'
```

### Response

One result per message, in order. `platformLabel` is `opt_out` or `null` here (the
endpoint takes no headers, so `autoReplyKind` is always `unknown`); see
[Classify an email](https://molted.email/docs/inbound.md#classify-an-email) for the full field reference. The
deprecated `intent` is still there, unchanged, and the response carries a
`Deprecation: true` header for it.

```json
{
  "results": [
    { "platformLabel": null, "autoReplyKind": "unknown", "safetyVerdict": "clean", "route": { "actionType": "notify_owner", "slaMinutes": 60, "status": "pending" }, "intent": "unclassified", "confidence": 0.5, "masked": true, "suggestedAction": "notify_owner" },
    { "platformLabel": "opt_out", "autoReplyKind": "unknown", "safetyVerdict": "clean", "route": { "actionType": "trigger_unsubscribe", "slaMinutes": 0, "status": "completed" }, "intent": "unsubscribe", "confidence": 0.95, "masked": false, "suggestedAction": "trigger_unsubscribe" }
  ]
}
```

## Limits

All batch endpoints accept a maximum of **500 items** per request. A request above the limit is rejected whole with a `400 Bad Request`, and nothing in it is processed:

```json
{ "error": "batch_too_large", "message": "sends has 600 items; the maximum is 500. Split it into batches of 500 or fewer.", "field": "sends", "max": 500, "received": 600 }
```

Split larger jobs into batches of 500 or fewer.

## Related

- [Sending Email](https://molted.email/docs/sending-email.md) -- single-recipient send with full policy trace
- [Policy Simulation](https://molted.email/docs/policy-simulation.md) -- understand how the policy engine evaluates sends
- [Templates](https://molted.email/docs/templates.md) -- manage the templates referenced in batch sends
- [Errors](https://molted.email/docs/errors.md) -- all possible block reasons and error codes
