← 文章

2026.06.12

把邮箱整个搬进 Cloudflare:收发、存储、推送全自托管的实战记录

目录

我把自己域名的邮箱从托管服务商整体搬到了 Cloudflare 上——收信、发信、存储、阅读界面、手机推送,全部跑在自己的 Cloudflare 账号里。

这篇文章记录完整的架构、关键决策,和一路踩过的坑。

起因:免费托管邮箱的天花板

我的域名邮箱原来托管在 Zoho Mail 免费版。它能用,但限制很实在:

  • 没有 IMAP/POP——只能在它自己的网页和 App 里读信,想把邮件接进自己的系统没有任何官方通道;
  • SMTP 可以发信,但配额和自动化能力都收得很紧;
  • 数据在别人手里,导出是个体力活。

对一个有自己博客系统(Next.js + Cloudflare Workers + D1)的人来说,这越来越别扭:我的文章、图片、项目数据全在自己的 D1/R2 里,唯独邮件是一块租来的飞地。

调研了一圈自建方案——传统 VPS 跑 Postfix/Dovecot(运维重、IP 信誉难养)、Mailcow/Mail-in-a-Box(还是要一台一直开着的机器)——最后发现第三条路:Cloudflare 自己的邮件能力这两年已经悄悄凑齐了一整套积木

架构:五块积木拼一个邮箱

收信  MX → Cloudflare Email Routing(免费)
        └─ catch-all 规则 → Email Worker
             ├─ 查 D1:地址不存在/已停用 → SMTP 550 拒收
             ├─ postal-mime 解析 MIME
             ├─ 附件 → R2,正文/元数据 → D1
             └─ 可选 forward 抄送一份到外部邮箱

发信  自己的 API → Worker 的 send_email 绑定(Email Service)
        └─ Cloudflare 代发,自动签 DKIM

读信  自己博客的 admin 后台:收件箱/会话/搜索/联系人/黑名单

推送  新邮件 → Web Push(PWA)+ APNs(自己的 iOS App)

每一块单看都不新鲜,合起来的化学反应在于:邮件变成了我数据库里的普通数据。会话分组是一条 SQL,全文搜索是一条 LIKE,垃圾黑名单是张表,"团队成员邮箱"是一行记录加一个转发字段。所有在托管邮箱里要"等官方出功能"的事,变成了一个晚上能写完的 CRUD。

收信:catch-all 进 Worker,拒收即管理

Email Routing 负责 MX 层(免费、无限量)。我没有按地址逐条配规则,而是一条 catch-all 全部丢给 Email Worker,由 Worker 查 D1 里的地址表决定收还是拒:

const box = await db.query.mailAddresses.findFirst(
  { where: eq(addresses.address, localPart) }
);
if (!box || !box.active) {
  message.setReject("550 5.1.1 mailbox unavailable");
  return;
}

这个设计有两个好处:

  1. 新建/停用邮箱地址不用碰 Cloudflare 控制台——后台加一行记录就是开通,active 置否就是注销,群发垃圾在 SMTP 层就被 550 弹回去了;
  2. plus-addressing 免费送:me+newsletter@ 自动折叠投递到 me@,原始地址保留在记录上,天然可以追踪谁泄露了你的邮箱。

解析 MIME 用 postal-mime(纯 JS,Workers 里跑得动),附件流式写进 R2,正文和元数据落 D1。值得一提的是 Email Worker 的容错语义很优雅:Worker 抛异常时,发件方的邮件服务器会按 SMTP 规范自动重试——部署炸了邮件也只是延迟,不会丢。

发信:Email Service 和那个所有人都会踩的坑

发信走 Cloudflare Email Service(公测中),Worker 里一个 send_email 绑定,往自己域名外的任意地址发,Cloudflare 代签 DKIM。

这里有个几乎必踩的坑:Email Service 在控制台开通后,必须重新部署一次 Worker,绑定才会真正挂到新服务上。否则发信走的还是旧的"仅限已验证地址"通道——没有 DKIM 签名,邮件大概率直接进收件人的垃圾箱,而且控制台的发送计数纹丝不动(这就是诊断信号:如果"已发送"始终是 0,说明绑定还是旧的)。

另一个测试陷阱:迁移期间从旧邮箱给自己发测试信,同服务商内部投递根本不出公网——测半天 DNS 配置其实什么都没验证。要测就用完全独立的第三方邮箱。

读信:后台里长出一个邮件客户端

cf-mail 收件箱

读信界面直接做在博客的 admin 里。因为邮件就是 D1 里的表,这些"高级功能"实现起来都出奇地直白:

  • 会话分组:按 References 头的根 Message-ID 聚合,一条 group by;

  • 全文搜索:主题/地址/正文 LIKE,对个人邮箱的量级绰绰有余;

  • 联系人:从往来记录自动聚合,不需要单独维护通讯录;

  • 黑名单:联系人表加一个 blocked 字段,收信 Worker 直接标垃圾并跳过转发,拉黑时还能回扫已收的邮件; 邮件详情

  • HTML 正文安全:入库原样存,渲染时过一遍 sanitize,再装进 sandboxed iframe——营销邮件的牛皮癣样式再野也撑不破布局。

推送:Workers 直连 APNs,实测可行

最后一块拼图是手机推送。网页端走标准 Web Push(VAPID 签名在 Worker 里用 WebCrypto 完成)。麻烦的是我还给自己写了个原生 iOS 管理 App,需要 APNs。

网上关于"Cloudflare Workers 能不能直连 APNs"的资料含混不清——APNs 只说 HTTP/2,而 Workers 的出站 fetch 对 HTTP/2 的支持文档着墨不多。实测结论:完全可行。Worker 里用 WebCrypto 签 ES256 的 provider token(缓存 45 分钟),直接 fetch api.push.apple.com,HTTP 200,手机横幅秒到。不需要任何中转服务。

顺带一个会浪费你半小时的细节:App Store Connect 的 API 密钥不能用来签 APNs token——同样是 p8、同样是 ES256,APNs 会返回 InvalidProviderToken。必须在开发者后台单独建一把 APNs 密钥。

于是收信链路的终点变成:邮件落库 → 遍历推送订阅表 → Web Push 和 APNs 按端点前缀分流 → 失效端点(410)自动清理。从邮件进 MX 到手机弹横幅,两三秒。

踩坑清单

现象 解法
Email Service 开通后未重新部署 发信无 DKIM、进垃圾箱、计数为 0 开通后重新部署一次 Worker
同服务商自投递测试 测试"通过"但什么都没验证 用独立第三方邮箱测
默认 User-Agent 调 API Cloudflare 1010 拦截 程序化请求带自定义 UA
ASC 密钥签 APNs InvalidProviderToken 单独建 APNs 密钥
DMARC p=reject 下多渠道发信 其他渠道伪装域名发信全灭 所有发信统一走签 DKIM 的通道
SPF 多条记录 校验失败 合并成一条 include

成本与边界

成本:收信免费(Email Routing 不限量);发信包含在 Workers Paid($5/月)里,每月 3,000 封,对个人远远够;D1/R2 的用量对邮件这点数据可以忽略。我本来就为博客开了 Workers Paid,所以邮箱的边际成本是零。

边界(诚实部分):

  • Email Service 还在公测,单封收件人上限 50,发信附件暂不支持(收信附件无限制);
  • 没有 IMAP——第三方邮件客户端接不进来,读信只能用自己的界面。对我这是特性(界面完全可控),对依赖 Apple Mail/Outlook 工作流的人是硬伤;
  • 多用户、多域名权限体系需要自己造,目前我是单人模式 + 转发字段实现的"团队邮箱"。

写在最后

这套东西最大的收获不是省了订阅费,而是邮件从"别人的服务"变成了"自己系统里的一等公民"。新邮件可以触发任何自动化,AI Agent 可以通过同一个 API 替我发通知邮件,黑名单逻辑想怎么改就怎么改——所有曾经要看服务商脸色的事,现在是一次 commit 的距离。

如果你已经在 Cloudflare 上跑东西,又恰好受够了托管邮箱的限制,这条路值得走:积木都是现成的,拼起来一个周末足够,剩下的只是把它打磨成自己顺手的样子。

相关文章