Cloudflare Email Service 收发实战:从 Email Routing 到 Workers、API 与 SMTP
截至 2026 年 7 月,系统介绍 Cloudflare Email Service 如何接收、转发、解析、回复和发送事务邮件,并给出 Nuxt、Workers、D1 与安全限流实践。

很长一段时间里,Cloudflare 与邮件的关系主要停留在两件事上:管理 DNS,以及用 Email Routing 把 support@example.com 转发到一个真实邮箱。
2026 年,这个边界明显向前移动了。
Cloudflare 在 4 月把 Email Sending 推进公开 Beta,让 Workers 可以直接调用 env.EMAIL.send() 发送事务邮件;6 月又加入了认证 SMTP。加上原有的 Email Routing 与 Email Workers,现在一套 Cloudflare Email Service 已经可以覆盖:
- 接收发往自有域名的邮件;
- 转发到经过验证的真实邮箱;
- 把来信交给 Worker 解析、过滤、入库或触发自动化;
- 在原邮件线程中回复;
- 从 Worker、REST API 或 SMTP 发送事务邮件;
- 查看发送日志、退信、抑制与投递指标。
这篇文章以 2026 年 7 月 28 日的官方能力为准,梳理 Cloudflare 邮件收发的完整模型,并给出一套适合 Nuxt、Workers 与 D1 项目的实现思路。
先说结论:
Cloudflare Email Service 不是传统的 IMAP / POP3 邮箱,也不是让你在网页里收信的企业邮箱套件。它更像一组“邮件入口、发送通道和可编程事件”,适合验证码、密码重置、订单通知、系统告警、支持工单和邮件驱动的自动化。
先把“接收”和“发送”分开
邮件收发看起来是一件事,在 Cloudflare 里却是两条独立链路。
| 方向 | Cloudflare 产品 | 入口 | Worker 能做什么 |
|---|---|---|---|
| 接收 | Email Routing / Email Workers | 外部邮件服务器连接 Cloudflare MX | 转发、回复、拒收、解析、写入 D1/R2、投递 Queue |
| 发送 | Email Sending | Workers binding、REST API、认证 SMTP | 发送文本/HTML、附件、自定义头,获得 messageId |
接收链路大致是:
发送方 SMTP
→ Cloudflare MX
→ SPF / DKIM / DMARC 与信誉检查
→ 匹配地址规则或 Catch-all
→ 转发到邮箱 / 交给 Worker / 丢弃
发送链路则是:
Nuxt API / Worker / 外部应用
→ EMAIL binding / REST API / SMTP
→ 发件域与参数校验
→ Cloudflare 投递管线
→ 收件方邮件服务器

这是一张概念图。真实判断还包含 SMTP 会话、邮件认证、规则匹配和 Worker 执行限制。
2026 年的产品边界与价格
Cloudflare 当前把接收与发送放在同一个 Email Service 下,但两部分的可用计划不同。
Email Routing:接收与转发
Email Routing 在 Workers Free 和 Workers Paid 都可以使用。官方价格页把入站邮件数量标为不限量,但如果邮件被交给 Worker,仍然会消耗对应的 Worker 请求与计算资源。
它适合:
- 把
support@yourdomain.com转发到现有个人邮箱; - 为不同网站创建独立的联系地址;
- 用
orders+shop-a@yourdomain.com这样的子地址区分来源; - 把来信交给 Worker 转换成工单、Webhook 或数据库记录。
Email Sending:事务邮件
Email Sending 当前仍是公开 Beta。
向任意收件人发送需要 Workers Paid 计划。按照 2026 年 7 月的官方价格:
- 每个账户每月包含 3,000 封出站邮件;
- 超出后每 1,000 封为 0.35 美元;
- 发往账户内“已验证目标地址”的邮件始终免费,不计入包含额度;
- 邮件被 Email Service 接受后,即使随后发生硬退信,通常也会计入额度;在 API 边界直接拒绝的请求不会计入。
价格和 Beta 状态都可能继续调整,真正上线前应重新检查官方价格页,而不是把本文数字长期写死在预算模型里。
第一步:正确接入域名
Email Service 要求域名使用 Cloudflare DNS。发送与接收会创建两组用途不同的 DNS 记录。
接收邮件使用根域记录
启用 Email Routing 后,Cloudflare 会在域名根节点配置:
- MX:把来信送到 Cloudflare 的邮件入口;
- SPF:授权 Cloudflare 转发邮件;
- DKIM:为转发链路提供签名。
这些记录负责的是“别人发到你的域名”。
发送邮件使用 cf-bounce 记录
启用 Email Sending 时,Cloudflare 会为 cf-bounce.yourdomain.com 配置:
- MX:处理退信;
- SPF:授权 Cloudflare 代域名发送;
- DKIM:验证邮件内容未在途中被修改;
_dmarc.yourdomain.com:声明认证失败时的处理策略。
发送和接收使用不同的 DKIM selector。看到两条 DKIM 记录并不意味着重复配置。
如果域名已有 Google Workspace、Microsoft 365 或其他邮件服务,尤其要检查 MX 和 SPF 是否冲突。一个域名只能有一条有效 SPF 策略;需要同时授权多个服务时,应合并 include,而不是再创建第二条 v=spf1。
我的建议是把不同信誉用途拆到子域:
noreply@example.com 账户验证码与密码重置
notify@mail.example.com 产品通知
support@example.com 人工支持与来信处理
事务通知、人工往来与未来可能出现的批量邮件互不拖累,故障和迁移边界也更清晰。
接收邮件:从转发规则到 Email Worker
最简单的接收方式完全不用代码:
- 在 Email Routing 中添加一个经过验证的目标邮箱;
- 创建
support@yourdomain.com; - 选择“发送到邮箱”;
- 来信会被 Cloudflare 转发到目标地址。
如果需要按主题分类、提取附件、建立工单或自动回复,就把动作改为“发送到 Worker”。
email() 是入站邮件入口
Email Worker 的入口不是 fetch(),而是 email():
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>
其中 message 是 ForwardableEmailMessage,常用字段和操作包括:
| 能力 | 用途 |
|---|---|
message.from | SMTP envelope 的发件人 |
message.to | SMTP envelope 的收件人 |
message.headers | 读取 Subject、Message-ID 等邮件头 |
message.raw | 原始 MIME 数据流 |
message.rawSize | 原始邮件大小 |
message.forward(address) | 转发到已验证目标地址 |
message.reply(email) | 在约束范围内回复原发件人 |
message.setReject(reason) | 返回永久 SMTP 拒收 |
这里有一个容易忽略的安全点:message.from / message.to 是 SMTP envelope,邮件正文头部中的 From: / To: 则可能是另一组值。做授权、路由与审计时,不要把显示给用户看的头部地址误当成可信身份。
需要正文时再解析 MIME
只做转发或按地址路由时,没有必要完整解析邮件。要提取正文和附件,可以使用 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
解析后的 HTML、文件名、Content-Type 与附件都来自外部输入。正确做法是:
- 不在管理页面直接插入来信 HTML;
- 限制正文、附件数量和解压后大小;
- 文件写入 R2 前重新生成对象键,不使用原始文件名作为路径;
- 不让来信内容直接组成 SQL、Shell、模板或 AI 工具参数;
- 大任务交给 Queue,不要把杀毒、OCR、模型调用全塞进入站事件的同步 CPU 时间。
转发、回复、拒绝与静默丢弃的区别
- 转发:适合人工邮箱继续处理,目标地址必须提前验证;
- 回复:适合自动确认或邮件机器人,但有额外限制;
- 拒绝:在 SMTP 阶段明确告诉发送方没有接收;
- 丢弃:规则匹配后不继续处理,也不给发送方业务层反馈。
自动回复不能被当成任意发送接口。官方当前要求:
- 来信必须有有效 DMARC 结果;
- 每个入站事件只能调用一次回复;
- 回复收件人必须是原始来信的发件人;
- 回复使用的发件域必须与接收邮件的域一致;
References超过 100 项时会拒绝回复,以避免邮件环路。
这组限制反而是一件好事:它让“回复这封邮件”和“向任意地址发一封新邮件”成为两种不同权限。
发送邮件:优先使用 Workers binding
如果应用本身运行在 Cloudflare Workers,Workers binding 是最自然的选择。它不需要在业务代码里保存 Cloudflare API Token。
先在 wrangler.jsonc 中声明绑定:
{
"send_email": [
{
"name": "EMAIL",
"allowed_sender_addresses": [
"noreply@example.com"
]
}
],
"vars": {
"EMAIL_FROM": "noreply@example.com"
}
}
allowed_sender_addresses 不只是文档,它是运行时权限边界。即使某个接口被误用,这个绑定也不能伪造其他发件地址。
还可以用以下配置进一步约束收件人:
destination_address:固定为一个目标地址;allowed_destination_addresses:只允许一组目标地址;allowed_sender_addresses:限制可使用的发件地址。
用于系统告警的 Worker 最适合固定 destination_address;用于用户验证码的服务则通常限制发件人,并在应用层校验收件人和频率。
在 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: '验证你的邮箱',
text: '你的验证码是 482913,5 分钟内有效。',
html: '<p>你的验证码是 <strong>482913</strong>,5 分钟内有效。</p>',
})
return Response.json({
messageId: result.messageId,
})
},
} satisfies ExportedHandler<Env>
Workers binding 成功时返回一个 messageId。这个结果代表 Cloudflare 接受了发送请求,不应该被解释为用户一定已经在收件箱里看到邮件。最终投递、延迟、退信和抑制状态还要结合 Email Service 日志与指标。
在 Nuxt 服务端 API 中发送
Nuxt 部署到 Workers 后,同样可以从服务端路由读取 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
}
// 实际项目还应在这里执行同源校验、IP/邮箱限流,
// 并在 D1 中原子领取一次发送资格。
const result = await env.EMAIL.send({
from: {
email: env.EMAIL_FROM,
name: 'Example App',
},
to: email,
subject: '验证邮箱',
text: '验证码将在 5 分钟后失效。',
html: '<p>验证码将在 <strong>5 分钟</strong>后失效。</p>',
})
return {
ok: true,
messageId: result.messageId,
}
})
不要把这个示例直接变成一个匿名、无限频率、可自由填写主题和正文的公开 API。那会把自己的域名变成垃圾邮件跳板。

SPF、DKIM 与 DMARC 解决的是“谁有权发送、内容是否被修改、失败时如何处置”,并不保证每封邮件都进入主收件箱。
REST API 与认证 SMTP 什么时候更合适
Cloudflare 目前提供三种发送入口:
| 方式 | 最适合 | 凭证与边界 |
|---|---|---|
| Workers binding | 已运行在 Workers 的 Nuxt、Hono、Agent | 不在代码中保存 API Token,可通过 binding 限制发件人与收件人 |
| REST API | 运行在其他云、CI 或后端平台的程序 | 使用最小权限 API Token,响应会按收件人区分 delivered、queued、permanent bounce |
| 认证 SMTP | 已经使用 Nodemailer、PHPMailer、JavaMail 等 SMTP 客户端的旧系统 | smtp.mx.cloudflare.net:465,仅隐式 TLS,密码是有 Email Sending 权限的 API Token |
认证 SMTP 是 2026 年 6 月加入的 Beta 能力。当前只支持 465 端口的 SMTPS,不提供 587 端口 STARTTLS,也不是可以匿名使用的 25 端口 relay。
SMTP Token 一旦泄漏,持有者可以使用账户中已接入的发件域发送邮件。因此:
- 优先使用 account-owned token;
- 权限只给
Email Sending: Edit; - 不写进仓库、镜像或前端环境变量;
- 不同系统使用不同 Token,便于单独吊销;
- 能用 Worker binding 的场景,不要为了“熟悉 SMTP”额外引入长期凭证。
必须知道的限制
截至 2026 年 7 月,官方平台限制中最值得在代码里提前防守的是:
| 限制 | 当前值 | 实现影响 |
|---|---|---|
| 每域 Email Routing 规则 | 200 | 大量租户不要为每个人创建独立静态规则 |
| 每账户已验证目标地址 | 200 | 转发到多邮箱前先规划账户级目标 |
| 入站邮件大小 | 25 MiB | 更大邮件在 SMTP 阶段被拒绝 |
| 单封出站收件人数 | 50 | To、Cc、Bcc 合计 |
| 普通出站邮件总大小 | 5 MiB | 包含附件与 MIME 编码开销 |
| 发往已验证目标的总大小 | 25 MiB | 仅适用于已验证目标地址 |
| 自定义头总大小 | 16 KB | 不要把业务数据塞进 Header |
| 单个 Zone 的发送/路由域名总数 | 30 | 根域与子域都计入 |
回复的 References 项数 | 100 | 超过后 reply() 会失败 |
附件会经过 Base64 和 MIME 封装。3 MiB 的原始附件不等于最终邮件仍是 3 MiB,所以应用限制应给编码、正文和邮件头预留空间。
平台还会根据账户信誉、投递表现和使用历史调整每日发送额度。不要假设“每月额度未用完”就等于某一秒可以无限并发发送。
验证码与密码重置应该怎样做
本博客的注册和密码重置邮件采用的是下面这条链路:
用户提交邮箱
→ 同源检查
→ IP 与邮箱限流
→ 生成 6 位验证码
→ D1 只保存加盐哈希、用途、过期时间和尝试次数
→ EMAIL binding 发送
→ 验证成功后原子标记已使用
关键策略包括:
- 同一邮箱 5 分钟只能发送一封;
- 验证码 5 分钟后失效;
- 验证码只能使用一次;
- D1 不保存验证码明文;
- 连续输错达到阈值后停止验证;
- 邮件发送失败时释放本次占位,避免用户被一个未送达的验证码锁住;
- 重置密码后撤销已有登录会话,而不是只替换密码哈希。
限流要在数据库中“原子领取资格”,不能写成“先查询上次发送时间,再插入一行”。两个并发请求可能同时通过查询。使用唯一键配合 INSERT ... ON CONFLICT ... WHERE,才能让数据库决定谁获得发送资格。
邮件接口的响应也不应暴露“这个邮箱是否已经注册”。否则攻击者可以批量枚举用户。
一个更完整的生产架构
当邮件从 Demo 进入生产,推荐把职责拆开:
HTTP 请求 / 业务事件
→ 鉴权、同源、参数校验、限流、幂等
→ Queue
→ 邮件模板渲染
→ EMAIL binding
→ messageId / 错误码写入 D1
→ Email Service 日志与告警
入站链路则可以是:
support@yourdomain.com
→ Email Routing
→ Email Worker
→ 认证与大小检查
→ MIME 解析
→ R2 保存附件 / D1 建立工单
→ Queue 做耗时处理
→ 转发人工邮箱或发送受约束回复
D1 建议记录什么
可以记录:
- 业务事件 ID 与幂等键;
- 模板 ID 和模板版本;
messageId;- 收件人数,不一定要保存完整地址;
- accepted / failed 等应用侧状态;
- Cloudflare 错误码;
- 创建、发送和最后更新时间。
不建议默认记录:
- 验证码明文;
- 密码重置 Token;
- 完整邮件正文;
- 附件内容;
- SMTP/API Token;
- 为调试方便长期保留的原始来信。
日志本身也可能成为新的隐私数据源。
不要把 Email Routing 的 “Dropped” 当成发送失败
官方特别说明:通过 send_email binding 发出的邮件,在 Email Routing 汇总页面可能显示为 dropped,即使邮件实际已经成功投递。
出站结果应该查看 Email Sending 的 Metrics 与 Logs,并用 messageId 关联应用日志。Routing 页面描述的是入站路由处理,不是完整的出站投递状态机。
本地怎么测试
Wrangler 已支持本地触发 Email Worker。运行:
pnpm exec wrangler dev
然后向本地入口发送一封原始邮件:
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.'
本地发送 binding 会把文本和 HTML 保存成可检查的文件,适合验证模板。二进制附件的本地模拟仍有单独限制,涉及附件时应补一轮远程测试。
至少覆盖这些用例:
- 正常文本和 HTML;
- 非 ASCII 主题与发件人显示名;
- 重复请求与幂等;
- 超过发送冷却时间前的重复验证码;
- 大正文与附件边界;
- Header 换行注入;
- 未验证发件域或受限收件人;
- 退信、抑制、额度和频率错误;
- 入站无匹配地址、Catch-all 和拒绝路径;
- HTML 邮件中的脚本、远程资源与危险附件名。
它适合什么,不适合什么
适合:
- 注册验证、魔法链接、密码重置;
- 订单、账单、部署、监控通知;
- 个人域名邮件转发;
- 联系表单与支持工单;
- 邮件触发的 Agent 或自动化流程;
- 希望少维护一台 SMTP 服务的 Cloudflare 应用。
不应直接拿来做:
- 未经同意的营销群发;
- 没有退订、投诉和抑制管理的 Newsletter;
- 匿名开放的“帮我发邮件”接口;
- 需要 IMAP 文件夹、日历、通讯录和多人邮箱协作的企业邮箱;
- 把任何来信正文直接交给有高权限工具的 Agent。
Cloudflare 替你维护了邮件入口与投递基础设施,但发件人信誉、用户授权、内容安全、频率控制和数据最小化仍然是应用自己的责任。
最后的判断
Cloudflare Email Service 最有价值的地方,不是“又多了一个 SMTP 厂商”,而是它让邮件和 Worker、D1、R2、Queue、Agents 处于同一运行边界。
接收邮件不再只能转发到人类邮箱,它可以成为一个受约束的事件入口;发送邮件也不再需要为验证码单独接入一个外部 SDK 和密钥体系。
如果项目本来就部署在 Workers,优先从这条最小链路开始:
- Email Routing 接收
support@; - Worker binding 发送验证码和密码重置;
- D1 实现冷却、一次性验证和最小日志;
- sender allowlist 限制 binding 权限;
- 用 Email Sending 日志核对投递,而不是只看接口返回成功。
等确实需要从外部平台发送时,再引入 REST API;只有现有系统已经深度依赖 SMTP 客户端时,才使用认证 SMTP。
留言
登录后加入讨论
你的邮箱不会公开,留言只显示昵称。