---
title: "POST /v1/emails"
method: POST
path: "/v1/emails"
---

# POST /v1/emails

`POST /v1/emails`

## Request

- The document declares no server URL.
- Auth: one of:
  - OAuth 2 (authorization code, authorization URL `/authorize`, token URL `/oauth/token`, scopes: `email.send`)
  - HTTP bearer

Send one email as any address that routes into the granted set.

## Headers

- `Idempotency-Key` string

## Request body

- SendRequest
  - `from` string, required: **Required — there is no default sender, ever.** The send is authorized by routing: mail to this address must route into SOME mailbox in the caller's granted set ("you can send from anyone you can receive from"), and that routed granted mailbox becomes the send's accounting identity — its rate limit, its idempotency scope, its audit row, and where the sent copy files (which is where the reply will land). When the address routes into several granted mailboxes, the FIRST in mailbox-key order is the accounting identity — deterministic, and independent of the order grants were made. An address that routes into no granted mailbox is refused with 422, never rewritten. Routing is resolved as inbound mail is: the exact address, then the address without its `+tag`, then the domain's catch-all (`*@domain`) — so a catch-all's holders may send as any address on its domain nobody else has claimed, and nobody may send as `*@domain` itself.
  - `to` union, required
    - string: One email address. ⚠️ **Bounded because we STORE it.** RFC 5321 §4.5.3.1.1 caps a path at 256 octets, which is the same number the inbound envelope enforces. The recipient COUNT was capped and the length of each was not, so fifty addresses of unbounded length were joined into one `sent_emails.to_addr` column and one Durable Object `to_addrs` blob. The domain refuses these too — this arm exists so a caller is told at the edge rather than by a 422 from three layers down.
    - EmailAddress[]: One address or several. A single string is normalized to a one-element list on parse — the two spellings are one request. At most 50 recipients across `to`+`cc`+`bcc` combined (the Email Service cap); the combined total is enforced as a 422.
  - `cc` union
    - string: One email address. ⚠️ **Bounded because we STORE it.** RFC 5321 §4.5.3.1.1 caps a path at 256 octets, which is the same number the inbound envelope enforces. The recipient COUNT was capped and the length of each was not, so fifty addresses of unbounded length were joined into one `sent_emails.to_addr` column and one Durable Object `to_addrs` blob. The domain refuses these too — this arm exists so a caller is told at the edge rather than by a 422 from three layers down.
    - EmailAddress[]: One address or several. A single string is normalized to a one-element list on parse — the two spellings are one request. At most 50 recipients across `to`+`cc`+`bcc` combined (the Email Service cap); the combined total is enforced as a 422.
  - `bcc` union
    - string: One email address. ⚠️ **Bounded because we STORE it.** RFC 5321 §4.5.3.1.1 caps a path at 256 octets, which is the same number the inbound envelope enforces. The recipient COUNT was capped and the length of each was not, so fifty addresses of unbounded length were joined into one `sent_emails.to_addr` column and one Durable Object `to_addrs` blob. The domain refuses these too — this arm exists so a caller is told at the edge rather than by a 422 from three layers down.
    - EmailAddress[]: One address or several. A single string is normalized to a one-element list on parse — the two spellings are one request. At most 50 recipients across `to`+`cc`+`bcc` combined (the Email Service cap); the combined total is enforced as a 422.
  - `replyTo` string: One email address. ⚠️ **Bounded because we STORE it.** RFC 5321 §4.5.3.1.1 caps a path at 256 octets, which is the same number the inbound envelope enforces. The recipient COUNT was capped and the length of each was not, so fifty addresses of unbounded length were joined into one `sent_emails.to_addr` column and one Durable Object `to_addrs` blob. The domain refuses these too — this arm exists so a caller is told at the edge rather than by a 422 from three layers down.
  - `subject` string: RFC 5322 caps a subject line at 998 characters.
  - `text` string: Per-part body cap — generous for transactional mail, bounds abuse and cost.
  - `html` string: Per-part body cap — generous for transactional mail, bounds abuse and cost.
  - `inReplyTo` string: A threading header value that still fits one RFC 5322 §2.1.1 line.
  - `references` string: A threading header value that still fits one RFC 5322 §2.1.1 line.
  - `templateId` string: Render a stored template instead of inline content. Naming both is a 400, never a guess. Resolved against the granted mailbox the `from` routes into — the same mailbox that accounts for the send.
  - `data` object: Merge variables for `templateId`.
  - `attachments` union[]: Files to attach — at most 32. ⛔ **The whole message must stay under 5 MiB, attachments included** — Cloudflare's outbound ceiling, and not the 25 MiB inbound one. (Their 25 MiB applies only to verified destination addresses within your own account, which a customer's recipients are not.) It is measured on the decoded files, not their base64. A message over the limit is refused with 422 rather than truncated: dropping an attachment from a message we then report as sent is worse than not sending it. ⛔ **32 files is Cloudflare's limit**, not ours: its send refuses a 33rd (`E_TOO_MANY_ATTACHMENTS`, measured 2026-10-07 and published on no limits page). Both bounds are checked before a file is decoded or read. Three shapes, mixed freely, sent in this order: a file you hold (`OutboundAttachment`, base64), one already on a message you can read (`AttachmentReference`), or one uploaded into the draft this sends (`DraftFileReference`). Each object is exactly one of them — an object carrying two sets of keys is refused rather than read as either.
    - union
      - OutboundAttachment: One file to send.
        - `filename` string, required: Bounds mirroring `domain/email/message-part.ts` — every one of these arrives in a stranger's MIME headers, so the cap is the contract, not tidiness.
        - `contentType` string, required
        - `content` string, required: Base64 of one attachment's bytes. ⛔ **A string, never binary, and there are two independent reasons.** Central relays a send to a tenant's proxy as JSON (`ProxyEmailSender` does `JSON.stringify` on the built message), which silently mangles an `ArrayBuffer`; and Cloudflare's own local simulator refuses to serialize binary attachment content at all (`Cannot serialize value: [object ArrayBuffer]`), so a binary path could not be exercised outside production. The cap is base64 of the 5 MiB whole-message ceiling — a per-field bound only. What actually governs is the SUM across the message, which no schema can express and the domain enforces.
        - `disposition` 'attachment' | 'inline': Defaults to `attachment`. `inline` is for an image the HTML body names by `cid:`.
        - `contentId` string: A `Content-ID` is a `Message-ID` by another name — RFC 5322 §2.1.1's 998-octet line.
      - AttachmentReference: A file already on a message this credential can read, attached by reference — no bytes on the wire. The engine reads the part back from storage and sends it exactly as a fresh upload. Both ids are the ones a thread read shows: the message's `id` (as `emailId`) and `Message.attachments[].id` on that message. Resolved across the whole granted set, received messages and sent copies alike. ⛔ **A reference that cannot be produced refuses the WHOLE send** with 422 — an unknown message, or an id that is not an attachment on it. The caller named the file; sending without it and reporting `sent` is the exact loss this refuses. ⛔ **The engine's id, never the Message-ID header.** A Message-ID is the sender's own text (RFC 5322 §3.6.4 leaves its uniqueness to whoever generated it), so a stranger could reuse one and swap the file a forward carried. `emailId` is minted by the engine; nothing anybody sends can collide with it. The name follows JMAP's (RFC 8621).
        - `emailId` string, required: The message's `id`, as a thread read shows it.
        - `attachmentId` string, required
      - DraftFileReference: A file uploaded into the draft this send names (`draftId`) — no bytes on the wire. A reference without `draftId`, or to a file the draft does not hold, refuses the whole send with 422.
        - `fileId` string, required
  - `draftId` string: The draft this sends, if it was one. Once the send is accepted the draft is gone from the mailbox the send went from, its files with it — so a sent message's draft never lingers, and a refused send keeps it. Naming a draft that is not there changes nothing about the send, unless `attachments` names its files.

## Response `200`

The request has succeeded.

- SendAccepted: A send the rail carried to the transport — `sent` on 200, `failed` on 502. The same shape on both arms so a caller correlates by `id` either way.
  - `id` string, required
  - `messageId` string, nullable, required
  - `status` 'sent' | 'failed' | 'rate_limited' | 'in_flight' | 'rejected', required: Every state a send outcome can report.
  - `error` string, nullable, required: Why the transport refused it. Null on success.
  - `replayed` boolean: Present when an `Idempotency-Key` replayed a send that had already gone.

## Other responses

- `400`: The server could not understand the request due to invalid syntax.
- `401`: Access is unauthorized.
- `403`: Access is forbidden.
- `404`: The server cannot find the requested resource.
- `409`: The request conflicts with the current state of the server.
- `413`: Client error
- `422`: Client error
- `429`: Client error
- `502`: Server error
- `default`: An unexpected error response.

---

[API](https://skmtc.dev/foundermail/apis/mailbox.md) - [All operations](https://skmtc.dev/foundermail/apis/mailbox/llms.txt) - [OpenAPI document](https://skmtc.dev/foundermail/apis/mailbox/revisions/6a50628a86ed?raw)
