目录
一、现场:SMTP 返回 250,不等于邮件进了收件箱
应用日志里是一行让人安心的记录:
250 2.0.0 Ok: queued as 4Xy2...用户说没收到。你去问,邮件在垃圾箱里;或者更糟,它根本没有被投递,而是在 DATA 结束后被收件方的过滤器拒了,只是拒信没有回到你的应用日志里。
250 的含义是「收件方的 MTA 愿意接过这封信」,不是「这封信会出现在收件箱」。从接受到投递,收件方至少还要做三层判断:
- 认证:这封信是不是真的由它声称的域名发出?SPF、DKIM、DMARC 在这一层工作。
- 声誉:这个 IP、这个域名过去发过什么?投诉率、退信率、发送量曲线是否正常?
- 内容:正文、链接、附件、图片比例,以及收件人是否愿意读。
这篇文章只讲第一层,因为它是少数能靠 DNS 配置和代码改清楚的部分。但「三层」这个划分本身很重要:认证通过不保证进收件箱,认证失败也不一定立刻被拒。很多「三条记录都配了,怎么还是进垃圾箱」的问题,答案在第二层和第三层;而很多「明明配了,怎么还失败」的问题,答案在第一层里一个更细的地方——三者校验的并不是同一个身份。
二、先分清三个身份:EHLO、MAIL FROM 与 From
一封邮件在 SMTP 会话里有两个「发件人」,在邮件头里还有第三个。它们经常不同,而 SPF、DKIM、DMARC 各自盯着其中一个。
| 身份 | 出现在哪里 | 作用 | 谁在检查 |
|---|---|---|---|
| EHLO/HELO 域 | SMTP 会话开头 | 客户端自我介绍;当 MAIL FROM 为空时,SPF 退回检查它 | SPF |
| RFC5321.MailFrom | MAIL FROM:<...>,投递后通常落到 Return-Path | 退信地址,也就是信封发件人 | SPF |
| RFC5322.From | 邮件头 From: | 收件人看到的发件人 | DMARC |
把它们放进一次投递里看:
发送方 MTA ── EHLO mailer.example.com ──────────────▶ 收件方 MX MAIL FROM:<bounce@mailer.example.com> DATA From: "Example" <hello@example.com> DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=2026; h=from:to:subject:date; bh=...; b=... .- SPF 拿连接 IP 去比对
mailer.example.com的 SPF 记录。它证明的是「这个 IP 被允许替这个信封域名发信」。 - DKIM 用
example.com的私钥对选中的头字段和正文做签名。它证明的是「持有example.com私钥的一方签过这封信,且签名覆盖的内容没有被改过」。 - DMARC 把
From:里的example.com拿出来,要求 SPF 或 DKIM 的通过结果与它对齐,再按域名所有者发布的策略决定怎么处置。
所以「SPF 通过」和「DKIM 通过」都只是原材料。没有对齐的通过,对 DMARC 来说等于没通过。 这是整条链里最容易被忽略、也最容易解释「都配了为什么还失败」的一句话。
三、SPF:授权的是「哪个 IP 可以替哪个信封域名发信」
3.1 记录长什么样
SPF 记录是放在信封域名下的 TXT 记录,必须以 v=spf1 开头:
example.com. 300 IN TXT "v=spf1 ip4:203.0.113.0/24 include:_spf.example.net -all"从左到右是一串机制(mechanism)和修饰符(modifier),收件方按顺序求值,遇到第一个匹配就返回对应结果。
| 写法 | 含义 | 是否触发 DNS 查询 |
|---|---|---|
all | 匹配任何 IP | 否 |
ip4:203.0.113.0/24 | 匹配 IPv4 网段 | 否 |
ip6:2001:db8::/32 | 匹配 IPv6 网段 | 否 |
a[:domain] | 匹配 domain 的 A/AAAA 记录 | 是 |
mx[:domain] | 匹配 domain 的 MX 主机地址 | 是 |
include:domain | 递归求值 domain 的 SPF,只有对方返回 pass 才算匹配 | 是 |
exists:domain | 对构造出的域名做 A 查询,查到就算匹配 | 是 |
ptr[:domain] | 反向解析连接 IP,再正向确认 | 是(不推荐使用) |
redirect=domain | 当前记录没有匹配时,转去求值另一个域名的 SPF | 是 |
每个机制前面可以带限定符:
| 限定符 | 结果 | 常见写法 |
|---|---|---|
+ | pass | +all(不要用) |
- | fail | -all(验证完成后使用) |
~ | softfail | ~all(过渡期使用) |
? | neutral | ?all |
~all 不是 -all。它返回的是 softfail,在 DMARC 的 SPF 输入里不算 pass。很多「我们写了 ~all,应该已经拒绝了吧」的误会就出在这里:~all 是一个温和的「我不确定」,不是拒绝。
3.2 三个硬限制
SPF 的求值成本全在 DNS 上,所以 RFC 7208 给了几个硬限制:
- 会产生 DNS 查询的 term 总数不超过 10 个。 包括
include、a、mx、ptr、exists和redirect。ip4、ip6、all不计入;exp只在生成解释时查询,也不计入求值上限。超过 10 个,结果是permerror。 - void lookup 不超过 2 次。 void lookup 指返回 NXDOMAIN 或空答案的查询。超过 2 次,结果是
permerror。 - 求值
mx时,每个 MX 记录最多查询 10 个地址记录。 超过同样permerror。
还有两个经常被忽略的规则:
- 同一个域名只能有一条 SPF 记录。 两条
v=spf1的 TXT 记录会让求值直接permerror。合并供应商时,正确做法是把机制合进同一条记录,而不是再发一条。 include的目标没有 SPF 记录时,结果是permerror,不是「跳过」。 这意味着上游供应商删掉自己的 SPF 记录,会把你一起拖下水。定期检查 include 链是必要的运维动作。
3.3 SPF 的三个边界
第一,SPF 不看 From: 头。 攻击者可以用自己的域名做信封发件人,SPF 照样 pass,而 From: 写成你的域名。拦住这种伪造的是 DMARC 的对齐检查,不是 SPF。
第二,SPF 在转发面前很脆弱。 转发服务器通常会把信封发件人改成自己的退信地址(否则退信会回到原发件人),于是收件方检查的是转发方的域名;如果转发方保留了原始信封,收件方又会拿原始域名去比对转发方的 IP,直接 fail。两种做法都不够稳。SRS(Sender Rewriting Scheme)能把信封改写成转发方域名,让 SPF 继续通过,但这时 SPF 的域名已经和 From: 不一致,仍然要靠 DKIM 的对齐来救 DMARC。
第三,SPF 不签内容。 它只回答「这个 IP 能不能替这个信封域名发信」,不回答「这封信在途中是否被改过」。后者是 DKIM 的事。
四、DKIM:签名的是「内容 + 选中的头」,不是「发件人」这个身份
4.1 签名和密钥记录
DKIM 在邮件头里加一个 DKIM-Signature:,用私钥对选中的头字段和正文摘要签名:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=2026; t=1793000000; h=from:to:subject:date:message-id; bh=base64-of-body-hash; b=base64-of-signature公钥放在 DNS 里,位置由选择器 s= 和签名域 d= 共同决定:
2026._domainkey.example.com. 300 IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."关键字段:
| 字段 | 含义 | 备注 |
|---|---|---|
v | 版本,固定 1 | 必须存在 |
a | 签名算法 | rsa-sha256 常见;ed25519-sha256 见 RFC 8463 |
c | 头/正文的规范化方式 | simple 或 relaxed,两段分别指定 |
d | 签名域 | DMARC 的 DKIM 对齐看这个字段,不是 From: |
s | 选择器 | 用于轮换密钥,不暴露主密钥 |
h | 参与签名的头字段列表 | 只有列出的头被签名 |
bh | 正文的 base64 摘要 | 正文被改,bh 对不上 |
b | 对头哈希的签名 | 验证时先把 b= 清空再算 |
l | 正文长度限制 | 有安全风险,见下 |
t / x | 签名时间 / 过期时间 | x 过期会导致验证失败 |
c= 的两种规范化方式决定了「多大的改动还能通过」:
- simple:不做任何改动,正文必须逐字节一致;
- relaxed:头字段名转小写、展开折行、压缩空白;正文去掉行尾空白、压缩连续空白、删掉末尾空行。它对「邮件系统顺手改了换行或空白」更宽容。
4.2 DKIM 验证通过,说明什么,不说明什么
它说明:持有 d= 对应私钥的一方签过这封信,并且 h= 列出的头字段和正文在签名之后没有被改过。
它不说明:
- 这个域名有权代表
From:发信。d=和From:可以不同。一个垃圾邮件发送者完全可以用自己的域名签名,DKIM 照样pass。 - 信件内容是善意的。签名只保证完整性,不保证意图。
- 信件在途中没有被追加内容。这正是
l=的问题:它只签正文的前l个字节,攻击者可以在后面追加内容。RFC 6376 专门用一节警告这个用法,除非有非常明确的理由,不要用。
4.3 最常见的四种 DKIM 断法
- 邮件列表改了主题或正文。 列表加上
[list]前缀、在底部加退订说明,都会让已签名部分的哈希对不上。列表如果重新签名,问题消失;不重签,就只能靠 ARC 或放宽策略。 - 转发改了正文。 只增加
Received头一般不会破坏签名,因为这些头通常不在h=里;但转发器如果重编码正文、加免责声明,签名就会断。 - 密钥轮换没有留重叠期。 新选择器上线后立刻删掉旧选择器的 DNS 记录,仍在途中的旧签名就验不过。正确做法是让新旧选择器并存一段时间。
- 密钥太短或记录写坏。 RFC 8301 要求签名方至少使用 1024 位 RSA,建议 2048 位;验证方必须支持 1024 到 4096 位。TXT 记录被拆成多段时,验证方会按顺序拼接,拼接错、少一段、或者把
p=写空(等于吊销),都会失败。
五、DMARC:把「通过」变成「对齐的通过」,再给出处置策略
5.1 记录和字段
DMARC 记录放在 _dmarc.<From 域名> 下:
_dmarc.example.com. 300 IN TXT "v=DMARC1; p=quarantine; sp=reject; pct=100; rua=mailto:dmarc-reports@example.com; adkim=r; aspf=r"常用字段:
| 字段 | 取值 | 作用 |
|---|---|---|
v | DMARC1 | 必须是记录里的第一个 tag |
p | none / quarantine / reject | 对失败邮件的处置请求 |
sp | 同上 | 对子域名的策略;只写在组织域记录上有效 |
pct | 0 到 100 | 对失败邮件按比例应用策略,默认 100 |
rua | mailto: 列表 | 聚合报告收件地址,通常每天一封 |
ruf | mailto: 列表 | 失败样本(取证)报告,很多收件方不发 |
adkim | r / s | DKIM 对齐用宽松(组织域)还是严格(完整域名) |
aspf | r / s | SPF 对齐用宽松还是严格 |
fo | 0 / 1 / d / s | 取证报告的触发条件 |
ri | 秒数 | 聚合报告间隔,默认 86400 |
5.2 对齐到底在比什么
DMARC 的对齐有两条独立的路径,任意一条通过,DMARC 就通过:
| 路径 | 被认证的域名来自 | 与谁比较 | 宽松模式(r) | 严格模式(s) |
|---|---|---|---|---|
| SPF 对齐 | MAIL FROM 的域名 | From: 的域名 | 组织域相同即可 | 完整域名完全相同 |
| DKIM 对齐 | DKIM-Signature 的 d= | From: 的域名 | 组织域相同即可 | 完整域名完全相同 |
「组织域」不是简单地取最后两段。news.example.co.uk 的组织域是 example.co.uk,不是 co.uk。判断它需要 Public Suffix List 这类规则,不能靠字符串切分。
举一个具体的例子:
From: billing@example.comMAIL FROM: bounce@mailer.example.comDKIM d=: example.comDMARC: p=reject; aspf=r; adkim=r- SPF 检查
mailer.example.com,通过;宽松对齐下它的组织域是example.com,和From:一致 → SPF 对齐通过。 - DKIM 的
d=example.com和From:完全一致 → DKIM 对齐通过。 - 两条路径都通过,DMARC
pass。
把 aspf 改成 s,SPF 路径就断了,因为 mailer.example.com 不等于 example.com;但只要 DKIM 仍然对齐,DMARC 还是 pass。反过来,如果 DKIM 的 d=mailer.example.com、aspf=s,两条路径都不对齐,DMARC 就 fail。这就是为什么「SPF 通过、DKIM 通过,DMARC 却失败」并不矛盾:通过的是认证,不是对齐。
5.3 策略是请求,不是命令
p=reject 的意思是「请收件方拒收失败邮件」,不是「收件方必须拒收」。RFC 7489 允许收件方结合本地情况调整处置,也允许在 pct 之下按比例采样。因此:
p=none不是「没配」。它仍然会请求聚合报告,只是告诉收件方不要对失败邮件采取动作。它是观察期的正确起点。p=quarantine通常意味着进垃圾箱,但具体动作由收件方决定。p=reject可能在 SMTP 阶段拒收,也可能静默丢弃;发件方看到的表现不一样。pct=25表示 25% 的失败邮件按策略处理,其余按更宽松的一档处理。它适合逐步放量,不适合长期停在低比例。
5.4 DMARC 不解决什么
DMARC 保护的是 From: 里的那个域名。它不保护:
- 相似域名:
examp1e.com、example.co不在你的策略范围内; - 显示名伪造:
From: "Example Support" <attacker@evil.example>的显示名可以随便写; - 被盗的合法账号:攻击者用真实账号、真实密码发信,认证全过;
- 传输加密:DMARC 不加密邮件,传输层要靠 TLS、MTA-STS 和 DANE;
- 收件箱位置:认证通过只说明「不是伪造」,不说明「用户愿意读」。
把这些边界说清楚,才不会把 DMARC 当成一把万能钥匙。
六、把三次检查串起来:一封邮件怎么被判 pass 或 fail
把前面的内容压成一张表:
| 检查 | 读什么 | 通过意味着 | 常见断点 |
|---|---|---|---|
| SPF | 连接 IP、MAIL FROM/HELO 域、该域的 TXT | 这个 IP 被授权替这个信封域名发信 | 转发、include 超 10 次、两条记录、~all |
| DKIM | DKIM-Signature、d= 的 DNS 公钥、签名覆盖的头和正文 | 持有 d= 私钥的一方签过,且内容未被改 | 列表改主题/正文、密钥轮换、l=、记录写坏 |
| DMARC | From: 域、SPF/DKIM 结果、对齐模式、策略记录 | 至少一条对齐的 SPF 或 DKIM 通过 | 对齐失败、子域没策略、p=reject 上得太早 |
收件方通常会把结论写进 Authentication-Results 头。它比应用日志有用得多:
Authentication-Results: mx.example.net; spf=pass smtp.mailfrom=mailer.example.com; dkim=pass header.d=example.com header.s=2026 header.b="..."; dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=example.com读这个头时,重点看三个域名:
smtp.mailfrom=是 SPF 实际检查的域名;header.d=是 DKIM 实际签名的域名;header.from=是 DMARC 拿来对齐的From:域名。
三者一致,通常就稳了;不一致时,先判断是「认证失败」还是「认证通过但没有对齐」。这两类问题的修法完全不同:前者去查记录和签名,后者去查对齐模式和发信路径。
用伪代码表示收件方的判断:
spf_aligned = spf_result == "pass" and aligned(mail_from_domain, from_domain, aspf)dkim_aligned = any(dkim_result == "pass" and aligned(dkim_d, from_domain, adkim) for each DKIM signature)dmarc_result = "pass" if spf_aligned or dkim_aligned else "fail"注意:聚合报告里的 policy_evaluated 字段已经是「对齐后」的结论,而 auth_results 字段是原始的 SPF/DKIM 结果。排查时两个都要看:auth_results 里 SPF pass、policy_evaluated 里 SPF fail,说明认证成功但对齐失败,问题在域名不一致,不在 IP 授权。
七、从报告往回查:三个可以直接跑的动作
7.1 先看原始头
在 Gmail 里点「显示原始邮件」,在 Outlook 里看「邮件属性 → 邮件头」,找到 Authentication-Results、Received-SPF 和 DKIM-Signature。不要只看应用日志里的 250,那里面没有认证结论。
7.2 查三条 DNS 记录
# SPF:注意可能有多条 TXT,只有 v=spf1 开头的是 SPFdig +short TXT example.com
# DKIM:选择器和签名域都要对上dig +short TXT 2026._domainkey.example.com
# DMARC:先查精确域名,再查组织域dig +short TXT _dmarc.example.com如果 dig 返回多条 v=spf1,不要猜哪条生效——按 RFC,这种情况下结果是 permerror,必须合并成一条。
7.3 数一数 SPF 到底查了几次 DNS
下面这个脚本用 Node 内置的 dns 模块递归展开 include 和 redirect,统计会触发 DNS 查询的 term 数。它不展开 %{...} 宏,含宏的记录需要专用工具;对常见的 include 链足够用。
import { Resolver } from "node:dns/promises";
const resolver = new Resolver();resolver.setServers(["1.1.1.1", "8.8.8.8"]);
// 只有这些 term 在求值时触发 DNS 查询,计入 10 次上限// 注意先去掉可选的限定符(+ - ~ ?),再匹配机制名const QUERYING = /^(include:|a(?::|\/|$)|mx(?::|\/|$)|ptr(?::|$)|exists:|redirect=)/i;
function terms(record) { return record.replace(/^v=spf1\s*/i, "").trim().split(/\s+/).filter(Boolean);}
function bare(term) { return term.replace(/^[+\-~?]/, "");}
async function txt(name) { try { const rows = await resolver.resolveTxt(name); return rows.map((chunks) => chunks.join("")); } catch (err) { if (err.code === "ENOTFOUND" || err.code === "ENODATA") return []; throw err; }}
async function walk(domain, depth = 0) { if (depth > 10) return { count: 0, errors: [`${domain}: include 嵌套过深`] };
const records = (await txt(domain)).filter((r) => /^v=spf1(\s|$)/i.test(r)); if (records.length === 0) { return { count: 0, errors: [`${domain}: 没有 SPF 记录(include 会得到 permerror)`] }; } if (records.length > 1) { return { count: 0, errors: [`${domain}: 存在 ${records.length} 条 SPF 记录(permerror)`] }; }
let count = 0; const errors = []; const hasAll = terms(records[0]).some((t) => /^[+\-~?]?all$/i.test(t));
for (const term of terms(records[0])) { const t = bare(term); if (!QUERYING.test(t)) continue; // 有 all 时 redirect 会被忽略,不参与求值 if (hasAll && /^redirect=/i.test(t)) continue; count += 1;
if (/^include:/i.test(t)) { const sub = await walk(t.slice("include:".length), depth + 1); count += sub.count; errors.push(...sub.errors); } else if (/^redirect=/i.test(t)) { const sub = await walk(t.slice("redirect=".length), depth + 1); count += sub.count; errors.push(...sub.errors); } } return { count, errors };}
const domain = process.argv[2];if (!domain) { console.error("用法: node spf-count.mjs <domain>"); process.exit(2);}
const result = await walk(domain);console.log(`${domain}: ${result.count} 次 DNS 查询(上限 10)`);for (const e of result.errors) console.log(` ! ${e}`);process.exit(result.count > 10 || result.errors.length > 0 ? 1 : 0);它只能告诉你「次数有没有超」和「include 链上有没有明显错误」,不能替代完整的 SPF 求值器。真实求值还要处理宏展开、机制匹配结果、void lookup 次数和 DNS 临时错误。把它当成上线前的体检,不要当成验证器。
7.4 把 DMARC 聚合报告读成一张表
rua 收到的是 gzip 压缩的 XML。下面这段 Python 只用标准库,把一份报告里的源 IP、邮件数、SPF/DKIM 结果和 header_from 汇总出来:
import gzipimport sysimport xml.etree.ElementTree as ETfrom collections import Counter
path = sys.argv[1]opener = gzip.open if path.endswith((".gz", ".gzip")) else open
with opener(path, "rb") as f: root = ET.parse(f).getroot()
published = root.findtext("policy_published/domain", "(unknown)")
rows = []for record in root.findall("record"): ip = record.findtext("row/source_ip", "?") count = int(record.findtext("row/count", "1")) pe = record.find("row/policy_evaluated") spf = pe.findtext("spf", "?") if pe is not None else "?" dkim = pe.findtext("dkim", "?") if pe is not None else "?" header_from = record.findtext("identifiers/header_from", "?") # policy_evaluated 已经是对齐后的结论 dmarc = "pass" if "pass" in (spf, dkim) else "fail" rows.append((ip, count, spf, dkim, dmarc, header_from))
total = sum(r[1] for r in rows)passed = sum(r[1] for r in rows if r[4] == "pass")print(f"domain={published} messages={total} dmarc_pass={passed} dmarc_fail={total - passed}")print(f"{'source_ip':<16}{'count':>7} {'spf':<5}{'dkim':<5}{'dmarc':<6}header_from")for ip, count, spf, dkim, dmarc, header_from in sorted(rows, key=lambda r: -r[1]): print(f"{ip:<16}{count:>7} {spf:<5}{dkim:<5}{dmarc:<6}{header_from}")
by_from = Counter()for ip, count, spf, dkim, dmarc, header_from in rows: if dmarc == "fail": by_from[header_from] += count
if by_from: print("\nfailures by header_from:") for domain, count in by_from.most_common(): print(f" {domain}: {count}")一段输出大致是这样:
domain=example.com messages=155 dmarc_pass=120 dmarc_fail=35source_ip count spf dkim dmarc header_from203.0.113.10 120 pass pass pass example.com198.51.100.7 35 fail fail fail example.com
failures by header_from: example.com: 35先按源 IP 分组,再看失败集中在哪个 header_from。如果失败来自一个你认识的供应商,去查它的 SPF include 和 DKIM 选择器;如果失败来自陌生 IP 且 header_from 是你的域名,那更可能是伪造,p=reject 正是为这种流量准备的。有些报告器会给 XML 加命名空间,解析不到字段时先打印 root.tag 确认。
八、部署顺序:从 p=none 到 p=reject
DMARC 的部署不是「把 p 改成 reject」,而是一条有退出条件的流水线。下面这张表可以直接当检查清单用。
| 阶段 | 目标 | 要做的事 | 退出条件 |
|---|---|---|---|
| 1. 盘点 | 知道有哪些发信口 | 列出所有域名、子域名和发信方:事务邮件、营销、CRM、工单、日历、监控告警、网站表单、第三方 SaaS | 每个发信方都能对应到一条 SPF 和一组 DKIM 选择器 |
| 2. SPF | 让授权可计算且不超限 | 每个域名只保留一条 v=spf1;合并供应商;补 ip6;把 include 链压到 10 次以内 | dig 只返回一条 SPF;计数脚本退出码为 0 |
| 3. DKIM | 让内容可验证 | 至少 2048 位 RSA;按供应商或用途分配选择器;轮换时新旧并存;确认所有出站流都签名 | 抽检的每类邮件都有 dkim=pass,且 header.d 与 From: 对齐 |
| 4. 观察 | 拿到真实流量 | 发布 p=none + rua;解析聚合报告;标出「认证失败」和「认证通过但未对齐」两类 | 连续两周能解释每一条失败来源 |
| 5. 隔离 | 先处理明确失败 | 修对齐、修 include、修 DKIM;对不确定的供应商先加白名单式放行 | 失败量降到可接受范围,且没有业务方报丢信 |
| 6. 收紧 | 逐步执行策略 | p=quarantine; pct=25 → pct=100 → p=reject;同时处理 sp= | 每个阶段至少观察一个完整的发信周期 |
| 7. 收尾 | 把链路补齐 | 转发场景评估 ARC;传输层加 MTA-STS/TLS-RPT;需要时上 DNSSEC;BIMI 需要 DMARC 强制执行 | 报告持续可解析,投诉和退信没有异常上升 |
几个容易踩的坑:
- 不要直接上
p=reject。 你还没盘点到的日历邀请、监控告警、工单通知,会在策略生效后一起失败。 - 不要用两条 SPF 记录「叠加」供应商。 这不是合并,是
permerror。 - 不要以为
~all等于拒绝。 它返回softfail,DMARC 的 SPF 路径不会把它当pass。 - 不要忘了子域名。
sp=只管组织域记录下的子域;如果某个子域自己发布了_dmarc记录,它会走自己的策略。营销、活动、短链域名经常是漏网的那一批。 - 不要把
rua设成没人看的邮箱。 报告是 gzip XML,不解析就等于没部署观察期。
九、DMARC 通过之后,为什么还会进垃圾箱
认证是必要条件,不是充分条件。一封 SPF、DKIM、DMARC 全过的邮件,仍然可能因为下面这些原因进垃圾箱:
- IP/域名声誉:新域名、新 IP、突然放大的发送量,都会触发观察期;
- 投诉率:用户点「举报垃圾邮件」比点「退订」的代价高得多,主流邮箱会把它当成强信号;
- 名单质量:买来的名单、长期不活跃地址、垃圾陷阱地址,会持续拉低声誉;
- 内容与结构:纯图片、短链、可疑附件、夸张主题,都会进入内容过滤;
- 参与度:收件人长期不打开、不回复,会影响后续同域名的投递;
- 认证之外的协议:传输层没有 TLS、没有 MTA-STS,某些收件方会降级处理。
所以「配好 SPF/DKIM/DMARC」是让邮件有资格被正常评估,不是让邮件自动进收件箱。前者是配置问题,后者是长期运营问题。
十、小结
- SMTP
250只代表收件方接过了信。 认证、声誉、内容三层都过了,才会出现在收件箱。 - SPF 看信封发件人,DKIM 看签名域与内容,DMARC 看
From:与两者的对齐。 三者不是三道独立开关,而是一条链。 - 没有对齐的
pass不算数。 「SPF 通过、DKIM 通过、DMARC 失败」通常不是矛盾,而是域名不一致。 - SPF 的硬限制是 10 次 DNS 查询、2 次 void lookup、每域一条记录。
include目标没有记录会permerror,不是跳过。 - DKIM 只证明签名域和内容完整性,不证明发件人获授权。 邮件列表改主题、密钥轮换不留重叠期、
l=滥用,都会让签名失效。 - DMARC 的策略是请求。
p=none是观察期的起点,p=reject要等盘点、对齐和报告都跑顺之后再上。 - 认证不等于送达。 声誉、投诉率、名单质量和内容,仍然是收件箱位置的一部分。
参考来源
- RFC 7208: Sender Policy Framework (SPF) for Authorizing Use of Domains in Email(SPF 机制、限定符、10 次 DNS 查询与 2 次 void lookup 限制、include 的 permerror 语义)
- RFC 6376: DomainKeys Identified Mail (DKIM) Signatures(签名头字段、规范化、
l=的安全警告) - RFC 8301: Cryptographic Algorithm and Key Usage Update to DKIM(RSA 密钥长度要求:签名方至少 1024 位、建议 2048 位)
- RFC 8463: A New Cryptographic Signature Method for DKIM(
ed25519-sha256) - RFC 7489: Domain-based Message Authentication, Reporting, and Conformance (DMARC)(对齐、组织域、策略发现、
pct采样与聚合报告) - RFC 8601: Message Header Field for Indicating Message Authentication Status(
Authentication-Results的语法与各方法的结果字段) - RFC 8617: The Authenticated Received Chain (ARC) Protocol(转发链上的认证封印)
- RFC 5321: Simple Mail Transfer Protocol(
MAIL FROM、EHLO与 SMTP 会话身份) - Public Suffix List(组织域判断不能只按最后两段切分)
- M3AAWG: Messaging, Malware and Mobile Anti-Abuse Working Group(邮件认证与反滥用实践的行业资料)