✉️

ActionMailerでフォールバック可能なSMTP通信を実現する

に公開

はじめに

スマサテで開発を担当している望月です。
弊社のサービスでは、メール送信に Ruby on Rails の ActionMailer と、SMTP サーバーとして Amazon Simple Email Service (SES) を利用しています。
しかし、2025年10月20日に発生した AWS の us-east-1 リージョンの障害 により、メール送信機能に障害が発生しました。

ユーザー登録時の確認メール、パスワードリセット、重要通知など、メールが届かないことはサービスにとって致命的です。
このような障害は直接ビジネスへ影響を及ぼすため、今後同様の事態が発生してもサービスを継続できるよう、任意の SMTP サーバー間で自動的にフォールバック可能な仕組みを構築しました。

なお、SES には グローバルエンドポイント という新機能が 2024 年 12 月にリリースされており、これを利用すると AWS 側で自動フォールバックが行われます。
ただし、(2025年10月時点で)この機能は HTTPS (API v2) 経由での送信のみをサポートしており、既存の SMTP 通信では利用できないという制約があるため、今回は採用を見送りました。

本記事では、このフォールバック機構の実装に至るまでの調査過程として以下の内容を整理します。

  • Rails(ActionMailer)におけるメール配信の仕組み
  • 内部で利用されている mail gem の SMTP 送信フロー
  • SMTP 通信で発生する主要なエラーの種類

これらを踏まえ、最終的に実装した カスタム delivery_method の内容を紹介します。

動作環境

本記事のコードは以下の環境で動作確認しています。

項目 バージョン
Ruby 3.4.5
Ruby on Rails 7.2.2.1
ActionMailer Railsに同梱(7.2.2.1)
mail gem ActionMailerに同梱(2.8.1)

ActionMailerの配信メカニズム

delivery_methodの仕組み

ActionMailerでは、メールの送信方法をdelivery_methodで切り替えることができます:

# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: 'smtp.example.com',
  port: 587,
  # ...
}

標準では:smtp:sendmail:test:fileが用意されていますが、カスタムのdelivery_methodを登録することも可能です。

delivery_methodがどのように切り替わるのか

ActionMailerのソースコードを追うと、delivery_methodの仕組みが見えてきます。

add_delivery_methodの内部実装

https://github.com/rails/rails/blob/33beb0a38db1c058123a8e3cc298cad918adfe32/actionmailer/lib/action_mailer/delivery_methods.rb#L21-L28

このメソッドは以下の処理を行います:

  1. smtp_settingsクラス属性を作成
  2. デフォルト設定を保存
  3. delivery_methodsハッシュに登録
module ActionMailer
  module DeliveryMethods
    extend ActiveSupport::Concern

    included do
      class_attribute :delivery_methods, default: {}.freeze
      # ...
    end

    module ClassMethods
      def add_delivery_method(symbol, klass, default_options = {})
        class_attribute "#{symbol}_settings" unless respond_to?("#{symbol}_settings")
        send("#{symbol}_settings=", default_options)
        self.delivery_methods = delivery_methods.merge(symbol => klass).freeze
      end
    end
  end
end

wrap_delivery_behaviorによるdelivery_methodのインスタンス化

https://github.com/rails/rails/blob/33beb0a38db1c058123a8e3cc298cad918adfe32/actionmailer/lib/action_mailer/delivery_methods.rb#L57-L77

これにより、カスタムのdelivery_methodクラスを登録すれば、標準と同じように使えることがわかります。


Mail gemのSMTP送信フロー

ActionMailerは内部でMail gemを使用しています。実際のSMTP送信処理を理解するため、Mail gemのコードを追ってみましょう。

ActionMailerからMail gemへの処理の流れ

UserMailer.welcome(user).deliver_now
  ↓
ActionMailer::MessageDelivery#deliver_now
  ↓
Mail::Message#deliver
  ↓
Mail::Message#do_delivery
  ↓
Mail::SMTP#deliver!
  ↓
Mail::SMTPConnection#deliver!
  ↓
Net::SMTP#sendmail

Mail::Message#deliverからNet::SMTP#sendmailまで

1. Mail::Message#deliver

https://github.com/mikel/mail/blob/b6b6cb737d47a85ddc720fda0e6b991e99224848/lib/mail/message.rb#L267-L272

インターセプター(送信前処理)とオブザーバー(送信後処理)を実行し、do_deliveryを呼び出します。

2. Mail::Message#do_delivery

https://github.com/mikel/mail/blob/b6b6cb737d47a85ddc720fda0e6b991e99224848/lib/mail/message.rb#L2142-L2150

delivery_methoddeliver!メソッドを呼び出します。ここで実際の送信処理に入ります。

3. Mail::SMTP#deliver!

https://github.com/mikel/mail/blob/b6b6cb737d47a85ddc720fda0e6b991e99224848/lib/mail/network/delivery_methods/smtp.rb#L99-L105

SMTPセッションを開始し、Mail::SMTPConnectionに処理を委譲します。

4. Mail::SMTPConnection#deliver!

https://github.com/mikel/mail/blob/b6b6cb737d47a85ddc720fda0e6b991e99224848/lib/mail/network/delivery_methods/smtp_connection.rb#L49-L55

ここで重要なMail::SmtpEnvelopeが登場します。

Mail::SmtpEnvelopeの役割

https://github.com/mikel/mail/blob/b6b6cb737d47a85ddc720fda0e6b991e99224848/lib/mail/smtp_envelope.rb#L11-L15

Mail::SmtpEnvelopeは以下の重要な役割を担っています:

1. smtp_envelope_from/toのサポート

メールヘッダーのFromとは別に、SMTPプロトコルレベルでのエンベロープアドレスを設定できます:

mail.smtp_envelope_from = 'bounce@example.com'
mail.smtp_envelope_to = ['recipient@example.com']

これにより、バウンスメールの処理などが柔軟に行えます。

2. アドレスバリデーション

https://github.com/mikel/mail/blob/b6b6cb737d47a85ddc720fda0e6b991e99224848/lib/mail/smtp_envelope.rb#L45-L55

セキュリティとプロトコル準拠のため、アドレスを検証します。

3. BCCの正しい処理

Mail::SmtpEnvelopeとBCCの関係を理解するには、以下の2つの概念を区別する必要があります。

SMTPエンベロープのto(配送先リスト):

エンベロープのtoには、To/CC/BCC すべての受信者が含まれます。これがSMTPプロトコルのRCPT TOコマンドに使用されます。

https://github.com/mikel/mail/blob/b6b6cb737d47a85ddc720fda0e6b991e99224848/lib/mail/message.rb#L1100-L1106

https://github.com/mikel/mail/blob/b6b6cb737d47a85ddc720fda0e6b991e99224848/lib/mail/message.rb#L1282-L1284

メッセージ本体からのBCC削除:

Mail gemでは、BccField#encodedメソッドがデフォルトで空文字列を返すことで、BCCヘッダーがメッセージ本体に含まれないようにしています。

https://github.com/mikel/mail/blob/b6b6cb737d47a85ddc720fda0e6b991e99224848/lib/mail/fields/bcc_field.rb#L41-L48

処理フロー:

Mail::SmtpEnvelope.new(mail)
  ↓
envelope.to = mail.smtp_envelope_to  # destinations (To/CC/BCC全て)
envelope.message = mail.encoded      # ← ここで BccField#encoded が呼ばれる
  ↓
Mail::Message#encoded
  ↓
ready_to_send!
header.encoded                       # 各フィールドの encoded を呼ぶ
  ↓
BccField#encoded → ''                # 空文字列を返す
  ↓
結果: envelope.message には BCC ヘッダーが含まれない

この仕組みにより、BCCの受信者は他の受信者に知られることなくメールを受け取ることができます。

つまり、Mail::SmtpEnvelope.new(mail)を呼び出すだけで、エンベロープ生成とBCC処理を含む必要な処理がまとめて実行されるということです。

Net::SMTP#sendmailによる実際の送信

https://github.com/ruby/net-smtp/blob/master/lib/net/smtp.rb

Mail::SMTPConnection#deliver!から最終的に呼び出されるのが、Ruby標準ライブラリのNet::SMTP#sendmailメソッドです。このメソッドがSMTPプロトコルに則って実際のメール送信を行います。

Net::SMTP#sendmailの引数
smtp.sendmail(envelope.message, envelope.from, envelope.to)

このメソッドは3つの引数を受け取ります:

  1. message: エンコードされたメールメッセージ本文(ヘッダー + 本文)
  2. from: SMTPエンベロープの送信者アドレス(MAIL FROMコマンドに使用)
  3. to: SMTPエンベロープの受信者アドレス配列(RCPT TOコマンドに使用)

これらはMail::SmtpEnvelopeで準備された値がそのまま渡されます。

SMTPコマンドへのマッピング

Net::SMTP#sendmail内部では、以下のようにSMTPコマンドが実行されます:

# 1. MAIL FROM コマンド
smtp.mailfrom(from)
# → "MAIL FROM:<sender@example.com>"

# 2. RCPT TO コマンド(受信者ごとに実行)
to.each do |addr|
  smtp.rcptto(addr)
end
# → "RCPT TO:<recipient1@example.com>"
# → "RCPT TO:<recipient2@example.com>"
# → "RCPT TO:<bcc@example.com>" # BCCもここに含まれる

# 3. DATA コマンド
smtp.data(message)
# → "DATA"
# → (メッセージ本文を送信) # BCCヘッダーは含まれない
# → "."

ここで重要なのは、BCCの受信者はRCPT TOコマンドには含まれるが、DATAで送信されるメッセージ本文には含まれないという点です。これにより、BCC受信者は他の受信者に知られることなくメールを受け取れます。

エラーハンドリング

Net::SMTP#sendmail実行中にSMTPサーバーからエラーレスポンスが返された場合、ステータスコードに応じた例外が発生します。

https://github.com/ruby/net-smtp/blob/e6d0d2aa256a143f66fff2825e716b90a27ef401/lib/net/smtp.rb#L1112-L1122

このエラー分類が、後述するフォールバックロジックの実装において重要になります。

カスタムdelivery_methodでの利用

カスタムdelivery_methodを実装する際は、以下のパターンでNet::SMTP#sendmailを呼び出します:

  def send_with_smtp(mail, smtp_settings)
    # Mail::SmtpEnvelopeを使用してエンベロープを作成
    envelope = Mail::SmtpEnvelope.new(mail)

    # SMTPセッションを構築(SMTPセッションについては割愛します)
    smtp = build_smtp_session(smtp_settings)

    # SMTPセッションを開始し、sendmailを実行
    smtp.start(
      smtp_settings[:domain],
      smtp_settings[:user_name],
      smtp_settings[:password],
      smtp_settings[:authentication]
    ) do |smtp_connection|
      # sendmailメソッドでメール送信
      smtp_connection.sendmail(envelope.message, envelope.from, envelope.to)
    end
  end

Mail::SmtpEnvelopeを使用することで、以下の処理が自動的に行われます:

  • envelope.message: BCCヘッダーが削除されたメッセージ本文
  • envelope.from: smtp_envelope_fromまたはFromヘッダーの値
  • envelope.to: smtp_envelope_toまたは全受信者(To/CC/BCC)

これにより、標準のMail::SMTPと完全に互換性のある実装が可能になります。

SMTP通信のステータスコードとエラーハンドリング

ステータスコードの分類(4xx vs 5xx)

SMTPのステータスコードはRFC 5321で定義されています:

ここでは一部代表的なものだけを取り上げます。

4xx - Transient Negative Completion Reply(一時的なエラー)

意味: コマンドは受け付けられなかったが、後で再試行すれば成功する可能性がある。

コード 名称 説明 典型的な原因
421 Service not available サービスが利用できず、接続を閉じる サーバー過負荷、メンテナンス、接続数制限
450 Requested mail action not taken: mailbox unavailable メールボックスが利用できない メールボックスがロック中、グレイリスティング
451 Requested action aborted: local error in processing 処理中のローカルエラー サーバー内部エラー、タイムアウト
452 Requested action not taken: insufficient system storage システムストレージ不足 ディスク容量不足、クォータ超過

5xx - Permanent Negative Completion Reply(永続的なエラー)

意味: コマンドは受け付けられず、同じ内容で再試行しても失敗する。

コード 名称 説明
500 Syntax error, command unrecognized コマンドの構文エラー
535 Authentication credentials invalid 認証失敗
550 Requested action not taken: mailbox unavailable メールボックスが存在しない、アクセスできない、ポリシー上の理由でコマンドが拒否された

Net::SMTPServerBusyの発生条件

先ほどmail gemのコードリーディング中にも見た通り、Rubyの標準ライブラリnet-smtpでは、ステータスコードに応じて異なる例外クラスを使用します。

https://github.com/ruby/net-smtp/blob/e6d0d2aa256a143f66fff2825e716b90a27ef401/lib/net/smtp.rb#L1112-L1122

ステータスコードが4で始まる場合、Net::SMTPServerBusyが発生します。
これを使えば再送すべき4xx系のエラーをカバーできそうです。


カスタムdelivery_methodの実装

Mail::MultiRegionSmtpクラスの設計

これまでの調査を踏まえて、カスタムdelivery_methodを実装します。

ファイル: lib/mail/multi_region_smtp.rb

module Mail
  class MultiRegionSmtp
    attr_accessor :settings

    # 再送信対象とする一時的なエラー
    RETRYABLE_ERRORS = [
      Net::SMTPServerBusy,
    ].freeze

    # 共通のSMTP設定
    DEFAULT_COMMON_SETTINGS = {
      port: 587,
      domain: 'sumasate.jp',
      # その他オプション
    }.freeze

    DEFAULT_PRIMARY_SETTINGS = DEFAULT_COMMON_SETTINGS.merge(
      address: 'email-smtp.ap-northeast-1.amazonaws.com',
      user_name: ENV['AWS_SES_USER_NAME'],
      password: ENV['AWS_SES_PASSWORD'],
    ).freeze

    DEFAULT_FALLBACK_SETTINGS = DEFAULT_COMMON_SETTINGS.merge(
      address: 'email-smtp.us-east-1.amazonaws.com',
      user_name: ENV['AWS_SES_FALLBACK_USER_NAME'],
      password: ENV['AWS_SES_FALLBACK_PASSWORD'],
    ).freeze

    def initialize(values = {})
      self.settings = values
    end

    def deliver!(mail)
      primary_settings = resolve_settings(:primary, DEFAULT_PRIMARY_SETTINGS)
      fallback_settings = resolve_settings(:fallback, DEFAULT_FALLBACK_SETTINGS)

      # プライマリリージョンで送信を試行
      send_with_smtp(mail, primary_settings)
    rescue *RETRYABLE_ERRORS => e
      Rails.logger.warn(
        "[MultiRegionSMTP] プライマリリージョン(#{primary_settings[:address]})でSMTP送信失敗: #{e.class.name} - #{e.message}. " \
        "フォールバックリージョン (#{fallback_settings[:address]}) で再試行します。"
      )

      send_with_smtp(mail, fallback_settings)
    end

    private

    # 設定を解決する(カスタム設定 > デフォルト設定)
    def resolve_settings(key, default)
      if settings.is_a?(Hash) && settings[key].is_a?(Hash)
        default.merge(settings[key])
      else
        default
      end
    end

    # SMTPでメールを送信する
    def send_with_smtp(mail, smtp_settings)
      # Mail::SmtpEnvelopeを使用してエンベロープを作成
      envelope = Mail::SmtpEnvelope.new(mail)

      # SMTPセッションを構築
      smtp = build_smtp_session(smtp_settings)

      smtp.start(
        smtp_settings[:domain],
        smtp_settings[:user_name],
        smtp_settings[:password],
        smtp_settings[:authentication]
      ) do |smtp_connection|
        # sendmailメソッドを使用してエンベロープを送信
        smtp_connection.sendmail(envelope.message, envelope.from, envelope.to)
      end
    end

    # ... build_smtp_sessionとssl_contextの実装は前述のリンクを参照
  end
end

Mail::SMTPとの互換性確保

カスタム実装が標準のMail::SMTPと同じように動作することが重要です。特に以下の点を確保します:

  1. 同じインターフェース: initialize(settings)deliver!(mail)
  2. Mail::SmtpEnvelopeの使用: エンベロープ処理の互換性
  3. TLS/SSL設定のサポート: 標準と同じ設定項目

build_smtp_sessionの実装

Mail::SMTPの実装を完全に踏襲します。

https://github.com/mikel/mail/blob/b6b6cb737d47a85ddc720fda0e6b991e99224848/lib/mail/network/delivery_methods/smtp.rb#L112-L147

フォールバックロジックの実装

def deliver!(mail)
  primary_settings = resolve_settings(:primary, DEFAULT_PRIMARY_SETTINGS)
  fallback_settings = resolve_settings(:fallback, DEFAULT_FALLBACK_SETTINGS)

  # プライマリリージョンで送信を試行
  send_with_smtp(mail, primary_settings)
rescue *RETRYABLE_ERRORS => e
  # 一時的なエラーの場合、フォールバックリージョンで再送信
  Rails.logger.warn(
    "[MultiRegionSMTP] プライマリリージョン(#{primary_settings[:address]})でSMTP送信失敗: #{e.class.name} - #{e.message}. " \
    "フォールバックリージョン (#{fallback_settings[:address]}) で再試行します。"
  )

  send_with_smtp(mail, fallback_settings)
end

ポイント:

  1. RETRYABLE_ERRORSで指定したエラーのみキャッチ
  2. 認証エラー等の永続的なエラーは即座に失敗
  3. フォールバック時は詳細なログを出力

ActionMailerへの統合

イニシャライザでの登録

ファイル: config/initializers/multi_region_smtp.rb

require 'mail/multi_region_smtp'

# ActionMailerにカスタムdelivery_methodを登録
ActionMailer::Base.add_delivery_method :multi_region_smtp, Mail::MultiRegionSmtp

add_delivery_methodを使用することで、標準のdelivery_methodと同じように扱えます。

環境変数による設定管理

.envまたは本番環境の環境変数に以下を追加:

# プライマリリージョン
AWS_SES_USER_NAME=your_primary_user_name
AWS_SES_PASSWORD=your_primary_password

# フォールバックリージョン
AWS_SES_FALLBACK_USER_NAME=your_fallback_user_name
AWS_SES_FALLBACK_PASSWORD=your_fallback_password

本番環境での設定例

ファイル: config/environments/production.rb

# AWS SESマルチリージョン対応のカスタムdelivery_methodを使用
config.action_mailer.delivery_method = :multi_region_smtp

これだけで、既存のActionMailerのコードを変更せずにフォールバック機能が有効になります:

# 従来通りの書き方で、自動的にフォールバック対応
UserMailer.welcome(@user).deliver_now

最後に

本記事では、ActionMailerのカスタムdelivery_methodを実装し、フォールバック可能なSMTP通信を実現する方法を解説しました。
ソースコード・RFCなどの一次情報を読むことの重要性を改めて実感した実装でした。皆さんのプロジェクトで参考になれば幸いです。

採用情報
弊社ではWebフルスタックエンジニア、AIエンジニアなどを積極的に採用しています。
ご興味のある方はぜひWantedlyをご覧ください。

https://www.wantedly.com/projects/2099580

スマサテ Tech Blog

Discussion