Email lets your app send transactional mail — receipts, magic links, notifications — from your own domain, and optionally receive mail back into the app. Both run on Cloudflare in your account.
Email is the one capability with real onboarding: sending from your own domain requires proving you control it. You bring a domain (a Cloudflare zone) and verify it; the agent can’t conjure a verified sender out of nothing.
You name the subdomain mail sends from. It defaults to mail, so you send as
hello@mail.yourdomain.com — but it’s yours to pick: hey, notifications, shop.mail,
whatever reads right to your customers. It’s fixed once set up, so choose deliberately.
It has to be a subdomain — never the domain itself, which is refused outright. Not because
sending from hello@yourdomain.com would be destructive; it wouldn’t. It’s because
receiving at yourdomain.com means owning its MX records, and those decide where all
mail for the domain goes — including the staff email you already run there. A mailbox is
one address you both send from and receive at, so the address has to live somewhere we can
take over safely. A subdomain is that place.
The API
Section titled “The API”Sending lives at /api/_email/send, served from your app’s own origin — but the app does not
write that fetch. It imports email, and the module calls it:
import { sendEmail, replyToEmail, EmailError } from "email";
await sendEmail({ to: "customer@example.com", subject: "Your receipt", html: "<p>Thanks for your order!</p>", text: "Thanks for your order!",});// → { sent: true, messageId }A refused send throws an EmailError carrying .status, .code and the server’s own
message, so a send that resolves succeeded — there is nothing to check on the way out.
| Method & path | Body | Returns |
|---|---|---|
POST /api/_email/send |
{"to":"a@b.com","subject":"...","html":"...","text":"...","from?":"support"} |
202 {"sent":true,"messageId"} |
Sending is the only endpoint this capability adds. Received mail is read with the Data API — see Receiving.
Sending
Section titled “Sending”sendEmail(fields) takes these:
| Field | |
|---|---|
to |
recipient, or an array of them (required) |
subject |
required |
html / text |
at least one required |
cc, bcc, replyTo |
optional |
headers |
optional — standard mail headers only, e.g. {"In-Reply-To": messageId} to thread a reply |
from |
optional — the name of one of your mailboxes, never a full address |
A rejected send tells you whose fault it is on EmailError.status: 400 is the payload’s (a
malformed address, a suppressed recipient, a disallowed header), 401 means nobody is signed
in, 403 means a recipient is a stranger to this app, 429 means slow down, and 502 means
the platform failed — only the last one is worth retrying.
server.mjs is a lone module with no module graph, so it can’t import email. There, call
the route in the table above through env.PLATFORM.fetch() — same path, same body, and it
carries the caller’s session.
Receiving
Section titled “Receiving”You configure mailboxes in the console. A mailbox is an address on your sending subdomain plus the Data collection its mail lands in:
| Address | Collection |
|---|---|
support@mail.yourdomain.com |
support_tickets |
legal@mail.yourdomain.com |
legal_mail |
There is no inbox endpoint, and email has no import for reading. You read a mailbox by
reading its collection, with the same Data module as anything else:
import { collection } from "data";
const { items } = await collection("support_tickets").list({ sort: "-createdAt" });// → [ { id, from, to, subject, text, messageId, inReplyTo, createdAt }, … ]Which means filtering, sorting, paging and aggregating all work the way they do for any
collection, and a Realtime socket on ?data=support_tickets turns
arriving mail into a live update with no polling. Turn notify on for a mailbox and each
message also emails you.
Each message is MIME-parsed on arrival: subject is decoded (non-ASCII included), text is
the real plain-text part (falling back to stripped HTML for HTML-only mail), and
messageId/inReplyTo carry the threading headers. Attachments are not stored.
A mailbox works in both directions
Section titled “A mailbox works in both directions”A reply goes out as the address it arrived at, so your customer’s next reply lands back in
the same collection. Hand replyToEmail() the message record and it sets that up for you —
from is the mailbox the message reached, to is whoever wrote, the subject is threaded, and
In-Reply-To/References carry the message id:
import { replyToEmail } from "email";
await replyToEmail(msg, { text: "Thanks — we're on it." });Anything sendEmail() takes works as the second argument, and overrides what the reply would
have used — a different subject, an html body, a cc.
The collection is yours to extend
Section titled “The collection is yours to extend”A mailbox collection is shared scope — one team inbox every signed-in user sees — and the
platform owns its six fields (from, to, subject, text, messageId, inReplyTo),
writing them on every message. You can declare the same collection in data.json to add
your own fields, like a status or assignee for a support queue, and write them like any
other record. The platform’s fields and its shared scope always win.
How it’s built
Section titled “How it’s built”Sending is backed by Cloudflare Email Sending; receiving by Email Routing, whose per-address rules deliver into a worker that MIME-parses each message and writes it as a Data record — the same store, the same read path, the same live updates as everything else your app keeps. Sending turns on when you ask the agent to add email and setup walks you through verifying the sending domain; mailboxes are configured in the console, because they touch your domain’s DNS. Because it depends on a domain you control, Email is the capability where the setup — not the code — is the real work.
Sending to arbitrary recipients needs the Workers Paid plan on your Cloudflare account.
On the free plan a send only reaches destination addresses you have verified in that account,
which is enough to test the wiring but not to email your app’s users. Receiving is free on
every plan. Cloudflare refuses the send itself, so a send that fails this way comes back from
sendEmail with Cloudflare’s own code and message rather than being retried.