skip to content
Liu Yang's Blog
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.com
Return-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.com
SMTP 名称: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 美元里。自己管邮件很有意思,值得保留;需要对外稳定发通知时,先让托管服务扛主要工作。两套方案各管一部分,比急着把所有邮件迁到新邮局更适合我现在的需求。