Open5

AWS SES (Simple Email Service) でメール配信

okita kamegorookita kamegoro

(そもそものモチベーションは、laravelには標準でaws ses のドライバーが存在しているが、テンプレートを生成して、ユーザごとにそれぞれ値が変化するようなメールの送信には対応していないようだ。)

特にマーケティングを目的としたメール配信では、数千, 数万単位での一括で送信を行うため、SESの制限に引っかからないような調整が必要となる。

具体的な制限は以下の通り。

  • 日次送信クォータ: 1日あたりの送信件数 (≦ 50,000件)
  • 最大送信レート: 1秒間あたりの送信件数 (≦ 14件)
  • 一括送信できる送信件数 (≦ 50件)

一括送信(SendBulkEmail)の上限が50件で秒間の送信上限が14件よりも多く、一回上限の50件を送信したら即座に上限に引っ掛かるのか気になる。

okita kamegorookita kamegoro

何か問題が発生した場合にログを参照したい場合がある。
ただ、ストリームが分散しているためストリーム内を読んで探すのは大変。
その場合は、CloudWatchのログインサイトが便利
以下のようなクエリを指定すると検索結果が出力されて、@logStreamに検索に一致するログのリンクが表示されるので、一発で目的のログへ飛ぶことができる。

fields @timestamp, @message, @logStream, @log
| filter @message like 'DONE' and @message like 'FAIL'
| sort @timestamp desc
| limit 30

ログを検索する際には、期間も指定可能なので、問題発生時の期間をフィルターした上で全てのストリームを検索できる。

ただ、この出力結果は最大でも10000レコードのみのダウンロードとなるので、結局全て見たい場合はダウンロードしてタイムスタンプを元に自分でソートするしかないのかも?

okita kamegorookita kamegoro

こういうミドルウェアを作成して、 quota のlimitを突破しそうなら一旦job を queueに戻して20秒待機するようにしてみた。
リトライ上限を設定しないとリトライ時に MaxAttemptsExceededException が発生するので注意

<?php

declare(strict_types=1);

namespace App\Jobs\MailSystem;

use App\Services\Aws\AwsService;
use Illuminate\Support\Facades\Log;

final class SesRateLimitedMiddleware
{
    private readonly AwsService $aws;

    public function __construct()
    {
        $this->aws  = app()->make(AwsService::class);
    }

    /**
     * If the SES rate limit is exceeded, it will be re-executed after 20 seconds.
     *
     * @param  callable  $next
     * @return mixed
     */
    public function handle(mixed $job, $next)
    {
        $result = $this->aws->ses->getAccount();
        /**
         * @var array{
         *     Max24HourSend: float,
         *     MaxSendRate: float,
         *     SentLast24Hours: float,
         * } $quota
         */
        $quota = $result->get('SendQuota');

        Log::info('SES Rate Limited', $quota);

        if ($quota['MaxSendRate'] < 3) {
            return $job->release(20); // @phpstan-ignore-line
        }

        return $next($job);
    }
}
okita kamegorookita kamegoro

ざっくりこんな実装で一気に dispatch すると 突っ込まれた大量のqueue がほぼ同時に発火して ses の limit rate の取得では、対応ができなかった。
(おそらく ses 側の limit rate が更新される前に取得して、制限に引っかかる状態)

$users->chunk(50)->each(
            fn (Collection $users, int $index) =>
            // @phpstan-ignore-next-line
            dispatch(new BulkSendSesMail($email, $users->pluck('id')->toArray(), $index === 0))
        );

このため、ループするた度に、x秒遅延を増やすような処理を追記して対応

$users->chunk(50)->each(
            fn (Collection $users, int $index) =>
            // @phpstan-ignore-next-line
            dispatch(new BulkSendSesMail($email, $users->pluck('id')->toArray(), $index === 0))
+              ->delay(now()->addSeconds(3 * $index))
        );
okita kamegorookita kamegoro

送信が成功したステータスなのに、メールが届かない場合

上記の症状に見舞われたことがあった。結論としては、メール本文内で参照する変数をメール送信時に指定していなかったからである。

<!-- メール本文 -->
<p>{{title}}</p>

このようにタイトルが要求される場合にはちゃんと BulkEmailEntriestemplate_data で指定する必要がある。