ActionMailerでのエラーハンドリングと、メール配信機能の冗長化

f:id:andpad-hijikata:20210908090929p:plain

バックエンドエンジニアの宮澤です。

弊社のANDPADでは多くのWebサービスと同じようにメールでの通知機能を持っています。 今回はRailsと複数のメール配信サービスを組み合わせて、メール配信機能を冗長化した事例を紹介します。

ANDPADで導入しているメール配信サービス

ANDPADでは2つのメール配信サービスを利用しています

通常はMailgunを主系として利用し、SendgridはMailgunでの送信失敗時に備えて待機する構成になっていて、Mailgunで障害が発生した場合でも自動的にSendgird経由でメール配信できるようになっています。

また、サブにSendgridを採用している理由にRFCに準拠しないメールアドレスへの対策もあります。 ANDPADでは個人の携帯キャリアメールで登録しているアカウントも多く、キャリアメールの過去の経緯からRFCに準拠しないアドレスも少数ですが登録されています。 MailgunではRFCに準拠しないメールアドレスは容赦なく弾く仕様となっているのですが、Sendgridは日本でのサービス開始時にそういったメールアドレスへの送信にも対応しています。

sendgrid.kke.co.jp

Railsでメール配信を冗長化する方法

では、Railsで2つのメール配信サービスをメイン/サブとして切り替えて利用する方法を紹介します。

Mailgunでのエラーをキャッチする

まずは主系であるMailgunで配信できなかったことを検知する仕組みです。 Mailgunでのメール送信には mailgun-ruby のGemを利用しています。 github.com

mailgun-rubyではMailgun APIからメール送信リクエストを弾かれると Mailgun::CommunicationError を投げるようになっているので、この例外をキャッチしてエラーハンドリングします。

私の入社時にはエラーハンドリングをこのように記述していました。

# サンプル
begin
  NotifyMailer.send_receive_message(@message).deliver
rescue Mailgun::CommunicationError => e
  # Mailgunで配信失敗したときの処理
  .
  .
  .
end

これではエラーハンドリングを各所のメール送信処理ごとに記述する必要があり、実際にこの記述が漏れて一部のメール送信を伴う機能では実装漏れも発見されました。 また、この方法ではメール送信処理を #deliver_later でエンキューする場合はキャッチすることができません(ANDPADでは #deliver をコールする処理自体をsidekiqジョブで実行することで、非同期実行でもエラーをキャッチしていましたが、非常にややこしい書き方になっていた)

そういった実装漏れや非同期でのメール送信ができない制限をクリアするためにも、メール送信基盤として Mailgun::CommunicationError のエラーハンドリングのリファクタリングを始めました。

調査したところ、Rails5.1からはActionMailerでもrescue_fromが実装されたのを知り、これを使って実装することにしました。

# /app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base
  rescue_from Mailgun::CommunicationError, with: :resend_by_smtp

  # Mailgunで送信新失敗したメールをSendgridで再送する
  def resend_by_smtp(e)
    # Mailgunで配信失敗したときの処理
    .
    .
    .
  end
end

メール送信の基底クラスとなるApplicatonMailerクラスでrescue_fromでのエラーハンドリングを記述することで、これを継承する各メール送信処理のクラスでもエラーキャッチできるようになって実装漏れが防げます。 また、この方法ではエラーキャッチをMailerクラスの呼び出し元ではなく、Mailerクラス内部に持つので #deliver_later でジョブとして実行する場合でもエラーをキャッチすることができます。

注意点として、Railsでは config.action_mailer.raise_delivery_errors をtrueにしておかないとメール配信での失敗時のエラーを握りつぶしてしまいます。 外部サービスでは配信失敗しているはずなのに例外をキャッチできない場合はこの値を確認してみましょう(自分はこの設定が原因で2時間ほど無駄にしました)

railsdoc.com

Sendgrid経由でのメール再送

次にエラーキャッチしたあとにSendgrid経由に切り替えてメール送信する方法を解説します。

これにはいくつかやり方があると思うのですが、ANDPADではSendgridの設定を config.action_mailer.smtp_settingsに設定して、エラーハンドリング中に切り替えるようにしています。

# /config/environments/xxxx.rb

# Mailgunの接続情報
config.action_mailer.delivery_method = :mailgun
config.action_mailer.mailgun_settings = {
  api_key: `xxxxxxxx`,
  domain: Settings.domain,
}

# Sendgridの接続情報
config.action_mailer.smtp_settings = {
  domain:         Settings.domain,
  address:        "smtp.sendgrid.net",
  port:            587,
  authentication: :plain,
  user_name:      'apikey',
  password:       `xxxxxxxx`
}
# /app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base
  rescue_from Mailgun::CommunicationError, with: :resend_by_smtp

  # Mailgunで送信新失敗したメールをSendgridで再送する
  def resend_by_smtp(e)
    # NOTE: Mail::Messageインスタンス
    message = mail

    # 送信設定をconfig/environments/xxxx.rbで設定したsmtp_settingsに上書きする
    message.delivery_method(:smtp, Rails.configuration.action_mailer.smtp_settings)

    # Sendgrid経由でメール再送する
    message.deliver
  end
end

これでメール配信サービスの冗長化ができました。

通常はメインのMailgunでメール配信して、Mailgunに障害が発生しても自動的にSendgrid経由で再送します。 実際にMailgun側の障害があったときにも、Sendgrid側で再送されてANDPADでの障害を防いだ場面が何度かありました。

RFC非準拠のメールアドレスへSendgrid経由でメール送信する

冒頭で書いたようにSendgridをサブで利用しているのには、Mailgunで弾かれてしまうRFC違反のメールアドレスへの配信という目的もあります。 RFC違反で弾かれた場合でも同様に Mailgun::CommunicationError が投げられるので、その対策もしておきます。 SendgridではローカルパートをダブルクォーテーションでくくることでRFCに準拠していないメールアドレスへも送信が可能です。

# /app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base
  rescue_from Mailgun::CommunicationError, with: :resend_by_smtp

  # Mailgunで送信新失敗したメールをSendgridで再送する
  def resend_by_smtp(e)
    # NOTE: Mail::Messageインスタンス
    message = mail

    # 送信設定をconfig/environments/xxxx.rbで設定したsmtp_settingsに上書きする
    message.delivery_method(:smtp, Rails.configuration.action_mailer.smtp_settings)

    # RFC非準拠のアドレスはローカルパートをダブルクォーテーションで囲う
    message.to = message.to.map do |to|
      address = Mail::Address.new(to)
      %("#{address.local}"@#{address.domain})
    end

    # Sendgrid経由でメール再送する
    message.deliver
  end
end

Sengridが障害で止まった場合にはRFCに準拠しないメールアドレスへの配信は止まってしまうのですが、そういったメールは少量なので一定期間はSidekiqがプールしてSendgridが復活次第に再送できる仕組みになっています。

おまけ

deviseでのメール送信にも適用する

ユーザー認証にdeviseを利用しているプロダクトでは、deviseの機能で登録完了メールやメール認証をしている場合もあります。 deviseのデフォルト設定ではメール送信を行うDevise::MailerクラスはActionMailer::Baseを直接継承しているので今回の実装が適用されないのですが、deviseの設定 parent_mailerで ApplicationMailerを継承させることができます。

# config/initializers/devise.rb

# Configure the parent class responsible to send e-mails.
config.parent_mailer = 'ApplicationMailer'

おわりに

アンドパッドでは様々に工夫をこらして開発しています!
興味のある方、下記サイトからお話しに来てください! engineer.andpad.co.jp