Laravelのメールをほぼ自動でトラッキングする!

こんにちは、こんばんわ、kubotakです。

今回の記事では、Laravelのメール送信においてメールの開封をトラッキングする仕組みをほぼ自動化したよ!という紹介をしたいと思います。
Laravelで同じようにメールの開封をトラッキングしたいと考えている方はぜひご参考にどうぞ

メールトラッキングの方法

そもそもどうやって送信されたメールが開封されたか検知できるのでしょうか。
一般的な方法だと思いますが、HTMLメールを用いてメール開封検知の仕組みを作ります。
imgタグによってメールが開かれた際に画像が読み込まれるときに発生するリクエストを用います。
imgタグで埋め込まれたURLに一意の情報を指定します。例えばUUIDや衝突可能性の低い何らかのハッシュなどを付与したものです。
そのURLにGETメソッドでリクエストされた通信によってデータベースにそのUUIDやハッシュを持つレコードを更新することでメールが開かれたことを検知します。
このメール開封検知のエンドポイントでは最終的に1pxの透過画像等をレスポンスします。
本稿ではこのエンドポイントの作成方法は割愛します。

注意

メーラークライアントや設定によっては画像を読み込まない設定やHTMLをメールではなくテキスト形式で表示させている場合もあるので正しくトラッキングできるものではありません。

仕組み化

さて、このメール開封検知(以下メールトラッキング)の仕組みを作成する場合以下のロジックが必要になります。

  1. メールを一意に識別するUUIDもしくはハッシュの作成(以下トラッキングトークン)
  2. メールテンプレートにトラッキングトークンを保持したURLのimgタグを埋め込み
  3. そのトラッキングトークンをデータベースに保存

これをメールを送る際に実行する必要があります。
メールを送る処理を作る際に毎回この仕組みを作るの正直めんどくさいですよね。
なので基本的にほぼすべてのメールにこれが仕込まれるような仕組みを作ってしまいましょう。

すべてのメール送信に仕込む際に注意すること

注意点は2つ

メールの送信パターン

まずLaravelでメールが送信されるパターンは2パターンあります。
Mailableのsendメソッドにより同期的に送信される場合とqueueメソッドにより別のQueueシステムを介して非同期的に実行されるパターンです。
※本稿ではafterCommitの考慮は含まれていません。

Laravelのコードが実行される環境

Laravelは高機能で、ユーザーのHTTPアクセス以外にも処理が実行できるパターンがあります。
注意する点としては以下の3パターンです。

  • ユーザーのHTTPアクセスによる処理
  • スケジュールなどで実行されるコンソール処理
  • Jobによる非同期実行される処理

メールトラッキングを自動発行する場合、この3つの実行パターンを念頭においておく必要があります。詳細については後述します。

ラッキングトークンの発行

まずはトラッキングトークンを発行する処理を見ていきます。
この時点ではトラッキングトークンはデータベースに保持しません。
先程の注意点で述べた通り、Mailableのsendメソッドとqueueメソッドを上書きする必要があります。
そのため、Mailableクラスを継承した独自のクラス、ここではBaseMailableクラスというを作成します。
メール送信クラスはこのBaseMailableを継承する形で作成していきます。

<?php

declare(strict_types=1);

namespace App\Mail\Base;

use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\SentMessage;
use Illuminate\Contracts\Queue\Factory as Queue;

abstract class BaseMailable extends Mailable
{
    use Queueable;
    use SerializesModels;

    public function send($mailer): SentMessage|null
    {
        return parent::send($mailer);
    }

    public function queue(Queue $queue)
    {
        return parent::queue($queue);
    }
}

このクラスでsendメソッドとqueueメソッドをオーバーライドする形で、詳細処理については継承親のメソッドが呼び出されるようにします。
続いてトラッキングトークンを発行する処理を作成します。
MailTrackingLogクラスを作成し、その中にログに保持したい内容を追加します。
この例ではトラッキングトークン以外にも実行されたMailableのクラス名や送信対象のメールアドレスを保持しています。

<?php

// 略

abstract class BaseMailable extends Mailable
{
    /**
     * @description メールトラッキングログの有効化
     */
    protected bool $trackingLog = true;
    /**
     * @description Queueで実行される場合はqueue()時点でセットされているためnullではない
     */
    public ?MailTrackingLog $mailTrackingLog = null;

// 略

    private function attachMailTrackingLog(): void
    {
        // to['address']を取得するためにbuildを実行する
        Container::getInstance()->call([$this, 'build']);
        if ($this->trackingLog && $this->mailTrackingLog === null) {
            $this->mailTrackingLog = MailTrackingLog::create(
                $this->to[0]['address'] ?? '', // 送信先が複数あることは想定しない
                static::class,
            );
            MailTrackingLogCollection::add($this->mailTrackingLog);
        }
    }
<?php

// 略

class MailTrackingLog
{
    public function __construct(
        public readonly TrackingToken $trackingToken,
        public readonly ?ReadAt $readAt,
        public readonly MailAddress $mailAddress,
        public readonly MailClass $mailClass,
    ) {
    }

    static public function create(
        string $mailAddress,
        string $mailClass,
    ): self {
        return new self(
            TrackingToken::generate(),
            null,
            new MailAddress($mailAddress),
            new MailClass($mailClass),
        );
    }

    public function alreadyRead(): bool
    {
        return $this->readAt !== null;
    }
}

ラッキングトークンはsendの場合とqueueの場合でそれぞれ作成しますが、注意する点としてはqueueで実行して非同期になったメールはそのプロパティを保持した状態でシリアライズされて、非同期実行される際にはプロパティを持った状態でsendメソッドが実行されるということです。ですのですでにmailTrackingLogプロパティに値がセットされている場合にはトラッキングログの作成処理はスキップされるようにしましょう。
ラッキングログが作成できたら、TrackingLogCollectionという独自で作成したコレクションクラスにログを追加しましょう。

このコレクションはシングルトンで値を保持するように作成しています。つまりPHPの実行プロセスがある間はメモリに保持されている状態となります。
そのため、メモリが共有される非同期処理を用いるSwoole等のケースではこの実装は利用できない点はご注意ください。

<?php

// 略

class MailTrackingLogCollection
{
    /**
     * @var MailTrackingLog[]
     */
    private static array $mailTrackingLogs = [];

    /**
     * @param MailTrackingLog[] $mailTrackingLogs
     */
    public function __construct(array $mailTrackingLogs)
    {
        self::$mailTrackingLogs = $mailTrackingLogs;
    }

    /**
     * @description メールトラッキングログはメール送信時・Queue発行時に生成される
     */
    static public function add(MailTrackingLog $mailTrackingLog): void
    {
        self::$mailTrackingLogs[] = $mailTrackingLog;
    }

    static public function isEmpty(): bool
    {
        return count(self::$mailTrackingLogs) === 0;
    }

    /**
     * @return MailTrackingLog[]
     */
    static public function toArray(): array
    {
        return self::$mailTrackingLogs;
    }

    static public function clear(): void {
        self::$mailTrackingLogs = [];
    }

    static public function persistent(): void
    {
        // 記事では紹介しませんがCollectionにあるMailTrackingLogをDBに保存する処理をここに作成する
    }
}

最後にsendメソッドとqueueメソッドでトラッキングトークンを作成するメソッドを呼び出します。

<?php

// 略

    public function send($mailer): SentMessage|null
    {
        $this->attachMailTrackingLog();
        return parent::send($mailer);
    }

    public function queue(Queue $queue)
    {
        $this->attachMailTrackingLog();
        return parent::queue($queue);
    }

これで仕組みの1が完了です。

ラッキングトークンをimgタグに埋め込み

ここまでの処理でBaseMailabeクラスのプロパティに$mailTrackingLogが設定されるようになりました。
LaravelのMailableクラスではパブリックプロパティはメールテンプレートから同名変数で参照できるのでテンプレート側に以下のようにimgタグを仕込みます。

@if(isset($mailTrackingLog))
    <img src="{{ route('service.mail_tracking.token', ['token' => $mailTrackingLog->trackingToken->rawValue()]) }}" alt="" />
@endif

'service.mail_tracking.token'はrouteに名前付けしたもので、このエンドポイントでトラッキングトークンをパスパラメータとして受け取りデータベースに既読として保存させます。
本稿では既読の保存処理は割愛します。
これで仕組みの3が完了です。

ラッキングトークンの保存

最後に仕組みの2を作成します。
BaseMailableでトラッキングトークンの作成の流れを見てきましたが、このままではトラッキングトークンはシングルトンのコレクションクラスに保持しているのみでデータベースに記録されません。
再掲となりますが以下の3つのパターンを考慮して、トラッキングトークンを保存します。

  • ユーザーのHTTPアクセスによる処理
  • スケジュールなどで実行されるコンソール処理
  • Jobによる非同期実行される処理

ユーザーのHTTPアクセスによる処理

HTTPアクセスであればLaravelのMiddlewareを利用することができます。
Middlewareでは次のように$next($request)以降の処理は一連のコントローラーの処理が終わったあとに実行されるので、実質終端の処理となります。
このタイミングでMailTrackingLogCollectionを保存する処理を実行します。

<?php
// 略

class MailTrackingLogSaveMiddleware
{
    public function handle(Request $request, Closure $next): mixed
    {
        try {
            $result = $next($request);
        } finally {
            // ここで発行されたトラッキングログをDBに保存する
            MailTrackingLogCollection::persistent();
        }
        return $result;
    }
}

スケジュールなどで実行されるコンソール処理

コンソールの場合はIlluminate\Console\Commandを継承したクラスを作成しますが、この場合以下のようなAbstractクラスを定義します。
CommandはLaravelのフレームワーク側でhandleが実行されるためこれを定義しますが、このAbstractクラスではexecメソッドを定義して、継承しているクラスはこのexecメソッドで実行するべき処理を実装します。
このようにすることでexecメソッドが実行された後(例外が発生したとしても)にMailTrackingLogCollectionを保存する処理を実行します。
例外が発生しても保存するかどうかはそのアプリケーションの性質に依るので参考程度でお考えください。

<?php
// 略
abstract class AbstractCommand extends Command
{
    final public function handle(): int
    {
        try {
            $this->exec();
        } catch (Throwable $exception) {
            // etc
        } finally {
            MailTrackingLogCollection::persistent();
        }
    }

    abstract public function exec(): void;
}

Jobによる非同期実行される処理

最後がJobでメールが送信される場合です。
これはEventServiceProviderのboot時にJobが完了した場合にMailTrackingLogCollectionを保存する処理を登録させます。

<?php
// 略
class EventServiceProvider extends ServiceProvider
{
    // 略
    public function boot(): void
    {
        if (isset($this->app['events'])) {
            $this->app['events']->listen(JobProcessed::class, function () {
                // ジョブが完了した後の処理
                MailTrackingLogCollection::persistent();
            });
        }
        parent::boot();
    }
}

最後に

以上超大作になってしまいましたが、いかがでしょうか。
この実装では大量のメール送信を行う場合に「メール送信後にメールトラッキングログの保存が完了していない」という問題がありますので例えばメールをShouldQueueにしてキューシステムに投げる際に意図的に遅延させたり、Collection自体に一定の閾値トークンが入ったら自動で保存させるなどの処理を検討してください。
最初にこういった仕組みを用意しておくと、新しくメールを作ったときにトラッキング処理を都度用意する必要がなくなるのでおすすめです!