日期:2026-06-20 公开版说明:本文保留自建 SMTP Relay Server 的搭建顺序、配置形状、验证方法和关键判断,环境相关标识已替换为示例值。 公开代码和配置示例我放在这个已脱敏的 GitHub Gist 里:[自建 SMTP HTTP relay 配置示例(中文)](https://gist.github.com/leconio/0a3f48b860f15616cf7a3ca019e6b07e#file-00-self-hosted-smtp-relay-server-zh-cn-md)。里面的 `server.js` 是从服务器上的 `/opt/coolmeow-mail-relay/server.js` 导出的脱敏版,同时也放了 systemd、Postfix、OpenDKIM、Cloudflare Tunnel、环境变量和 DNS 示例,便于对照每个配置文件应该放在哪里。英文说明在同一个 Gist 的 `01-self-hosted-smtp-relay-server.en.md` 里。 正文为了方便复用,仍然用 `example.com`、`203.0.113.10`、`example-mail-relay` 这些示例名来讲。要看我这台服务器实际把代码和配置放在哪里,就看上面的 Gist;要照着搭自己的机器,只需要把这些名字换成自己的域名、IP、服务目录和发件地址。 这篇按实际搭建顺序写。目标不是做一个公网开放的 SMTP submission 服务,而是在一台 VPS 上搭一个只给自己后端用的小型事务邮件 relay: ```text 业务服务 -> HTTP/HTTPS /send -> Node relay -> 本地队列 -> sendmail -> Postfix -> OpenDKIM -> 收件方 ``` 公网暴露层不是 SMTP relay 的必备组件。你可以用 Nginx、VPN、mTLS、Cloudflare Tunnel,甚至只让同机服务访问 `127.0.0.1`。必备的是:MTA 身份清楚、DNS 认证完整、Postfix 能稳定投递、relay 不变成开放发信接口。 这套配置只负责发信,不负责收信。它不会接管域名 MX,也不会影响原来负责收信的邮箱服务器。用户回复、退信和 DMARC 报告仍然会按 DNS 里的 MX 记录走到原来的收信系统。 从上到下的关系是:先判断这台 VPS 和这个 IP 值不值得拿来发信,再固定发信域名、MTA 主机名和 HTTP relay 入口;这些名字确定后,才能去 DNS 和服务商后台做 A、PTR/rDNS、SPF、DMARC、MX。DNS 身份有了,再在 VPS 上装 Postfix,让它只监听本机;接着接 OpenDKIM,让 Postfix 出站邮件自动签名;然后写一个只监听 `127.0.0.1` 的 HTTP relay,把业务请求变成邮件并交给 sendmail。最后才考虑 systemd 托管、外部入口、收件箱原始邮件头验证和旧 DNS 清理。 ## 第 0 步:先确认这台 VPS 和 IP 能不能发信 这一步还不碰 Postfix,也不改 `/etc/postfix`。先在服务商后台、信誉查询网站和 VPS shell 里判断这台机器是否适合做 direct-to-MX 出站投递。VPS 里最多需要 `dig`、`nc` 这类检查工具;真正要编辑的是服务商后台里的 rDNS/PTR 设置入口。只有确认 IP 信誉、出站 TCP 25 和 PTR/rDNS 都说得过去,后面固定 MTA 主机名才有意义。 不是所有 VPS、云服务器和公网 IP 都适合自建 SMTP。很多云厂商默认封出站 TCP 25;有些机房的 IP 段历史上发过垃圾邮件、跑过代理、挖矿、扫描器,刚拿到手就已经在多个信誉库里很差;还有些服务商不允许改 PTR/rDNS。遇到这些情况,Postfix、DKIM、DMARC 配得再认真,也很难稳定进收件箱。 开机前或刚拿到 IP 后,先查三件事。 第一,查 IP 信誉、滥用记录和欺诈风险。 可以把发信 IP 放到这些工具里查: ```text Spamhaus IP and Domain Reputation Checker https://check.spamhaus.org/ Cisco Talos IP and Domain Reputation Center https://www.talosintelligence.com/reputation_center AbuseIPDB https://www.abuseipdb.com/ Scamalytics IP Fraud Check https://scamalytics.com/ip ``` 这里不要只看“有没有被列入黑名单”。还要看: ```text 这个 IP 是否被标成 proxy / VPN / hosting abuse 高风险 这个 IP 或相邻网段是否有大量 abuse report Talos 里 email reputation 是否异常 Spamhaus 是否命中 SBL / CSS / PBL 等列表 这个 ASN / 机房是否常见于垃圾邮件或扫描流量 ``` 如果 IP 刚买来就有明显 abuse 历史,最省时间的做法通常不是申诉,而是换 IP、换机房或换服务商。事务邮件不需要大带宽,但需要干净的出站身份。 第二,确认出站 25 端口可用。 自建 MTA 直接投递时,Postfix 要从这台 VPS 主动连接收件方 MX 的 TCP 25。这个是出站端口,不是入站端口。 在 VPS 上测: ```sh dig +short MX gmail.com nc -vz gmail-smtp-in.l.google.com 25 ``` 正常时会看到 TCP 连接成功,或者能拿到远端 SMTP banner。失败时常见表现是 timeout、network unreachable、connection refused,或者云厂商直接返回策略拦截。 如果出站 25 被封,先看服务商后台有没有 SMTP unblock、port 25 unlock、mail sending request 之类的申请入口。没有的话,直接问客服: ```text 这台 VPS 的公网 IP 是否允许出站 TCP 25? 是否允许运行自建 MTA 直接投递到收件方 MX? 如果默认封禁,能否为这个账号或这个 IP 解封? ``` 如果服务商政策不允许开放出站 25,就不要继续按“自建 direct-to-MX”路线做。可以换服务商,或者改成使用上游 SMTP relayhost。465/587 能连通不等于 direct-to-MX 可用;465/587 是提交到上游邮件服务的端口,不是大多数收件方 MX 接收服务器之间投递的端口。 第三,确认服务商允许更新 rDNS / PTR。 PTR/rDNS 不在 Cloudflare 这种普通权威 DNS 里改,通常要在 VPS 服务商后台改,因为反向解析区由 IP 地址拥有者控制。你要确认服务商允许把: ```text 203.0.113.10 -> mail.example.com ``` 然后在自己的 DNS 里保证正向解析也闭环: ```text mail.example.com -> 203.0.113.10 ``` 查询方式: ```sh dig +short -x 203.0.113.10 dig +short mail.example.com A ``` 理想结果是: ```text 203.0.113.10 反查到 mail.example.com mail.example.com 正查回 203.0.113.10 ``` 如果服务商不允许改 PTR,或者只能显示一串云厂商默认主机名,这个 IP 仍然可以发邮件,但会明显影响 MTA 身份可信度。做正式事务邮件前,最好换到允许设置 PTR 的 VPS。 ## 第 1 步:先定发信身份 这一步仍然不是安装软件,而是先把后面所有配置会引用的名字定下来。可以先写在部署笔记或 DNS 控制台草稿里:发信域名、默认发件人、MTA 主机名、HTTP relay 入口、VPS 发信 IP。后面 DNS 记录、`/etc/postfix/main.cf`、OpenDKIM 表、relay 环境文件都会反复用这些值。这里没想清楚,后面就会出现 DNS、EHLO、DKIM、DMARC 各说各话的问题。 不要一上来就装 Postfix。先定清楚这些名字: ```text 发信域名:example.com 发件人:[email protected] MTA 主机名:mail.example.com HTTP relay 入口:mail-relay.example.com/send VPS 发信 IP:203.0.113.10 ``` 这里最容易混的是 `mail.example.com` 和 `mail-relay.example.com`。 ```text mail.example.com:SMTP 世界里的 MTA 主机名,Postfix HELO/EHLO 会用它 mail-relay.example.com:HTTP 世界里的 relay 入口,可以换成别的入口方式 ``` 它们可以是两个不同名字。`mail-relay.example.com` 只是给业务服务调用 `/send` 用的;`mail.example.com` 才是收件方在 SMTP 会话、PTR/rDNS 和信誉判断里会看到的 MTA 身份。 ## 第 2 步:先把 DNS 必备项规划好 现在开始落到 DNS 控制台和 VPS 服务商的 rDNS/PTR 后台。这里不需要安装服务器软件,改的是 DNS zone 记录和服务商的反向解析设置;验证时再用 `dig`。A、PTR/rDNS、SPF、DMARC、MX 可以先规划并填写,DKIM 的 TXT 记录要等第 4 步在 VPS 上生成公钥后再补。第 3 步 Postfix 里的 `myhostname` 和 `smtp_helo_name` 必须和这里的 A/PTR 规划一致。 自建 SMTP 能不能进收件箱,关键不在 Node relay,而在 MTA 身份、DNS 认证和历史信誉。下面这些我会按生产必备看待。少一项不一定立刻退信,但很容易进垃圾箱,或者在发信量稍微上来后开始被限流。 ```text A / AAAA:MTA 主机名正向解析到发信 IP PTR / rDNS:发信 IP 反向解析到 MTA 主机名 HELO / EHLO:Postfix 对外自报的主机名,应该和 DNS/rDNS 规划一致 SPF:声明哪些 IP 或服务可以代表 envelope sender 域名发信 DKIM:给邮件内容和关键头做域名签名 DMARC:要求 From 域名和 SPF/DKIM 对齐,并告诉收件方失败时怎么处理 MX:让这个域能接收回复、退信和 DMARC 报告 TLS:SMTP 传输能加密 低投诉率:认证都过也挡不住用户点垃圾邮件 ``` 这些东西不是都在一个地方改。A、SPF、DMARC、MX 在 DNS 控制台;PTR/rDNS 在 VPS 服务商后台;HELO/EHLO 和 TLS 是下一步写进 `/etc/postfix/main.cf` 的 Postfix 配置;DKIM 则要先在 VPS 上生成私钥和公钥,再把公钥贴回 DNS。把这个边界分清楚,后面排查时才不会去错地方。 ### A / AAAA 如果 Postfix 使用: ```text myhostname = mail.example.com smtp_helo_name = mail.example.com ``` 那至少要有: ```text mail.example.com A 203.0.113.10 ``` 如果服务器真的用 IPv6 发信,再加: ```text mail.example.com AAAA 2001:db8::10 ``` 没有配置 IPv6 出站时不要随便加 AAAA。很多“IPv4 正常但邮件偶尔失败”的问题,最后都是因为域名有 AAAA,而 IPv6 的 PTR、DKIM、路由或防火墙没配好。 ### PTR / rDNS PTR 通常不在 DNS 控制台里加,要去 VPS 服务商后台设置。推荐闭环是: ```text 203.0.113.10 -> mail.example.com mail.example.com -> 203.0.113.10 ``` 这叫 forward-confirmed reverse DNS。Google 和 Yahoo 的发件人要求都把有效正向和反向 DNS 当作基础要求。PTR 如果还是云厂商默认名,例如 `vps-203-0-113-10.provider.example`,邮件会更像临时机器发出的,容易被降权。 ### HELO / EHLO Postfix 发信时会在 SMTP 会话里报: ```text EHLO mail.example.com ``` 这个值来自 `myhostname` 或 `smtp_helo_name`。它不一定等于 HTTP relay 入口域名,但必须可解析,最好和 PTR/rDNS 对应。 所以清理旧 DNS 时要小心:如果 `mail.example.com` 仍然是 Postfix 的 HELO/EHLO,就不要直接删掉它的 A 记录。正确做法是二选一: ```text 保留 mail.example.com 的 A/PTR/rDNS 对应 或先把 Postfix myhostname / smtp_helo_name 改到新的 MTA 主机名 ``` ### SPF SPF 检查的是 SMTP envelope sender,也就是常见于 `Return-Path` 的域名。它回答的是: ```text 这台发信 IP 有没有被 example.com 授权发邮件? ``` 示例: ```text example.com TXT "v=spf1 ip4:203.0.113.10 include:_spf.mx.cloudflare.net ~all" ``` 含义: ```text v=spf1:这是一条 SPF 记录 ip4:203.0.113.10:这台 VPS 可以发 include:_spf.mx.cloudflare.net:如果还用 Cloudflare Email Routing/MX 相关服务,也允许它 ~all:其他来源软失败 ``` 如果只允许这台 VPS 发,可以更收紧: ```text example.com TXT "v=spf1 ip4:203.0.113.10 -all" ``` `-all` 更严格,但切之前必须确认没有其他合法发信源。 ### DKIM DKIM 是出站邮件签名。Postfix 通过 OpenDKIM 给邮件加签,收件方再到 DNS 查公钥验证。 示例: ```text mail202606._domainkey.example.com TXT "v=DKIM1; h=sha256; k=rsa; p=<public-key>" ``` `mail202606` 是 selector。selector 可以按年月命名,方便以后轮换密钥。 DKIM 解决的是 SPF 解决不了的问题:只要签名域 `d=example.com` 和用户看到的 `From: [email protected]` 对齐,DMARC 就可以用 DKIM pass 证明这封信属于这个域。 ### DMARC DMARC 看的是用户看到的 `From:` 域名,并要求 SPF 或 DKIM 至少有一个和它对齐。 示例: ```text _dmarc.example.com TXT "v=DMARC1; p=quarantine; pct=100; rua=mailto:[email protected]; adkim=s; aspf=s" ``` 含义: ```text v=DMARC1:这是一条 DMARC 记录 p=quarantine:认证失败时建议收件方隔离,常见结果就是放垃圾箱 pct=100:策略应用到 100% 邮件 rua=mailto:[email protected]:接收聚合报告 adkim=s:DKIM 严格对齐,d= 必须和 From 域一致 aspf=s:SPF 严格对齐,Return-Path 域必须和 From 域一致 ``` `p=none` 只观察,不要求收件方处理失败邮件。生产发信域名建议逐步走到: ```text p=quarantine ``` 确认没问题后再考虑: ```text p=reject ``` 差别很直观: ```text quarantine:失败邮件倾向进垃圾箱 reject:失败邮件倾向在 SMTP 阶段被拒收 ``` 如果要做 BIMI,Gmail 这类收件方通常也要求 DMARC 不能停在 `p=none`。 ### MX 纯出站投递从协议上不一定要求发信域有 MX,但生产域名最好有。 原因是: ```text 用户会回复 [email protected] 退信会发到 Return-Path 或相关地址 DMARC rua 报告要能投递到 [email protected] 很多收件方会把“完全不能收信的 From 域名”视为低质量信号 ``` 示例: ```text example.com MX 10 route1.mx.cloudflare.net. example.com MX 20 route2.mx.cloudflare.net. example.com MX 30 route3.mx.cloudflare.net. ``` MX 可以指向自建收信服务,也可以用托管邮箱或 Email Routing。重点是:发出去的域名不是只能发不能收的空壳。 不要为了这个发信 relay 把 MX 指向这台 VPS,除非你真的准备在它上面配置收信。已有的 Google Workspace、Cloudflare Email Routing、企业邮箱或另一台自建收信服务器都可以继续保留 MX;这个 relay 只走出站投递,不改变入站路径。 ### TLS 和垃圾箱 TLS 不是 DNS 字段,但也是现实投递要求。Postfix 常见配置: ```text smtp_tls_security_level = may smtpd_tls_security_level = may ``` `may` 表示能加密就加密。更严格的 MTA-STS、TLS-RPT、DANE 是进阶项,不是第一天必做,但基础 TLS 不能没有。 SPF、DKIM、DMARC、PTR 都通过,也不保证一定进 Inbox。它们只是入场券。真正是否进垃圾箱,还会看: ```text 发信 IP 历史信誉 域名历史信誉 是否新 IP / 新域名突然放量 Gmail Postmaster Tools 里的 spam rate 用户是否频繁点“举报垃圾邮件” 正文是否像钓鱼或营销垃圾 链接域名是否和 From 域名关系混乱 HTML 是否只有图片没有文字 是否缺少纯文本版本 邮件体是否符合 RFC 5322 营销或批量邮件是否提供退订方式 是否大量发送给不存在的地址 Postfix 是否频繁重试、退信或被远端 4xx/5xx ``` 所以这套 relay 更适合事务邮件:账号安全、工单通知、系统提醒。营销群发最好用专门 ESP,不要让小 VPS 硬扛投诉率和信誉。 ## 第 3 步:配置 Postfix,只做本机出站 MTA 现在才到 VPS shell 里安装和编辑 Postfix。Debian/Ubuntu 上通常安装 `postfix`,必要时加 `mailutils` 或 `bsd-mailx` 方便本机测试。主要编辑 `/etc/postfix/main.cf`,不要为了这套 relay 去打开 `/etc/postfix/master.cf` 里的 submission/smtps。这里要得到的不是公网可登录的 SMTP 服务,而是一个只监听 `127.0.0.1:25`、本机可用 `/usr/sbin/sendmail` 投递的出站 MTA。第 4 步 OpenDKIM 会接到它的 milter,第 5 步 HTTP relay 会调用它的 sendmail 兼容接口。 这台机器的 Postfix 不作为公网收信或公网 submission 服务。更稳的形态是只监听本机: ```text inet_interfaces = loopback-only inet_protocols = ipv4 myhostname = mail.example.com myorigin = $mydomain mynetworks = 127.0.0.0/8 smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination smtp_tls_security_level = may smtpd_tls_security_level = may ``` 这一步先只验证本机 sendmail 能投递。不要先开放公网 25/587 submission。Postfix 的公网 25 出站是它向外投递邮件需要的;公网入站或 submission 不是这套 relay 的必要条件。 这台服务器脱敏后的监听形状是: ```text 127.0.0.1:25 Postfix smtpd 127.0.0.1:2525 Node HTTP relay 127.0.0.1:8891 OpenDKIM milter ``` 注意这里没有: ```text 0.0.0.0:25 0.0.0.0:465 0.0.0.0:587 ``` 也就是说,公网机器不能把邮件投递进这台 Postfix;只有本机 relay 可以通过 sendmail 或本机 SMTP 把邮件交给 Postfix,再由 Postfix 主动连接收件方 MX 发出去。这就是“只能发信,不能收信”的关键。 对应的 Postfix 关键项可以简化成这样: ```text inet_interfaces = loopback-only inet_protocols = ipv4 myhostname = mail.example.com smtp_helo_name = mail.example.com mynetworks = 127.0.0.0/8 relayhost = smtp_tls_security_level = may smtpd_tls_security_level = may smtpd_milters = inet:127.0.0.1:8891 non_smtpd_milters = inet:127.0.0.1:8891 smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination ``` `relayhost =` 留空,表示这台 Postfix 直接按收件人域名查询对方 MX 并出站投递。它不等于“接收这个域名的邮件”。是否收信由 MX 决定,不由这台出站 relay 决定。 真正投递时,relay 会调用: ```sh /usr/sbin/sendmail -t -oi -f [email protected] ``` 这里有几个点: - `-t` 让 sendmail 从邮件头里读取收件人。 - `-oi` 避免正文里单独一行 `.` 被当成结束。 - `-f` 设置 envelope sender,配合 SPF/DMARC 对齐。 ## 第 4 步:接 OpenDKIM Postfix 能本机投递后,再在 VPS 上安装 `opendkim` 和 `opendkim-tools`。这一段跨两个地方:私钥和 OpenDKIM 配置留在 VPS,公钥要回到 DNS 控制台发布。VPS 上通常会编辑 `/etc/opendkim.conf`、`/etc/opendkim/SigningTable`、`/etc/opendkim/KeyTable`、`/etc/opendkim/TrustedHosts`,再回到 `/etc/postfix/main.cf` 把 Postfix 的 milter 指向 OpenDKIM。第 5 步 relay 交给 sendmail 的邮件会先进入 Postfix,再被这个 milter 加上 DKIM 签名。 OpenDKIM 只监听本机 milter 端口: ```text Socket inet:[email protected] ``` Postfix 侧接上 milter: ```text milter_protocol = 6 milter_default_action = accept smtpd_milters = inet:127.0.0.1:8891 non_smtpd_milters = inet:127.0.0.1:8891 ``` OpenDKIM 的表通常长这样: ```text SigningTable: *@example.com -> mail202606._domainkey.example.com KeyTable: mail202606._domainkey.example.com -> example.com:mail202606:/etc/opendkim/keys/example.com/mail202606.private ``` 然后把公钥发布到 DNS: ```text mail202606._domainkey.example.com TXT "v=DKIM1; h=sha256; k=rsa; p=<public-key>" ``` 这一步验证重点是:发出去的邮件头里要有 `DKIM-Signature`,收件方原始邮件头里要看到: ```text dkim=pass [email protected] ``` ## 第 5 步:写一个只监听本机的 HTTP relay 到这里才写应用层 relay。它运行在 VPS 上,需要 Node.js 运行时;如果项目用 TypeScript 或构建工具,再装对应工具链。代码可以放在 `/opt/example-mail-relay/server.js` 或自己的项目目录里,但监听地址仍然只用 `127.0.0.1:2525`。它做的事很窄:接收 `GET /health` 和 `POST /send`,把合法请求转成 MIME message,然后交给 `/usr/sbin/sendmail`。第 6 步会给这个入口加鉴权和输入限制,第 7 步再补队列。 relay 只监听本机: ```text 127.0.0.1:2525 ``` 对外只提供两个接口: ```text GET /health POST /send ``` `/health` 返回 `ok`,用于反向代理、Tunnel、负载检查或运维检查。 `/send` 接收 JSON: ```json { "to": "[email protected]", "from": { "email": "[email protected]", "name": "Example Support" }, "replyTo": "[email protected]", "subject": "Your request has been received", "text": "Plain text body...", "html": "<!doctype html>..." } ``` relay 自己要求的请求头很少: ```text Authorization: Bearer <relay-token> Content-Type: application/json ``` 如果前面还有 Cloudflare Access、mTLS 或别的网关认证,再额外加那一层需要的头或证书。那些是暴露层逻辑,不是 relay 本身必须知道的逻辑。 ## 第 6 步:把 relay 做成安全的小入口 这一节还是改 relay 代码,不是改 Postfix。主要文件仍然是 `/opt/example-mail-relay/server.js`,运行时密钥放在 VPS 本地的 `/etc/example-mail-relay.env`,不要进仓库。一般不需要额外系统软件;如果要用限流、schema 校验或结构化日志库,就作为 relay 项目的依赖安装。这里的目标是让入口即使被网关转发到了,也不能随便伪造 From、无限制提交邮件,或者通过换行注入邮件头。通过这些校验的请求,下一步才允许进入本地队列。 Relay 对输入做几类限制: ```text body 最大 512 KiB token 至少 32 字节 每个来源 IP 每分钟最多 60 次请求 to 必须是合法邮箱 from 必须在白名单里 replyTo 如果存在,也必须是合法邮箱 subject 最长 200 字符 text/html 会按最大 body 限制截断 ``` 发件人白名单类似: ```js const allowedFrom = new Set([ "[email protected]", "[email protected]", "[email protected]" ]); ``` 这条限制很重要。否则任何拿到 relay token 的内部服务都能伪造任意 From,最后会把域名信誉和 DMARC 对齐搞乱。 邮件头也要清洗。`Subject`、`From name`、`Reply-To` 这类字段不能允许原始换行,否则会变成 header injection 风险。 ## 第 7 步:先落盘,再交给 Postfix 这里继续改 relay 项目,不需要额外系统软件。代码还是在 `/opt/example-mail-relay/server.js` 一类的位置,但要在 VPS 上创建真正的队列目录,例如 `/var/spool/example-mail-relay/pending`、`processing` 和 `failed`。这一层把 HTTP 请求和 SMTP 投递解耦:HTTP 只负责校验后入队,后台 worker 再移动 job、调用 sendmail、失败重试。第 8 步把这个 worker 交给 systemd 托管,并限制它能写的目录。 Relay 不要把 HTTP 请求直接阻塞到 SMTP 投递完成,而是先落盘: ```text /var/spool/example-mail-relay/pending /var/spool/example-mail-relay/processing /var/spool/example-mail-relay/failed ``` 写入队列时用临时文件加 rename,避免写一半的 JSON 被后台任务读到。 处理流程: ```mermaid flowchart TD req["POST /send"] validate["校验 token、sender、body"] write["原子写入 pending"] move["移动到 processing"] send["调用 sendmail"] ok["发送成功<br/>删除 job"] retry{"是否还能重试?"} pending["写回 pending<br/>设置 nextAttemptAt"] failed["移动到 failed"] req --> validate validate --> write write --> move move --> send send -->|成功| ok send -->|失败| retry retry -->|是| pending retry -->|否| failed ``` 失败重试用指数退避: ```text 第一次失败:约 30 秒后重试 第二次失败:约 60 秒后重试 之后继续翻倍 最长退避:1 小时 最大尝试次数:8 ``` 这个队列不是为了大规模吞吐,而是为了“不因为 Postfix 短时异常就丢邮件”。小流量事务邮件场景里,串行 drain 更容易排查。 ## 第 8 步:用 systemd 独立用户运行 队列逻辑有了之后,再把它变成系统服务。这一步仍然在 VPS shell 里做;systemd 通常已经在系统里,前提是 Node.js 和 relay 代码已经放好。这里要新增独立用户,例如 `mailrelay`,把密钥写进 `/etc/example-mail-relay.env`,再编辑 `/etc/systemd/system/example-mail-relay.service`。这样 relay 才能重启自恢复、用 `journalctl` 查日志,并通过 `ReadWritePaths` 把可写范围收窄。第 9 步如果要外部访问,也只是把网关转发到这个本机服务,不应该去暴露 Postfix。 Relay 用独立用户运行,例如: ```text User=mailrelay Group=mailrelay ``` systemd 服务做基础收缩: ```text PrivateTmp=true PrivateDevices=true ProtectSystem=strict ProtectHome=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true LockPersonality=true RestrictRealtime=true SystemCallArchitectures=native ``` 只开放必要路径: ```text ReadOnlyPaths=/opt/example-mail-relay /etc/example-mail-relay.env ReadWritePaths=/var/spool/postfix /var/lib/postfix /var/mail /var/spool/example-mail-relay /run /tmp ``` 脱敏后的服务单元关键形状是: ```ini [Service] User=mailrelay Group=mailrelay WorkingDirectory=/opt/example-mail-relay EnvironmentFile=/etc/example-mail-relay.env ExecStart=/usr/bin/node /opt/example-mail-relay/server.js Restart=always PrivateTmp=true PrivateDevices=true ProtectSystem=strict ProtectHome=true NoNewPrivileges=true ReadOnlyPaths=/opt/example-mail-relay /etc/example-mail-relay.env ReadWritePaths=/var/spool/postfix /var/lib/postfix /var/mail /var/spool/example-mail-relay /run /tmp ``` 服务环境文件不提交到仓库,放在 VPS 本地: ```text /etc/example-mail-relay.env ``` 必需项: ```text SMTP_RELAY_TOKEN=<long-random-token> ``` 可选项: ```text QUEUE_ROOT=/var/spool/example-mail-relay QUEUE_MAX_ATTEMPTS=8 SENDMAIL_TIMEOUT_MS=30000 ``` 部署脚本可以进仓库,环境文件不能进仓库。 ## 第 9 步:选择暴露方式 如果只有同机服务调用,到第 8 步其实就够了,业务服务直接请求 `127.0.0.1:2525`。如果要让外部后端调用,才进入这一节。这里改的不是 Postfix,而是网关层:可能是 Nginx 站点配置、VPN allowlist、mTLS 反向代理,也可能是 `/etc/cloudflared/config.yml` 和 Cloudflare Zero Trust 里的 Access 应用、service token。无论选哪种方案,目标都一样:把受控入口转发到 `http://127.0.0.1:2525`,不要开放公网 25/465/587 submission。先在第 10 步本机测通,再用第 11 步从外部测试网关和 Bearer token。 这一步是可选的。如果只有同机服务调用,直接访问 `127.0.0.1:2525` 就够了。 如果要从外部后端调用,可以选择: ```text Nginx + HTTPS + allowlist VPN / Tailscale / WireGuard 内网访问 mTLS 反向代理 Cloudflare Tunnel + Access ``` 如果选择 Cloudflare Tunnel,形态是: ```text https://mail-relay.example.com/send -> http://127.0.0.1:2525/send ``` 如果再加 Cloudflare Access,调用方需要额外带: ```text CF-Access-Client-Id CF-Access-Client-Secret ``` 但核心原则不是“必须用 Cloudflare”,而是:不要把 relay 变成一个裸奔的公网开放发信接口。网关层挡网络入口,Bearer token 挡应用入口,这两层最好不要混成一个东西。 ## 第 10 步:本地验证 先在 VPS 本机验证,不要一上来从公网入口测。这里主要用 `curl`、Postfix 自带的 `postqueue` 和 systemd 自带的 `journalctl`。如果本机 health、sendmail、队列、日志、DKIM 签名都不对,就回到第 3 到第 8 步修;这时去改 Nginx、Cloudflare 或调用方凭据没有意义。只有 `127.0.0.1:2525 -> sendmail -> Postfix -> OpenDKIM` 这条本机链路稳定后,才进入外部入口验证。 先在 VPS 本地验证 relay: ```sh curl -i http://127.0.0.1:2525/health ``` 预期: ```text HTTP/1.1 200 OK ok ``` 再验证 Postfix 队列: ```sh postqueue -p ``` 正常空队列会显示: ```text Mail queue is empty ``` 看 relay 日志: ```sh journalctl -u example-mail-relay.service -n 100 --no-pager ``` 正常发送时应该能看到: ```text queue_enqueued queue_delivered ``` ## 第 11 步:外部入口验证 外部验证要换到调用方视角:本地电脑、CI、另一台后端服务器都可以。这里通常只需要 `curl`,如果用了 Cloudflare Access,还要准备对应的 service token。要改的地方不在 Postfix,而在调用方环境变量、凭据管理处,或者第 9 步的网关 allowlist。测试顺序也要先失败后成功:无网关凭据应该进不来,无 Bearer token 应该 401,错误 From 应该被拒,最后才发一封完整认证的测试邮件。这个测试只说明邮件入队成功,第 12 步还要看收件箱原始邮件头。 如果用了外部入口,要先测失败路径: ```text 没有网关认证:应该被网关拦住 有网关认证但没有 Bearer token:relay 应该返回 401 Bearer token 错误:relay 应该返回 401 from 不在白名单:relay 应该返回 400 ``` 带完整头发送测试邮件: ```sh curl -i https://mail-relay.example.com/send \ -H 'content-type: application/json' \ -H 'Authorization: Bearer <relay-token>' \ --data '{ "to": "[email protected]", "from": {"email": "[email protected]", "name": "Example Support"}, "replyTo": "[email protected]", "subject": "SMTP relay test", "text": "This is a test message." }' ``` 如果暴露层还有自己的认证,例如 Cloudflare Access,再把对应的认证头加上。 成功时 relay 返回: ```json {"ok":true,"queued":true,"id":"<job-id>"} ``` 这个结果只代表入队成功。还要看日志里的 `queue_delivered`,以及 Postfix 队列有没有积压。 ## 第 12 步:看原始邮件头,不只看收到了 这一步不在服务器上改配置,而是在收件箱里看真实结果。用 Gmail 的 Show original、企业邮箱的原始邮件查看器,或者邮件客户端的原始邮件头功能。这里看到 SPF 失败,就回第 2 步查 SPF 和发信 IP;DKIM 失败,就回第 4 步查 OpenDKIM 和 DNS 公钥;HELO/PTR 异常,就回第 2 和第 3 步查 DNS 与 Postfix 主机名;邮件内容或 From 不对,就回第 5 到第 7 步查 relay。只有原始邮件头稳定后,才适合清理旧 DNS。 最后到收件箱里看原始邮件头。重点不是“收到了”,而是这些认证都过: ```text spf=pass [email protected] dkim=pass [email protected] dmarc=pass header.from=example.com ``` 同时确认: ```text 邮件在 Inbox,不在 Spam From 是 [email protected] Reply-To 是 [email protected] Return-Path 和 SPF 域名符合预期 DKIM selector 是当前 selector HTML 和纯文本版本都存在 链接域名和 From 域名不要混乱 ``` 如果 Gmail 显示 `dmarc=pass (p=QUARANTINE)`,说明 DMARC 策略已经是隔离级别,但这封邮件通过认证,所以没有按失败邮件处理。 ## 第 13 步:清理旧 DNS 清理发生在 DNS 控制台,必要时也会动服务商的 rDNS/PTR 后台;不需要安装任何服务器软件。只删除已经不再被 Postfix、DKIM、MX 或业务发信使用的旧记录。删完以后不要直接收工,再抽样发一封邮件,回到第 12 步看原始邮件头,确认没有把仍在使用的 SPF、DKIM selector 或 MTA 主机名删掉。 迁移完成后再清理旧记录,尤其是: ```text 旧 SPF 旧 DKIM selector 旧 _dmarc.mail.example.com 旧的发信子域 ``` 但如果 `mail.example.com` 仍然是 Postfix 的 `myhostname` 或 `smtp_helo_name`,就不要直接删除它的 DNS;要么保留这条主机名记录,要么先把 Postfix 切到新的、可解析的 MTA 主机名。 ## 第 14 步:BIMI 和头像 BIMI 是最后的品牌展示项,不参与 relay 投递链路,也不需要在 relay VPS 上安装软件。要动的是 DNS 控制台里的 `default._bimi.example.com` TXT,以及一个公开可访问的 BIMI SVG。前提是 DMARC 已经走到 `p=quarantine` 或 `p=reject`,需要稳定显示头像时还要准备 VMC 或 CMC 证书。SPF、DKIM、DMARC 没稳定前,不要先把精力放在这里。 DMARC 到 `p=quarantine` 或 `p=reject` 后,可以准备 BIMI: ```text default._bimi.example.com TXT "v=BIMI1;l=https://app.example.com/.well-known/bimi/logo.svg" ``` 但这只是基础记录。Gmail 要稳定显示品牌头像,还需要 VMC 或 CMC 证书。没有证书时,BIMI SVG 可以先准备好,但不要把它当成“头像已经一定显示”的完成状态。 BIMI SVG 要从正式 logo 生成,不要临时手工重画。手工画一个“差不多”的图,放大一看就不像品牌图标。 ## 踩过的坑 下面这些坑不是孤立经验,基本都能对应回前面的某一层。只测 API 200,是跳过了第 10 到第 12 步的队列、日志和原始邮件头验证;From 随便传,是第 6 步白名单没收住;正文塞进命令行,是第 5 步和第 7 步没有把 MIME message 和 job 队列分开;`processing` 目录不能恢复,是第 7 步的队列状态和第 8 步的 systemd 重启流程没闭环;网关认证和 Bearer token 混在一起,则是第 6、9、11 步的边界没分清。 ### 只测 API 200 不够 Relay 返回 202 只代表邮件入队,不代表远端邮箱已经接受。要同时看 relay 日志、Postfix 日志和收件箱原始邮件头。 ### From 不要让调用方随便传 如果 relay 接受任意 From,很快会出现 SPF/DKIM/DMARC 对不齐的问题。更好的做法是固定几个允许的发件人,例如 support、noreply、tickets。 ### 不要把正文塞进命令行参数 邮件正文可能很长,也可能包含 HTML、换行、引号和特殊字符。relay 里把 MIME message 写给 sendmail stdin,比拼 shell 命令安全得多。 ### processing 目录要能恢复 服务重启时,`processing` 里可能有上次没处理完的 job。启动时把它们移回 `pending`,可以避免邮件卡死在中间状态。 ### 网关认证和 Bearer token 是两层东西 网关层负责挡住不该进来的网络请求,Bearer token 负责 relay 应用层鉴权。网关层可以是 Cloudflare Access,也可以是 VPN、mTLS 或反向代理 allowlist。不要只依赖“URL 很隐蔽”。如果 relay 需要被公网服务调用,网关层和应用层 token 最好都保留。 ## 回滚 回滚也按依赖倒序做:先停外部入口,再停 relay,再处理队列,最后才动 Postfix、DKIM 和 DNS。不要一上来删 DNS 或清空队列,否则最难判断哪些邮件已经发出、哪些只是入队。 如果 relay 有问题: ```sh systemctl stop example-mail-relay.service ``` 如果想临时阻止外部调用: ```text 撤销网关凭据 关闭反向代理或 tunnel route 从防火墙或 VPN allowlist 移除调用方 ``` 如果 Postfix 投递有问题: ```text 保留 pending 队列 修复 Postfix / DKIM / DNS 再重启 relay 让队列继续 drain ``` 如果某批邮件不应该再发: ```text 先停止 relay 检查 pending / processing 移动或删除对应 job 再启动 relay ``` 回滚时不要急着删整个队列目录。先看 job 内容、收件人和 subject,再决定是重投、移到 failed,还是删除。 ## 参考 - Postfix 文档:用于理解本机 MTA、队列和 sendmail 兼容接口。 - Spamhaus IP and Domain Reputation Checker:用于检查 IP、域名或哈希是否命中 Spamhaus blocklist,`https://check.spamhaus.org/` - Cisco Talos IP and Domain Reputation Center:用于查询 IP、域名信誉和邮件流量信号,`https://www.talosintelligence.com/reputation_center` - AbuseIPDB:用于查询 IP 是否有公开 abuse report,`https://www.abuseipdb.com/` - Scamalytics IP Fraud Check:用于查询 IP fraud score、proxy、VPN、Tor 等风险信号,`https://scamalytics.com/ip` - Gmail Email sender guidelines:用于核对 SPF/DKIM、PTR/rDNS、TLS、DMARC 和 spam rate 要求,`https://support.google.com/mail/answer/81126` - Yahoo Sender Best Practices:用于核对认证、反向 DNS、投诉率和格式要求,`https://senders.yahooinc.com/best-practices/` - DMARC RFC 7489:用于理解 DMARC 对齐、`p=quarantine` 和 `p=reject`,`https://datatracker.ietf.org/doc/html/rfc7489` - SPF RFC 7208:用于理解 envelope sender 授权,`https://www.rfc-editor.org/rfc/rfc7208` - Cloudflare Tunnel 文档:如果选择 Tunnel,可用于把本机 HTTP relay 暴露成受控 HTTPS 服务。 - Cloudflare Access service token 文档:如果选择 Access,可用于机器到机器访问控制。 - RFC 5322:用于理解邮件头、From、To、Subject、Message-ID 等字段。
文章评论