🐘

AWS SES(SNS)×Laravelのバウンスメール対策

に公開

1.概要

AWS SESのバウンスメール対策をLaravelで行う機会がありましたので、記事を作成しました。Laravelでのコードサンプルや実装する上での注意点等を載せますので、閲覧者のご参考になればと思います。

2.バウンスメールとは?

バウンスメール(Bounce Mail) とは、送信したメールが何らかの理由で宛先に届かず、メールサーバから返ってくるエラーメールのことです。「跳ね返ってきたメール」というイメージらしい。

3.バウンスの種類

大きく分けて 2 種類あります。

3.1.ハードバウンス(Hard Bounce)

永続的なエラーで、今後も送信しても届かない可能性が高く、必ず送信リストから除外すべきものになります。

  • 主な原因
    • 宛先のメールアドレスが存在しない
    • ドメインが存在しない
    • 受信サーバが完全に拒否している

3.2.ソフトバウンス(Soft Bounce)

一時的なエラーで、状況が改善すれば届く可能性があるが、繰り返し発生するようなら送信対象から外した方がよいものになります。

  • 主な原因
    • 相手のメールボックスがいっぱい
    • 受信サーバが一時的にダウンしている
    • 添付ファイルが大きすぎる

4.なぜバウンス対策が必要?

  • バウンス率が高いと、送信ドメインや IP がスパム扱いされる
  • AWS SESの場合、バウンス等が一定数以上となると、アカウント制限や利用停止につながる恐れがある
  • 宛先管理を適切に行うことで、配信成功率・到達率を上げることができる

5.処理イメージ

バウンスメールは、メールが送信されてみないと検知できません。
メール送信後、バウンスメール判定されたメールアドレスからメールが送信されないようにするという処理フローになります。
メール送信前の対策としては、Laravel等のバリエーションをうまく活用して、こちらも対策しておくことをお勧めします。

6.実装

以下が実装サンプルコードになります。
クリーンアーキテクチャ寄りの実装ですが、各プロジェクトに合わせて実装して頂ければと思います。

route層

use App\Http\Controllers\Api\SesController;

// aws ses
Route::prefix('ses')->name('api.ses.')->group(function () {
    Route::post('/bounce', [SesController::class, 'bouncedEmail'])->name('bounced-email');
});

controller層

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Ses\SesBouncedEmailRequest;
use App\UseCases\Api\Ses\SesBouncedEmailUseCase;
use App\Presenters\Api\Ses\SesBouncedEmailPresenter;
use \Illuminate\Http\JsonResponse;

/**
 * SesController
 */
class SesController extends Controller
{
    /**
     * bouncedEmail
     *
     * @param SesBouncedEmailRequest $input
     * @return JsonResponse
     */
    public function bouncedEmail(SesBouncedEmailRequest $input, SesBouncedEmailUseCase $useCase, SesBouncedEmailPresenter $presenter): JsonResponse
    {
        $output = $useCase->execute($input);
        return $presenter->execute($output);
    }
}

request層

特に不要ですが、必要な方は設定してください。
SNSから送られてくるレスポンスは固定ですが、確かAWS側で、カスタマイズもできたと思います。

<?php

namespace App\Http\Requests\Api\Ses;

use App\Http\Requests\Api\ApiRequest;

class SesBouncedEmailRequest extends ApiRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
     */
    public function rules(): array
    {
        return [];
    }

    public function attributes()
    {
        return [];
    }

    /**
     * Get custom messages for validator errors.
     *
     * @return array
     */
    public function messages(): array
    {
        return [];
    }
}

useCase層

SNSから2回叩かれるイメージです。
1回目
SubscriptionConfirmationが入っているレスポンスが送信され、SubscribeURLを叩いてSNSサブスクリプション確認が行われる。SubscribeURLを叩いて確認しないと、処理が進まず止まります。

2回目
1回目の成功後、Notificationが入っているレスポンスが送信される。
ここで、バウンスメールとなったメールアドレスの情報も入ってきます。

<?php

namespace App\UseCases\Api\Ses;

use App\Enums\BouncedEmailStatus;
use App\UseCases\Api\ApiUseCase;
use App\Services\LogService;
use App\Http\Requests\Api\Ses\SesBouncedEmailRequest;
use App\Repositories\BouncedEmailRepository;
use Illuminate\Http\Exceptions\HttpResponseException;

/**
 * SesBouncedEmailUseCase
 */
class SesBouncedEmailUseCase extends ApiUseCase
{
    /** @var BouncedEmailRepository $bouncedEmailRepository */
    private $bouncedEmailRepository;

    public function __construct(BouncedEmailRepository $bouncedEmailRepository)
    {
        parent::__construct();
        $this->bouncedEmailRepository = $bouncedEmailRepository;
    }

    /**
     * execute
     *
     * @param SesBouncedEmailRequest $input
     * @return array
     * @throws HttpResponseException
     */
    public function execute(SesBouncedEmailRequest $input): array
    {
        try {
            $snsPayload = json_decode($input->getContent(), true, 512, JSON_THROW_ON_ERROR);
            $type = $snsPayload['Type'] ?? '';

            $log = [
                'type'  => $type,
                'sns'   => $snsPayload,
                'input' => $input->all(),
            ];
            LogService::userInfo($type, $log);

            return match ($type) {
                'SubscriptionConfirmation' => $this->handleSubscriptionConfirmation($snsPayload),
                'Notification'             => $this->handleNotification($snsPayload),
                'UnsubscribeConfirmation'  => $this->handleUnsubscribeConfirmation(),
                default                    => ['type' => $type],
            };
        } catch (\Throwable $e) {
            throw new HttpResponseException($this->response->serverErrorResponse($e));
        }
    }

    /**
     * SubscriptionConfirmation
     * SNSサブスクリプション確認を行い、SubscribeURLを叩いて確認
     *
     * @param array $snsPayload
     * @return array
     */
    private function handleSubscriptionConfirmation(array $snsPayload): array
    {
        $ch = curl_init($snsPayload['SubscribeURL']);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 5);
        $response = curl_exec($ch);
        $err = curl_error($ch);
        curl_close($ch);

        if ($err) {
            throw new HttpResponseException(
                $this->response->serverErrorResponse(new \Exception($err))
            );
        }

        return ['type' => 'SubscriptionConfirmation'];
    }

    /**
     * Notification
     * Bounce通知を受け取った場合、メールアドレスを保存
     *
     * @param array $snsPayload
     * @return array
     */
    private function handleNotification(array $snsPayload): array
    {
        $payload = json_decode($snsPayload['Message'], true, 512, JSON_THROW_ON_ERROR);
        $notificationType = $payload['notificationType'] ?? '';

        $emails = [];
        if ($notificationType === 'Bounce') {
            foreach ($payload['bounce']['bouncedRecipients'] as $recipient) {
                $values = [
                    'status' => BouncedEmailStatus::BOUNCE->value,
                    'email'  => $recipient['emailAddress'],
                ];
                $this->bouncedEmailRepository->store($values);
                $emails[] = $recipient['emailAddress'];
            }
        }

        return [
            'type'   => 'Notification',
            'emails' => $emails,
        ];
    }

    /**
     * UnsubscribeConfirmation
     *
     * @param array $snsPayload
     * @return array
     */
    private function handleUnsubscribeConfirmation(): array
    {
        return [
            'type' => 'UnsubscribeConfirmation',
        ];
    }
}

repository層

必要なテーブルに保存 or 更新処理をしてください。

<?php

namespace App\Repositories;

use App\Enums\BouncedEmailStatus;
use App\Models\BouncedEmail;
Use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Crypt;

class BouncedEmailRepository
{
    public function __construct()
    {
        //
    }

    /**
     * store
     *
     * @param array $bouncedEmail
     * @return BouncedEmail
     */
    public function store(array $bouncedEmail): BouncedEmail
    {
        return BouncedEmail::create(
            [
                'status'       => $bouncedEmail['status'],
                'email'        => $bouncedEmail['email'],
            ]
        );
    }
}

presenter層

SubscriptionConfirmationの場合、ステータスコード200のレスポンスを返すようにしてください。
それ以外は、特に指定はなったと思います。

<?php

namespace App\Presenters\Api\Ses;

use App\Presenters\Api\ApiPresenter;
use Illuminate\Http\JsonResponse;

class SesBouncedEmailPresenter extends ApiPresenter
{
    /**
     * execute
     *
     * @param array $output
     * @return JsonResponse
     */
    public function execute(array $output): JsonResponse
    {
        if (array_key_exists('emails', $output)) {
            $contents = [
                'type'   => $output['type'],
                'emails' => $output['emails'],
            ];
            return $this->response->successCreatedResponse($contents);
        }

        $contents = [
            'type'   => $output['type'],
        ];
        return $this->response->successResponse($contents);
    }
}

7.参考

Discussion