# Sending Email

Source: https://molted.email/docs/sending-email

Send email with policy enforcement, autonomy-level gating, and delivery tracking.

Molted Email provides multiple send endpoints depending on your use case. For most agent integrations, use the **agent send** endpoint which includes thread tracking, policy traces, and [autonomy level](https://molted.email/docs/autonomy-levels.md) gating.

## Send endpoints

| Endpoint | Best for | Extra features |
| - | - | - |
| `POST /v1/agent/request-send` | Agent integrations | Policy trace, scheduled sends, lease check, threading headers, attachments, Reply-To |
| `POST /v1/agent/outbound/send` | Agentic mailbox clients | Thread projection, canary token detection, auto-thread creation. See [Outbound](https://molted.email/docs/outbound.md). |
| `POST /v1/send/request` | Simple integrations | Basic send with policy checks |

All endpoints require [Bearer authentication](https://molted.email/docs/authentication.md) and enforce [policy checks](https://molted.email/docs/policy-simulation.md) and [autonomy levels](https://molted.email/docs/autonomy-levels.md). In the CLI the agent send is `molted send`; in MCP it is `send_email`.

## Agent send (recommended)

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

This is the recommended endpoint for agent integrations. It supports thread tracking, scheduled sends, and returns a full policy decision trace.

### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where a missing one is `401 missing_tenant_id`; `mailbox.molted.email`, the CLI and MCP fill it in from your key. |
| `recipientEmail` | `string` | Yes | The recipient's email address. |
| `templateId` | `string` | No | The template slug (e.g. `welcome`, `order-confirmation`) or ID. Defaults to `_default`. An id starting with `_` (e.g. `_default`, `_followup`) paired with inline `payload.html`/`payload.text` is never looked up as a stored template: it is used only as the policy bucket (cooldown/dedupe grouping), and your inline content is what gets sent. An id without a leading `_` is always resolved as a stored template, even alongside inline `html`/`text` (the stored template's content is sent, not your inline fields). |
| `dedupeKey` | `string` | Yes, unless `idempotencyKey` is set | A unique key to prevent duplicate sends. See [Deduplication](#deduplication). Empty or whitespace-only is `400 dedupe_key_required`. |
| `payload` | `object` | No | Template variables and email content (defaults to `{}`). See [Payload fields](#payload-fields). |
| `mailboxId` | `string` | No | Send from a specific mailbox. Omitted, the send goes out from your workspace's default mailbox (for a key scoped to some mailboxes: the oldest mailbox it can send from). The sending mailbox's autonomy level applies either way. |
| `threadId` | `string` | No | Send as a reply in an existing thread: the send (queued, scheduled or held) shows up in that thread, the [thread-risk hold](https://molted.email/docs/autonomy-levels.md#holds-that-apply-at-every-level) applies, and a thread a human has taken over is `409 thread_taken_over`. The cooldown is skipped only when the recipient is the thread's contact (or the Reply-To its inbound mail resolves to) and they have written since your last message to them in it: one reply per message received. The send goes out from the thread's mailbox: leave `mailboxId` out or pass that same mailbox, otherwise `400 thread_mailbox_mismatch`. An unknown thread is `404`; a thread in a mailbox outside your key's scope is `403 mailbox_scope_denied`. |
| `headers` | `object` | No | Threading headers for a reply. Only `In-Reply-To` and `References` are kept; every other header is dropped. A non-object is a `400`. |
| `attachments` | `array` | No | Up to 20 `{ "id": "<attachment uuid>" }` refs to files uploaded beforehand (`molted attachments upload`, `POST /v1/attachments`). Each must belong to your tenant and have passed its [file scan](https://molted.email/docs/storage.md#what-the-file-scan-checks): an unknown id, or one still scanning, is a `400`. A quarantined attachment needs an `operator`-scoped key (`403 quarantined_requires_operator`); one still being classified (`403 pending_classification`) or held by the inbound allowance (`403 inbound_quota_held`) is refused for every key. |
| `sendReason` | `string` | No | Annotate why the agent is sending (for audit trails). |
| `idempotencyKey` | `string` | No | Alternative deduplication key. When both are set, `idempotencyKey` is the one used. |
| `scheduledAt` | `string` | No | ISO 8601 timestamp to schedule the send. Must be at least 60 seconds and at most 1 year ahead, and a string needs an explicit UTC offset or `Z` (`400` otherwise); impossible dates such as `2030-02-30` are refused. Echoed and stored as the normalized UTC instant. See [Scheduled sends](#scheduled-sends). |
| `humanize` | `boolean` | No | `false` always delivers the email exactly as written. `true` asks for the [humanizer](https://molted.email/docs/humanizer.md), but can't turn it on by itself: see [Humanizer precedence](https://molted.email/docs/humanizer.md#precedence). |
| `humanizeStyle` | `string` | No | `casual`, `professional`, or `friendly`. Honored whenever the humanizer runs on this send. Anything else is `400 invalid_humanize_style`. |
| `humanizePreviewId` | `string` | No | A [humanizer preview](https://molted.email/docs/humanizer.md#preview-a-rewrite)'s `previewId`: deliver exactly that rewrite. The content must be what was previewed (`409 humanize_preview_mismatch` otherwise); an unknown or expired id is `404 humanize_preview_not_found`, and an id sends once (`409 humanize_preview_used`). Not with `humanize: false`, nor with a `humanizeStyle` other than the preview's. See [Send exactly what you previewed](https://molted.email/docs/humanizer.md#send-exactly-what-you-previewed). |
| `replyTo` | `string` | No | Where the recipient's replies go: one plain address. See [Reply-To](#reply-to). |

The agent that initiated the send is taken from your API key automatically: a key gets bound to an agent through [agent adoption](https://molted.email/docs/adoption.md) or by registering with `bindKey` (the CLI and MCP default it on; see [Register an agent](https://molted.email/docs/agents.md#register-an-agent)), not at creation time, and a key minted directly (`molted auth signup`, the portal) has no bound agent until one of those happens. That bound agent is the agent the [lease check](https://molted.email/docs/agents.md#contact-leases) compares against a contact's lease holder, and the agent whose [humanizer config](https://molted.email/docs/humanizer.md#precedence) applies. When you call `api.molted.email` directly with a key that has no bound agent, the `X-Agent-Id` header names the agent you act as instead, as long as that agent is registered in your workspace and no API key is bound to it (anything else is ignored: the send runs as no agent); a bound agent wins over the header. The mailbox gateway the CLI and MCP go through does not forward `X-Agent-Id`.

### Response

```json title="Queued"
{
  "requestId": "3f0c2a8e-...",
  "status": "queued",
  "policyTrace": {
    "decision": { "decision": "allow", "reason": null },
    "auditEvents": [ ... ]
  }
}
```

| Field | Meaning |
| - | - |
| `requestId` | The send request's id. Use it for [follow-ups](https://molted.email/docs/outbound.md#schedule-followup), `molted trace <requestId>` and `molted timeline <requestId>`. `null` on a `lease_conflict`. |
| `status` | `queued` (headed for delivery), `scheduled` (deferred: see `scheduledAt`), `pending_approval` (held for a human) or `blocked` (see `reason`). |
| `reason` | With `blocked`, the block reason (see [Errors & Policy Blocks](https://molted.email/docs/errors.md)). With `pending_approval`, why it is held: `autonomy_level`, `thread_risk_flagged` or `content_check`. With a `scheduled` send the domain warmup deferred, `warmup_defer`. |
| `message` | With `pending_approval`, a sentence explaining the hold. |
| `scheduledAt` | With `scheduled`, when the send goes out. |
| `warnings` | Humanizer notices, e.g. `humanizer_ignored_owner_disabled` or `humanizer_credit_cap_reached`. The send still goes out. |
| `leaseHolder` | With `lease_conflict`: the `agentId`, `agentName` and `expiresAt` of the agent holding the lease on this contact. |
| `policyTrace` | The recorded policy decision and audit events for this request. |

A held send waits for a person (see [Approval Queues](https://molted.email/docs/approval-queues.md)); it is not an error. A contact or thread a human has taken over is an HTTP `409` with `error: "thread_taken_over"` and the `threadId`, not a blocked body.

## Simple send

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

The basic send endpoint. It runs the same policy checks and autonomy gate, and takes `scheduledAt` and `replyTo` like the agent send, but has no lease check, thread or policy trace. To reply in a thread use the [agent send](#agent-send-recommended) with `threadId`.

### Request body

| 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. |
| `recipientEmail` | `string` | Yes | The recipient's email address. Must be a valid email format (e.g. `user@example.com`). |
| `templateId` | `string` | Yes | The template slug (e.g. `welcome`, `order-confirmation`). Also accepts a template ID. See the note on `templateId` above the [agent send](#agent-send-recommended) fields: a leading `_` with inline `html`/`text` in `payload` makes this a policy bucket, not a template lookup. |
| `dedupeKey` | `string` | Yes | A unique key to prevent duplicate sends. Must contain a non-whitespace character. The same key is answered as `duplicate`. |
| `payload` | `object` | Yes | Template variables and email content. |
| `mailboxId` | `string` | No | Mailbox UUID to send from. Same default as on the agent send. |
| `attachments` | `array` | No | Up to 20 `{ "id", "filename", "contentType" }` refs (all three required here; the stored file's own name and type are what is sent). Same checks as on the agent send. |
| `humanize` | `boolean` | No | Same as on [agent send](#agent-send-recommended) above. |
| `humanizeStyle` | `string` | No | Same as on [agent send](#agent-send-recommended) above. |
| `humanizePreviewId` | `string` | No | Same as on [agent send](#agent-send-recommended) above. |
| `threadId` | `string` | No | Not supported here: a body with `threadId` is a `400` with `error: "thread_id_unsupported"`. Reply in a thread with `POST /v1/agent/request-send` (or the mailbox reply route). |
| `scheduledAt` | `string` | No | ISO 8601 timestamp to schedule the send; at least 60 seconds and at most 1 year ahead, with an explicit UTC offset or `Z` (`400` otherwise). |
| `replyTo` | `string` | No | Where replies go: one plain address. See [Reply-To](#reply-to). |

The response is `{ "requestId", "status", "reason"?, "scheduledAt"?, "warnings"? }`, with the same `status` and `reason` values as the agent send.

### Payload fields

The `payload` object contains your email content and any template variables:

| Field | Type | Description |
| - | - | - |
| `subject` | `string` | The email subject line. |
| `html` | `string` | The HTML body of the email. |
| `text` | `string` | Plain text fallback body. |
| _custom keys_ | `any` | Any additional variables your template expects. |

`subject`, `html` and `text` are the content for `_default` and `_`-bucket sends. A named template renders its own subject and body from its variables. A `_default` send with neither `html` nor `text` goes out with a placeholder test body ("This is a test email from Molted Mail"), so always pass one of them; any other key (such as `body`) is not content.

## Example request

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/send/request \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipientEmail": "user@example.com",
    "templateId": "order-confirmation",
    "dedupeKey": "order-456-user@example.com",
    "payload": {
      "subject": "Your order has been confirmed",
      "html": "<h1>Order Confirmed</h1><p>Thank you for your purchase.</p>",
      "text": "Order Confirmed. Thank you for your purchase.",
      "orderId": "456",
      "customerName": "Jane"
    }
  }'
```

```bash title="CLI"
molted send --to user@example.com --template order-confirmation \
  --dedupe-key "order-456-user@example.com" --payload '{"orderId": "456", "customerName": "Jane"}'

# shorthand: the _default template with inline content, dedupe key generated
molted send --to user@example.com --subject "Your order has been confirmed" --body "Thank you for your purchase."
```

```json title="MCP (send_email)"
{ "to": "user@example.com", "template_id": "order-confirmation", "dedupe_key": "order-456-user@example.com", "payload": { "orderId": "456", "customerName": "Jane" } }
```

`molted send` calls the agent send. It also takes `--mailbox`, `--reason`, `--idempotency-key`, `--attachments '[{"id":"<attachment-id>"}]'`, `--reply-to`, the humanizer flags, and `--dry-run` (a [simulation](https://molted.email/docs/policy-simulation.md) instead of a send). MCP `send_email` takes `to`, `template_id`, `payload` or the `body`/`html`/`subject` shorthand, `dedupe_key`, `mailbox_id`, `send_reason`, `idempotency_key`, `attachment_ids`, `reply_to` and the humanizer fields.

## Response

### Queued (success)

```json
{
  "requestId": "req_abc123",
  "status": "queued"
}
```

The email has passed all policy checks and is queued for delivery.

### Blocked

```json
{
  "requestId": "req_abc123",
  "status": "blocked",
  "reason": "suppressed"
}
```

The email was rejected by the policy engine. See [Errors & Policy Blocks](https://molted.email/docs/errors.md) for all possible `reason` values. Always check `status`: a blocked or held send is an HTTP `200`, and the CLI exits `0` for it.

## Email format validation

`/v1/send/request` (and the mailbox's `/v1/agent/outbound/send`) reject a `recipientEmail` that is not a valid email address with a `400 Bad Request` before anything else runs:

```json
{
  "statusCode": 400,
  "message": ["recipientEmail must be an email"],
  "error": "Bad Request"
}
```

`/v1/agent/request-send` (what `molted send` and MCP `send_email` call) and the batch send only check that it is a string. Validate addresses before sending: at minimum, check that the value contains `@` and a domain with a dot (e.g. `user@example.com`).

## Deduplication

The `dedupeKey` field prevents sending the same email twice. A key is used up for good by the first request made with it, whatever happened to that request: any later request with the same key comes back `blocked` with reason `duplicate` and the first request's `requestId`, and sends nothing. So a retry after a timeout never sends twice, but a send that was blocked (e.g. `trial_not_activated`, a quota limit, a paused mailbox) must be retried with a new key.

Use a deterministic key based on the action -- for example, `{templateId}-{recipientEmail}` or `{orderId}-{recipientEmail}`. When you leave the key out, `molted send` generates a random one (`cli-...`), so every call is a new send, and MCP `send_email` uses a hash of the recipient, template, content, mailbox, attachments and humanizer settings (so an identical retry is deduplicated).

## Scheduled sends

Pass `scheduledAt` (at least 60 seconds and at most 1 year ahead, with an explicit UTC offset or `Z`, e.g. `2030-01-01T09:00:00Z`; an offsetless or impossible date is a `400`) and the send is accepted as `status: "scheduled"` with its `scheduledAt`. It runs the policy checks now and is delivered at that time. A marketing send can also come back `scheduled` without a `scheduledAt` of yours, with `reason: "warmup_defer"`, when the sending domain's warmup cap is full for today.

Cancel a scheduled send before it goes out:

```bash title="curl"
curl -X DELETE "https://mailbox.molted.email/v1/agent/sends/REQUEST_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

It answers `{ "cancelled": true }`, or `{ "cancelled": false, "reason": "not_found" | "not_cancellable" }` (only a send still waiting as `scheduled` can be cancelled). The key needs `send` on the send's mailbox.

## Propose before you send

`POST /v1/agent/propose-email` (`molted send propose --to <email>`, MCP `draft_email`) lists your active templates, runs a policy pre-check for the recipient and returns contact context, without writing anything:

```json title="Response"
{
  "draftCandidates": [{ "templateId": "...", "templateSlug": "welcome", "templateName": "Welcome" }],
  "policyPreCheck": { "allow": true, "wouldRequireApproval": false, "notChecked": ["template_render", "template_type_rules", "content_check", "duplicate", "cooldown", "template_lint", "consent"] },
  "contactContext": null
}
```

No template is chosen yet, so `notChecked` lists the checks that need one: `allow: true` is not a promise. [Simulate](https://molted.email/docs/policy-simulation.md) the send with the template you pick for the full verdict.

## Reply-To

A reply to your email goes to the mailbox that sent it. Set `replyTo` when the reader should answer someone else. The common case is a website contact form: each submission is sent to the site owner, and with the visitor's address as `replyTo`, the owner's Reply goes straight to the visitor instead of back to the Molted mailbox. Bookings, notifications, introductions and hand-offs to a person work the same way.

```json
{
  "recipientEmail": "owner@example.com",
  "templateId": "_default",
  "dedupeKey": "contact-form-4821",
  "payload": { "subject": "New message from the website", "text": "Ada wrote: can you call me back?" },
  "replyTo": "ada@gmail.com"
}
```

- **One plain address.** No display name (`Ada <ada@gmail.com>`), no list, no spaces or line breaks, at most 254 characters. Anything else is a `400` whose message names `replyTo`; nothing is sent.
- **Any domain.** The address doesn't need to be on your domain. The email is still sent from your mailbox, so SPF, DKIM and DMARC are unaffected. A key scoped to one mailbox can set any `replyTo`: the scope controls which mailbox sends, not where replies go.
- **Recorded, not held.** A `replyTo` that differs from the From address is recorded in the audit log as `send.reply_to_external`, with the Reply-To's domain only (never the full address). The send is not held or blocked for it.
- **Kept through the send's life.** A send held for approval, a scheduled send, a soft-bounce retry and a [follow-up](https://molted.email/docs/outbound.md#schedule-followup) scheduled on it all keep its `replyTo`.
- **Visible.** `GET /v1/agent/threads/:id` returns `replyTo` on the outbound message (`null` when none was set), and the portal's thread page shows where replies go.

It works on `POST /v1/agent/request-send`, [`POST /v1/agent/batch/request-send`](https://molted.email/docs/batch-operations.md) (a batch default, overridable per recipient), and the mailbox's [`/v1/agent/outbound/send` and `/v1/agent/outbound/reply`](https://molted.email/docs/outbound.md), and `POST /v1/send/request`. In the CLI it is `--reply-to <address>` on `molted send`, `molted send batch` and `molted threads reply`; in MCP it is `reply_to` on `send_email`, `reply` and `batch_send`.

```bash
molted send --to owner@example.com --subject "New message from the website" \
  --body "Ada wrote: can you call me back?" --reply-to ada@gmail.com
```

## Per-recipient unsubscribe links

Include the literal placeholder `{{molted:unsubscribe_url}}` anywhere in your `html` or `text` content and Molted replaces every occurrence at send time with a signed, recipient-specific hosted unsubscribe URL:

```json
{
  "payload": {
    "subject": "Your weekly digest",
    "html": "<p>...</p><a href=\"{{molted:unsubscribe_url}}\">Unsubscribe</a>",
    "text": "...\n\nUnsubscribe: {{molted:unsubscribe_url}}"
  }
}
```

How it works:

- Each recipient gets their own URL (`/u/<token>`), signed and scoped to that (tenant, recipient) pair, valid for about one year. Batch sends mint a distinct URL per recipient.
- Visiting the link shows a hosted confirmation page; confirming (or an RFC 8058 one-click `POST`) adds a tenant-scoped suppression with reason `unsubscribe_link`. Subsequent sends to that recipient are blocked by the standard suppression check.
- `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers are attached automatically when the delivery provider supports custom headers (Resend, Postmark). The body link works on every provider.
- Content without the placeholder is passed through unchanged -- the substitution is strictly opt-in.
- A stored template can use `{{unsubscribe_url}}` instead (see [System variables](https://molted.email/docs/templates.md#system-variables)).

## Related reading

- [How to Send Your First Policy-Checked Email](https://molted.email/blog/send-your-first-policy-checked-email) -- end-to-end walkthrough from signup to first send
- [What Is Agent-Native Email?](https://molted.email/blog/what-is-agent-native-email) -- why email infrastructure needs a policy layer for AI agents
