🦁

ActionMailer の perform_deliveries を false にしても、メール配信が停止しないパターンがある件について

に公開1

自分が携わっているプロダクトにはメール配信機能があるのですが、特定のお客様へのメール配信を全て停止するイレギュラーな仕様がありました。しかし、その特定のお客様に「実際にはメール配信が行われていた」というインシデントが発生しました。

このインシデントを調査する中で、学びがあったためその内容を共有したいと思い、この記事を書きました。

原因

既存のメール配信停止を行う処理を確認したところ、全ての Mailer クラスの親である ApplicationMailer クラスの after_action コールバックにて、特定の条件の時にのみ perform_deliveriesfalse にすることでメール配信が停止するようになっていました。

さらに、perform_deliveries に関しては、以下のような説明が Rails ガイドに記載されており、今回実現したいことにマッチしているように見えました。

Mailのメッセージにdeliverメソッドを実行したときに実際にメール配信を行なうかどうかを指定します。デフォルトでは配信が行われます。機能テストなどで配信を一時的にオフにしたい場合に便利です

https://railsguides.jp/action_mailer_basics.html#perform-deliveries

app/mailers/application_mailer.rb
# イメージ
class ApplicationMailer < ActionMailer::Base
  after_action :abort_sending

  private

  def abort_sending
    mail.perform_deliveries = false unless send?(mail)
  end
end

ところが、ActionMailer の中身を確認したところ、丁寧に以下のコメントがありました。。

https://github.com/rails/rails/blob/ac98964910369dd9821316a68f5477bd0d8e4ced/actionmailer/lib/action_mailer/message_delivery.rb#L106-L117

なんと、deliver_now! を実行した時には perform_deliveries の値が無視されるのです😭
つまり、これが今回のインシデントの原因でした!

さらに深掘り

メール送信自体は、gem mail の責務となるため、そちらも確認しました。
deliver_now! は内部で mail#deliver! を呼び出しますが、この deliver! メソッドの中では perform_deliveris を参照していないことが確認できました。

https://github.com/mikel/mail/blob/9a7e2bf499d7a35658113c4042ed7ebb7d9f865e/lib/mail/message.rb#L261-L272

一方で、deliver_now の内部で呼ばれる mail#deliver(実際には mail#do_delivery)では、perform_deliveries を参照してメール送信の可否を判断していることが確認できました。

https://github.com/mikel/mail/blob/9a7e2bf499d7a35658113c4042ed7ebb7d9f865e/lib/mail/message.rb#L2145-L2153

このことから、deliver_now では perform_deliveries が有効であり、deliver_now! では無効であることが分かりました。

暫定対応

プロダクト内には deliver_later! や deliver_now! を使用している箇所が 100 か所以上あり、全てを把握して修正するには時間がかかりすぎると判断しました。
そのため、以下のようにモンキーパッチを当て、deliver_now! でも perform_deliveries を参照するようにしました。

config/initializers/action_mailer.rb
ActiveSupport.on_load(:action_mailer) do
  module ActionMailer
    class MessageDelivery
      # 元の処理: https://github.com/rails/rails/blob/2fe1ca0f058c3e9ffd7fa7616e27c03aed8dba7a/actionmailer/lib/action_mailer/message_delivery.rb#L111-L117
      def deliver_now!
        return self unless perform_deliveries

        processed_mailer.handle_exceptions do
          processed_mailer.run_callbacks(:deliver) do
            message.deliver!
          end
        end
      end
    end
  end
end

そもそもどうすべきだったのか?

deliver_now! や deliver_later! を利用している箇所を調査したところ、実行時の例外をハンドリングしたいという意図がありました。
しかし、個人的には deliver_now や deliver_later で十分だと思います。というのも、mail#do_delivery 内では raise_delivery_errors が true の場合、例外を外に投げる仕組みになっているからです。

https://github.com/mikel/mail/blob/9a7e2bf499d7a35658113c4042ed7ebb7d9f865e/lib/mail/message.rb#L2145-L2153

また、raise_delivery_errors は Rails ガイドにも記載があるようにデフォルトで true になっています。

https://railsguides.jp/action_mailer_basics.html#raise-delivery-errors

つまり、deliver_now でも deliver_now! と同様に例外をキャッチできるわけです。

さいごに

今回のインシデントを通じて、ActionMailer の内部処理について理解を深めることができ、非常に勉強になりました。

しくみのテックブログ

Discussion

yasulabyasulab

あーーー、これRailsチュートリアルのコードでも同じことが言えそうですね... 💦 調査してみて、問題なければ更新してみようかと思います。分かりやすい記事ありがとうございます...!! 🙏✨

deliver_now! や deliver_later! を利用している箇所を調査したところ、実行時の例外をハンドリングしたいという意図がありました。
...
raise_delivery_errors は Rails ガイドにも記載があるようにデフォルトで true になっています。つまり、deliver_now でも deliver_now! と同様に例外をキャッチできるわけです。

1