MOLTED EMAIL

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

EndpointBest forExtra features
POST /v1/agent/request-sendAgent integrationsPolicy trace, scheduled sends, lease check, threading headers, attachments, Reply-To
POST /v1/agent/outbound/sendAgentic mailbox clientsThread projection, canary token detection, auto-thread creation. See Outbound.
POST /v1/send/requestSimple integrationsBasic 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.

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

FieldTypeRequiredDescription
tenantIdstringNoOnly 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.
recipientEmailstringYesThe recipient's email address.
templateIdstringNoThe 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).
dedupeKeystringYes, unless idempotencyKey is setA unique key to prevent duplicate sends. See Deduplication. Empty or whitespace-only is 400 dedupe_key_required.
payloadobjectNoTemplate variables and email content (defaults to {}). See Payload fields.
mailboxIdstringNoSend 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.
threadIdstringNoSend 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.
headersobjectNoThreading headers for a reply. Only In-Reply-To and References are kept; every other header is dropped. A non-object is a 400.
attachmentsarrayNoUp 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.
sendReasonstringNoAnnotate why the agent is sending (for audit trails).
idempotencyKeystringNoAlternative deduplication key. When both are set, idempotencyKey is the one used.
scheduledAtstringNoISO 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.
humanizebooleanNofalse always delivers the email exactly as written. true asks for the humanizer, but can't turn it on by itself: see Humanizer precedence.
humanizeStylestringNocasual, professional, or friendly. Honored whenever the humanizer runs on this send. Anything else is 400 invalid_humanize_style.
humanizePreviewIdstringNoA 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.
replyTostringNoWhere 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

Queued
{
  "requestId": "3f0c2a8e-...",
  "status": "queued",
  "policyTrace": {
    "decision": { "decision": "allow", "reason": null },
    "auditEvents": [ ... ]
  }
}
FieldMeaning
requestIdThe send request's id. Use it for follow-ups, molted trace <requestId> and molted timeline <requestId>. null on a lease_conflict.
statusqueued (headed for delivery), scheduled (deferred: see scheduledAt), pending_approval (held for a human) or blocked (see reason).
reasonWith 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.
messageWith pending_approval, a sentence explaining the hold.
scheduledAtWith scheduled, when the send goes out.
warningsHumanizer notices, e.g. humanizer_ignored_owner_disabled or humanizer_credit_cap_reached. The send still goes out.
leaseHolderWith lease_conflict: the agentId, agentName and expiresAt of the agent holding the lease on this contact.
policyTraceThe 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/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 with threadId.

Request body

FieldTypeRequiredDescription
tenantIdstringNoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
recipientEmailstringYesThe recipient's email address. Must be a valid email format (e.g. user@example.com).
templateIdstringYesThe 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.
dedupeKeystringYesA unique key to prevent duplicate sends. Must contain a non-whitespace character. The same key is answered as duplicate.
payloadobjectYesTemplate variables and email content.
mailboxIdstringNoMailbox UUID to send from. Same default as on the agent send.
attachmentsarrayNoUp 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.
humanizebooleanNoSame as on agent send above.
humanizeStylestringNoSame as on agent send above.
humanizePreviewIdstringNoSame as on agent send above.
threadIdstringNoNot 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).
scheduledAtstringNoISO 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).
replyTostringNoWhere 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:

FieldTypeDescription
subjectstringThe email subject line.
htmlstringThe HTML body of the email.
textstringPlain text fallback body.
custom keysanyAny 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
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"
    }
  }'
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."
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 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
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:

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 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 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 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 (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.com

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 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).