联系表单邮件收不到的原因和解决方法
· 小村 豪 · 邮件投递, SPF, DKIM, DMARC, 咨询流程优化, B2B
执行摘要
联系表单通知邮件「明明发信成功却收不到」的最大原因,通常不在 SMTP 连接本身,而在于看得见的发件人(From:)与实际被认证的发件人(SPF 的 MAIL FROM / DKIM 的 d=)没有对齐。DMARC 以 RFC5322.From 为基准,检查 SPF 或 DKIM 是否至少有一项带着对齐(alignment)通过。也就是说,把表单提交者的 Gmail 或公司地址直接放进 From: 这种设计,看起来方便回复,实际运行中却很容易失败。
实务上的结论很简单。联系表单通知邮件的 From: 应固定使用自家网站的域名,表单使用者的地址则放进 Reply-To:。Sender: 只在「作者与实际发送主体不同」时才使用,Return-Path 不应手动写进标头,而应在 MTA 或邮件服务端,以信封发件人(envelope sender)的形式设置。
此外,转发容易破坏 SPF,当邮件列表或中继服务器改写了正文或标头,DKIM 也会失效。正因如此,联系表单不能只依赖 SPF、必须同时启用 DKIM,DMARC 也应先以 p=none 观测,再逐步升级到 quarantine / reject,这样才安全。
再者,Google 针对发往 Gmail 的邮件要求:发信域名要配置 SPF 或 DKIM;达到一定发送规模的发件人,SPF、DKIM、DMARC 三项都要齐备;并且From: 标头的域名必须与 SPF 或 DKIM 已认证的域名一致。投递问题的真正原因,通常不是「邮件代码」,而是「发件人设计」。
SPF、DKIM、DMARC 与 From 标头的作用
邮件递送时,必须把信封(envelope)与标头(header)分开来看。SPF 主要验证的是 SMTP 会话过程中的 MAIL FROM,这是递送层面的发件人。最终递送时,这条回程路径应该只保留一个 Return-Path,发送端的 SMTP 系统从一开始就不应该构造带有 Return-Path 标头的消息。另一方面,From: 表示消息标头端「看起来是谁发出的」,Reply-To: 表示回复对象,Sender: 则表示实际发出消息的主体。
RFC 5322 将 From: 定义为消息的作者,将 Sender: 定义为实际发送主体。如果作者与发送主体相同,就不应该使用 Sender:;如果想让回复对象与作者不同,正确做法是使用 Reply-To:。RFC 5322 还明确指出,From: 不应放入非作者本人的地址。联系表单的通知通常不是用户本人通过 MUA 发出的,而是由网站端系统生成的通知,所以把用户地址放进 From: 的设计,也容易偏离规范本来的含义。
DKIM 会对消息的部分标头与正文附加签名,收件端再用 DNS 上的公钥进行验证。签名域名以 DKIM-Signature 的 d= 表示,公钥则通过选择器(selector)查询,例如 selector._domainkey.example.com。DKIM 被视为「即使经过转发也相对容易存活」的认证方式,但如果正文或被签名的标头在传输过程中被修改,bh= 的正文哈希或签名验证就会失败。
DMARC 不仅看 SPF 或 DKIM 是否通过,还会看这些已认证的域名是否与 From: 域名一致。这正是关键所在。即使 SPF 通过,如果 MAIL FROM=bounces.vendor.net 而 From: contact@example.com,SPF 的对齐(alignment)依然会失败。若 DKIM 以 d=example.com 通过,DMARC 就能通过,但如果连 DKIM 也没有,DMARC 就会失败。DMARC 定义了通过 adkim / aspf 决定 strict / relaxed 对齐模式、p=none|quarantine|reject 的策略,以及 rua / ruf 的报告接收地址。
Gmail 近期的指南,直接把这种设计理念落实成了运营要求。Google 明确要求「不要伪造 From: 标头」「直接发送的邮件,From: 标头中的域名必须与 SPF 域名或 DKIM 域名一致」。联系表单通知并不是营销邮件,但收件端过滤器的基本逻辑是一样的,忽视这个原则送达率就会下降。
sequenceDiagram
participant User as 表单用户
participant App as Web 应用
participant SMTP as 发信 MTA / SMTP 服务
participant DNS as DNS
participant MX as 收件端 MX
User->>App: 提交表单
App->>App: 决定 From / Reply-To / Sender
App->>SMTP: 发出 SMTP 发信请求
SMTP->>SMTP: 附加 DKIM 签名
SMTP->>MX: MAIL FROM / RCPT TO / DATA
MX->>DNS: 查询 SPF(MAIL FROM)
MX->>DNS: 查询 DKIM 公钥(selector._domainkey)
MX->>DNS: 查询 DMARC(_dmarc + From 域名)
MX->>MX: 进行 Alignment 判定
MX-->>App: 收件・进垃圾邮件・拒收・退信
这个流程中最重要的一点是,DMARC 的判定基准从头到尾都以 From: 域名为中心。应用端负责 From:、SMTP 端负责 MAIL FROM、DNS 端则分别处理 SPF/DKIM/DMARC,这些是各自独立配置的,只修复其中一项并不能解决不达问题。
联系表单常见的失败场景
最常见的是把表单用户的地址放进 From: 的情况。例如通过网站的 SMTP 发信却写成 From: taro@gmail.com,SPF 或 DKIM 通常通过的是网站端域名,而不是 Gmail 端域名。结果就是 From: 显示 Gmail、已认证域名却是 example.com,这样的落差会导致 DMARC 对齐失败。Google 自身也要求避免伪造 From:,并要求 From: 与 SPF/DKIM 域名一致。
其次常见的是转发导致 SPF 失效的情况。邮件转发时,最终收件人看到的发送源 IP,很容易变成「不在原始发送域名的 SPF 记录里的中继服务器」,因此即使是合法邮件,SPF 也会失败。Google 也建议「转发过的邮件容易导致 SPF 失败,因此务必同时启用 DKIM」。此外,如果中继端对正文、主题加上前缀或附加页脚,连 DKIM 也会失效。
使用外部 SMTP 服务时初始配置不足也是典型情况。SendGrid 若未配置 Domain Authentication,某些情况下就无法发信;开启 Automated Security 后会生成基于 CNAME 的认证记录,需要时也可以配置 Custom Return Path 或 Custom DKIM Selector。SES 默认使用 amazonses.com 的 MAIL FROM,SPF 会隐式成立,但如果想让 SPF 与网站域名对齐,就需要配置自定义 MAIL FROM。Mailgun 也一样,如果发送域名的 SPF/DKIM 与所需的 MX 未配置齐全,就无法建立正确的发送签名。
共享主机的本地 MTA 是容易被忽略的地雷。即使自家网站已完成认证,如果实际发出去的 IP 是评价不佳的共享 IP、没有 PTR,或者主机端没有配置 DKIM,送达率同样会下降。Google 已将发送 IP 的 PTR 列为要求,并说明共享 IP 评价不佳时也可能导致 5.7.1 系列的错误。
PHP 的 mail() 或发信库的用法也很容易出错。PHP 手册说明 mail() 需要 From 标头,并且可以通过额外参数以 sendmail -f 指定信封发件人。也就是说,正确做法不是自己拼装 Return-Path: 标头,而是把信封发件人交给 MTA 处理。一旦误解这一点,SPF 判定所使用的发件人,和应用所设想的发件人就会不一致。
最后,还有一种情况是明明该用 Reply-To,却去动了 Sender 或 From。如果只是想把回复对象指向用户,用 Reply-To 就足够了。Sender 是用来明确表示「作者与实际发送主体不同」的标头,并不是联系表单常用的字段。把设计目的分成「想把回复发回用户」和「想标明通知的责任主体」两件事分开考虑,就能减少出错。
诊断步骤与命令
首先该做的是在查看代码之前,先看原始邮件标头。Gmail 可以通过「显示原始邮件」、Outlook 可以通过「邮件详细信息」或「Internet 标头」,确认 Authentication-Results、Return-Path、From、Reply-To、DKIM-Signature、Received。只要看这里,就能相当精准地判断问题是「根本没发出去」,还是「认证与一致性出了问题」。
标头中最先要看的要点
最优先要看的是这 5 点。
From:的域名是什么Return-Path:的域名是什么Authentication-Results:是否显示spf=pass/dkim=pass/dmarc=passdkim=pass时,header.i=或d=是哪个域名dmarc=fail时,原因是认证本身失败,还是对齐失败(alignment failure)
Google 的 DMARC 故障排查文档也明确指出,即使消息通过了其他认证,只要标头没有对齐,DMARC 依然会失败。
确认 DNS 的命令
dig 是 DNS 故障排查的经典工具,BIND 的手册也把它描述为输出灵活明确的 DNS 查询工具。nslookup 则是可以在非交互模式下使用、更轻量的确认命令。联系表单的认证确认,至少要查询 SPF、DKIM、DMARC 这 3 项。
# SPF
dig +short TXT example.com
# DKIM
dig +short TXT form2026._domainkey.example.com
# DMARC
dig +short TXT _dmarc.example.com
# 如果是 Windows
nslookup -type=txt example.com
nslookup -type=txt _dmarc.example.com
nslookup -type=txt form2026._domainkey.example.com
预期的输出示例大致如下。
"v=spf1 include:sendgrid.net include:mailgun.org ip4:203.0.113.10 -all"
"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ..."
"v=DMARC1; p=none; rua=mailto:dmarc-agg@example.com; ruf=mailto:dmarc-afrf@example.com; adkim=r; aspf=r; fo=1"
这里该检查的是:SPF 记录是否整合为一条、DKIM 公钥能否查到、DMARC 是否有 p=。RFC 7208 规定,会触发 DNS 查询的 SPF 机制总数应限制在 10 个以内,超过就会导致 permerror。
SMTP 连接与 TLS 的确认
openssl s_client 是 OpenSSL 提供的通用 SSL/TLS 客户端,用来确认 SMTP 服务器的 STARTTLS 或证书链非常方便。用 -starttls smtp 可以启动 SMTP 的 STARTTLS,-showcerts 则可以查看服务器返回的证书列表。
printf 'QUIT\r\n' | openssl s_client \
-connect smtp.example.com:587 \
-starttls smtp \
-servername smtp.example.com \
-showcerts \
-brief
输出示例如下。
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Verification: OK
250 CHUNKING
这样至少可以确认:是否能连接到 SMTP 服务器、STARTTLS 是否有效、证书验证是否存在明显异常。Google 要求达到一定规模的发件人必须使用 TLS,未使用 TLS 可能是 5.7.29 错误的原因。
实际发送的复现测试
swaks 是专门用于 SMTP 测试的实用工具,能灵活复现包含 TLS、认证、SMTP 扩展的发送测试。调查联系表单不达问题时,「跳过应用、用相同的 SMTP / 相同的 From / 相同的 Reply-To / 相同的收件人」发送一封邮件来复现,是很有效的做法。
swaks \
--server smtp.example.com \
--port 587 \
--tls \
--auth LOGIN \
--auth-user contact@example.com \
--auth-password '********' \
--from bounce@example.com \
--to yourtest@gmail.com \
--h-From "网站通知 <contact@example.com>" \
--h-Reply-To "张三 <visitor@gmail.com>" \
--header "Subject: swaks test" \
--body "This is a test"
发送成功时的典型情况如下。
=== Trying smtp.example.com:587...
=== Connected to smtp.example.com.
<- 250-STARTTLS
<- 250-AUTH LOGIN PLAIN
-> STARTTLS
<- 220 Ready to start TLS
...
<- 250 2.0.0 Ok: queued as ABC123DEF
如果通过应用发信会失败,但用 swaks 却能成功,原因很可能偏向库的标头拼装方式或信封发件人设置。反过来,如果用 swaks 同样会失败,就可以把范围缩小到 DNS、SMTP、收件端策略的问题。
用 mail-tester 做外部诊断
mail-tester 是一个服务:让你发送一封邮件到随机生成的测试地址,再分析该消息、发信服务器与发信 IP,返回详细报告。适合用来对「本地 MTA 或共享服务器莫名收不到邮件」的情况做初步诊断。
用法很简单。
- 获取 mail-tester 生成的测试地址
- 用和联系表单相同的路径发送一封邮件
- 查看分数,以及 SPF / DKIM / DMARC / 反向解析 / 黑名单 / 正文结构方面指出的问题
不能只靠 mail-tester 的分数来判断正式环境的发送结果,但至少可以及早发现「根本看不到 SPF」「DKIM 公钥查不到」「正文或发件人结构不自然」之类的问题。
标头解析示例
这是失败示例。
Return-Path: <bounce-123@vendor.example.net>
Authentication-Results: mx.google.com;
spf=pass (google.com: domain of bounce-123@vendor.example.net designates 198.51.100.10 as permitted sender) smtp.mailfrom=vendor.example.net;
dkim=none;
dmarc=fail (p=quarantine sp=quarantine dis=none) header.from=gmail.com
From: 张三 <visitor@gmail.com>
Reply-To: 张三 <visitor@gmail.com>
Subject: 咨询
这封邮件虽然 SPF 本身通过,但因为 From: 是 gmail.com,所以 DMARC 失败。这是联系表单把用户地址放进 From: 时,典型的失败模式。
成功示例则是这样。
Return-Path: <bounce@example.com>
Authentication-Results: mx.google.com;
spf=pass smtp.mailfrom=example.com;
dkim=pass header.i=@example.com header.s=form2026;
dmarc=pass header.from=example.com
From: Example Site <contact@example.com>
Reply-To: 张三 <visitor@gmail.com>
Subject: 联系表单通知
用这种写法,可以通过 Reply-To: 保留对用户回复的便利性,同时让看得见的发件人与已认证的发件人都统一在 example.com,送达率会大幅稳定下来。
推荐的 From 设计模式
联系表单不动摇的原则是:让「用于认证的域名」与「呈现给收件人的 From 域名」保持一致。在此基础上,只把回复对象移到 Reply-To:。把设计拆成「Sender: 只在必要时使用」「Return-Path 通过信封设置」这种三层结构,设计就会稳定下来。
| 模式 | 标头示例 | 适合的场景 | 优点 | 注意事项 |
|---|---|---|---|---|
| 推荐模式 | From: contact@example.comReply-To: visitor@gmail.comReturn-Path: bounce@example.com |
几乎所有联系表单 | 容易通过 DMARC/方便回复/实现简单 | 忘记设置 Reply-To 的话,回复对象就会变成网站端 |
| 子域名分离 | From: contact@form.example.comReply-To: visitor@gmail.comReturn-Path: bounce.form.example.com |
想把表单通知与主邮件分开管理 | 容易分离信誉/方便管理 | 也要在子域名端配置好 SPF/DKIM/DMARC |
Sender 明示型 |
From: contact@example.comSender: mailer@example.comReply-To: visitor@gmail.com |
想明确标示发送主体的特殊需求 | 可以呈现运营责任主体 | 通常不需要。若作者与发送主体相同就显得多余 |
| 不推荐模式 | From: visitor@gmail.comReply-To: visitor@gmail.com |
只是想突出回复对象的实现 | 表面上看起来自然 | 容易导致 DMARC 失败,联系表单通知应避免 |
这张表的依据是 RFC 5322 对 From / Sender / Reply-To 的定义,以及 RFC 5321 对 Return-Path 的处理方式,再加上 DMARC 以 From: 为基准判定一致性的规范。表单通知的默认值,用第一行的「推荐模式」就已经足够。即使很想在 From: 放用户地址,只要把回复对象放进 Reply-To:,同样能达成目的。
特别要记住的是,Return-Path 不是「用来编辑的标头」,而是「递送时使用的信封发件人所产生的结果」。PHP mail() 的话用 -f,SMTP 服务的话则通过 Custom MAIL FROM / Return Path / bounce domain 之类的配置项来处理,才是正确的实现方式。
按架构分类的配置指南
接下来,按照现场常见的 3 种模式,整理配置思路。前提是:要写入 DNS 的准确数值,请以各服务管理后台生成的值为准。以下的记录示例,只是用来理解结构的代表性示例。
网站使用外部 SMTP 时
使用外部 SMTP 时最重要的一点,是先完成自家域名的认证。Google 也建议:使用邮件服务提供商时,要确认该服务是否已认证自家域名的 SPF 与 DKIM。
推荐配置
From:使用contact@example.com或contact@form.example.comReply-To:使用表单用户的地址Return-Path/ MAIL FROM 使用自己管理的退信子域名,例如bounce.example.com- DKIM 用
example.com或发信子域名来签名 - DMARC 部署在看得见的
From:域名上
SendGrid 的典型配置
SendGrid 以 Domain Authentication 为前提,开启 Automated Security 后会生成 3 条 CNAME。若关闭,则会生成 1 条 MX 与 2 条 TXT,也可以配置 Custom Return Path 或 Custom DKIM Selector。
; 示例:SendGrid(实际数值请使用管理后台生成的内容)
em123.example.com. CNAME u123456.wl.sendgrid.net.
s1._domainkey.example.com. CNAME s1.domainkey.u123456.wl.sendgrid.net.
s2._domainkey.example.com. CNAME s2.domainkey.u123456.wl.sendgrid.net.
_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc-agg@example.com"
SendGrid 容易踩到的坑,是「只完成 SMTP 认证,域名认证却没有配置」的状态。这种状态下虽然可以发信,但从收件端来看,From: 与已认证域名之间的关联会变弱。基本做法是先通过 Domain Authentication,再根据需要配置 Custom Return Path。
SES 的典型配置
SES 默认使用 amazonses.com 子域名的 MAIL FROM,因此 SPF 本身会隐式成立。不过,如果想让网站域名与 SPF 对齐,就要使用自定义 MAIL FROM。此时 SES 会要求自定义 MAIL FROM 域名必须有 SPF TXT 与 MX,而且 MX 必须恰好只有 1 条。另外,Easy DKIM 会在 DNS 中添加 3 条 CNAME。
; 示例:SES Easy DKIM
abcde12345._domainkey.example.com. CNAME abcde12345.dkim.amazonses.com.
fghij67890._domainkey.example.com. CNAME fghij67890.dkim.amazonses.com.
klmno54321._domainkey.example.com. CNAME klmno54321.dkim.amazonses.com.
; 示例:SES 自定义 MAIL FROM
bounce.example.com. MX 10 feedback-smtp.ap-northeast-1.amazonses.com.
bounce.example.com. TXT "v=spf1 include:amazonses.com -all"
_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc-agg@example.com"
SES 设计上重要的一点是,MAIL FROM 使用的域名不应该直接用发信用的 From: 域名本身,而应使用退信专用的子域名。AWS 也建议 MAIL FROM 使用的子域名,不要与实际发信的域名完全相同。
Mailgun 的典型配置
Mailgun 在验证发信域名时,需要 SPF 用 TXT 与 DKIM 用 TXT,还需要额外新增 2 条 MX。如果已经有 SPF 记录,不要另外新增一条 SPF 记录,而是在现有记录中加入 include:mailgun.org。DKIM 密钥有时会看到多个,但只要当前使用的密钥正确登记在 DNS 上,就能发信。
; 示例:以子域名方式使用 Mailgun
mg.example.com. TXT "v=spf1 include:mailgun.org ~all"
mx._domainkey.mg.example.com. TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ..."
mg.example.com. MX 10 mxa.mailgun.org.
mg.example.com. MX 10 mxb.mailgun.org.
email.mg.example.com. CNAME mailgun.org.
_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc-agg@example.com"
Mailgun 和子域名的运营方式兼容性很好,如果划分出像 mg.example.com 这样的专用发信子域名,就能更方便管理表单通知或交易通知。
网站通过共享主机的本地 MTA 发信时
使用共享主机时,首先该看的不是自己的应用,而是主机端发信基础设施的质量。如果发信 IP 的 PTR、DKIM 支持、共享 IP 的评价、发信日志的可见性偏弱,这种架构本身就很不利。Google 也很重视发信 IP 的 PTR,并说明共享 IP 评价不佳时,可能成为被封锁的原因。
实务上,按这个顺序进行比较安全。
- 确认主机商是否能通过管理后台或客服支持配置 SPF/DKIM/PTR
From:一律固定使用自家域名- 把主机的发信源 IP 或已授权的发信域名加入 SPF
- 用主机提供的功能启用 DKIM。如果没有,就改用外部 SMTP
- 如果可行,把 MAIL FROM 用的退信地址独立出来,例如
bounce.example.com
在共享主机自行创建 DKIM 的典型示例中,OpenDKIM 系列可以用 opendkim-genkey 生成私钥与 DNS 用 TXT 记录。DKIM 公钥放在带有选择器的 selector._domainkey.example.com 这种结构,符合 RFC 6376 的规范。
mkdir -p /etc/opendkim/keys/example.com
opendkim-genkey -D /etc/opendkim/keys/example.com -d example.com -s form2026
chown opendkim:opendkim /etc/opendkim/keys/example.com/form2026.private
chmod 600 /etc/opendkim/keys/example.com/form2026.private
生成后大致会是这样。
form2026._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ..."
# /etc/opendkim/KeyTable
form2026._domainkey.example.com example.com:form2026:/etc/opendkim/keys/example.com/form2026.private
# /etc/opendkim/SigningTable
*@example.com form2026._domainkey.example.com
不过,如果在共享主机上无法自行控制 PTR 或出站 relay,改用外部 SMTP 会是更快的捷径。即使像联系表单通知这种少量发信的场景,认证薄弱的本地 MTA 对 Gmail 或企业邮件系统来说依然不利。
网站使用 PHP mail() 或 SMTP 库时
PHP 的 mail() 很方便,但就认证与送达率而言,取决于「背后接的 MTA 到底是谁」。PHP 手册说明,邮件需要 From 标头,通过 sendmail_path 发信时,可以用额外参数指定信封发件人。反过来说,使用 mail() 并不会自动帮你把 SPF/DKIM/DMARC 整理妥当。
至少先按这样设计。
From:使用contact@example.comReply-To:使用表单用户- 信封发件人使用
bounce@example.com - 正文中也明确写出用户的地址
- 如果可行,尽量不用
mail(),改用已认证的 SMTP
mail() 的最小配置示例
把用户输入直接放进标头是很危险的。如果 $name 或 $email 混入 CR/LF,攻击者就能插入像 Bcc: 这样的额外标头,让表单变成垃圾邮件的中继站。PHP 手册也建议,用在标头中的外部输入必须先验证/规范化。下面的示例中,包括信封发件人参数(additional_params)在内,会混入标头的数值都会事先做过清理(sanitize)。
<?php
// 只返回可以用于标头的值。若包含 CR/LF/NUL 就拒绝。
function sanitize_header_value(string $value): string {
if (preg_match('/[\r\n\0]/', $value)) {
throw new InvalidArgumentException('Invalid characters in header value');
}
return trim($value);
}
// 按 RFC 规范验证电子邮件地址。
function sanitize_email(string $email): string {
$clean = sanitize_header_value($email);
if (!filter_var($clean, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email address');
}
return $clean;
}
$to = 'ops@example.com';
$subject = sanitize_header_value('联系表单通知');
// $name / $email / $message 是表单输入。$message 用于正文,允许 CR/LF;
// 但用在标头的 $name / $email 必须屏蔽 CR/LF。
$safeName = sanitize_header_value($name);
$safeEmail = sanitize_email($email);
$body = <<<TEXT
Name: {$safeName}
Email: {$safeEmail}
{$message}
TEXT;
$headers = [
'From' => 'Example Site <contact@example.com>',
'Reply-To' => sprintf('%s <%s>', $safeName, $safeEmail),
'Content-Type' => 'text/plain; charset=UTF-8',
];
// additional_params 最终也会传给 shell,因此只使用固定值,不混入动态输入。
mail($to, $subject, $body, $headers, '-fbounce@example.com');
这个示例的重点有两个。第一,不是把 Return-Path: 写成标头,而是通过第 5 个参数的 -f 传入信封发件人。第二,混入 Reply-To: 等标头的用户输入,都经过会屏蔽 CR/LF 的清理函数。如果不做清理直接拼装标头,攻击者就能注入像 \r\nBcc: victim@example.com 这样的字符串,插入额外标头,因此 PHP 手册也把「外部输入用于标头时必须验证」列为必要事项。additional_params 最终也会传给 shell,请只使用固定值,不要混入用户输入。
使用 SMTP 库的示例
<?php
$mail->isSMTP();
$mail->Host = 'smtp.example.com';
$mail->Port = 587;
$mail->SMTPAuth = true;
$mail->SMTPSecure = 'tls';
$mail->Username = getenv('SMTP_USER');
$mail->Password = getenv('SMTP_PASS');
$mail->setFrom('contact@example.com', 'Example Site');
$mail->addAddress('ops@example.com');
$mail->addReplyTo($email, $name);
// 根据库的不同,也可以单独配置 Sender / return-path
$mail->Sender = 'bounce@example.com';
$mail->Subject = '联系表单通知';
$mail->Body = $body;
$mail->send();
SMTP 库的优点是,容易把标头发件人与信封发件人分开控制。在联系表单的用途上,最适合的设计就是把 From: 固定为网站域名,只把回复对象放进 Reply-To:。
SPF、DKIM、DMARC 的具体示例
SPF 的基本示例
example.com. TXT "v=spf1 ip4:203.0.113.10 include:sendgrid.net -all"
SPF 必须把实际会用到的所有发信来源都包含进去。使用第三方发信服务时,Google 也要求确认该服务是否已通过 SPF 与 DKIM 完成认证。另外,由于 SPF 的 DNS 查询次数有限制,也要注意不要叠加太多 include。
DKIM 的基本示例
form2026._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ..."
选择器(selector)是用来做密钥轮换的,可以让多个公钥共存在同一个域名上。实务上,比起用 default 这种名称,按用途或年月区分的名称,日后查看会更清楚。
DMARC 的接入示例
先从观测模式开始。
_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc-agg@example.com; ruf=mailto:dmarc-afrf@example.com; adkim=r; aspf=r; fo=1"
接下来,是想隔离一部分的时候。
_dmarc.example.com. TXT "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-agg@example.com; adkim=r; aspf=r"
最后,是严格执行的情况。
_dmarc.example.com. TXT "v=DMARC1; p=reject; sp=reject; rua=mailto:dmarc-agg@example.com; adkim=s; aspf=s"
p=none 表示只做监控,quarantine 表示建议隔离,reject 表示建议在 SMTP 阶段拒收。adkim 与 aspf 用来切换 strict/relaxed 模式。比起一开始就跳到 reject,先用 none 观测流量与合法发送来源,再逐步升级会更安全。
另外,如果要把 rua / ruf 发送到公司外部的聚合服务,根据 RFC 7489,第三方那一侧需要额外的 DNS 记录。例如要把 example.com 的报告发送到 thirdparty.example.net,接收方就必须公开 example.com._report._dmarc.thirdparty.example.net TXT "v=DMARC1"。
故障排查清单
最后整理现场可以直接套用的确认顺序。联系表单的邮件不达问题,从上往下依次排除是最快的做法。
首先要确认的事项
From:是不是自家域名Reply-To:有没有放用户地址Authentication-Results中是否有spf=pass或dkim=pass,而且同时有dmarc=pass- 若
dmarc=fail,是认证失败还是对齐失败(alignment failure) - SPF 是否整合为一条记录
- SPF 的查询次数是否过多
- DKIM 公钥能否查到
- DMARC 是否有
p= - 发信 IP 的 PTR 与反向解析是否正常
- 是否使用共享 IP,或该 IP 的信誉是否已经恶化
从退信与日志中查看的地方
如果收到退信,message/delivery-status 格式的 DSN 里的 Final-Recipient、Status、Action、Diagnostic-Code 很重要。RFC 3464 定义了这类机器可读的递送失败信息。例如若出现 Diagnostic-Code: smtp; 550 relay not permitted 这样的内容,就代表是 SMTP 端的拒绝,而不是应用层的问题。
在服务器端,至少要查看MTA 的投递日志。以 Postfix 为例,会看到投递成功/失败、队列积压、拒绝转发、DNS 解析失败、DKIM milter 的警告信息。Exim 也是一样。这里列出代表性的确认命令。
# 示例:Postfix
journalctl -u postfix -n 200 --no-pager
postqueue -p
# 示例:Exim
exim -bp
不同主机商的日志路径与命令权限可能不同,请先确认能看到的是「邮件投递的日志」,而不是「应用的日志」。如果环境无法看到这部分,改用外部 SMTP 会更容易分析问题。
Gmail 与 Outlook 的查看方式
Gmail 可以通过「显示原始邮件」,微软的 Outlook 则可以通过「邮件详细信息」或「Internet 标头」确认原始标头。调查联系表单不达问题时,比起截图,保存标头全文的文本内容再进行对比会更有效。
判断收件端错误的方法
Gmail 系列的错误,通常可以从代码推断出原因。
| 错误示例 | 含义 | 主要对策 |
|---|---|---|
5.7.27 |
SPF 未通过 | 在 SPF 记录中添加发信来源 |
5.7.30 |
DKIM 未通过 | 修正 DKIM 密钥/签名配置 |
4.7.32 |
From: 与 SPF/DKIM 的组织域名不一致 |
重新审视 From: 的设计 |
5.7.25 |
PTR/反向解析不完善 | 整理发信 IP 的反向解析 |
Google 的 FAQ 也明确列出了这些错误与应对方针。联系表单中特别常见的是 4.7.32 这种对齐不一致的问题。
最终的判断标准
如果同时满足以下 3 个条件,就可以说联系表单通知的设计相当稳固。
From:属于example.com之下Reply-To:是表单用户的地址Authentication-Results中出现dmarc=pass
只要这 3 点都到位,无论使用 SendGrid、SES、Mailgun、共享主机还是 SMTP 库,设计上都是站得住脚的。反过来说,只要缺了其中一项,最快的方式就是先从 From: 的设计开始怀疑。
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
为什么邮件安全领域中PPAP不可行?正确做法是什么?
本文整理把附件压缩成带密码的ZIP、再用另一封邮件发送密码的PPAP为何存在风险,并从实务角度梳理TLS / S/MIME / 带身份验证的下载等现实可行的替代方案。
不依赖特定服务的中小企业群发邮件设计方法
本文整理中小企业在不引入专用EDM服务的前提下,如何借助现有域名与官网搭建小型邮件发送体系:按收件人逐一发送、订阅与抑制名单管理、自动退订流程,以及SPF/DKIM/DMARC与反向PTR、TLS等发信认证的最低要求,帮助企业以数十到数百封规模长期稳定地持续发信。
服务页面该怎么做 - 技术型・B2B 的整理步骤
面向技术型・B2B 企业,整理服务页面的角色、标题、说明文字、CTA、咨询转化路径该如何梳理。
用 PerfView 与 dotnet-trace 定位“变慢”的原因 ── .NET 性能排查实务入门
当业务应用出现“变慢”“CPU 占满”“偶尔卡死”时,该用哪个工具看什么?本文梳理 PerfView 与 dotnet-trace 的分工、CPU 采样的解读方法(inclusive/exclusive)、用 ThreadTime 排查阻塞时间,以及与 EventSourc...
面向 BtoB 企业的 Google 广告小预算运营 ── 用每月数万日元做出成果的设计与每周操作流程
本文面向希望以每月数万日元预算启动 Google 广告的 BtoB 企业,是一份实务指南。内容整理了平均每日预算与结算上限的运作原理、转化跟踪的搭建、聚焦高意向关键词、否定关键词与搜索字词报告的每周运营方法,以及小预算下的出价策略选择,均以 Google 官方资料为依据。
常见问题
汇总了咨询这一主题时常见的问题。
- 联系表单邮件收不到最常见的原因是什么?
- 问题通常不在 SMTP 连接本身,而在于看得见的发件人(From:)与实际被认证的发件人(SPF 的 MAIL FROM / DKIM 的 d=)没有对齐。最常见的情况是把表单使用者的 Gmail 地址直接放进 From:。例如通过网站的 SMTP 发信却写 From: taro@gmail.com,实际被认证的是网站端域名,但 From: 却显示 Gmail,造成落差,导致 DMARC 对齐失败。
- 联系表单通知邮件的 From 应该如何设置?
- 基本原则是 From: 固定使用自家网站的域名,并把表单使用者的地址放进 Reply-To:。这样既能确保方便回复,也能让看得见的发件人与已认证的发件人在域名上保持一致,送达率会更稳定。Sender: 只在「作者与实际发送主体不同」时才使用,Return-Path 不应手动写进标头,而应在 MTA 或邮件服务端设置为信封发件人(envelope sender)。
- 调查邮件不达,第一步该做什么?
- 在检查代码之前,先看收到邮件的原始标头。以 Gmail 为例,可以从「显示原始邮件」确认 Authentication-Results、Return-Path、From、DKIM-Signature。最优先要确认的是 From: 的域名、Return-Path: 的域名,以及 spf / dkim / dmarc 是否都为 pass。若 dmarc=fail,要分辨是认证本身失败,还是对齐失败(alignment failure)。DNS 端则用 dig 或 nslookup 查询 SPF、DKIM、DMARC 这 3 条记录来确认。
- DMARC 可以一开始就设为 reject 吗?
- 比起一开始就设为 reject,先用 p=none 的观测模式确认流量与合法发送来源,再逐步升级到 quarantine、reject 会更安全。p=none 只是监控,quarantine 建议隔离,reject 建议在 SMTP 阶段拒收。此外,转发容易破坏 SPF,中继过程中如果正文或标头被改写,DKIM 也会失效,因此不要只依赖 SPF,务必同时启用 DKIM 也很重要。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。