首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >网站邮件总进垃圾箱:SMTP、SPF、DKIM、DMARC 该按什么顺序配,每步怎么验证

网站邮件总进垃圾箱:SMTP、SPF、DKIM、DMARC 该按什么顺序配,每步怎么验证

原创
作者头像
用户10345225
发布于 2026-10-24 14:02:11
发布于 2026-10-24 14:02:11
20
举报

网站的联系表单提交了,客户说没收到;你自己发一封测试信,落到垃圾箱。这时候多数人的第一反应是去搜「怎么配 SPF」,然后在 DNS 里加一条记录,再测,还是进垃圾箱。

问题往往不在某一条记录配错,而在配置顺序错了:你在给一个还没固定下来的发信源做背书。

结论:顺序是 SMTP → SPF → DKIM → DMARC

顺序

这一步解决什么

不做会怎样

主要验证手段

SMTP

信从哪个 IP、以哪个账号身份发出去

出口 IP 漂移、From 域和中继账户不同域,后面三条全部无意义

swaks 发一封,看返回码

SPF

声明「哪些 IP 有权用这个域发信」

收件方查不到授权,直接判 softfail/fail

dig 查 TXT + 收件方头里的 Received-SPF

DKIM

给邮件加密码学签名,证明内容没被改

转发场景下 SPF 必然断,没有 DKIM 就没有第二条命

dig 查公钥 + 头里的 dkim=pass

DMARC

告诉收件方「SPF/DKIM 都对不上时怎么办」,并收报告

前三条白配,别人冒你的域发信你也不知道

dig 查 _dmarc + 解析聚合报告

排序的理由很实在:

  • SMTP 在最前面,因为它决定「发信源」这个事实。发信源没固定,SPF 里写哪个 IP 都是猜。
  • SPF 排第二,因为它是三条认证里唯一「不用签名也能生效」的,改完几分钟就能在收件方的结果里看到,反馈最快。
  • DKIM 排第三,工序最长(生成密钥对、发公钥、配签名器、保管私钥),但它和 SPF 是并列关系,只要有一条通过并能对齐就算过,所以不急着排在 SPF 前面。
  • DMARC 最后,因为它不产生任何认证结果,只是读取 SPF/DKIM 的对齐结果再决定处置。前三条没通就上 DMARC,你只会收到一屏 fail 报告,看不出该修哪。

第 0 步:先搞清楚现在到底是谁在发信

WordPress 的 wp_mail() 默认交给 PHP 的 mail(),PHP 再交给本机的 MTA(邮件传输代理,就是服务器上负责把信投出去的那个后台程序)。共享主机上的本机 MTA 有两个典型问题:出口 IP 没有反向解析,且这个 IP 根本不在你域的 SPF 里。

判断方法:给自己发一封测试信,在收件方里打开「显示原始邮件」,看 Received: 链最上面一跳的 IP 和主机名。如果那台机器你没见过,说明信不是从你以为的地方发出的。

改法是把 wp_mail() 接管到一个固定的 SMTP 中继上。凭据放 wp-config.php,不要硬编码在主题里:

代码语言: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
复制
<?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 对齐就没了。


第 1 步 SMTP:把出口固定下来

这一步有三条路,选一条走通就行,不要三条混着用:

  1. 自己维护上面那段 mu-plugin。凭据在 wp-config.php 里,改配置就是改文件,好排查,适合只有一台站的情况。
  2. 用插件面板填中继的主机、端口、账号。省掉代码,代价是凭据落在数据库里,换站要重新填一遍。
  3. 把「站装到自己的主机上」这件事按项目交出去。AumCreate 的代部署就是这个范围:模板装到你自己的主机、每一页换成你的内容、交付时不留示例文字,先给预览链接、含一轮修改,材料收齐后 5 个工作日上线,报价和范围写在 Setup 页 上。

选第 3 条也要把边界看清楚:域名和主机是你自己付给第三方的,DNS 记录也只有域名持有人(或拿着域名管理权限的人)能改。所以下面 SPF、DKIM、DMARC 这三条记录,最终还是得你自己或你的 DNS 管理员去加。代部署交的是站点,不替你接管域名。

需要从中继服务商那里确认的东西,一共五项,缺一项就先别往下走:

  1. 主机名
  2. 端口(587 或 465)
  3. 加密方式(STARTTLS 还是隐式 TLS)
  4. 认证方式(通常是账号密码,也有用 API key 当密码的)
  5. 允许的发信域,以及它要求的 From 地址格式

验证用 swaks,一条命令就能把 TLS、认证、发信全跑一遍:

代码语言:bash
复制
# 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。
  • 卡住不返回 —— 端口被封。云主机上出方向 25 端口被封很常见,改用 587 或 465,别在 25 上耗。

第 2 步 SPF:声明哪些 IP 有权发信

SPF 就是一条 TXT 记录,写在你的一级域上。形如:

代码语言: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,观察两周再决定要不要收紧。
  • 一个域只能有一条 SPF 记录。两条 v=spf1 同时在,收件方按规范会判 permerror,等于没配。合并成一条。

另外有个硬性上限:SPF 记录在解析过程中引发的 DNS 查询(include、a、mx、ptr、exists、redirect 都算)不能超过 10 次,超过也判 permerror。很多团队是加到第 4 个 include 才炸的。

验证:

代码语言:bash
复制
dig +short TXT example.com | grep -i 'v=spf1'

注意 dig +short 输出的 TXT 可能被拆成多段带引号的字符串,那是 DNS 的 255 字节分段,不是两条记录。人眼看拼起来的总长度,机器校验要按规范无空格拼接。

再看效果:用上面那条 swaks 再发一封,到 Gmail 原始邮件里找这一行:

代码语言:txt
复制
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 是指向一个过时域名,这种情况真实存在。


第 3 步 DKIM:签名

DKIM 是一对密钥:私钥留在发信方做签名,公钥发到 DNS 里让收件方验签。

生成密钥对:

代码语言:bash
复制
openssl genrsa -out dkim.key 2048
openssl rsa -in dkim.key -pubout -out dkim.pub

公钥要处理成一行,去掉 -----BEGIN PUBLIC KEY----- 和 -----END PUBLIC KEY-----,删掉所有换行:

代码语言:bash
复制
# 输出可以直接粘进 DNS 的 base64 串
grep -v -- '-----' dkim.pub | tr -d '\n' ; echo

然后在 DNS 加一条 TXT:

代码语言: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。如果面板不自动分段,手动写成多个引号串:

代码语言:txt
复制
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..." "...剩下的部分"

签名器有两条路:让中继商代签(多数 SMTP 服务商提供,你只要把它给的公钥贴进 DNS),或者自建 OpenDKIM(本机装、配置选择器和私钥路径、接到 MTA 上)。能用代签就用代签,少一个需要维护的进程和一只要保管的私钥。

验证公钥是否发布正确:

代码语言:bash
复制
dig +short TXT mail._domainkey.example.com

拼出来的 base64 必须和 dkim.pub 去掉头尾换行后的内容逐字符一致。差一个字符就验不过。

如果本机装了 opendkim-tools,可以在服务器侧直接自检密钥对是否匹配:

代码语言:bash
复制
opendkim-testkey -d example.com -s mail -k /etc/dkim/mail.private -vvv

最终验证还是看收件方。发一封信,在原始邮件里找:

代码语言:txt
复制
dkim=pass header.i=@example.com header.s=mail header.b="..."

如果是 dkim=none,说明这封信压根没带签名,问题在签名器没启用 / 没走中继。如果是 dkim=permerror 或 dkim=fail,问题在 DNS 里的公钥。


第 4 步 DMARC:策略和对齐

DMARC 记录加在 _dmarc.你的域 上:

代码语言:txt
复制
_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 过没过」,而是:

  • SPF 校验用的那个域(信封发件人,smtp.mailfrom,也就是 Return-Path 里的域),和你邮件头里用户看到的 From: 域,是不是同一个域;
  • DKIM 签名里的 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。两种解法:

  1. 在中继商后台配置自定义退信域(Custom Return-Path / CFL),把信封发件人改成 bounce.example.com,并在 SPF 里加上它要求的记录;
  2. 保证 DKIM 用你的域签名(d=example.com),靠 DKIM 这一条对齐通过。这条路更省事,也是多数人实际走的路。

上线节奏,不要一步到位:

阶段

记录

目的

第 1 周

p=none + rua=

只收报告,不处置。看清楚到底有哪些 IP 在用你的域发信

第 2–3 周

p=quarantine; pct=25

小比例进隔离,观察有没有业务信被误伤

第 4 周起

p=quarantine; pct=100

全量隔离

稳定后

p=reject

直接拒收伪造信

有子域要单独管控时用 sp=(给子域的策略),p= 只作用于当前域。pct 是策略生效的百分比,缺省 100。

验证记录本身:

代码语言:bash
复制
dig +short TXT _dmarc.example.com

验证效果,看原始邮件里这一行:

代码语言:txt
复制
dmarc=pass (p=NONE sp=NONE) header.from=example.com

收报告并读报告

rua= 指定的邮箱会收到聚合报告,通常是 .xml.gz,有时外层还套一层 zip。写个脚本把来源 IP 和处置结果列出来,比一封封点开看高效得多:

代码语言:python
复制
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 的记录(这种多半是对齐问题,不是认证问题)。


一次性自检:四条记录一起看

代码语言:bash
复制
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 也是白写):

代码语言:bash
复制
dig +short TXT spf.relay-provider.com

排错对照表

收件方头里的现象

先查什么

常见原因

spf=fail / softfail

Received-SPF 里的 client-ip

出口 IP 不在 SPF;用了本机 MTA;SPF 有两条;include 超过 10 次查询

dkim=none

邮件里有没有 DKIM-Signature 头

签名器没启用;没走中继;中继的代签没开

dkim=permerror

dig 查到的 p=

公钥被面板截断;分段没拼对;公私钥不是一对

dkim=fail

签名头里的 d= 和 s=

选择器写错;DNS 还没生效;内容被中间环节改过

spf=pass 但 dmarc=fail

header.from 与 smtp.mailfrom 的域

不对齐。改自定义退信域,或让 DKIM 用主域签

四条全 pass,还是进垃圾箱

内容、链接、投诉率、域名使用历史

这已经不是认证问题,是声誉问题

最后一句实话:认证做全,只保证你不会因为「身份不明」被拦,不保证进收件箱。内容里的短链、图片与文字比例、收件人有没有点过「举报垃圾邮件」、这个域名是不是昨天刚注册,都在同一套评分里。

所以顺序别搞反:先把发信源钉死,再授权,再签名,最后才谈策略。前一步没验证通过就往下配,后面每一步的失败信息都会指向错误的地方,你会一直修错东西。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 结论:顺序是 SMTP → SPF → DKIM → DMARC
  • 第 0 步:先搞清楚现在到底是谁在发信
  • 第 1 步 SMTP:把出口固定下来
  • 第 2 步 SPF:声明哪些 IP 有权发信
  • 第 3 步 DKIM:签名
  • 第 4 步 DMARC:策略和对齐
    • 收报告并读报告
  • 一次性自检:四条记录一起看
  • 排错对照表
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档