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)におけるメール配信の仕組み
- 内部で利用されている
mailgem の 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の内部実装
このメソッドは以下の処理を行います:
-
smtp_settingsクラス属性を作成 - デフォルト設定を保存
-
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のインスタンス化
-
delivery_methodがシンボルの場合、対応するクラスをdelivery_methodsハッシュから取得 -
#{method}_settingsで取得したデフォルト設定と、引数のoptionsをマージ -
mail.delivery_method(klass, settings)で実際のdelivery_methodインスタンスを生成
これにより、カスタムの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
インターセプター(送信前処理)とオブザーバー(送信後処理)を実行し、do_deliveryを呼び出します。
2. Mail::Message#do_delivery
delivery_methodのdeliver!メソッドを呼び出します。ここで実際の送信処理に入ります。
3. Mail::SMTP#deliver!
SMTPセッションを開始し、Mail::SMTPConnectionに処理を委譲します。
4. Mail::SMTPConnection#deliver!
ここで重要なMail::SmtpEnvelopeが登場します。
Mail::SmtpEnvelopeの役割
Mail::SmtpEnvelopeは以下の重要な役割を担っています:
1. smtp_envelope_from/toのサポート
メールヘッダーのFromとは別に、SMTPプロトコルレベルでのエンベロープアドレスを設定できます:
mail.smtp_envelope_from = 'bounce@example.com'
mail.smtp_envelope_to = ['recipient@example.com']
これにより、バウンスメールの処理などが柔軟に行えます。
2. アドレスバリデーション
セキュリティとプロトコル準拠のため、アドレスを検証します。
3. BCCの正しい処理
Mail::SmtpEnvelopeとBCCの関係を理解するには、以下の2つの概念を区別する必要があります。
SMTPエンベロープのto(配送先リスト):
エンベロープのtoには、To/CC/BCC すべての受信者が含まれます。これがSMTPプロトコルのRCPT TOコマンドに使用されます。
メッセージ本体からのBCC削除:
Mail gemでは、BccField#encodedメソッドがデフォルトで空文字列を返すことで、BCCヘッダーがメッセージ本体に含まれないようにしています。
処理フロー:
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による実際の送信
Mail::SMTPConnection#deliver!から最終的に呼び出されるのが、Ruby標準ライブラリのNet::SMTP#sendmailメソッドです。このメソッドがSMTPプロトコルに則って実際のメール送信を行います。
Net::SMTP#sendmailの引数
smtp.sendmail(envelope.message, envelope.from, envelope.to)
このメソッドは3つの引数を受け取ります:
- message: エンコードされたメールメッセージ本文(ヘッダー + 本文)
- from: SMTPエンベロープの送信者アドレス(MAIL FROMコマンドに使用)
- 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サーバーからエラーレスポンスが返された場合、ステータスコードに応じた例外が発生します。
このエラー分類が、後述するフォールバックロジックの実装において重要になります。
カスタム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では、ステータスコードに応じて異なる例外クラスを使用します。
ステータスコードが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と同じように動作することが重要です。特に以下の点を確保します:
-
同じインターフェース:
initialize(settings)とdeliver!(mail) - Mail::SmtpEnvelopeの使用: エンベロープ処理の互換性
- TLS/SSL設定のサポート: 標準と同じ設定項目
build_smtp_sessionの実装
Mail::SMTPの実装を完全に踏襲します。
フォールバックロジックの実装
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
ポイント:
-
RETRYABLE_ERRORSで指定したエラーのみキャッチ - 認証エラー等の永続的なエラーは即座に失敗
- フォールバック時は詳細なログを出力
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をご覧ください。
Discussion