联系表单邮件收不到的原因和解决方法

· · 邮件投递, 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-Signatured= 表示,公钥则通过选择器(selector)查询,例如 selector._domainkey.example.com。DKIM 被视为「即使经过转发也相对容易存活」的认证方式,但如果正文或被签名的标头在传输过程中被修改,bh= 的正文哈希或签名验证就会失败。

DMARC 不仅看 SPF 或 DKIM 是否通过,还会看这些已认证的域名是否与 From: 域名一致。这正是关键所在。即使 SPF 通过,如果 MAIL FROM=bounces.vendor.netFrom: 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 域名一致」。联系表单通知并不是营销邮件,但收件端过滤器的基本逻辑是一样的,忽视这个原则送达率就会下降。

收件端 MXDNS发信 MTA / SMTP 服务Web 应用表单用户收件端 MXDNS发信 MTA / SMTP 服务Web 应用表单用户提交表单决定 From / Reply-To / Sender发出 SMTP 发信请求附加 DKIM 签名MAIL FROM / RCPT TO / DATA查询 SPF(MAIL FROM)查询 DKIM 公钥(selector._domainkey)查询 DMARC(_dmarc + From 域名)进行 Alignment 判定收件・进垃圾邮件・拒收・退信

这个流程中最重要的一点是,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,却去动了 SenderFrom。如果只是想把回复对象指向用户,用 Reply-To 就足够了。Sender 是用来明确表示「作者与实际发送主体不同」的标头,并不是联系表单常用的字段。把设计目的分成「想把回复发回用户」和「想标明通知的责任主体」两件事分开考虑,就能减少出错。

诊断步骤与命令

首先该做的是在查看代码之前,先看原始邮件标头。Gmail 可以通过「显示原始邮件」、Outlook 可以通过「邮件详细信息」或「Internet 标头」,确认 Authentication-ResultsReturn-PathFromReply-ToDKIM-SignatureReceived。只要看这里,就能相当精准地判断问题是「根本没发出去」,还是「认证与一致性出了问题」。

标头中最先要看的要点

最优先要看的是这 5 点。

  1. From: 的域名是什么
  2. Return-Path: 的域名是什么
  3. Authentication-Results: 是否显示 spf=pass / dkim=pass / dmarc=pass
  4. dkim=pass 时,header.i=d= 是哪个域名
  5. 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 或共享服务器莫名收不到邮件」的情况做初步诊断。

用法很简单。

  1. 获取 mail-tester 生成的测试地址
  2. 用和联系表单相同的路径发送一封邮件
  3. 查看分数,以及 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.com
Reply-To: visitor@gmail.com
Return-Path: bounce@example.com
几乎所有联系表单 容易通过 DMARC/方便回复/实现简单 忘记设置 Reply-To 的话,回复对象就会变成网站端
子域名分离 From: contact@form.example.com
Reply-To: visitor@gmail.com
Return-Path: bounce.form.example.com
想把表单通知与主邮件分开管理 容易分离信誉/方便管理 也要在子域名端配置好 SPF/DKIM/DMARC
Sender 明示型 From: contact@example.com
Sender: mailer@example.com
Reply-To: visitor@gmail.com
想明确标示发送主体的特殊需求 可以呈现运营责任主体 通常不需要。若作者与发送主体相同就显得多余
不推荐模式 From: visitor@gmail.com
Reply-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.comcontact@form.example.com
  • Reply-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 评价不佳时,可能成为被封锁的原因。

实务上,按这个顺序进行比较安全。

  1. 确认主机商是否能通过管理后台或客服支持配置 SPF/DKIM/PTR
  2. From: 一律固定使用自家域名
  3. 把主机的发信源 IP 或已授权的发信域名加入 SPF
  4. 用主机提供的功能启用 DKIM。如果没有,就改用外部 SMTP
  5. 如果可行,把 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.com
  • Reply-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 阶段拒收。adkimaspf 用来切换 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=passdkim=pass,而且同时dmarc=pass
  • dmarc=fail,是认证失败还是对齐失败(alignment failure)
  • SPF 是否整合为一条记录
  • SPF 的查询次数是否过多
  • DKIM 公钥能否查到
  • DMARC 是否有 p=
  • 发信 IP 的 PTR 与反向解析是否正常
  • 是否使用共享 IP,或该 IP 的信誉是否已经恶化

从退信与日志中查看的地方

如果收到退信,message/delivery-status 格式的 DSN 里的 Final-RecipientStatusActionDiagnostic-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 个条件,就可以说联系表单通知的设计相当稳固。

  1. From: 属于 example.com 之下
  2. Reply-To: 是表单用户的地址
  3. Authentication-Results 中出现 dmarc=pass

只要这 3 点都到位,无论使用 SendGrid、SES、Mailgun、共享主机还是 SMTP 库,设计上都是站得住脚的。反过来说,只要缺了其中一项,最快的方式就是先从 From: 的设计开始怀疑。

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

不依赖特定服务的中小企业群发邮件设计方法

本文整理中小企业在不引入专用EDM服务的前提下,如何借助现有域名与官网搭建小型邮件发送体系:按收件人逐一发送、订阅与抑制名单管理、自动退订流程,以及SPF/DKIM/DMARC与反向PTR、TLS等发信认证的最低要求,帮助企业以数十到数百封规模长期稳定地持续发信。

面向 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表