把 DMARC 推到強制的那天:三個「看起來成功」的假訊號
本篇是「誰在冒用你的 Domain 寄信」系列的第 4 / 4 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。
三週前我寫了 #3 那份 playbook,把 DMARC 從 p=none 推到 p=reject 的分階段流程整理得很整齊。這篇是實際執行那天的記錄。
實際的節奏跟原訂計畫不完全一樣:監控期比 playbook 寫的短,quarantine 那一階也沒有停滿。這是公司內部的排程決定,就照著做了。結果是沒出事,但過程中踩到三個「看起來已經完成、實際上沒有」的訊號——那才是這篇要記的東西。
📎 這是「誰在冒用你的 Domain 寄信」系列的第 4 篇,執行紀錄。完全不熟 SPF/DKIM/DMARC 先看 #2 白話入門;想照流程自己做一次,看 #3 實戰 playbook;整件事的起點是 #1 我如何重現一個資安評等的 F。
先講結果
當天做完的東西:
- 12 筆 DNS 變更(SPF、DKIM、DMARC 三類)
- 6 條寄件管道逐條盤點確認
- 11 條 DKIM 鏈全部追到最終記錄驗過
- 最後把 DMARC 切到
p=reject,SPF 收緊成-all
驗收方式很土:寄一封測試信到 Gmail,開「顯示原始郵件」,看那三行。

三行全綠。看起來就是「做完了」。
問題是,這張截圖只證明了我手動寄的這一封通過。當天真正花時間的,是搞清楚另外五條管道到底有沒有跟上——而那件事,dig 查不出來。
三個假訊號住在哪裡
一封信從「我設定好了」到「對方真的收下」,中間有五個環節。我原本以為只要第一個對了,後面就會跟著對:
flowchart LR
A["DNS 上有記錄<br/>dig 查得到"] -->|假訊號 2| B["DKIM 鏈追到底<br/>真的有公鑰"]
B -->|假訊號 1| C["SaaS 後台真的<br/>用你的網域簽章"]
C --> D["寄出去<br/>API 回 200"]
D -->|假訊號 3| E["收件方真的收下"]
三個假訊號各自住在其中一個縫隙裡。(編號是我遇到的順序,不是管線上的順序。)
假訊號 #1:DNS 加了 DKIM ≠ DKIM 開了
Zendesk 的設定文件會叫你在 DNS 加兩筆 CNAME:zendesk1 和 zendesk2。我加了,dig 查得到,一切看起來完成。
但 Zendesk 後台還有一個勾選框叫 Custom domain for DKIM。沒勾之前,Zendesk 照樣用它自己的 d=zendesk.com 簽章。

用 #2 那條姓氏規則來說:火漆印確實有蓋,但印上壓的是 Zendesk 家的姓,不是我們家的。對齊要的是同姓,不同姓就不算數——連分部都不是。
而最陰險的地方是:從 DNS 那端完全看不出差別。
flowchart TB
DNS["DNS 上:zendesk1 / zendesk2<br/>兩筆 CNAME 都加了、都解得到"]
DNS --> OFF["後台開關<br/>沒勾"]
DNS --> ON["後台開關<br/>勾了"]
OFF --> R1["簽章送出 d=zendesk.com<br/>跟 From 不同姓 → DKIM 對齊失敗"]
ON --> R2["簽章送出 d=yourdomain.com<br/>同姓 → DKIM 對齊通過"]
左右兩條路,dig 看到的東西一模一樣。差別只存在於一個別人家後台裡的勾選框。
Google Workspace 是同一個模式:DKIM 的 TXT 加進 DNS 之後,還要回後台按 Start authentication,沒按就是沒開。
順序不能反
Zendesk 自己的文件把這件事寫得很清楚:那個開關要等 CNAME 生效之後才能開。反過來做——先勾開關、CNAME 還沒生效——送出去的信會帶著一把查不到公鑰的簽章,DKIM 這條路直接廢掉;在 p=reject 之下,能不能撐住就只剩 SPF 那條沒把握的路。
flowchart LR
subgraph OK["正確順序"]
direction LR
A1["加 CNAME"] --> A2["確認解得到"] --> A3["才勾開關"]
end
subgraph NG["順序反了"]
direction LR
B1["先勾開關"] --> B2["CNAME 還沒生效"] --> B3["查不到公鑰<br/>→ 投遞失敗"]
end
也就是說,這個開關早開會讓 DKIM 直接失效、晚開會沒對齊——p=reject 之下兩邊都是把賭注壓在沒把握的 SPF 上。順序本身就是唯一的安全路徑。
一個要講清楚的精確度
DKIM 沒對齊不等於 DMARC 一定失敗。DMARC 是 OR 邏輯——SPF 對齊且通過、或 DKIM 對齊且通過,任一成立就算 pass。所以 d=zendesk.com 的信會不會被退,還要看那條管道的 SPF 對齊狀況。
網路上關於 Zendesk 的 SPF 對齊有互相矛盾的說法,它自己的文件也沒交代 Return-Path 怎麼設,我沒辦法證實任何一邊。所以當天的做法很單純:把 DKIM 這條補到確定對齊,不去賭 SPF 那條。
怎麼真的驗:寄一封測試信,看 Authentication-Results 裡的 dkim=pass header.d= 是誰。header.d= 是你的網域才算數。這是唯一從外面看得出差別的方法。
假訊號 #2:CNAME 解得到 ≠ DKIM 有效
Shopify 這條的狀況不一樣,而且更難發現。
它的 selector CNAME 指向 100% 正確——名字對、目標對、dig 一路都解得到。但把這條鏈追到最終目標的 TXT,那格是空的。目標端當下還沒 provision 好。
比喻的話:DNS 上的路標完全正確,指向存放印模的櫃子;但櫃子當下是空的。郵局照路標去找,找到一個空櫃,驗不了章。
flowchart LR
A["selector._domainkey.<br/>yourdomain.com"] -->|"CNAME 完全正確"| B["Shopify 的<br/>DKIM 主機"]
B --> C{"目標端的 TXT<br/>真的有公鑰嗎?"}
C -->|"有"| D["DKIM 可用"]
C -->|"空的,還沒 provision"| E["DKIM 驗不過<br/>但 CNAME 一路都對"]
大部分人(包括我)查到第二格就收工了,因為看到 CNAME 有回應就以為結束了。
# 只查 CNAME:看得到指向,看不到內容
dig +short CNAME selector._domainkey.yourdomain.com
# 追到底:這才是收件方真正拿去驗章的東西
dig +short TXT selector._domainkey.yourdomain.com
第二個指令的回傳裡如果看不到 v=DKIM1; p=... 那一段,就是還沒好。驗 DKIM 不能只看 CNAME 有沒有解到,要追到最終的 TXT。
後來 Shopify 那端 provision 完成,後台狀態才轉成 Authenticated:

同一個 p=reject,兩家 SaaS 的反應完全不同
這是當天另一個意外收穫。同樣是「你的網域已經 p=reject、但這條管道沒設好」:
- Zendesk:照寄。萬一那條管道的 SPF 也沒對齊,信就會被收件方退掉——至少會有退信、有聲音。(前面說過,Zendesk 的 SPF 對齊我沒能證實,所以這條路徑實際會不會退,我只能說「有機會有聲音」。)
- Shopify:偵測到驗證記錄沒設好時,把 From 換成它自己的網域寄出去。不會退信,但寄件人不是你。
Shopify 文件寫的觸發條件是「authentication records aren’t configured」,不是「偵測到你的網域有 DMARC」。這兩件事在 debug 的時候很容易被講混,講混了就會去查錯的東西。
「不會退信」聽起來比較友善,但它其實更難抓:信有送到、收件人也讀了,只是寄件人顯示的不是你的品牌。整條路上沒有任何錯誤訊息——除非你自己去看那封收到的信長什麼樣子。
假訊號 #3:email_logs 有紀錄 ≠ 信送到了
這是當天最有料的一個。
我們自己有一張 email_logs,每寄一封信就寫一列。要驗「今天的系統信有沒有正常寄出」,直覺就是去查這張表:表裡有那一列 → 寄出去了。
不是。
Laravel 的 MessageSent 事件,是在 Mailer.php 把訊息交給 transport 之後才觸發的。用 Mailgun 的話,那個「之後」的精確時間點是——Mailgun API 回 HTTP 200 的那一刻。
而 Mailgun 那個 200 的 response body,依它自己的文件是:
{"id": "<20260907...@mg.yourdomain.com>", "message": "Queued. Thank you."}
(這是 Mailgun 文件記載的回應字串,不是我當天抓的封包。)
Queued. 它一直很誠實。是我們把「排進佇列」讀成了「寄到了」。
而我們那張表存的是:
// 我們自己的 listener(示意)
class LogSentMessage implements ShouldQueue
{
public function handle(MessageSent $event): void
{
EmailLog::create([
'from' => ...,
'to' => ...,
'subject' => ...,
'body' => ...,
]); // 沒有 status、沒有存 Mailgun 回的 message id
}
}
所以一封被 550 退掉的信,在這張表裡跟一封成功送達的信長得一模一樣:
email_logs 裡是同一種樣子。畫成時序圖更清楚——問題不在哪一步做錯了,而在最後那條回饋路徑根本不存在:
sequenceDiagram
autonumber
participant App as Laravel App
participant MG as Mailgun API
participant Q as queue worker
participant DB as email_logs
participant MTA as 收件方 MTA
App->>MG: POST /messages
MG-->>App: HTTP 200 — Queued. Thank you.
App->>App: 觸發 MessageSent 事件
App->>Q: dispatch listener(ShouldQueue)
Q->>DB: INSERT from / to / subject / body
Note over DB: 這一列寫下的瞬間<br/>信還躺在 Mailgun 的佇列裡
MG->>MTA: 幾秒到幾分鐘後才真的投遞
MTA-->>MG: 550 拒收
MG--xApp: repo 裡沒有 webhook 接這個事件
兩個容易被忽略的精確度細節
(a) transport 判斷成功是精確比對 HTTP 200,不是任何 2xx。 Mailgun 目前就是回 200,所以沒事;但這個假設沒有寫在任何地方,哪天對方改成 202 Accepted 之類的,行為會怎麼變是另一件事。
(b) 寫這張表的 listener 是 ShouldQueue。 那一列是 queue worker 寫進去的,不是寄信的那個 request 寫的。這代表這張表兩個方向都不能當證據:
- 有 row,只證明 Mailgun 回了 200 Queued。
- 缺 row,什麼都證明不了——queue job 掉了、worker 沒跑、job 正在失敗重試,都會沒有 row。信可能好好地寄到了。
人寄的看得到,系統寄的看不到
當天最後理清楚的是這件事:同樣被收件方擋掉,人跟系統的「可見度」差非常多。
flowchart TB
subgraph HUMAN["同仁用 Gmail 寄"]
direction TB
H1["Gmail 寄出"] --> H2["對方擋掉"] --> H3["退信通知回到<br/>同仁自己的收件匣"] --> H4["有人看到 → 會來問"]
end
subgraph SYSTEM["系統走 Mailgun 寄"]
direction TB
S1["API 回 200 Queued"] --> S2["對方擋掉"] --> S3["退信回到 Mailgun 的<br/>Return-Path"] --> S4["repo 裡沒有 webhook<br/>接退信事件"] --> S5["沒有任何人看到"]
end
p=reject 上線後,如果哪條系統寄件管道沒設好,你會知道的唯一方式是——有人跑來跟你說「我沒收到」。
一個誠實的但書:也有收件方是先收下、再默默丟進垃圾桶或直接丟棄的,那種情況連退信都不會產生。所以反過來也成立——「沒有退信」也不等於「有送到」。
如果你手上真的抓到退信,順帶提醒一句:Gmail 的 550 5.7.26 不是 DMARC 專用碼,它有三種文件記載的意義。判讀請連錯誤訊息後面那串文字一起讀,只看數字會判錯方向。
當天沒做的一件事,和差點引錯的一份文件
沒把對齊改成嚴格
當天有人問:既然都要推 p=reject 了,要不要順便把 adkim / aspf 改成 s(嚴格)?看起來比較安全。
沒改,而且這是當天最果斷的一個決定。我們的交易信走 Mailgun,DKIM 的 d= 和 Return-Path 兩邊都掛在 mg. 子網域上。兩個 tag 同時收緊,這條管道的 SPF 和 DKIM 會在同一瞬間一起失去對齊——DMARC 的 OR 邏輯救不了它,因為兩條路同時斷了。整批交易信會當場掛掉。
換來的收益幾乎是零。改成嚴格擋不掉一般的冒名信——偽造信本來就不姓我們家的姓,在寬鬆規則下早就在失敗了。你多擋掉的幾乎都是自己的電子報、帳單和密碼重設信。姓氏規則的完整說明在 #2。
RFC 7489 已經不是現行標準
當天翻規範才發現的:DMARC 在 2026-05 由 RFC 9989 取代了 RFC 7489(同時也取代 RFC 9091),並從 Informational 升級成 Standards Track。2026 年的文章還把 7489 當現行標準引用是錯的——包括我自己這系列前幾篇的初稿(已改)。
連帶的實務影響是 pct 標籤被移除了,「用 pct=25 → 50 → 100 漸進放大」那套要重新想。這件事 #3 已經整理過,這裡不重複。
順帶:Gmail 2024 的要求不適用你以為的那些人
Gmail 2024 的寄件者要求只適用於個人 @gmail.com 收件者,不適用 Google Workspace 託管的信箱。拿「Gmail 已經強制要求了」當理由去推內部排程之前,先確認你的收件方到底是哪一種——講錯了,下次就沒人相信你講的期限。
三個假訊號對照表
| 假訊號 | 看起來像什麼 | 實際是什麼 | 怎麼真的驗 |
|---|---|---|---|
| #1 SaaS 開關 | dig 查得到 zendesk1 / zendesk2 兩筆 CNAME | 後台開關沒勾,還在用 d=zendesk.com 簽 | 寄測試信,看 Authentication-Results 的 header.d= 是不是你的網域 |
| #2 CNAME 鏈 | CNAME 指向 100% 正確、一路解得到 | 目標端的 TXT 是空的,還沒 provision | dig +short TXT 追到最終記錄,確認看得到 v=DKIM1; p= |
| #3 應用層紀錄 | email_logs 裡有那一列 | 那一列只代表 Mailgun 回了 200 Queued | 去 Mailgun 的 delivery log 對;長期解法是掛 webhook 收 bounce |
收尾
三個假訊號有個共同點很直白:我查得到的每一個訊號,都停在「我把東西交出去了」那一刻,沒有一個訊號來自「對方收下了」那一端。 dig 停在 DNS、後台截圖停在設定頁、email_logs 停在 API 回 200。
那 12 筆 DNS 變更前後花不到一小時。驗這三件事花掉一整個下午。
當天最後補上的東西不是 DNS 記錄,是把 Mailgun 的 delivery log 打開來當對照。至於把 bounce webhook 接進 email_logs、讓那張表至少長出一個 status 欄位——那件事還沒做。
想從頭理解這三個 DNS 記錄在幹嘛,看 #2 白話入門;想照分階段流程自己推一次,看 #3 實戰 playbook;想知道整件事為什麼會從一個 F 開始,回 系列起點。