Sending and Receiving Email with Cloudflare Email Service—from Routing to Workers, API, and SMTP
A practical July 2026 guide to receiving, forwarding, parsing, replying to, and sending transactional email with Cloudflare Email Service, including Nuxt, Workers, D1, security, and rate limiting.
Video explainer / 16:9
Cloudflare Email Service in Practice—an 11-minute Configuration Guide
From DNS, Email Routing, Email Workers, and the send_email binding to REST, SMTP, Nuxt, D1, five-minute verification codes, and production checks.
Press play for the full explainer. Seeking, picture-in-picture, and fullscreen are supported.
For a long time, Cloudflare’s relationship with email was mostly about two things: managing DNS and using Email Routing to forward support@example.com to a real mailbox.
In 2026, that boundary moved considerably.
In April, Cloudflare moved Email Sending into public beta, allowing Workers to call env.EMAIL.send() directly for transactional email. Authenticated SMTP followed in June. Combined with Email Routing and Email Workers, Cloudflare Email Service can now:
- Receive mail sent to your own domain.
- Forward it to a verified mailbox.
- Hand incoming mail to a Worker for parsing, filtering, storage, or automation.
- Reply within the original email thread.
- Send transactional mail from a Worker, REST API, or SMTP client.
- Expose sending logs, bounces, suppressions, and delivery metrics.
This article reflects the official platform as of July 28, 2026. It explains the complete sending and receiving model and proposes an implementation for projects built on Nuxt, Workers, and D1.
The short conclusion:
Cloudflare Email Service is not a conventional IMAP / POP3 mailbox, and it is not a webmail-based enterprise suite. It is better understood as a set of email entry points, sending channels, and programmable events for verification codes, password resets, order notifications, system alerts, support tickets, and email-driven automation.
Separate receiving from sending first
Sending and receiving look like one feature, but they are two independent paths inside Cloudflare.
| Direction | Cloudflare product | Entry point | What a Worker can do |
|---|---|---|---|
| Receive | Email Routing / Email Workers | An external mail server connects to Cloudflare MX | Forward, reply, reject, parse, write to D1/R2, or enqueue work |
| Send | Email Sending | Workers binding, REST API, or authenticated SMTP | Send text/HTML, attachments, and custom headers, then receive a messageId |
The receiving path is roughly:
Sender SMTP
→ Cloudflare MX
→ SPF / DKIM / DMARC and reputation checks
→ address rule or Catch-all match
→ mailbox forwarding / Worker / drop
The sending path is:
Nuxt API / Worker / external application
→ EMAIL binding / REST API / SMTP
→ sender-domain and parameter validation
→ Cloudflare delivery pipeline
→ recipient mail server

This is a conceptual diagram. Real decisions also include the SMTP session, email authentication, rule matching, and Worker execution limits.
Product boundaries and pricing in 2026
Cloudflare currently groups receiving and sending under Email Service, but the two parts have different plan availability.
Email Routing: receiving and forwarding
Email Routing is available on both Workers Free and Workers Paid. The official pricing page lists unlimited inbound email, although messages processed by a Worker still consume the corresponding Worker requests and compute.
It works well for:
- Forwarding
support@yourdomain.comto an existing personal mailbox. - Creating a separate contact address for each site.
- Using sub-addresses such as
orders+shop-a@yourdomain.comto identify a source. - Turning incoming mail into a ticket, webhook, or database record with a Worker.
Email Sending: transactional email
Email Sending remains in public beta.
Sending to arbitrary recipients requires the Workers Paid plan. According to the official July 2026 pricing:
- Each account includes 3,000 outbound messages per month.
- Additional messages cost $0.35 per 1,000.
- Messages sent to “verified destination addresses” in the account are always free and do not use the included allowance.
- A message accepted by Email Service normally counts even if it later hard-bounces; a request rejected at the API boundary does not.
Pricing and beta status can change. Check the official pricing page again before production instead of permanently encoding these numbers into a budget model.
Step one: connect the domain correctly
Email Service requires the domain to use Cloudflare DNS. Sending and receiving create two sets of records for different purposes.
Receiving uses root-domain records
When Email Routing is enabled, Cloudflare configures the domain root with:
- MX records that deliver incoming mail to Cloudflare.
- SPF authorization for Cloudflare forwarding.
- DKIM signatures for the forwarding path.
These records cover “someone sends mail to your domain.”
Sending uses cf-bounce records
When Email Sending is enabled, Cloudflare configures cf-bounce.yourdomain.com with:
- MX records for bounce processing.
- SPF authorization for Cloudflare to send on behalf of the domain.
- DKIM verification that the message was not modified in transit.
_dmarc.yourdomain.com, which declares how authentication failures should be handled.
Sending and receiving use different DKIM selectors. Seeing two DKIM records does not mean the setup is duplicated.
If the domain already uses Google Workspace, Microsoft 365, or another mail service, check MX and SPF conflicts carefully. A domain can have only one valid SPF policy. To authorize several providers, merge their include values instead of creating a second v=spf1 record.
I recommend separating reputation-sensitive purposes across subdomains:
noreply@example.com account verification and password resets
notify@mail.example.com product notifications
support@example.com human support and incoming mail
Transactional notifications, human correspondence, and possible future bulk mail will not damage one another’s reputation, and failure and migration boundaries become clearer.
Receiving mail: from forwarding rules to an Email Worker
The simplest way to receive mail requires no code:
- Add a verified destination mailbox in Email Routing.
- Create
support@yourdomain.com. - Select “Send to an email.”
- Cloudflare forwards incoming messages to the destination.
If you need to classify by subject, extract attachments, create tickets, or send automatic replies, change the action to “Send to a Worker.”
email() is the inbound entry point
An Email Worker enters through email(), not fetch():
interface Env {
SUPPORT_FORWARD_TO: string
}
export default {
async email(message, env): Promise<void> {
const recipient = message.to.toLowerCase()
const subject = message.headers.get('subject') ?? ''
if (message.rawSize > 10 * 1024 * 1024) {
message.setReject('Message is too large for this mailbox')
return
}
if (recipient === 'support@example.com') {
console.log('Support email accepted', {
fromDomain: message.from.split('@').at(-1),
subjectLength: subject.length,
rawSize: message.rawSize,
})
await message.forward(env.SUPPORT_FORWARD_TO)
return
}
message.setReject('Unknown recipient')
},
} satisfies ExportedHandler<Env>
Here, message is a ForwardableEmailMessage. Common fields and operations include:
| Capability | Purpose |
|---|---|
message.from | Sender in the SMTP envelope |
message.to | Recipient in the SMTP envelope |
message.headers | Read Subject, Message-ID, and other headers |
message.raw | Raw MIME stream |
message.rawSize | Size of the raw message |
message.forward(address) | Forward to a verified destination address |
message.reply(email) | Reply to the original sender within platform constraints |
message.setReject(reason) | Return a permanent SMTP rejection |
There is an easy-to-miss security detail: message.from and message.to come from the SMTP envelope, while From: and To: inside the message headers can contain different values. For authorization, routing, and auditing, do not mistake a user-visible header address for a trusted identity.
Parse MIME only when you need the body
There is no reason to fully parse a message if you only forward it or route it by address. To extract the body and attachments, use postal-mime:
import PostalMime from 'postal-mime'
export default {
async email(message): Promise<void> {
const parsed = await PostalMime.parse(message.raw)
console.log({
subject: parsed.subject,
textLength: parsed.text?.length ?? 0,
attachmentCount: parsed.attachments.length,
})
},
} satisfies ExportedHandler
Parsed HTML, filenames, Content-Type values, and attachments all come from external input. Safe handling means:
- Do not inject incoming HTML directly into an administration page.
- Limit body size, attachment count, and expanded size.
- Generate a new object key before writing a file to R2; do not use the original filename as the path.
- Never let message content directly construct SQL, shell commands, templates, or AI tool parameters.
- Send expensive work to a Queue instead of putting malware scanning, OCR, and model calls into the synchronous CPU time of an inbound event.
Forwarding, replying, rejecting, and silently dropping are different
- Forward: continue handling in a human mailbox; the destination must already be verified.
- Reply: useful for confirmations or mail bots, with additional restrictions.
- Reject: explicitly tell the sender during SMTP that the message was not accepted.
- Drop: stop processing after a rule match without business-level feedback to the sender.
An automatic reply is not an arbitrary sending interface. The current platform requires:
- A valid DMARC result on the incoming message.
- Only one reply call per inbound event.
- The reply recipient to be the original sender.
- The reply sender domain to match the domain that received the message.
- Fewer than 100 entries in
References, preventing mail loops.
These restrictions are helpful: “reply to this message” and “send a new message to any address” become two different permissions.
Sending mail: prefer a Workers binding
If the application already runs on Cloudflare Workers, the Workers binding is the most natural option. It does not require a Cloudflare API token in business code.
Declare the binding in wrangler.jsonc:
{
"send_email": [
{
"name": "EMAIL",
"allowed_sender_addresses": [
"noreply@example.com"
]
}
],
"vars": {
"EMAIL_FROM": "noreply@example.com"
}
}
allowed_sender_addresses is not merely documentation; it is a runtime permission boundary. Even if an endpoint is misused, the binding cannot impersonate another sender address.
The following settings can constrain recipients further:
destination_address: fix the binding to one destination.allowed_destination_addresses: allow only a set of destinations.allowed_sender_addresses: restrict available sender addresses.
A Worker used for system alerts is a good fit for a fixed destination_address. A user-verification service normally restricts the sender and validates recipient and frequency in the application.
Sending inside a Worker
interface Env {
EMAIL: SendEmail
}
export default {
async fetch(_request, env): Promise<Response> {
const result = await env.EMAIL.send({
from: {
email: 'noreply@example.com',
name: 'Example App',
},
to: 'reader@example.net',
subject: 'Verify your email',
text: 'Your verification code is 482913. It is valid for 5 minutes.',
html: '<p>Your verification code is <strong>482913</strong>. It is valid for 5 minutes.</p>',
})
return Response.json({
messageId: result.messageId,
})
},
} satisfies ExportedHandler<Env>
A successful Workers binding call returns a messageId. It means Cloudflare accepted the sending request; it does not prove the message is already visible in the user’s inbox. Final delivery, delay, bounce, and suppression status belong in Email Service logs and metrics.
Sending from a Nuxt server API
Once Nuxt is deployed to Workers, a server route can read the same binding:
import { z } from 'zod'
const schema = z.object({
email: z.string().trim().email().max(254),
})
export default defineEventHandler(async (event) => {
const { email } = schema.parse(await readBody(event))
const env = event.context.cloudflare.env as {
EMAIL: SendEmail
EMAIL_FROM: string
}
// A real project should also validate same-origin requests,
// rate-limit IPs and email addresses, and atomically claim
// one sending opportunity in D1.
const result = await env.EMAIL.send({
from: {
email: env.EMAIL_FROM,
name: 'Example App',
},
to: email,
subject: 'Verify your email',
text: 'The verification code will expire in 5 minutes.',
html: '<p>The verification code will expire in <strong>5 minutes</strong>.</p>',
})
return {
ok: true,
messageId: result.messageId,
}
})
Do not turn this sample into an anonymous public API with unlimited frequency and freely supplied subjects and bodies. That would turn your domain into a spam relay.

SPF, DKIM, and DMARC answer who may send, whether content was modified, and what to do on failure. They do not guarantee that every message reaches the primary inbox.
When are REST API and authenticated SMTP a better fit?
Cloudflare currently offers three sending entry points:
| Method | Best fit | Credentials and boundaries |
|---|---|---|
| Workers binding | Nuxt, Hono, or an agent already running on Workers | No API token in code; sender and recipient restrictions can be enforced by the binding |
| REST API | Programs running in another cloud, CI system, or backend platform | Uses a least-privilege API token; responses distinguish delivered, queued, and permanent bounce per recipient |
| Authenticated SMTP | Legacy systems already using Nodemailer, PHPMailer, JavaMail, or another SMTP client | smtp.mx.cloudflare.net:465, implicit TLS only; the password is an API token with Email Sending permission |
Authenticated SMTP was added as a beta feature in June 2026. It currently supports only SMTPS on port 465. There is no STARTTLS on port 587 and no anonymous relay on port 25.
If an SMTP token leaks, its holder can send using the sender domains connected to the account. Therefore:
- Prefer an account-owned token.
- Grant only
Email Sending: Edit. - Never put it in a repository, image, or frontend environment variable.
- Give each system a separate token so it can be revoked independently.
- If a Worker binding is available, do not introduce a long-lived credential merely because SMTP feels familiar.
Limits you need to know
As of July 2026, these platform limits deserve explicit application-side defenses:
| Limit | Current value | Implementation effect |
|---|---|---|
| Email Routing rules per domain | 200 | A multi-tenant system should not create one static rule per user |
| Verified destination addresses per account | 200 | Plan account-level destinations before forwarding to many mailboxes |
| Inbound message size | 25 MiB | Larger messages are rejected during SMTP |
| Recipients per outbound message | 50 | Combined To, Cc, and Bcc |
| Standard outbound message size | 5 MiB | Includes attachments and MIME encoding overhead |
| Message size to a verified destination | 25 MiB | Only for verified destination addresses |
| Total custom-header size | 16 KB | Do not put business data into headers |
| Sending/routing domains per Zone | 30 | Root domains and subdomains both count |
References entries in a reply | 100 | reply() fails above the limit |
Attachments are wrapped in Base64 and MIME. A raw 3 MiB attachment does not mean the final email is still 3 MiB, so leave room for encoding, body content, and headers.
The platform can also adjust daily sending allowances based on account reputation, delivery performance, and usage history. Do not assume that unused monthly allowance means unlimited concurrency at any given second.
How verification codes and password resets should work
This blog sends registration and password-reset messages through the following path:
User submits email
→ same-origin check
→ IP and email rate limits
→ generate a six-digit code
→ D1 stores only salted hash, purpose, expiry, and attempt count
→ EMAIL binding sends the message
→ successful verification atomically marks the code as used
The important rules are:
- The same email address can receive only one message every five minutes.
- A verification code expires after five minutes.
- A code can be used only once.
- D1 never stores a plaintext code.
- Verification stops after a threshold of consecutive incorrect attempts.
- If sending fails, release the claimed slot so an undelivered code does not lock the user out.
- Resetting a password revokes existing login sessions instead of only replacing the password hash.
The rate limit must atomically claim eligibility in the database. Do not query the previous sending time and then insert a row. Two concurrent requests can both pass that check. A unique key combined with INSERT ... ON CONFLICT ... WHERE lets the database decide which request receives the sending slot.
The email API response should not reveal whether an address is registered, or an attacker can enumerate users in bulk.
A more complete production architecture
Once email moves from a demo into production, separate the responsibilities:
HTTP request / business event
→ authentication, origin check, validation, rate limit, idempotency
→ Queue
→ email template rendering
→ EMAIL binding
→ write messageId / error code to D1
→ Email Service logs and alerts
The inbound path can be:
support@yourdomain.com
→ Email Routing
→ Email Worker
→ authentication and size checks
→ MIME parsing
→ save attachments to R2 / create ticket in D1
→ Queue for expensive processing
→ forward to a human mailbox or send a constrained reply
What should D1 record?
Useful fields include:
- Business event ID and idempotency key.
- Template ID and template version.
messageId.- Recipient count, without necessarily storing every complete address.
- Application-side status such as accepted or failed.
- Cloudflare error code.
- Creation, sending, and last-updated timestamps.
Avoid storing by default:
- Plaintext verification codes.
- Password-reset tokens.
- Complete email bodies.
- Attachment contents.
- SMTP/API tokens.
- Raw inbound messages retained indefinitely for convenient debugging.
Logs can become a new source of private data themselves.
Do not treat “Dropped” in Email Routing as an outbound failure
The official documentation specifically notes that messages sent through the send_email binding can appear as dropped on the Email Routing summary page even when they were delivered successfully.
Inspect Email Sending Metrics and Logs for outbound results and correlate application logs with the messageId. The Routing page describes inbound routing behavior, not the complete outbound delivery state machine.
Testing locally
Wrangler supports local Email Worker events. Start it with:
pnpm exec wrangler dev
Then send a raw message to the local handler:
curl -X POST \
'http://localhost:8787/cdn-cgi/handler/email?from=sender@example.net&to=support@example.com' \
-H 'Content-Type: text/plain' \
--data-binary $'From: sender@example.net\nTo: support@example.com\nSubject: Local test\n\nHello from local development.'
The local sending binding stores text and HTML as inspectable files, which is useful for template validation. Local simulation still has separate limitations for binary attachments, so attachment flows need an additional remote test.
Cover at least:
- Normal text and HTML.
- Non-ASCII subjects and sender display names.
- Duplicate requests and idempotency.
- Repeated verification-code requests before the cooldown expires.
- Large body and attachment boundaries.
- Header newline injection.
- Unverified sender domains or restricted recipients.
- Bounce, suppression, allowance, and frequency errors.
- Unmatched inbound addresses, Catch-all, and rejection paths.
- Scripts, remote resources, and dangerous attachment names in HTML mail.
What is it suitable for—and what is it not?
Good fits:
- Registration verification, magic links, and password resets.
- Order, billing, deployment, and monitoring notifications.
- Personal-domain email forwarding.
- Contact forms and support tickets.
- Email-triggered agents or automation.
- Cloudflare applications that want to avoid maintaining an SMTP server.
Poor direct fits:
- Marketing blasts without consent.
- Newsletters without unsubscribe, complaint, and suppression management.
- An anonymous public “send email for me” endpoint.
- Enterprise mail that needs IMAP folders, calendars, contacts, and shared-mailbox collaboration.
- Passing arbitrary incoming message bodies directly to an agent with high-privilege tools.
Cloudflare maintains the email entry and delivery infrastructure, but sender reputation, user consent, content safety, frequency controls, and data minimization remain the application’s responsibility.
Final assessment
The most valuable part of Cloudflare Email Service is not that it is “another SMTP provider.” It puts email inside the same runtime boundary as Workers, D1, R2, Queues, and Agents.
Incoming mail no longer has to be forwarded only to a human mailbox; it can become a constrained event entry point. Outbound mail no longer requires a separate third-party SDK and credential system just to send a verification code.
If the project already runs on Workers, begin with this minimal path:
- Receive
support@through Email Routing. - Send verification and password-reset messages through a Worker binding.
- Use D1 for cooldowns, one-time verification, and minimal logs.
- Restrict binding permissions with a sender allowlist.
- Verify delivery in Email Sending logs instead of trusting only a successful API response.
Introduce the REST API only when an external platform truly needs to send mail. Use authenticated SMTP only when an existing system already depends deeply on an SMTP client.
Comments
Sign in to join the discussion
Your email remains private; only your display name appears.