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 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. |
POST /v1/send/request | Simple integrations | Basic send with policy checks |
All endpoints require Bearer authentication and enforce policy checks and autonomy levels. 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-sendThis 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. Empty or whitespace-only is 400 dedupe_key_required. |
payload | object | No | Template variables and email content (defaults to {}). See 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 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: 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. |
humanize | boolean | No | false always delivers the email exactly as written. true asks for the humanizer, but can't turn it on by itself: see Humanizer 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'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. |
replyTo | string | No | Where the recipient's replies go: one plain address. See 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 or by registering with bindKey (the CLI and MCP default it on; see 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 compares against a contact's lease holder, and the agent whose humanizer config 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
{
"requestId": "3f0c2a8e-...",
"status": "queued",
"policyTrace": {
"decision": { "decision": "allow", "reason": null },
"auditEvents": [ ... ]
}
}| Field | Meaning |
|---|---|
requestId | The send request's id. Use it for follow-ups, 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). 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); 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/requestThe 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 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 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 above. |
humanizeStyle | string | No | Same as on agent send above. |
humanizePreviewId | string | No | Same as on agent send 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. |
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
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"
}
}'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."{ "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 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)
{
"requestId": "req_abc123",
"status": "queued"
}The email has passed all policy checks and is queued for delivery.
Blocked
{
"requestId": "req_abc123",
"status": "blocked",
"reason": "suppressed"
}The email was rejected by the policy engine. See Errors & Policy Blocks 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:
{
"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:
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:
{
"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 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.
{
"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 a400whose message namesreplyTo; 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
replyTothat differs from the From address is recorded in the audit log assend.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 scheduled on it all keep its
replyTo. - Visible.
GET /v1/agent/threads/:idreturnsreplyToon the outbound message (nullwhen 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 (a batch default, overridable per recipient), and the mailbox's /v1/agent/outbound/send and /v1/agent/outbound/reply, 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.
molted send --to owner@example.com --subject "New message from the website" \
--body "Ada wrote: can you call me back?" --reply-to ada@gmail.comPer-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:
{
"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 reasonunsubscribe_link. Subsequent sends to that recipient are blocked by the standard suppression check. List-UnsubscribeandList-Unsubscribe-Post: List-Unsubscribe=One-Clickheaders 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).
Related reading
- How to Send Your First Policy-Checked Email -- end-to-end walkthrough from signup to first send
- What Is Agent-Native Email? -- why email infrastructure needs a policy layer for AI agents