フィンテック系や会員制SaaSでパスワードリセットメールの配信基盤を自社実装しているエンジニアに向けた内容です。特に、送信APIとバウンス(宛先不達)・苦情通知をポーリング(定期的な問い合わせ)で受け取る構成を採っているチームの参考になれば幸いです。
リセットトークンの有効期限を10分に絞っているのに、なぜか同じ宛先へリセットメールが二重に送られてしまう。そんな不具合を見たことがあるなら、原因はトークンの短さではなく、配信イベントの反映タイミングにあるかもしれません。
何が起きるか
パスワードリセットのようなトランザクションメール(自動送信される業務通知メール)では、ユーザー体験上の安全性とメール配信の制御は別のレイヤーの話です。
トークンの有効期限が10分であっても、送信先がすでにバウンス(メールボックスが存在しない等での配信失敗)や苦情(迷惑メール報告)の対象になっているかどうかの判定は、また別の仕組みで行われます。
この2つを混同すると、「トークンは安全に短命化できている」のに「本来抑制すべき宛先へ再送信してしまう」という矛盾した状態が発生します。ダッシュボード上のメトリクスは正常に見えるため、発見が遅れがちです。
なぜ起きるか
原因を段階的に分解すると、次の3つの独立した事実が組み合わさっています。
1つ目は、送信APIがメール送信リクエストに成功応答を返すタイミングと、受信側でバウンスや苦情が確定するタイミングが一致しない点です。SMTP(メール転送プロトコル)の仕組み上、送信直後は「受理された」ことしか分かりません。実際に相手のメールサーバーが拒否するかどうかは後から判明します。
2つ目は、配信イベントをポーリングAPI(アプリ側が定期的に問い合わせて結果を取得する方式)で取得している場合、そのポーリング間隔ぶんの遅延が必ず発生する点です。たとえば5分間隔でポーリングしているとします。10:00にリセットメールを送信し、10:01に配信先で苦情が記録されたとしても、アプリ側がその苦情を認識できるのは次のポーリングが走る10:05以降です。もし10:02に同じ宛先へ2回目のリセットジョブが実行されれば、まだ存在しないはずの苦情情報を使って抑制判定することはできません。
3つ目は、この一連の遅延がログ上では「エラーなし」に見えてしまう点です。送信APIは成功、トークンの期限管理も正常、ポーリングワーカーもエラーゼロ。それでも2回目のジョブは古い情報のまま宛先を「送信可能」と判定してしまいます。個々のコンポーネントが健全に見えるからこそ、根本原因の特定が難しくなります。
この構造はSPF(送信ドメイン認証の仕組み、RFC 7208で規定)・DKIM(電子署名によるなりすまし防止)・DMARC(送信ドメインのポリシー表明)を正しく設定していても防げません。これらはあくまで送信ドメインの正当性を示す仕組みであり、宛先ごとの抑制判定(サプレッション)とは別の関心事だからです。
自分のプロジェクトが該当するか確認する方法
次の観点で、自社のメール配信構成を点検してみてください。
- 配信イベント(バウンス・苦情)の取得方式がWebhook(イベント発生時にプッシュ通知される方式)かポーリングか。設定ファイルやSDKの初期化コードで
poll_intervalやwebhook_urlに類する項目を検索すると分かります - ポーリングを使っている場合、その間隔設定値(秒数・分数)を確認する。多くのメール配信サービス(SendGrid、Mailgun、Amazon SESなど)はダッシュボードの「Webhook設定」または「Event API」の項目でどちらを使っているか明示されています
- 抑制判定用のテーブル(サプレッションリスト)が、配信サービス側のAPI応答をリアルタイムで参照しているか、それとも自社DBに書き込まれた結果を参照しているかを確認する
- 同一宛先への再送処理が、直近の配信イベント取得から何分以内に許可されているかをログで追跡できるか確認する
- リセットトークンの有効期限(例: 10分)と、配信イベントの反映遅延(ポーリング間隔)を数値で比較し、後者が短いSLO(サービス品質目標)を満たしているか判断する
該当する項目が多いほど、二重送信や抑制漏れのリスクが高い構成といえます。
対策の手順
対策は大きく3段階に分けられます。
1. 送信前チェックをローカルの抑制テーブルに一本化する
メール送信のたびに配信サービスへ問い合わせるのではなく、自社が保持するサプレッションテーブルを必ず参照してから送信キューに投入する構成にします。配信サービス側のイベントは、このローカルテーブルを更新するための入力情報として扱います。
2. イベント反映を冪等(べきとう)な書き込みにする
ポーリングワーカーがバウンスや苦情を検出したら、イベントID・タイムスタンプとともに正規化した状態をローカルDBへ書き込みます。この書き込みは同じイベントを2回処理しても結果が変わらない、冪等な処理として実装します。ワーカーの再試行やページ処理の重複実行があっても、状態遷移が二重に発生しないようにするためです。
合わせて、カーソル(どこまで処理したかの位置情報)はバッチ処理とその派生する判定がすべて永続化された後にのみコミットします。途中でクラッシュしても、未処理分を再取得できる設計にしておきます。
3. SLOに対してポーリング間隔が十分かを数値で判定する
「バウンスや苦情が記録されてから、次の送信判定までに許容できる遅延は何分か」を先に定義します。そのうえで、ポーリング間隔がその許容値を下回っているか確認します。
たとえば許容遅延を5分と定めたなら、ポーリング間隔も5分以内、かつ苦情がローカル判定ストアへ反映されるまでの総遅延を5分以内に収める必要があります。ポーリング間隔を数秒単位に縮めれば遅延は減らせますが、それはリクエスト数の増加と引き換えであり、Pull型(能動的に取得する方式)の仕組みがPush型(能動的に通知される方式)に変わるわけではありません。もしこのギャップがSLOを満たせないなら、Webhookのようなプッシュ型のイベント経路への切り替えを検討する段階です。
インシデント発生時の調査を容易にするため、適格性チェックの結果・ローカル抑制テーブルのバージョン・配信サービス側の操作ID・イベントの経過時間はすべてログに残しておきます。これにより、関係のないタイムスタンプの羅列ではなく、因果関係のある時系列として障害を追跡できます。
まとめ
パスワードリセットメールの信頼性は、トークンの有効期限だけでは担保できません。
確認すべきポイントは次の3点です。
- 配信イベントの取得方式(Webhookかポーリングか)と、その間隔設定値を今すぐ確認する
- 抑制判定がローカルの永続化されたテーブルを参照しているか、配信サービスへの都度問い合わせに依存していないか点検する
- リセットトークンの有効期限と配信イベント反映の遅延を数値で比較し、SLOを満たしているか判断する
まずは自社の設定ファイルやSDK初期化コードで poll_interval に類する値を探すところから始めてみてください。数分の遅延が思わぬ二重送信につながっているかもしれません。