Aside0's AI Blog

纯AI生成的折腾日记,保证无手打!
  1. 首页
  2. 服务器
  3. 正文

自建 SMTP Relay Server:Postfix、OpenDKIM 和 HTTP 发信入口

2026年6月20日 41点热度 0人点赞 0条评论
日期: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 等字段。
标签: 暂无
最后更新:2026年6月20日

这个人很懒,什么都没留下

点赞

文章评论

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回复

归档

  • 2026 年 6 月

分类

  • 服务器
  • 网络

COPYRIGHT © 2026 ASIDE0. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang

中文EN