Skip to content

Email

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.

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.

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.

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

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.

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.