网站的联系表单提交了,客户说没收到;你自己发一封测试信,落到垃圾箱。这时候多数人的第一反应是去搜「怎么配 SPF」,然后在 DNS 里加一条记录,再测,还是进垃圾箱。
问题往往不在某一条记录配错,而在配置顺序错了:你在给一个还没固定下来的发信源做背书。
顺序 | 这一步解决什么 | 不做会怎样 | 主要验证手段 |
|---|---|---|---|
SMTP | 信从哪个 IP、以哪个账号身份发出去 | 出口 IP 漂移、From 域和中继账户不同域,后面三条全部无意义 |
|
SPF | 声明「哪些 IP 有权用这个域发信」 | 收件方查不到授权,直接判 softfail/fail |
|
DKIM | 给邮件加密码学签名,证明内容没被改 | 转发场景下 SPF 必然断,没有 DKIM 就没有第二条命 |
|
DMARC | 告诉收件方「SPF/DKIM 都对不上时怎么办」,并收报告 | 前三条白配,别人冒你的域发信你也不知道 |
|
排序的理由很实在:
WordPress 的 wp_mail() 默认交给 PHP 的 mail(),PHP 再交给本机的 MTA(邮件传输代理,就是服务器上负责把信投出去的那个后台程序)。共享主机上的本机 MTA 有两个典型问题:出口 IP 没有反向解析,且这个 IP 根本不在你域的 SPF 里。
判断方法:给自己发一封测试信,在收件方里打开「显示原始邮件」,看 Received: 链最上面一跳的 IP 和主机名。如果那台机器你没见过,说明信不是从你以为的地方发出的。
改法是把 wp_mail() 接管到一个固定的 SMTP 中继上。凭据放 wp-config.php,不要硬编码在主题里:
// wp-config.php
define( 'AUM_SMTP_HOST', 'smtp.example.com' );
define( 'AUM_SMTP_PORT', 587 );
define( 'AUM_SMTP_USER', 'postmaster@example.com' );
define( 'AUM_SMTP_PASS', '在此填密码' );
define( 'AUM_SMTP_FROM', 'no-reply@example.com' );
define( 'AUM_SMTP_FROM_NAME', 'Example Co' );建目录 wp-content/mu-plugins/(没有就新建),放一个 smtp.php:
<?php
// wp-content/mu-plugins/smtp.php
add_action( 'phpmailer_init', function ( $phpmailer ) {
if ( ! defined( 'AUM_SMTP_HOST' ) ) {
return;
}
$phpmailer->isSMTP();
$phpmailer->Host = AUM_SMTP_HOST;
$phpmailer->Port = (int) AUM_SMTP_PORT;
$phpmailer->SMTPAuth = true;
$phpmailer->Username = AUM_SMTP_USER;
$phpmailer->Password = AUM_SMTP_PASS;
// 465 走隐式 TLS,587 走 STARTTLS
$phpmailer->SMTPSecure = ( 465 === (int) AUM_SMTP_PORT ) ? 'ssl' : 'tls';
$phpmailer->setFrom( AUM_SMTP_FROM, AUM_SMTP_FROM_NAME, false );
} );
add_action( 'wp_mail_failed', function ( $error ) {
error_log( '[wp_mail_failed] ' . $error->get_error_message() );
} );这里有个必须守住的约束:AUM_SMTP_FROM 的域名,必须和中继账户的域名一致(或至少是中继服务商允许的发信域)。用中继商的账号去发 @你的域名,多数服务商会直接拒,或者强行改写 From,改写之后 DMARC 对齐就没了。
这一步有三条路,选一条走通就行,不要三条混着用:
wp-config.php 里,改配置就是改文件,好排查,适合只有一台站的情况。选第 3 条也要把边界看清楚:域名和主机是你自己付给第三方的,DNS 记录也只有域名持有人(或拿着域名管理权限的人)能改。所以下面 SPF、DKIM、DMARC 这三条记录,最终还是得你自己或你的 DNS 管理员去加。代部署交的是站点,不替你接管域名。
需要从中继服务商那里确认的东西,一共五项,缺一项就先别往下走:
验证用 swaks,一条命令就能把 TLS、认证、发信全跑一遍:
# 587 / STARTTLS
swaks --to you@gmail.com \
--from no-reply@example.com \
--server smtp.example.com:587 \
--auth LOGIN \
--auth-user postmaster@example.com \
--auth-password '你的密码' \
--tls
# 465 / 隐式 TLS
swaks --to you@gmail.com \
--from no-reply@example.com \
--server smtp.example.com:465 \
--auth LOGIN \
--auth-user postmaster@example.com \
--auth-password '你的密码' \
--tlsc返回码怎么读:
250 2.0.0 Ok: queued as ... —— 中继收下了,这一步通了。535 —— 账号密码错,或者该账号没开 SMTP 权限。550 / 553 —— From 域不被允许,回去改 AUM_SMTP_FROM。SPF 就是一条 TXT 记录,写在你的一级域上。形如:
example.com. TXT "v=spf1 include:spf.relay-provider.com include:_spf.another-provider.net ~all"三个要点:
include: 后面的域名必须从中继商文档里原样抄,不要自己拼。~all 和 -all 的区别是处置力度。~all 是「对不上就标记为可疑」(softfail),-all 是「对不上就直接判失败」(fail)。刚开始配、还没摸清有哪些系统在用你的域发信(监控告警、CRM、工单、OA 都会发),先用 ~all,观察两周再决定要不要收紧。v=spf1 同时在,收件方按规范会判 permerror,等于没配。合并成一条。另外有个硬性上限:SPF 记录在解析过程中引发的 DNS 查询(include、a、mx、ptr、exists、redirect 都算)不能超过 10 次,超过也判 permerror。很多团队是加到第 4 个 include 才炸的。
验证:
dig +short TXT example.com | grep -i 'v=spf1'注意 dig +short 输出的 TXT 可能被拆成多段带引号的字符串,那是 DNS 的 255 字节分段,不是两条记录。人眼看拼起来的总长度,机器校验要按规范无空格拼接。
再看效果:用上面那条 swaks 再发一封,到 Gmail 原始邮件里找这一行:
Received-SPF: pass (google.com: domain of no-reply@example.com designates 203.0.113.10 as permitted sender) client-ip=203.0.113.10;如果显示 softfail 或 fail,把 client-ip 后面的地址拿去和中继商公布的出口 IP 段对比。中继商换了 IP 段而你的 include 是指向一个过时域名,这种情况真实存在。
DKIM 是一对密钥:私钥留在发信方做签名,公钥发到 DNS 里让收件方验签。
生成密钥对:
openssl genrsa -out dkim.key 2048
openssl rsa -in dkim.key -pubout -out dkim.pub公钥要处理成一行,去掉 -----BEGIN PUBLIC KEY----- 和 -----END PUBLIC KEY-----,删掉所有换行:
# 输出可以直接粘进 DNS 的 base64 串
grep -v -- '-----' dkim.pub | tr -d '\n' ; echo然后在 DNS 加一条 TXT:
记录名: mail._domainkey.example.com
类型: TXT
值: v=DKIM1; k=rsa; p=<上一步输出的 base64>mail 是选择器(selector),名字自己定,但必须和签名器配置里的选择器完全一致,否则收件方按 mail._domainkey 去查,查不到就是 dkim=none。
这里有个高频坑:2048 位密钥的 p= 值大约 400 个字符,而 DNS 单段字符串上限是 255 字符。多数 DNS 服务商的面板会自动帮你拆成多段,但有些面板会直接报错或者悄悄截断。截断的后果是 dkim=permerror。如果面板不自动分段,手动写成多个引号串:
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..." "...剩下的部分"签名器有两条路:让中继商代签(多数 SMTP 服务商提供,你只要把它给的公钥贴进 DNS),或者自建 OpenDKIM(本机装、配置选择器和私钥路径、接到 MTA 上)。能用代签就用代签,少一个需要维护的进程和一只要保管的私钥。
验证公钥是否发布正确:
dig +short TXT mail._domainkey.example.com拼出来的 base64 必须和 dkim.pub 去掉头尾换行后的内容逐字符一致。差一个字符就验不过。
如果本机装了 opendkim-tools,可以在服务器侧直接自检密钥对是否匹配:
opendkim-testkey -d example.com -s mail -k /etc/dkim/mail.private -vvv最终验证还是看收件方。发一封信,在原始邮件里找:
dkim=pass header.i=@example.com header.s=mail header.b="..."如果是 dkim=none,说明这封信压根没带签名,问题在签名器没启用 / 没走中继。如果是 dkim=permerror 或 dkim=fail,问题在 DNS 里的公钥。
DMARC 记录加在 _dmarc.你的域 上:
_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com; adkim=r; aspf=r; pct=100"这里必须理解一个概念,否则你会一直困惑「明明 SPF 和 DKIM 都 pass 了,为什么 DMARC 还是 fail」——对齐(alignment)。
DMARC 检查的不是「SPF 过没过」,而是:
smtp.mailfrom,也就是 Return-Path 里的域),和你邮件头里用户看到的 From: 域,是不是同一个域;d= 域,和 From: 域,是不是同一个域。只要其中一项对齐且通过,DMARC 就算 pass。adkim=r / aspf=r 是宽松模式(relaxed),允许子域匹配(mail.example.com 对 example.com 算对齐);s 是严格模式,必须完全相同。默认就是 r,不用写。
最常见的对齐失败场景:中继商默认用 bounce.relay-provider.com 做信封发件人。这时 SPF 是通过的(对 relay-provider.com 通过),但对你的域没对齐,DMARC 照样 fail。两种解法:
bounce.example.com,并在 SPF 里加上它要求的记录;d=example.com),靠 DKIM 这一条对齐通过。这条路更省事,也是多数人实际走的路。上线节奏,不要一步到位:
阶段 | 记录 | 目的 |
|---|---|---|
第 1 周 |
| 只收报告,不处置。看清楚到底有哪些 IP 在用你的域发信 |
第 2–3 周 |
| 小比例进隔离,观察有没有业务信被误伤 |
第 4 周起 |
| 全量隔离 |
稳定后 |
| 直接拒收伪造信 |
有子域要单独管控时用 sp=(给子域的策略),p= 只作用于当前域。pct 是策略生效的百分比,缺省 100。
验证记录本身:
dig +short TXT _dmarc.example.com验证效果,看原始邮件里这一行:
dmarc=pass (p=NONE sp=NONE) header.from=example.comrua= 指定的邮箱会收到聚合报告,通常是 .xml.gz,有时外层还套一层 zip。写个脚本把来源 IP 和处置结果列出来,比一封封点开看高效得多:
import gzip, glob, zipfile, os
import xml.etree.ElementTree as ET
NS = {'r': 'urn:ietf:params:xml:ns:dmarc-1'}
def load(path):
if path.endswith('.zip'):
with zipfile.ZipFile(path) as z:
for name in z.namelist():
yield z.read(name)
else:
yield gzip.open(path, 'rb').read()
for f in sorted(glob.glob('*.xml.gz') + glob.glob('*.zip')):
for raw in load(f):
root = ET.fromstring(raw)
pub = root.find('r:policy_published', NS)
print(f"{os.path.basename(f)} domain={pub.find('r:domain', NS).text} p={pub.find('r:p', NS).text}")
for rec in root.findall('r:record', NS):
row = rec.find('r:row', NS)
ev = row.find('r:policy_evaluated', NS)
dkim = ev.find('r:dkim', NS)
spf = ev.find('r:spf', NS)
print(' ', row.find('r:source_ip', NS).text,
'count=' + row.find('r:count', NS).text,
'disposition=' + ev.find('r:disposition', NS).text,
'dkim=' + (dkim.text if dkim is not None else 'none'),
'spf=' + (spf.text if spf is not None else 'none'))读报告时重点看两件事:出现频率高的陌生 IP(可能是你自己的监控或 CRM 没加进 SPF),以及 disposition=quarantine 但 dkim=pass 的记录(这种多半是对齐问题,不是认证问题)。
DOMAIN=example.com
SELECTOR=mail
echo '== SPF ==' ; dig +short TXT "$DOMAIN" | grep -i 'v=spf1'
echo '== DKIM ==' ; dig +short TXT "$SELECTOR._domainkey.$DOMAIN"
echo '== DMARC ==' ; dig +short TXT "_dmarc.$DOMAIN"
echo '== MX ==' ; dig +short MX "$DOMAIN"顺便确认 SPF 里 include 的域是不是真有记录(空的 include 也是白写):
dig +short TXT spf.relay-provider.com收件方头里的现象 | 先查什么 | 常见原因 |
|---|---|---|
|
| 出口 IP 不在 SPF;用了本机 MTA;SPF 有两条; |
| 邮件里有没有 | 签名器没启用;没走中继;中继的代签没开 |
|
| 公钥被面板截断;分段没拼对;公私钥不是一对 |
| 签名头里的 | 选择器写错;DNS 还没生效;内容被中间环节改过 |
|
| 不对齐。改自定义退信域,或让 DKIM 用主域签 |
四条全 pass,还是进垃圾箱 | 内容、链接、投诉率、域名使用历史 | 这已经不是认证问题,是声誉问题 |
最后一句实话:认证做全,只保证你不会因为「身份不明」被拦,不保证进收件箱。内容里的短链、图片与文字比例、收件人有没有点过「举报垃圾邮件」、这个域名是不是昨天刚注册,都在同一套评分里。
所以顺序别搞反:先把发信源钉死,再授权,再签名,最后才谈策略。前一步没验证通过就往下配,后面每一步的失败信息都会指向错误的地方,你会一直修错东西。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。