How to send email with Cloudflare Email Service
By Flavio Copes
Send transactional email from a Cloudflare Worker using the native Email Service binding, with domain setup, local testing, and error handling.
Cloudflare Email Service lets you send transactional emails directly from a Cloudflare Worker.
You don’t need a separate email provider or an API key in the Worker. You add a binding, call env.EMAIL.send(), and Cloudflare sends the email.
You can use it for welcome emails, password resets, order confirmations, and application alerts.
Email Sending is still in beta. Sending to arbitrary recipients requires the Workers Paid plan. Cloudflare gives new accounts a conservative daily quota and adjusts it over time, so check the current limit in your account instead of building around a number from a tutorial.
There is one useful exception. You can send to destination addresses you have verified in Email Routing on any plan, and those messages do not count toward the sending quota. This is enough for alerts sent to yourself or a small fixed team.
This is for transactional email. Keep using a newsletter service for marketing emails.
Email Sending and Email Routing are different
Cloudflare has two email products under Email Service.
Email Sending sends outbound email from your application.
Email Routing receives email on your domain. You can forward it to another address or process it with a Worker. I covered that in Cloudflare Email Workers: run code when an email arrives.
In this tutorial we’ll focus on sending.
Set up the sending domain
Your domain must use Cloudflare DNS.
In the Cloudflare dashboard, open Compute > Email Service > Email Sending. Click Onboard Domain and choose the domain you want to send from.
Cloudflare adds the DNS records it needs:
- MX records on a
cf-bouncesubdomain - an SPF record
- a DKIM record
- a DMARC record
Those records let receiving mail servers verify that Cloudflare can send email for your domain.
DNS changes usually finish in a few minutes. Cloudflare says they can take up to 24 hours.
Add the email binding
Add a send_email binding to wrangler.jsonc:
{
"send_email": [
{
"name": "EMAIL"
}
]
}
The name becomes the property we use on env:
env.EMAIL
There is no API key to store in a Worker secret.
The unrestricted-looking binding is still limited by your Email Service setup. Before you onboard Email Sending, it can send only to verified destination addresses in your account.
For an alert Worker, make that restriction explicit:
{
"send_email": [
{
"name": "EMAIL",
"allowed_destination_addresses": [
"[email protected]"
]
}
]
}
This is safer than letting application code choose any address when the Worker only needs one.
Send an email
Let’s make a Worker that sends a welcome email when it receives a POST request:
export default {
async fetch(request, env) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 })
}
const result = await env.EMAIL.send({
to: '[email protected]',
from: {
email: '[email protected]',
name: 'My App'
},
subject: 'Welcome!',
html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
text: 'Welcome! Thanks for signing up.'
})
return new Response(`Email sent: ${result.messageId}`)
}
}
Replace yourdomain.com with the domain you onboarded.
I always include both html and text. Some email clients prefer plain text, and it gives every recipient a readable version.
The returned messageId identifies the email in Cloudflare’s logs.
Test without sending a real email
By default, wrangler dev simulates the email binding. It logs the email and saves its content to local files instead of delivering it.
Start the Worker:
npx wrangler dev
Then call it:
curl -X POST http://localhost:8787/
This is the safest way to check the subject and body while you work.
When you want to send a real test email from local development, add remote: true to the binding:
{
"send_email": [
{
"name": "EMAIL",
"remote": true
}
]
}
Now env.EMAIL.send() uses the real Cloudflare Email Service.
Be careful with this setting. Every local request sends a real email.
Handle sending errors
Email can fail because the domain is not ready, an address is suppressed, or you reached a limit.
Wrap the call in try...catch:
try {
await env.EMAIL.send({
to: '[email protected]',
from: '[email protected]',
subject: 'Welcome!',
text: 'Thanks for signing up.'
})
} catch (error) {
console.error(error.code, error.message)
}
The error has a code property. For example, E_SENDER_NOT_VERIFIED means the sending domain is not ready. E_RATE_LIMIT_EXCEEDED means you should wait and retry later.
Don’t make a public endpoint that accepts any recipient, subject, and body. That would turn your Worker into an email relay anyone could abuse.
Keep the email contents in your application, validate the action that triggers it, and protect the endpoint like any other application endpoint.
Useful limits to know
An email can have up to 50 recipients across to, cc, and bcc.
The total message size is 5 MiB, including attachments. New accounts start with a conservative daily quota that can grow as Cloudflare learns your sending reputation. Sends to verified destination addresses are exempt from that quota.
You can see delivery results, bounces, and suppressions in the Email Service dashboard.
For a Worker already running on Cloudflare, the native binding is a nice option. There is less configuration, no extra email API key, and sending an email is one method call.
See the Cloudflare Email Service documentation for the current limits, REST API, and SMTP options too.
Related posts about cloudflare: