月間数百万通のメール送信サービスをIPウォームアップしつつ切り替えたら到達率が向上した話

はじめに

こんにちは。コミューンでスクラムマスターをしているまつむらと申します。

今回はコミューンで私が取り組んだ技術課題のなかから「メール送信システムのリプレース」について記載させていただこうと思います。

背景

コミューンは、BtoBおよびBtoC向けにコミュニティを作成できるプロダクト「commmune」を提供しています。

commmune ではメール送信のために SendGrid という SaaS を利用しているのですが、 2022年5月頃、事情により SendGrid のアカウントを変更することになりました。

当時のメールの送信数は月間数百万通ほどあったため、IPウォームアップ(次節で簡単に説明)を行う必要がありました。

IPウォームアップとは?

※ 本記事のテーマとは逸れるため、ごくごく簡単な説明に留めさせていただきます。

メールサービス(Gmailなど)が施している迷惑メール対策の一つに「メール送信者のIPアドレスの評価(IPレピュテーション)」があります。

迷惑メール対策の一つに「送信元のIPアドレスの信頼性」があります。 一度もメールを送ったことがないIPアドレスの信頼性は、良くも悪くゼロです。
この状態で大量件数のメールを送信すると、謎IPアドレスからのメールバラマキと判断され、スパム扱いされるリスクが高くなります。すなわちお客様の受信箱にメールが届かなくなってしまいます。

この対策として、少量のメール送信件数で実績を積み、徐々に送信件数を増やしていくことでIPアドレスの評価を高めていくことを IPウォームアップ と呼びます。 具体的には、1日ごとに以下のように送信数を増やしていきます。

IPウォームアップスケジュールの一例
Generic_IP_Warmup_Schedule.pdf より引用

移行計画のための準備

IPウォームアップを効果的に行うために、commmuneで運営されているコミュニティにおいて以下の分析を行いました。

メールの開封率

特にIPウォームアップの序盤において、開封率やクリック率の高い人に対してメールを送ることができれば、より効果的にIPウォームアップが可能なのではないか?と考えました。

調査の結果、BtoB向けのコミュニティは開封率が高く、BtoC向けのコミュニティでは開封率が低い傾向がありました。
また同じコミュニティにおいても、メールの種別によって開封率に差があることがわかりました。

ドメイン乖離度合

IPアドレスの評価はメールサービスごとに決まります。すなわち、いくらGmailの評価を高めることができてもYahoo!メールの評価はゼロのままです。
そのため、コミュニティに所属するユーザーのメールアドレスのドメインの構成比率に注目しました。

コミューンのサービス全体の構成比率と、コミュニティごとの構成比率がどの程度乖離しているかを指標としました。

調査の結果、全体のドメイン比率(調査当時)は以下のようでした。

2022年5月現在のコミューン登録ユーザーのドメイン比率

またBtoC向けコミュニティだと乖離が小さく、またBtoB向けコミュニティだと企業ドメインが多く乖離が大きい傾向でした。

メール送信数

上記指標についてどんなに優秀なコミュニティだとしても、メール送信数が少ないとIPウォームアップが不十分になります。
そのため、ある程度メールを送信していることも考慮することにしました。

調査の結果、BtoC向けの参加者が多いコミュニティがメール送信数が多い傾向にありました。

上記3つのパラメータからコミュニティごとの優先度を決定し、IPウォームアップのスケジュールを作成しました。

実際に策定した送信スケジュール

実装

要件

前節で作成した移行計画を実現するため、おおまかな機能要件を以下の通りとしました。

  1. コミュニティのIDおよびメールの種別から、新アカウントから送信する確率(0以上1以下)を返す
    1. コミュニティIDは「特定のコミュニティIDを指定」と「全コミュニティ」がある
    2. メール種別は「投稿通知メール」「ダイジェストメール」「全通知メール」「全メール」がある
  2. 確率情報はデータベースに保存される
    1. メール送信件数が多いため、DBのデータをキャッシュする必要がある

メール種別は以下の通りです。

メール
├── 通知メール
│   ├── 投稿通知メール
│   └── その他の通知メール
├── ダイジェストメール
└── その他のメール

通知メールとは、例えばコミュニティ内で自分がメンションされたり自分の投稿が「いいね」された際などに送られてくるメール
ダイジェストメールとは、コミュニティで起こったことのサマリーについて週に1度送られてくるメール
その他のメールとは、ユーザーがメールアドレスを登録した際に有効なメールアドレスかどうかを検証するための認証メールなどを指します。

ソースコード

これに基づき以下のような実装を行いました。 ※ 実際に使用したソースコードからは一部改変しています。

export type MailType = "Post" | "Digest" | "OtherNotification" | "Other";

export class MailSwitchService {
  // ☆ 工夫ポイントその1 ☆
  // DBに保存されている、取得時刻現在に有効な条件データを全件キャッシュする
  // 「全コミュニティ」「全メール」という条件基準があるので、
  // コミュニティIDやメール種別をキーとしたMapで保持することができない。
  // また期間を通して同時に有効になる条件はせいぜい数十である。
  // ゆえに有効なレコードを全件保持しておく。
  private switchingConditionsCache: SwitchingCondition[];
  // 上記キャッシュの有効期限
  private cacheExpireAt: Date;

  constructor(
    // データベースにアクセスするもの。中身の詳細は省略
    private repository: SwitchingConditionRepository,
    // ☆ 工夫ポイントその2 ☆
    // 乱数生成器を外部から注入することで、単体テストをしやすくする
    private random: () => number,
    // キャッシュの最大有効期間
    private cacheExpireDurationMillis: number
  ) {
    this.switchingConditionsCache = [];
    // 初期値(サーバ起動直後)は、キャッシュ有効期限を現在時刻にしておく。
    // こうすることで初回呼び出し時にはキャッシュが利用されない。
    this.cacheExpireAt = new Date();
  }

  public async shouldUseNewAccount(
    communityId: number,
    mailType: MailType
  ): Promise<boolean> {
    try {
      const now = new Date();
      // 現在有効な条件レコードを取得して
      const conditions = await this.getSwitchingConditions(now);
      // 新アカウントで送信する確率を計算
      const newAccountRate = this.calcNewAccountRate(
        communityId,
        mailType,
        conditions
      );
      // その確率で true を返す
      return this.probabilisticTrue(newAccountRate);
    } catch (e) {
      return false;
    }
  }

  private async getSwitchingConditions(now: Date) {
    // まだキャッシュが有効な時刻であれば、キャッシュをそのまま利用
    if (this.cacheExpireAt > now) {
      return this.switchingConditionsCache;
    }

    // DBから現在時刻で有効なレコードを全件取得
    const newSwitchingConditions = await this.repository.findByDate(now);

    // キャッシュおよびキャッシュの有効期限を更新
    this.switchingConditionsCache = newSwitchingConditions;
    this.cacheExpireAt = this.calcNewCacheExpireAt(now, newSwitchingConditions);

    return this.switchingConditionsCache;
  }

  private calcNewCacheExpireAt(now: Date, conditions: SwitchingCondition[]) {
    // ☆ 工夫ポイントその3 ☆
    // キャッシュ有効期限は、以下のうちの早い方とする:
    // 1. 条件レコードの有効期限の時刻のうち、最も早いもの
    // 2. 現在時刻から1時間後(キャッシュの有効期限が経過した時刻)
    const newCacheExpireTime = Math.min(
      now.getTime() + this.cacheExpireDurationMillis,
      ...conditions.map((cond) => cond.sendEndAt.getTime())
    );
    return new Date(newCacheExpireTime);
  }

  private calcNewAccountRate(
    communityId: number,
    mailType: MailType,
    conditions: SwitchingCondition[]
  ): number {
    const scoredConditions = conditions
      // 例えば「communityId = 1 指定」「全コミュニティ」のレコードが両方存在した場合
      // communityId = 1 においては前者のレコードを
      // communityId = 2 においては後者のレコードを
      // それぞれ利用したい。
      // ゆえに、条件に対してスコア付を行い、
      // スコアの最も高いレコードに書かれている確率値を採用する。
      .map((condition) => ({
        condition,
        score: this.scoreCondition(communityId, mailType, conditions),
      }))
      // ただし、スコアがゼロ(条件に全く合致しないため採用すべきでない)のものは除外する
      .filter((scoredCondition) => scoredCondition.score > 0)
      // スコアを昇順で並び替える
      .sort((a, b) => b.score - a.score);
    if (scoredConditions.length === 0) {
      return 0;
    }
    return scoredConditions[0].condition.newAccountRate;
  }

  private scoreCondition(
    communityId: number,
    mailType: MailType,
    condition: SwitchingCondition
  ): number {
    let score = 0;

    // コミュニティIDの条件に対して、以下の順に重み付けを行う
    // 完全一致 > 全コミュニティ
    if (condition.communityId === communityId) {
      score += 90;
    } else if (condition.communityId === -1) {
      score += 50;
    } else {
      // 該当コミュニティIDの条件レコードが存在しないので、スコア無し
      return 0;
    }

    // メール種別の条件に対して、以下の順に重み付けを行う
    // 現在確率を求めたいメール種別が Post の場合
    //   Post(完全一致) > OtherNotification > Other
    // 現在確率を求めたいメール種別が Post以外 の場合
    //   完全一致 > Other
    if (condition.mailType === mailType) {
      score += 9;
    } else if (
      condition.mailType === "OtherNotification" &&
      mailType === "Post"
    ) {
      score += 8;
    } else if (condition.mailType === "Other") {
      score += 5;
    } else {
      // 該当メール種別の条件レコードが存在しないので、スコア無し
      return 0;
    }

    return score;
  }

  private probabilisticTrue(rate: number): boolean {
    const random = this.random();
    return rate > random;
  }
}

データベース

テーブルは以下のように設定しました

name 説明
communityId 対象となるコミュニティID。全コミュニティが対象の場合は-1を入れる
mailType 投稿通知 = 'Post', ダイジェストメール = 'Digest', 通知すべて = 'OtherNotification', 全メール = 'Other'
newAccountRate 新アカウントから送信する割合
sendStartAt レコードの有効開始時刻(DBから一括取得する際に利用)
sendEndAt レコードの有効終了時刻(DBから一括取得する際と、キャッシュの有効期限の計算に利用)

工夫ポイント

その1: データ全件取得

最終的には全コミュニティ・全メールが新しいアカウントから送信されます。
それを表現するため、条件レコードは「全コミュニティ」「全メール」という条件が含まれることになります。

  1. コミュニティIDは「特定のコミュニティIDを指定」と「全コミュニティ」がある
  2. メール種別は「投稿通知メール」「ダイジェストメール」「全通知メール」「全メール」がある

そのため、DBから直接条件レコードを引いてこようとすると条件文が非常に煩雑になります。
またキャッシュを持とうとした場合、 コミュニティID × メール種別 でキーとして持つ必要があり、キャッシュ量が多くなります。

これを解決するために、逆に条件レコードは先にすべてDBから取得しておき、プログラム側で絞り込みを行う戦略を取りました。
同時に有効になる条件レコードはせいぜい数十件であったため、キャッシュ量も少なくて済みます。
またどこのコミュニティ・どのメール種別であっても、初回1回のみのアクセスで済むためDBアクセス数も減らすことができます。
さらに絞り込み条件が 現在時刻で有効なもの のみとなるため、複雑なSQLを書く必要もなくなりました。

※ 厳密に言うとロック等をしていないため、キャッシュが無効な状態で同時にリクエストが合った場合、複数回のDBアクセスになる可能性はあります。しかし今回のケースで問題になることはありません。

その2: 乱数生成器を外部から注入

乱数を使うと再現性が乏しくなるため、テストが困難になりがちです。
そのため、関数を外部から注入する戦略を取りました。

実環境でのインスタンス化には単純に Math.random を注入しました。
(そこまで厳密なランダム性を求められていないため)

単体テストのためのインスタンス化においては以下のような関数を注入し、乱数を制御しました。

const mockRandom = (...nums: number[]) => {
  let i = 0
  return () => {
    return nums[i++] ?? 0
  }
}

const service = new MailSwitchService(
  mockRepository, 
  mockRandom(0.0, 0.3, 0.6, 0.9), 
  10000,
)

// 以下テストケース実装

こうすることで、呼び出し n 回目の乱数を制御することができ、期待値を完全にコントロールすることができました。

その3: キャッシュ有効期限のコントロール

今回の要件は、ある時刻で挙動を切り替えたいがキャッシュを利用したいというものでした。
そのため、キャッシュの有効期限の設定値がいかに長かろうと、条件レコードの有効期限が来たら強制的にキャッシュを無効化する仕組みを作成しました。

副次的に、動作確認時はキャッシュを短くしたい、実稼働時は長くしてもいい、ということを解決することができました。
すなわち、キャッシュの有効期限を長い設定にしていても、動作確認中は有効期限を数分刻みにしたレコードを入れておくことで、数分で設定を切り替えることができ、キャッシュが切れるまで待つ(あるいはサーバを再起動する)ことなくたくさんの条件を変えながらの動作確認が可能となりました。

結果

上記切り替えのための実装により、IPウォームアップしながら少しずつメール送信を新しいアカウントに切り替えていくことができました!
また到達率を向上させることにも成功しました!

IPウォームアップの切り替え結果

やらかし

キャッシュ方針もうまくいき、単体テストもしっかり書くことができたまではよかったのですが、ひとつやらかしがありました。

問題はダイジェストメールで発生しました。
ダイジェストメールは性質上100通まとめてバッチ的にメール送信を行っているため以下のような実装を行いました。

// 100通のメールが来る
const mails = buildMails()

const newAccountMails = []
const oldAccountMails = []

for (const mail of mails) {
  const shouldUseNewAccount = await service.shouldUseNewAccount(...)
  // 新旧どちらのアカウントを使うべきかで分散させる
  if (shouldUseNewAccount) {
    newAccountMails.push(mail)
  } else {
    oldAccountMails.push(mail)
  }
}

// ここでやらかしがあった!!!!
newAccountClient.send(mails)
oldAccountClient.send(mails)
// 正しくは以下の通り
// newAccountClient.send(newAccountMails)
// oldAccountClient.send(oldAccountMails)

メールを分割させたところまでは良かったのですが、分割前のメール配列を送信クライアントに渡してしまいました。
そのため新旧両方からのメールが送信されてしまい、同じメールが2通届いてしまうという障害を発生させてしまいました。

単体テストや型エラーでは検知できない部分から障害が発生してしまうことを痛感する結果になってしまいました。
やはり実環境での動作確認は非常に大切であり、ダイジェストメールは性質上動作確認が困難だったため、不十分な確認しかできていなかったことが反省点です。

まとめ

メールアカウントの切り替えにあたり、確率的に分散させる実装を試してい見てうまくいきました。
乱数をモックする戦略により、単体テストを書くこともできました。

しかしそれを利用するところでバグを仕込んでしまったというところは反省点となりました。

本記事執筆にあたりメール送信状況を改めて確認したところ、2022年12月現在のメール送信数は2022年5月と比較して約1.7倍になっていました! このように急成長しているプロダクトを一緒に支えてくれるエンジニアを募集しています!

forms.gle