以下の内容はhttps://www.m3tech.blog/entry/2025/08/28/113000より取得しました。


メルマガ配信基盤のバウンスメール発生率を60%減らした話

はじめまして。4月にQLife(エムスリーグループ会社)に中途入社した安田(@Quarter_st)です。主に社内向けのメルマガ配信基盤の開発に携わっております。

バウンスメールはメール送信に失敗すると送信先サーバからenvelope-fromに返されるメールです。 弊社のメルマガ配信基盤で使用しているエムスリーのメールサーバの信頼性を下げないためにもバウンスメールの発生率を下げておきたいです。

今回は、そんなバウンスメールの発生理由を可視化し、発生理由が二度と相手に届かない類のものである場合は該当アドレスを送信対象から除外し、バウンスメール発生率を低下させた話をご紹介します。

はじめに

この取り組みによってバウンスメールの平均発生率は約60%減少しました。技術的にどのようなアプローチを取ったかを共有させてください。

※記載しているソースコードは簡略化しているのでコピー&ペーストしても正しく動かない場合がありますのでご了承ください。

バウンスメールの発生理由を可視化した

バウンスメールは次のような形式で送信されます(一部抜粋)。

This is the mail system at host xxx.xxx.

I'm sorry to have to inform you that your message could not
be delivered to one or more recipients. It's attached below.

For further assistance, please send mail to postmaster.

If you do so, please include this problem report. You can
delete your own text from the attached returned message.

                   The mail system

パッと見ただけじゃなぜエラーが発生したのかがわかりません。全文だともっと長いので、配信ごとに何万件も発生するバウンスメールを人間の目で毎回確認するのは難しいです。こういうときはサードパーティのライブラリに頼ってみましょう。

Sisimaiはバウンスメールを解析してくれるライブラリ

今回はSisimaiを利用してバウンスメールを解析しました。記事執筆時点でPerl版・Ruby版・Go版の3つがリリースされていますが、Ruby版を使用することにしました。

Sisimai.rise()関数は引数にバウンスメール全文をそのまま渡すと、次のような発生理由(reason)を含むオブジェクトの配列で解析結果を返してくれます。

[
  {
    "destination": "google.example.com",
    "lhost": "gmail-smtp-in.l.google.com",
    "hardbounce": 0,
    "reason": "authfailure",
    ...
  }
]
引用元: https://libsisimai.org/ja/start/#usage

reasonに関しては、Sisimai公式のバウンス理由の一覧表に次のように記載されています。

Sisimaiは以下の36種類のバウンス理由を検出し、 解析済みデータの"reason"にバウンス理由を入れます。 "reason"の値は"userunknown"や "expired"のように、全て小文字で表記されます。

今回はこの値を使用してバウンスメール発生理由を判定します。

ちなみにSisimaiは2025/08/16で最初のPerl版リリースから11周年を迎えたそうです。おめでとうございます!🥳

SisimaiをLambdaで動かしてバウンスメール発生理由をDBに保存する

Sisimaiはバウンスメールを受信するごとにLambdaで動かします。全体のインフラ構成は次の通りです。

バウンスメール解析処理

SESはenvelope-fromに送信されるメール(=バウンスメール)を受信し、S3に保存します。 その後、S3イベント通知でSQSに送信し、イベントソースマッピングでSQSキューをトリガーとしてRubyのLambdaにバウンスメールを渡してSisimaiを動かします。

RubyのLambda上でSisimai.rise()した後はTypeScriptのLambdaに解析結果を渡してDBにバウンス発生理由を保存してもらいます。

# Lambda バウンス解析(Ruby)
result = Sisimai.rise(bounce_mail_text)[0]
## TypeScriptのLambdaに解析結果を渡す
lambda.invoke({
  function_name: "<Lambda バウンス保存>",
  payload: result.to_json,
})
// Lambda バウンス保存(TypeScript)
await prisma.bounce.create({
  data: {
    // convert()でUPPER_SNAKE_CASEに変換
    reason: convert(args.reason),
    ...
  },
});
// Prismaのスキーマ
model Bounce {
  id     Int    @id
  email  String
  reason String?
  ...
}

バウンスメール発生理由を管理画面に表示する

保存したバウンスメール発生理由を管理画面に表示します。reasonにはMAILBOX_FULLのような英単語が保存されます。 このまま表示すると少しわかりづらいので、日本語に変換してから表示します。

// バウンス理由のオブジェクト配列
const reasons = [
  {
    type: "USER_UNKNOWN",
    name: "宛先ユーザ不明",
  },
  {
    type: "MAILBOX_FULL",
    name: "メールボックス容量超過",
  },
  {
    type: "SUSPEND",
    name: "アカウント一時停止",
  },
    ...
];
// バウンス理由を日本語文字列に変換する
const getReasonName = (type: string) =>
  reasons.find((r) => r.type === type)?.name ?? "不明";

管理画面に表示されるバウンスエラー発生理由

私自身バウンスメール発生理由のすべてがどのようなものか熟知しているわけではないので、Sisimaiのドキュメントに記載されている一覧表を参考にして日本語名を作成しました。

libsisimai.org

これでどのアドレスにどんな配信エラーが発生しているのかを一覧できるようになりました。

ハードバウンスが発生しているアドレスを送信対象から除外した

「二度と相手に届かない類」のバウンスはハードバウンスと呼ばれます。ハードバウンスが発生しているアドレスにはどれだけメールを送信しても永遠に届かないので、送信しないようにする必要があります。

メルマガ配信基盤では1回の配信ごとに配信リストを毎回アップロードします。アドレスは複数のリストで使用されることがあるので、ハードバウンスが発生した時点で配信リストから削除しても、新しく作成される配信リストにはそのアドレスが含まれる可能性があるのです。なので、対応としては、メール送信時に毎回リストをチェックしてハードバウンスが発生したアドレスを送信対象から除外することにしました。

アドレス取得クエリを改善

既存のメール送信処理では次のように配信リストに存在するアドレスすべてに対してメールを送信しています。

// 特定の配信リストからアドレスを全件取得
const addresses = await prisma.address.findMany({
  where: { listId: args.listId },
});
for (const address of addresses) {
  // 送信処理
}

ここからハードバウンス発生履歴のあるアドレスを送信対象から除外するように変更します。下記3つのバウンス理由をハードバウンスとして扱います。

  • Host Unknown:宛先ホスト名が存在しない
  • User Unknown:宛先メールアドレスは存在しない
  • Has Moved:宛先メールアドレスは移動した
// 配信リストの最初と最後のIDを取得
const {
  _min: { id: firstId },
  _max: { id: lastId },
} = await prisma.address.aggregate({
  _min: { id: true },
  _max: { id: true },
  where: { listId: args.listId },
});

// prismaでselectするデータの型定義
type Address = {
  id: number
  listId: number
  email: string
  ...
}

// ハードバウンス文字列の配列
const HARD_BOUNCE = ["HOST_UNKNOWN", "USER_UNKNOWN", "HAS_MOVED"];

// チャンク処理
let cursor = firstId - 1;
while (cursor < lastId) {
  // 10,000件ごとにハードバウンス発生履歴のないアドレスを取得
  const addresses = await prisma.$queryRaw<Address[]>`
    SELECT a.*
    FROM address a
    LEFT JOIN bounce b
      ON a.email = b.email
        AND b.reason IN (${Prisma.join(HARD_BOUNCE)})
    WHERE
      a."listId" = ${args.listId}
      AND a.id > ${cursor}
      AND a.id <= ${lastId}
      AND b.email IS NULL
    ORDER BY a.id ASC
    LIMIT 10000;
  `;
  // カーソルを更新
  cursor = addresses.at(-1) ? addresses.at(-1).id : lastId;
  for (const address of addresses) {
    // 送信処理
  }
}

LEFT JOINの条件として b.reason IN (${Prisma.join(HARD_BOUNCE)}) を入れることでハードバウンスのレコードがアドレスに紐づきます。LEFT JOIN時点でaddressはすべて残っているので、このうち紐づくハードバウンスがない(b.email IS NULLである)アドレスのみをWHEREで抽出します。

また、JOINによるDBへの負担を下げるため、カーソルページネーションを用いてチャンクに分割して処理を行っています。LIMITよりもWHEREの方が実行タイミングが先なので、例えば処理中に配信リスト内のアドレスにハードバウンスが発生しても処理漏れのidが発生することはありません。

送信対象絞り込み前後でバウンスメール発生率を比較

ハードバウンスが発生したアドレスをメール送信対象から除外したことでどれくらいバウンスメール発生率に影響したかを調べてみます。

リリース前後の特定の3週間の配信をそれぞれ抽出して比較すると、リリース後の3週間のバウンスメール発生率の平均値・中央値はどちらもリリース前より約60%減少していました。

まとめ

バウンスメールの発生理由を判定し、ハードバウンス発生履歴のあるアドレスをメルマガ配信対象から除外したことでバウンスメール発生率を下げることができました。

AWSが本当の意味で何もわからない状態から取り組みましたが、上司に色々教えてもらいながらリリースさせることができました。メールの仕組みについてはまだまだ勉強しなきゃといったところです。

We are Hiring!

QLifeでは一緒にプロダクト開発をするエンジニアを絶賛募集中です!カジュアル面談も行っていますので、ご興味ある方は是非ご応募ください!

www.qlife.co.jp




以上の内容はhttps://www.m3tech.blog/entry/2025/08/28/113000より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14