Mail 的三兩事,包含源碼、SPF、DKIM 跟 DMARC 的一些看法

Stephen Chen
16 min readJul 30, 2023

因為原先的 mail provider 無法滿足需求,所以改用 Mailgun.

記錄遇到的問題、優化歷程、觀察源碼以及研究 SPF、DKIM 跟 DMARC 的種種歷程.

條件 & 環境

  • 擁有 Mailgun 的帳號,設好 Domain name 跟 Sending API Key
  • 環境我用 Laravel 8
  • 前端 Nuxt3
Domain name & Sending API Key

假設需求

  • 前台會員忘記密碼流程

初步拆解需求

  • 會員前台點擊忘記密碼按鈕
  • Request API 到後端並寄信( 省略業務邏輯… )
  • 會員收到信件點擊回到前台畫面( 驗證…等 )
  • 會員輸入新密碼

由於這篇文章著重在信件部分所以其他部分一概不理會

實作 Mail 的部分

首先 Make Mail

取名叫 DemoMail 搭配 markdown flag.

蠻推薦用 markdown 不然 Mail client 對於 CSS 支援度非常的低,很容易跑版,當然還有其他工具可幫助排版.

php artisan make:mail DemoMail --markdown=emails.demo

瀏覽器上看畫面

routes/web.php

畫面如下

完善 .env

# --------------------------------------------------------------------------
# Mail
# --------------------------------------------------------------------------

MAIL_DRIVER="mailgun"
MAIL_SENDER="sender@gmail.com"
MAIL_SUBJECT="SUBJECT"
MAIL_RECEIVER="receiver@gmail.com"

# --------------------------------------------------------------------------
# MAILGUN
# --------------------------------------------------------------------------

MAILGUN_DOMAIN="<your domain here>"
MAILGUN_SECRET="<your key here>"
MAILGUN_ENDPOINT="api.mailgun.net"

在 DemoMail 的 build function

  public function build()
{
$body = [
'title' => 'Test Forgot Email',
'url' => 'https://yourdomain.com.tw/forget-password?token=aaaaaaaaaaaa',
];

return $this
->subject('subject')
->markdown('emails.demo', $body);
}

這邊 body 有個 url,根據上面忘記密碼的需求這 url 會是前台忘記密碼的頁面,所以當會員收到信並點擊按鈕就會轉到此 url,這邊記得要驗證,比如多久沒完成又或者是使用者把此頁面加到我的最愛,下次進來該怎麼驗證…等.

備註:驗證可以考慮 temporarySignedRoute,具有時效性以及唯一性.

use Illuminate\Support\Facades\URL;

return URL::temporarySignedRoute(
'forget-password', now()->addMinutes(30), ['user' => 1]
);

在 demo.balde.view 的 button 裏面綁定 url


@component('mail::button', ['url' => $url])
Button Text
@endcomponent

使用 tinker 快速寄信

\Illuminate\Support\Facades\Mail::to('<your email>')->send(new \App\Mail\DemoMail());

確認收信並點擊按鈕

打該前台忘記密碼頁面

但這邊會發現一個問題,本來期待網址應該導向

https://yourdomain.com.tw/forget-password?token=aaaaaaaaaaaa

但點開連結卻是這樣

http://email.<your_domain>/c/eJwEwEGuhDAIANDTtEsDFCssephS0G-ifxJtMtef520XHig5GtZNFRFA81-DqFYJZARjdDFb0Vcpm5kX26Tks5UiK4WKoEYEq-zolTuzovnQkhje77nP5Yj_ePoMz1dLRIkoP2321wCYNDEcdz-vZXzuXwAAAP__tG4oBg

原因是 Mailgun 默認會開啟 event tracking ( X-Mailgun-Track ),其目的是為了追蹤使用者是否打開 mail,是否點擊按鈕….等事件,所以並不會出現預期的網址,因目前需求面來說不需要所以暫關掉。

X-Mailgun-Track

而關閉有幾種方法

(ㄧ)到後台關閉

(二)使用 code

code 的方式這邊提供三種作法

  1. 使用 event listener 的方式
  2. 使用 AppServiceProvider 的方式搭配 Plugin
  3. 每個 extends Mailable 的 class

而上面三種做法不外乎就是想辦法在 request 的 header 中加上 X-Mailgun-Track: no

搜尋 X-Mailgun-Track

1. 使用 event listener 的方式

當監聽到寄信的事件之後,觸發 add Header 的事件

創 event

php artisan make:listener MailgunCustomHeaderListener

在 MailgunCustomHeaderListener 的 handle 中

public function handle($event)
{
$message = $event->message;
$message->getHeaders()->addTextHeader('X-Mailgun-Track', 'no');
// Add `X-Mailgun-Tag` for future use
}

在 EventServiceProvider

// EventServiceProvider

protected $listen = [
MessageSending::class => [
AddCustomHeaderToEmails::class,
],
];

2. 使用 AppServiceProvider 的方式搭配 Plugin

// In AppServiceProvider

public function register()
{
$config = config('mail');
$isMailgun = $config[ 'driver' ] === 'mailgun';

if ($isMailgun) {
$mailgun = $config[ 'mailers' ][ 'mailgun' ];
$plugin = new MailgunHttpHeadersPlugin($mailgun);

$this->app->resolving('mail.manager', function (MailManager $mailManager) use ($plugin) {
$swiftMailer = $mailManager->mailer()->getSwiftMailer();
$swiftMailer->registerPlugin($plugin);
});
}
}

Plugin 的 code

<?php

namespace App\Plugin;

use App\Constant\Constant;
use Swift_Events_SendEvent;
use Swift_Events_SendListener;

final class MailgunHttpHeadersPlugin implements Swift_Events_SendListener
{
/**
* @inheritDoc
*/
public function beforeSendPerformed(Swift_Events_SendEvent $evt)
{
$message = $evt->getMessage();
// Add `X-Mailgun-Track` prevent mailgun change links
$message->getHeaders()->addTextHeader('X-Mailgun-Track', 'no');
}

/**
* @inheritDoc
*/
public function sendPerformed(Swift_Events_SendEvent $evt)
{
// TODO: Implement sendPerformed() method.
}
}

那 mail.manager 從哪邊來的?

在寫這行的時候我很好奇為什麼是 mail.manager,於是以下是我嘗試追源碼找到解答的經過

從進入點開始

public/index.php
bootstrap/app.php
application.php

application.php 的源碼

找到 mail.manager

於是回答上面的問題,mail.manager 來源是寫死在 Application.php 裡面的

繼續往下 registerCoreContainerAliases 這 function 的下面你會發現其實就是放到一個 aliases 的 array 裏面,因為 Application.php 繼承 Container.php

而 declare aliases 這變數是在 Container.php 中

registerCoreContainerAliases 這方法
Container.php

解釋完為什麼是 mail.manager 接著繼續追 resolving 的源碼

resolving

resolving 源碼在 Container 裏面,裡面實作如上圖,但這都只是前置作業,因為當你真正要使用寄信功能的時候,也就是像下面這樣才會去觸發

Mail::send(new MailableClass());

3. 寫在每個 extends Mailable 的 class

這樣做的好處是很彈性,可以指定特別的 mail,但壞處是假如系統有好幾封,code 要寫很多次,取決於你的需求

所以回到最原本的問題,為了不讓忘記密碼的連結被換掉而提供了兩種方法,其中 code 的方法又有 3 種方式來實作。

紀錄現實遇到的其他方面問題

問題:客人說沒有收到郵件,如同下方 ChatGPT 回答的這些可能解法一一排除

但想再補充一下 SPF、DKIM 跟 DMARC 這三個.

SPF、DKIM 跟 DMARC 解決什麼樣的問題

一句話就是防止別人偽造,解決電子郵件詐欺跟網路釣魚.

要防止偽造就是透過上面這三種機制來驗證,下圖附上一張都 PASS 的圖片,所以這封信很高的機率是從 Udemy 來的,不是某路人甲寄得.

An email has successfully passed SPF、DKIM and DMARC checks.

以下會各別介紹 SPF、DKIM、DMARC.

SPF ( Sender Policy Framework )

名字看起來很嚇人,一句簡短的話

SPF 是一種 DNS TXT Record,紀錄了哪些 Servers 有授權可以用你的 Domain 發信,比如你的 domain 是 contact-me@mail.my-domain.com

而我從這影片中找到一篇蠻好理解的圖文來解析背後的流程

https://www.youtube.com/watch?v=c9fLp5uIxp8

如上圖四個步驟分別為

(步驟ㄧ)把 SPF Record Publish 到 DNS,通常這會在 Cloudflare 又或者是 Domain host ( Ex. GoDaddy ) 上去實現,比如在 Mailgun 上新增一個新的 Domain,它會要求你新增一組 TXT Record

Cloudflare & Mailgun 示意圖

當然跟其他 DNS Reocrd 一樣 ( ex:A、AAAA…etc ) 可以 lookup

mxtoolbox 查找 ( lookup )

上面 Syntax 部分 spf1 代表版本,後面的 include:mailgun.org ~all 給 ChatGPT 回答 😃

Syntax 大致上遵循 mechanisms & value 這樣的 pattern

比如下圖的 ip4、include、v 跟 all 都是 part of mechanisms 而後面的就是 value,下圖貼一張稍微複雜的範例跟解說

https://support.google.com/a/answer/10685031?sjid=9482860371253344582-AP#workspace-record 範例( 左 )解說( 右 )

(步驟二)寄信

(步驟三)類似 DNS lookup

Receiving mail server 會去 lookup 步驟一所設定的 TXT Record 然後決定 Sending mail server 是否 authorized to send email on behalf this domain.

(步驟四)收到信之後的結果

以上講這麼多其實能做的也就是設定 DNS TXT Record 而已.

DKIM(DomainKeys Identified Mail )

一樣一句話的解說,用非對稱加密的方式來驗證寄出來的信

https://www.youtube.com/watch?v=c9fLp5uIxp8

(步驟ㄧ)把 Public key 放上 DNS

用 mailgun 例子,創立的時候會要輸入下圖箭頭所指定的在 mailgun

Cloudflare & Mailgun 示意圖

(步驟二)寄信,並用私鑰來簽名( sign ),簽名這部分會在 mailgun 底層去完成.

(步驟三)Receiving Server 會去 DNS 拿 public key 來驗證簽名 ( verify signatures ).

(步驟四)看步驟三結果,失敗可能就放到垃圾信….之類的

以上一樣其實能做的也就是設定 DNS TXT Record 而已.

DNS DMARC ( Domain-based Message Authentication Reporting and Conformance )

一樣一句話的解說,用什麼方式處理 SPF 跟 DKIM 兩者驗證之後的結果.

就不解說拉

比如這封信怪怪的話我可以拒絕,也可以變成垃圾郵件

來個栗子🌰

v=DMARC1; p=quarantine; rua=postmaster@mailgun.com;
  • v:版號,目前只有 DMARC1 這一版
  • p:怎麼處理當 SPF 跟 DKIM 失敗之後,目前好像有以下三種方式
取自 https://www.mailgun.com/blog/deliverability/implement-dmarc/#subchapter-2
  • rua 代表要把 aggregate report 寄到哪,以此範例當驗證失敗把 report 寄到 postmaster@mailgun.com.

當然還有一堆這就得自行研究惹

結語

沒想到一封信背後這麼複雜,害我前前後後寫了 2 個月…,不過這還是冰山一角,比如 MX Record…等,也許等哪天需要自己架設 mail server 再來研究其他更細節的吧.

Ref.

--

--