Gmailは 2024 年 2 月以降、Gmail アカウントに 1 日あたり 5,000 件以上のメールを送信する送信者に対していくつかの義務付けを発表しました。その1つに、「受信者がメールの配信登録を容易に解除できるようにすること」とあります。毎日何十万というメルマガを送信する私のクライアントではメルマガの受信者の大半がGmailのメールアドレスを使用しています。メルマガはLaravelのプログラムから送信されます。対応しないと迷惑メールになりますよというGoogleの警告は恐ろしいですが、Laravelなら簡単に対応できます。

※11/25:以下においてCSRFトークンに関して書き忘れがあったの追加しています。

メールの配信登録を容易に解除できるとは?

Googleが発表したメール送信のガイドラインは以下で詳細が閲覧できます。
https://support.google.com/mail/answer/81126?sjid=16148365418482843036-NC

サーバーでのSPFとDKIMの両方の対応はサーバーのシステムでの設定ですが、アプリ側では以下の要求があります。

そこで言及されているワンクリックとは、PCのブラウザで開いたウェブのGmailで、以下のように、受信メールの送信元の隣に表示される登録解除のリンクのことです。

これをクリックすると以下のようなポップアップの画面が表示され「登録解除」のボタンを押すと、送信者のサイトに遷移することなくメルマガの登録解除が実行されます。

これがどうして可能なのかなのですが、受信したメールのソースコードを見ると、

先のガイドラインで指示したように、List-UnsubscribeList-Unsubscribe-Postのヘッダーが含まれているからです。

つまりそれらのヘッダーを含むメールを送信すれば、対応できるわけです。

ワンクリックのリンクを含むMailableを作成

もう1度、Googleが勧める追加すべきヘッダーを見てみましょう。

    List-Unsubscribe-Post: List-Unsubscribe=One-Click
    List-Unsubscribe: <https://solarmora.com/unsubscribe/example>

最初の方は固定のヘッダーですが、後者の方はリンクは購読解除のプログラムの作成が必要とされます。

まず、このヘッダーを含むMailableを作成します。
以下のコードでは、 送信先のメールアドレスを暗号化して、List-Unsubscribeの登録解除のURLでtokenの引数とします。
実際には、暗号化にはメールアドレスだけでなく発行した日時やどのキャンペーンのメルマガかなどの他の情報と合わせて入れる必要があるかもしれません。


namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
use Illuminate\Mail\Mailables\Envelope;

class Newsletter extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * Create a new message instance.
     */
    public function __construct(public string $email)
    {
        $this->email = $email;
    }

    /**
     * Get the message headers.
     */
    public function headers(): Headers
    {
        // 送信先のメールアドレスを暗号化する
        $url = route('unsubscribe.store',['token' => encrypt($this->email)]);

        return new Headers(
            text: [
                'List-Unsubscribe' => '<'.$url.'>',
                'List-Unsubscribe-Post' =>'List-Unsubscribe=One-Click',
            ],
        );
    }

    /**
     * Get the message envelope.
     */
    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'メルマガの配信です',
        );
    }

    /**
     * Get the message content definition.
     */
    public function content(): Content
    {
        return new Content(
            view: 'newsletter',
        );
    }
}

上のMailableで使用されるブレードは以下です。

<html>
    <body>
        <h1>メルマガの配信です</h1>
        <p>いつもメルマガを購読して頂いてありがとうございます。</p>
    </body>
</html>

さてこれで、メルマガ登録解除のヘッダーを含むメールの送信が可能です。
tinkerを使い、メルマガを送信します。$emailの変数の値は自分のGmailのメールアドレスを使用してくださいね。

> use App\Mail\Newsletter;

> $email = 'test@example.com';
= "test@example.com"

> Mail::to($email)->send(new Newsletter($email));
= Illuminate\Mail\SentMessage {#7251}

さて、届いた部分をGmailで開くと、なんと「メールリスト登録解除」のリンクが表示されていません!

しかし、ソースコードを見ると、必要なヘッダーはしっかり含まれています。

これではテストできませんね。ネットで調査したところ、メール送信元のサイトの評価と関係しているとかで、評価が良くないと逆に使用されたメールアドレスが正しいということでスパムをたくさん送信されるとかの理由らしいです。つまり開発環境などから送信したメールは評価がないので、リンクは表示されないのです。評価が良い私のクライアントからのメルマガでは、受信したメールに登録解除のリンクは表示されていました。

しかしながら、どうテストしたらよいものか。

登録解除のPOSTを受け取るコントローラ

とりあえず、登録解除のPOSTを受け取るコントローラが必要です。以下に作成してみました。

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class UnsubscribeController extends Controller
{
    /**
     * Store a newly created resource in storage.
     */
    public function store(Request $request)
    {
        $email = decrypt($request->token); //非暗号化してメールアドレスを取り出す

        // 実際はメールアドレスを将来メルマガに使用しないようなDB処理が必要なところ
        // ログファイルにメールアドレスを保存
        Log::debug('email: '.$email);
    }
}

POSTを受け取るのでこのデモではstore()だけにしています。

それを使うルートの方は、こんな感じ。


use App\Http\Controllers\UnsubscribeController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::resource('unsubscribe', UnsubscribeController::class)->only('store');

さらに、通常のCSRFトークンは意味がないので、以下で例外とします。

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array<int, string>
     */
    protected $except = [
        'unsubscribe',
    ];
}

さて、届いたメールに登録解除のリンクがなくて、どうこのPOSTを確認するかですが、これもtinkerで可能です。
ローカル環境でまずウェブサーバーを立ち上げます。

$ php artisan serve
   INFO  Server running on [http://127.0.0.1:8000].

tinkerで、以下のようにLaravelのHttp Clientを使用してPOSTを実行します。$tokenの値はさきほどのメールのソースコードから持ってきます。

> $token='eyJpdiI6IkxaaWhEeW5OSnl4OTZYWkJCMG5ZaEE9PSIsInZhbHVlIjoib2VCNFhBaWZCak1PUGZsN3Q2RFphTEZZc25TN2UwT29wOXZyMitrd0pPST0iLCJtYWMiOiJmODY4YjZmOGU1NzYxODg3NDJjZmRmM2JmMTA3YTdiMDQzNzQ4YTY1NTJmZmI0ZjZmZmIxMGY5NDNhMzExYTY5IiwidGFnIjoiIn0%3D';
> Http::post('http://127.0.0.1:8000/unsubscribe?token='.$token)->body();
= ""

実行後にlaravel.logの中身を見ると、

[2023-11-23 01:39:06] local.DEBUG: email: test@example.com

しっかり、非暗号化してメールアドレスを取り出すことができました。

最後に

ワンクリックのメルマガ登録解除の簡単な対応を紹介しましたが、注意が必要なのは今回はPCのブラウザでGmailを開いたときの対応だけだということです。他のOutlookなどのメールクライアントには関係のないことです。また、スマホのGmailのアプリでも解除のリンクは表示されません。それゆえに、メールの内容にもメールの登録解除のリンクを入れて対応することが必要です。

By khino