Resend + Cloudflare 还是自建 Stalwart:我的邮件分工
/ 25 min read
Table of Contents
给自己的域名配邮箱,最省心的是托管服务:Resend 负责程序发信,Cloudflare Email Routing 把来信转到常用邮箱,两边都有免费额度。不过我手头正好有一台年费 10 美元、支持设置 rDNS 的 RackNerd 西雅图 VPS,就借着 AI 的帮助自建了一套 Stalwart:申请 PTR、查黑名单、接网页邮箱,最后还给 Typecho 写了通知和退订插件。
折腾完,我的分工是:对外程序发信用 Resend,个人收信用 Cloudflare 转发,自建 Stalwart 留给内部通知和实验。 这篇记录为什么这样分工,以及搭建时容易混淆的几个问题。
文中自建邮局的域名用
example.com、我的个人域名用blog.com代称,IP 均为文档示例地址;价格和额度以 2026 年 10 月 25 日的官方页面为准。
1. Resend + Cloudflare:把收信和发信分开
这套组合的思路很直接:
- Resend 负责程序发信,应用通过 API 或 SMTP 提交邮件。
- Cloudflare Email Routing 负责收信转发,把发给个人域名的邮件转到已有的常用邮箱。
- 常用邮箱负责保存和阅读邮件,不用额外维护收件箱服务。
sequenceDiagram
accTitle: Resend 与 Cloudflare 的收发分工
accDescr: 发信经 Resend,普通回复或直接来信经 Cloudflare 转发,自动退信交发信服务处理。
participant App as 自建应用
participant R as Resend
participant Other as 对方邮箱
participant CF as Cloudflare Routing
participant Me as 我的常用邮箱
App->>R: API / SMTP 提交通知
R->>Other: 投递邮件,From 使用授权域名
Note over Other,CF: 回复到 Reply-To,未设置时通常使用 From
Other->>CF: 回复或直接写信给 me@blog.com(查询 MX)
CF->>Me: 按规则转发,保存与阅读
opt 投递失败并产生退信
Other-->>R: 发往信封地址,例如 send.blog.com 下的地址
end关键在于:发出去的邮件和别人回给我的邮件,可以经过不同的服务。
这样我不用维护一台面向公网的邮件服务器,也不用自己处理出站 IP 的 PTR、队列和信誉。域名仍然是自己的,程序和个人收件箱也能分开。代价是要遵守服务商的额度,邮件处理依赖第三方;而邮件内容和域名信誉,并不会因为用了托管服务就自动变好。
免费套餐够不够
只算事务邮件和普通转发,域名注册费不计:
| 项目 | 免费额度或限制 |
|---|---|
| Resend 事务邮件 | 每月 3,000 封,每日 100 封,发送和接收共用额度 |
| Resend 域名与收件箱 | 最多 3 个已验证域名、3 个 Inboxes |
| Resend 计数方式 | 发信的多个 To、CC、BCC 收件人分别计数;每日额度按 UTC 自然日重置 |
| Cloudflare Email Routing | 入站邮件数量不设总量上限,普通转发免费 |
| Cloudflare 路由规则 | 每个域名 200 条规则,每个账号 200 个目标地址 |
| Cloudflare 入站邮件大小 | 最大 25 MiB,包含附件 |
来源:Resend 额度说明、Resend 定价、Cloudflare 定价、Cloudflare 限制。
对个人站点,月额度多半够用,每日 100 封反而更容易先碰到,比如一次给很多人发通知。Resend 收到的邮件也占这份额度,不是“每天发 100 封,收信另算”。走 Cloudflare 转到常用邮箱的来信则不经过 Resend。
其实两家都能单独完成收发:Resend 本身支持收信;Cloudflare 的 Email Sending 也能向外部发信,但需要 Workers Paid(每月 5 美元起),包含每月 3,000 封,超出按每千封 0.35 美元计费,只有发给账号内已验证地址才免费。我选这个组合,是因为两家的免费部分正好互补。
2. 搭邮局前要分清的几件事
我之前更熟悉 A、AAAA、CNAME,真正搭邮箱时才把 MX、发件身份和回复路径放到一起理解。
MX 管收信,SMTP 管投递,IMAP/JMAP 管访问邮箱
以 notify@example.com 为例:
example.com. MX 10 mail.example.com.mail.example.com. A 203.0.113.10别人给这个地址写信,发送方查询的是 @ 后面 example.com 的 MX。MX 指向收信主机,再通过这台主机的 A/AAAA 找到地址;10 是优先级,数字越小越优先。MX 本身不会创建 notify 账号,服务器上还得配置这个邮箱或转发规则。
| 协议 | 在这次部署中的用途 |
|---|---|
| SMTP | 应用提交邮件、邮件服务器之间投递邮件 |
| IMAP | 邮件客户端读取和同步服务器上的邮箱 |
| JMAP | 网页客户端通过 HTTPS 访问邮件、同步状态,也可提交发信操作 |
所以 mail.example.com 是服务器地址,notify@example.com 是邮箱身份;一台服务器可以承载多个邮箱,甚至多个邮件域名。
MX 只管收信方向,发信不必用同一家服务。 blog.com 的 MX 可以继续指向 Cloudflare,同时授权 Resend 发信。根域和子域也各有各的 MX:我另外给 agent.blog.com 配了 Resend 收信,个人地址不用跟着迁。
也不要为了让两家同时收信,就把两家的 MX 混着填。MX 优先级描述的是投递顺序,不会按用户名分流;分工应该靠子域或明确的转发规则实现。
回复和退信,去的是两个地方
以一封博客通知为例,关键的几个头是这样的:
From: 博客通知 <notify@blog.com>Reply-To: me@blog.comReturn-Path: <bounce-id@send.blog.com>- From:收件人看到的发件身份。
- Reply-To:建议普通回复发到这里;没有设置时,通常回复到 From。
- MAIL FROM:SMTP 信封发件人,用于接收投递失败等反馈,送达后通常体现为 Return-Path。
Return-Path 由投递系统根据信封生成,应用在邮件头里随手填一个,并不能决定退信去向。邮件格式标准
在这个例子里,对方点“回复”和直接给 me@blog.com 写信,都会查询 blog.com 的 MX,经过 Cloudflare 的同一条转发规则。邮件由 Resend 发出,回复却不会进入 Resend。
如果没有 Reply-To,回复会落到 notify@blog.com,这个地址也必须能收信或转发。能以一个地址发信,不等于这个地址已经能收信。另外,收到转发后从常用邮箱回复时,要留意发件身份;想继续显示自己的域名,就得配置对应的授权发信渠道。
SPF、DKIM、DMARC 分别证明什么
| 配置 | 主要检查什么 |
|---|---|
| SPF | 实际出站 IP 是否获准使用相应的信封域名发信 |
| DKIM | 使用签名域的 DNS 公钥,验证邮件签名及其覆盖内容 |
| DMARC | 通过验证的 SPF 或 DKIM 身份,是否与可见 From 的域名对齐,并声明失败处理策略 |
| PTR / rDNS | 把 IP 反查为主机名;需要由控制反向解析的一方配置 |
SPF 授权写在被使用的域名一侧,由域名管理员修改 DNS,不能只在另一台服务器上声称“我可以代表这个域名”。多个发信来源也应合并成一条 SPF,而不是叠加多条记录。认证原理可参考 Cloudflare 邮件认证说明。
这也解释了为什么 Stalwart 可以替多个域名发信:服务器主机名不必和每个 From 一致,但账号权限、签名和域名授权必须配对。
一台服务器能替别的域名收发吗
可以。mail.example.com 是服务器的名字,blog.com 是它承载的邮件域名,两者不必相同,只要 blog.com 的 DNS 由你或授权你的管理员控制。发信和收信是两件独立的事:
| 想实现的效果 | 发信侧 | 收信侧 |
|---|---|---|
| 只让 Stalwart 代发 | 授权使用 @blog.com 发件身份,配置发件认证 |
blog.com 的 MX 继续交给 Cloudflare 或原邮箱服务 |
| Stalwart 同时收发 | 同上 | blog.com 的 MX 指向 mail.example.com,并创建邮箱或路由 |
| 来信经 Cloudflare 转进 Stalwart | 按实际使用的渠道配置发信 | 保留 Cloudflare MX,设置 me@blog.com → notify@example.com 转发 |
只代发不需要改 MX,但服务器和 DNS 两头都要配好。Stalwart 里先添加 blog.com 域名,再新建 me@blog.com 账号,或把它设为已有账号的别名:默认开启的 mustMatchSender 只允许登录账号用自己拥有的地址作信封发件人(它不检查邮件头里的 From)。DNS 上,在 stalwart._domainkey.blog.com 发布 Stalwart 生成的 DKIM 公钥(selector 别和 Resend 的撞名),把 VPS 的 IP 合并进 blog.com 原有的那条 SPF,再在 _dmarc.blog.com 配置策略,先用 p=none 观察。只有 example.com 的 SPF 通过,证明不了 From: me@blog.com 合法;反过来,只要 DKIM 以 d=blog.com 验证通过,SPF 不对齐也不影响 DMARC 通过。
要让 Stalwart 直接收信,就把 blog.com 的 MX 指向 mail.example.com,再在 Stalwart 里添加这个域名、创建邮箱或路由;PTR 和 SMTP 主机名继续用 mail.example.com 即可。回复去哪仍由回复地址决定:只改 Reply-To,不会改变别人直接写给 me@blog.com 的路径。
还有个容易漏的细节:如果公网 MX 仍在 Cloudflare,Stalwart 却把 blog.com 当作本地域名,发往同域的邮件可能直接走本地投递。只代发时,可以在出站路由的 route 表达式里把 blog.com 单独指向 mx,让它像外部域名一样查公网 MX。
3. 为什么还是搭了 Stalwart
托管组合省事,但我也想自己保存回信、管理账号、观察邮件队列,试试 JMAP,再把几个自建服务的通知集中起来。
机器就是开头那台 RackNerd 西雅图 VPS(下文简称 RN-sea),1 GB 内存,上面还跑着别的服务。我通过工单把 PTR 改成 mail.example.com,再确认正向 A 记录和 SMTP 主机名一致。服务商“支持 rDNS”只是能帮你改,IP 不会因此自带好信誉;25 端口的连通性和 IP 的投递情况仍要单独检查。
我的软件组合
最初部署的是 Mox,它已经能正常收发。后来换到 Stalwart,主要是想试它的协议和管理能力,而不是 Mox 有问题。
| 组件 | 我用它做什么 |
|---|---|
| Stalwart 0.16.27 | SMTP、IMAP、JMAP,邮箱存储、账号和邮件规则 |
| Bulwark Lite | 日常使用的网页邮箱,通过 JMAP 访问 Stalwart |
| 现有 Caddy | 提供静态网页,并反向代理 HTTPS 接口 |
| Cloudflare | DNS、网页入口代理、管理后台 Access |
Bulwark Lite 是纯静态的 HTML/JavaScript,直接复用 Caddy 托管,不用再跑一套 Node.js 或 PHP 的 Webmail 后端。Bulwark 官方介绍
吸引我的是在一套服务里管理协议、账号和权限,再配一个轻量前端读信。1 GB 内存比较紧,我给机器开了 zswap 加磁盘 swap 来缓解;部署前建议先对照 Stalwart 系统要求。
服务凭据分开,回信集中
Cloudreve、Forgejo 和 Typecho 分别使用 cloud@example.com、git@example.com、blog@example.com 发信,各自有独立凭据,来信统一转给 notify@example.com,日常只看一个收件箱。
独立账号方便定位问题和单独重置凭据,但三个账号仍共用出站 IP 和域名,信誉并没有隔离。
年费 10 美元让实验的门槛很低,真正的成本在后面:升级、备份、凭据管理、异常发送检查和投递排障,这些都不在 VPS 账单里。
4. 投递测试:满分和进收件箱是两件事
第一封测试邮件到了 Gmail,但进了垃圾箱。我从 Gmail 回复,自建邮局能正常收到,说明基本收发链路已经通了。
当时 PTR 还是服务商默认值。工单处理后,我确认下面三项一致,再测一次,还是进垃圾箱:
正向解析:mail.example.com → 服务器 IP反向解析:服务器 IP → mail.example.comSMTP 名称:mail.example.com之后我用 MailTester.eu 和 AboutMy.email 检查邮件,当天几次测试的结果如下:
| 检测对象 | 结果 | 如何理解 |
|---|---|---|
| 初次 MailTester.eu 测试邮件 | 9.3/10 | 核心认证通过,缺少 List-Unsubscribe 扣了 0.7 分 |
| AboutMy.email 测试邮件 | 群发最佳实践 7/9 | 缺少退订能力;不是通用信誉分 |
| 后续 Typecho 插件生成的通知 | MailTester.eu 10/10 | 补齐真实退订机制后,这封邮件全部通过 |
| 同次报告的 Rspamd | −1.01,no action | 未达到该测试引擎的处理阈值,不是 Gmail 的评分 |
报告确认了 SPF、DKIM、DMARC 和 PTR,AboutMy.email 还确认这次投递用的是 TLS 1.3。但这些检查替 Gmail 决定不了邮件放在哪。
排查时还发现 IP 在 Fabel 黑名单上,已经提交了移除申请。目前还没确认移除结果,也没有证据表明 Gmail 的分类就是这个名单导致的。
为了退订补了什么
为了让 Typecho 的评论回复通知真正可退订,我写了 BlogMail 插件:访客主动勾选才订阅,通知进入 SQLite 队列并失败重试;邮件带正文退订链接和一键退订头,DKIM 覆盖相关邮件头。
GET 只展示确认页,POST 才修改状态,避免扫描器预先打开链接造成误退订;退订后,发送前的检查也会拦住已经排队的通知。这正是 RFC 8058 要解决的问题,而不是往邮件里塞一个链接就算完。
插件通过了 18 项检查和一次真实退订验证。博客评论目前按我的选择保持关闭,插件先备着,重新订阅的管理界面还没做,具体实现以后另写一篇。
需要注意的是,不应为了凑分,就给密码重置这类账号邮件也套上订阅逻辑。
5. 小黄云的限制与安全边界
Cloudflare 的代理(俗称小黄云)只代理 HTTP/HTTPS,SMTP、IMAP、POP3 过不去。所以作为 MX 目标和客户端连接地址的 mail.example.com 必须是灰云(DNS only):MX 记录本身不代理,它指向的主机名也要保持灰云。Cloudflare 邮件代理说明
但 JMAP、网页邮箱和管理后台都走 HTTPS,可以放在另外的黄云域名下。
最终怎么拆
flowchart TB
accTitle: Stalwart 的三个访问入口
accDescr: 邮件协议通过灰云直连,网页邮箱通过黄云和 Caddy,管理后台额外经过 Access,三路共用同一套 Stalwart。
Mail["邮件服务器 / 邮件客户端"] --> DNS["mail.example.com · 灰云"]
Web["网页邮箱用户"] --> CF["webmail.example.com · 黄云"]
Admin["管理员"] --> Access["admin.example.com · 黄云 + Access"]
subgraph VPS["RN-sea · 同一台 VPS"]
Public["Caddy · Bulwark Lite 静态页与同源邮件 API"]
Management["Caddy · 受信任的管理反代"]
Stalwart["同一套 Stalwart:邮件存储、协议与管理接口"]
Public -->|JMAP / 认证 · 保留访客 IP| Stalwart
Management -->|管理接口 · 限制管理员凭据来源| Stalwart
end
DNS -->|SMTP 25 / 465 · IMAP 993| Stalwart
CF -->|HTTPS · 源站仅接受 Cloudflare 来源| Public
Access -->|HTTPS · 源站仅接受 Cloudflare 来源| Management三个域名背后是同一套 Stalwart,并不是三个邮局。
| 入口 | 代理与访问方式 | 用途 |
|---|---|---|
mail.example.com |
灰云直连 | 入站 SMTP 25、提交 SMTP 465、IMAP 993 |
webmail.example.com |
黄云,经 Caddy | Bulwark Lite 和同源 JMAP、认证接口 |
admin.example.com |
黄云 + Cloudflare Access,经 Caddy | Stalwart 管理后台和管理接口 |
用浏览器访问 mail.example.com 会跳转到网页邮箱,SMTP 和 IMAP 客户端不受影响。
第一次拆分并不彻底:网页邮箱的 API 仍然调用 admin.example.com,给 admin 整站加 Access 就会连带把邮箱挡住。后来把邮件 API 和认证请求移到 webmail.example.com 自己的域名下,两者才真正分开。
中间还试过自建 Turnstile 网关,结果登录时出现 Forbidden 等问题,最后撤掉了。对单人维护的小邮局来说,多一套验证服务的成本太高,最终靠 Stalwart 原生限制、现有的 Caddy 和后台 Access 完成隔离。
我实际加固了哪些地方
- 管理入口整站加 Access。 用 GitHub 或白名单邮箱验证身份,而不是只保护后台首页。
- 限制旁路。 admin 和 webmail 的源站 HTTPS 入口只接受 Cloudflare 网络来源,备用域名只开放必要路径,并验证管理凭据无法经公开邮件 API 绕过边界。
- 处理真实 IP。 公共邮箱入口保留实际访客 IP,只信任来自可信代理的转发头,既不把 Cloudflare 节点当成用户,也防止伪造。
- 收紧 HTTP 与账号权限。 关闭公开监控、SCIM 等用不到的接口;管理员移除普通邮箱收发权限,普通邮箱账号不能调用管理接口。
- 登录失败封禁。 24 小时内失败 10 次,封禁来源 IP 24 小时;SMTP 单会话最多失败 3 次,失败响应延迟 5 秒。
管理入口有个特殊实现:受信任的 Caddy 管理反代以 loopback 来源把请求交给 Stalwart,管理员密码凭据只允许从这个来源使用,管理员的外部身份和 IP 由 Access 记录。这个信任边界依赖源站限制和反代一起成立,“把所有请求都伪装成本机”不能当作通用做法。
这些做法的核心是缩小接口范围和最小权限,而不是加一道验证码了事,可以对照 Stalwart 官方安全指南理解每一层的职责。
还有一点不能忽略:灰云的邮件主机会暴露服务器 IP,小黄云并没有把整台机器藏起来。 SMTP/IMAP 仍然直接面对公网,认证、TLS、限流和防开放中继都要靠邮件服务自己;Access 只保护划定的 HTTPS 管理入口。
最后我确认了后台和网页邮箱都能正常登录、读信,协议认证和匿名中继拒绝检查也通过了。这些是功能和边界检查,加固之后还没有重新做外部投递测试;每个账号的发送额度、服务凭据的来源限制等,也还有完善空间。
最后的分工
| 场景 | 我的分工方向 |
|---|---|
@blog.com 个人收信 |
Cloudflare 转到常用邮箱 |
| 对外程序通知 | 以 Resend 为主 |
@agent.blog.com 收件箱实验 |
Resend |
@example.com 内部通知、邮件实验 |
自建 Stalwart |
这是接下来的方向,不是已经完成的迁移:当天接入 Stalwart 的 Cloudreve、Forgejo、Typecho 目前还在用自建邮局发信,Agent 收件箱也只是建好,还没启用自动处理。
“内部通知”说的是用途:如果邮件发到我自己的 Gmail 或 QQ,仍然算外部投递,照样要过对方的垃圾邮件过滤。
这次最大的收获,是把“邮件发成功了”拆成三层来看:连接和认证正常、对方服务器接受了邮件、收件人真的在收件箱里看到了它。 一层通过只说明那一层通过,250 OK 和测试满分都不能直接跳到最后一层。
一年 10 美元确实能把邮局搭起来,但投递信誉和维护精力不在这 10 美元里。自己管邮件很有意思,值得保留;需要对外稳定发通知时,先让托管服务扛主要工作。两套方案各管一部分,比急着把所有邮件迁到新邮局更适合我现在的需求。