env.EMAIL sends transactional email from your app — receipts, notifications, magic links — through a managed delivery service. There are no SMTP credentials or API keys to set up.
Sending
env.EMAIL is an attachable resource — ask your assistant to enable email (it runs attach_resource) and the binding is wired on your next deploy.// api/notify.js
export default {
async fetch(request, env, ctx) {
await env.EMAIL.send({
to: "customer@example.com",
subject: "Your order shipped",
html: "<h1>On its way!</h1><p>Tracking: ABC123</p>",
text: "On its way! Tracking: ABC123",
});
return Response.json({ ok: true });
},
};Pass to, subject, and an html and/or text body. Optionally set from to choose a verified sender; otherwise the app's default sender is used. The platform handles the routing details for you.
Sending from your own domain
By default mail goes out from a per-app address on a Cloudrizz domain. To send under your own brand, verify a domain once — from the app's Email sender settings in the dashboard, or with the manage_email_sender tool. Add the DNS records it shows you, and every app in your organization can then send from that domain.
- Set a default
fromfor the app (e.g.noreply@yourdomain.com), or passfromon any individualsend()call. - Any local part on a verified domain works —
hello@,billing@,team@— with no need to verify each one. - Verify more than one domain if you send under multiple brands, and choose which to use per message.
Let your customers use their own domains
The section above is for domains you own. But if you're building a multi-tenant app — say a booking platform where each client wants confirmation emails to come from their brand (e.g. noreply@eikenga.no, not your domain) — your app can register and verify each customer's domain at runtime, entirely from your own code. Your customer never touches this dashboard; they only add a few DNS records to their own domain.
Four methods on env.EMAIL drive the whole flow:
| Method | What it does |
|---|---|
env.EMAIL.addDomain(domain) | Registers domain for this app and returns the DNS records the customer must add. Status starts as "pending". |
env.EMAIL.verifyDomain(domain) | Re-checks the domain's DNS and flips its status to "verified" once the records resolve. Call it after the customer has added the records. |
env.EMAIL.listDomains() | Returns { domains: [...] } — every domain this app has registered, each with its current status. |
env.EMAIL.removeDomain(domain) | Removes a domain from this app. Returns { removed: true, domain }. |
- Register the domain. When your customer enters their domain in your app's UI, call
addDomain. You get back the DNS records to show them.// api/customer-domains.js — your customer submits "eikenga.no" const result = await env.EMAIL.addDomain("eikenga.no"); // result = { // domain: "eikenga.no", // status: "pending", // dnsRecords: [ // { type: "TXT", name: "…", value: "…" }, // DKIM / SPF // { type: "MX", name: "…", value: "…", priority: 10 }, // … // ], // createdAt: 1737000000000, // verifiedAt: null, // } return Response.json({ records: result.dnsRecords });Show every entry in
dnsRecordsto your customer verbatim —type,name, andvalue(pluspriorityfor MX records). They add each one at their DNS provider. - Verify once they've added the records. DNS can take anywhere from a minute to a few hours to propagate, so
verifyDomainis safe to call repeatedly — behind a "Check status" button, or from a scheduled job.const check = await env.EMAIL.verifyDomain("eikenga.no"); if (check.status === "verified") { // Ready — this domain can now send. } else if (check.status === "pending") { // DNS hasn't propagated yet. Ask them to wait and check again. } else { // "failed" — the records don't match. Have them re-check against dnsRecords. } - Send from the verified domain. Once the status is
"verified", set it as thefromon any send. Any local part works — you don't verifynoreply@,hello@, etc. separately.await env.EMAIL.send({ from: "Eikenga <noreply@eikenga.no>", to: "buyer@example.com", subject: "Your booking is confirmed", html: "<p>See you on the 20th.</p>", });
These methods throw on failure (they don't return an error object), so wrap them in try / catch and surface the message to your customer. addDomain throws if:
- your plan doesn't include customer sending domains (it's a paid feature),
- you've hit your plan's per-app domain limit (remove one, or upgrade), or
- the domain is a public mailbox provider (gmail.com, outlook.com, …) — those can never be registered as sending domains.
A from is only accepted once its domain is verified; an unverified or unknown sender domain is rejected. That, plus the DNS records being under the customer's control, is what stops anyone sending as a domain they don't own.
addDomain and verifyDomain work against real DNS on any deploy, so you can build and test the whole flow on a preview — but actual mail only reaches your customers' recipients from a production deploy.Preview-safe by default
On a preview or branch deploy, email is automatically made safe: the recipient is rewritten to the deploying user, the subject is prefixed with [PREVIEW], and a banner is added to the body. Real recipients only receive mail from a production deploy — so testing a flow never emails your customers.
Limits
Sending is capped per app, per month, by plan, plus a per-app daily ceiling and a short-window rate limit to contain bugs and abuse. See Limits & quotas.
env.EMAIL yourself for those.