Railsで、DeviseのMailerにControllerから値を渡す方法を調べてみた

Deviseの

  • recoverable
  • confirmable
  • lockable

の各モジュールでは、各種確認のためにメールを送信します。

メール送信時に使われるのが Devise::Mailer にある reset_password_instructions などのメソッドです。
https://github.com/heartcombo/devise/blob/master/app/mailers/devise/mailer.rb

 
ただ、それらのメソッドはControllerから直接呼ばれていません。

例えば、 recoverable モジュールによるパスワードリセットメールの送信では

No モジュール メソッドの種類 メソッド コード
1 PasswordsController インスタンスメソッド create ここ
2 recoverable クラスメソッド send_reset_password_instructions ここ
3 recoverable インスタンスメソッド send_reset_password_instructions ここ
4 recoverable protected インスタンスメソッド send_reset_password_instructions_notification ここ
5 authenticatable protected インスタンスメソッド send_devise_notification ここ

を経て、Devise::Mailerの reset_password_instructions が呼ばれます (ここ)。

そのため、Controllerから直接値を渡すのは難しそうでした。

 
そこで、Controllerから値を渡すにはどうしたらよいかを、同僚に教わりつつ調べた時のメモを残します。

 
目次

 

環境

  • Rails 6.1.4
  • Devise 4.8.0

 
また、今回使用するRails + Deviseアプリは、以下のコードのmainブランチのものです。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer

このアプリに対し、 recoverable モジュールを使った時にControllerから値を渡す方法を調べていきます。

なお、今回は recoverable モジュールを例にしますが、confirmablelockable モジュールでも同じ方法が取れそうです。

 

Deviseのモンキーパッチ + 各メソッドに引数を追加した実装

StackOverflowに回答がありました。
ruby on rails - How to pass additional data to devise mailer? - Stack Overflow

initializerでDeviseにモンキーパッチし、Controllerの値を引数として渡せるようにメソッドをオーバーライドすれば良いようです。

ただ、モンキーパッチやメソッド変更の影響で、将来のDeviseのバージョンアップがしづらくなる可能性もあったため、他の方法も考えてみました。

 

Deviseのモンキーパッチ + Modelのattr_accessorを追加した実装

上記のStackOverflowにおいて、 send_reset_password_instructions メソッドのオーバーライドは

Devise::Models::Recoverable::ClassMethods
  # extract data from attributes hash and pass it to the next method
  def send_reset_password_instructions(attributes = {})
    # ここでControllerからの値を取り出す
    data = attributes.delete :data
    recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)

    # ここで引数に渡す
    recoverable.send_reset_password_instructions(data) if recoverable.persisted?
    recoverable

という実装でした。

変数 recoverable には何が入るか調べようとメソッド find_or_initialize_with_errors を見たところ、Modelのインスタンスでした (このあたり)。

であれば、Modelに attr_accessor を追加することにより、各メソッドにおける引数の追加が不要になりそうだったので、試してみることにしました。

 

attr_accessorの追加

Modelに attr_accessor を追加します。

class User < ApplicationRecord
  devise :database_authenticatable, :recoverable, :validatable

  attr_accessor :controller_value  # 追加
end

 

Deviseのモンキーパッチ

次に、Deviseのモンキーパッチを以下のように修正します。

# config/initializers/devise_monkey_patch.rb

module DeviseMonkeyPatch
  def send_reset_password_instructions(attributes = {})
    # attributesより、Controllerから渡された値を取り出す
    controller_value = attributes.delete :controller_value

    recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)

    if recoverable.persisted?
      # attr_accessorとして用意した項目に入れる
      recoverable.controller_value = controller_value

      recoverable.send_reset_password_instructions
    end

    recoverable
  end
end


Devise::Models::Recoverable::ClassMethods.module_eval do
  prepend DeviseMonkeyPatch
end

 
ここで、 ClassMethod.module_eval については

です。

 
また、 prepend DeviseMonkeyPatch については

です。

 

Mailerの準備

続いて、Devise::Mailerを継承したMailerを用意し、メールの件名や本文にControllerの値を設定できるようにします。

# app/mailers/my_devise_mailer.rb

class MyDeviseMailer < Devise::Mailer
  def reset_password_instructions(record, token, opts = {})
    # attr_accessorに設定したControllerの値を取り出し、テンプレートで使えるようインスタンス変数にセット
    @controller_value = record.controller_value

    super(record, token, opts)
  end

  protected

  def subject_for(key)
    # 件名の先頭に、インスタンス変数に入れた controller_value をセット
    subject = super(key)
    "[#{@controller_value}] #{subject}"
  end
end

 
このMailerをDeviseで使うように差し替えます。

# config/initializers/devise.rb

config.mailer = 'MyDeviseMailer'

 

Controllerの用意

また、 Devise::PasswordsController を継承したControllerを用意し、値を渡します。

class Users::PasswordsController < Devise::PasswordsController
  def create
    params[:user][:controller_value] = 'from controller'
    super
  end

 

routeの設定

最後に、routeで差し替えたControllerを使うように設定します。

# config/routes.rb

Rails.application.routes.draw do
  devise_for :users,
             controllers: {
               passwords: 'users/passwords'
             }

 
これで完成です。

 

ここまでのソースコード

ソースコード全体はこちらのプルリクになります。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer/pull/1/files

 

Modelのattr_accessorのみで実装

上記で、メソッドの引数追加は避けられたものの、それでもモンキーパッチが残っているのが気になりました。

そこで、もう少しDeviseのソースコードを見たところ、

# https://github.com/heartcombo/devise/blob/v4.8.0/lib/devise/models/recoverable.rb#L135

def send_reset_password_instructions(attributes = {})
  recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)
  recoverable.send_reset_password_instructions if recoverable.persisted?
  recoverable
end

のように、メソッド find_or_initialize_with_errors がメールを送信するメソッドの中ではいつも呼ばれていることに気づきました。

メソッド find_or_initialize_with_errors を見たところ、引数を元にModelのインスタンスを作っているようでした( このあたり)。

 

find_or_initialize_with_errorsのオーバーライド

そこで、 find_or_initialize_with_errors をオーバーライドし、attr_accessorに値を設定します。

def self.find_or_initialize_with_errors(required_attributes, attributes, error = :invalid)
  # 値を取り出す
  controller_value = attributes.delete(:controller_value)

  user = super(required_attributes, attributes, error)
  user.tap do |u|
    # 値を設定する
    u.controller_value = controller_value
  end
end

 
これでControllerの値の取り出しとModelのattr_accessorへの値設定ができたので、同じような処理をしていたモンキーパッチは不要となりました。

 

ここまでのソースコード

ソースコード全体はこちらのプルリクになります。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer/pull/2

 

Modelのattr_accessor + Mailerのparamsによる、メールの非同期配信に対応した実装

話が変わりますが、ここまでの実装ではDeviseからのメールは同期配信となっていました。

しかし、メールの配信は非同期で行いたい場合もあります。

その場合、Deviseの authenticatable モジュールのコメントのように実装することで、Deviseからのメール送信をActive Jobを使って非同期配信できるようになります。
https://github.com/heartcombo/devise/blob/v4.8.0/lib/devise/models/authenticatable.rb#L137

 
ただ、ここで問題になるのが、 attr_accessor です。

Active JobでModelを扱う場合、GlobalIDにシリアライズされるため、 attr_accessor に設定されている値が消えてしまいます。
ruby on rails - attr_accessor not accessible in mailer when using deliver_later - Stack Overflow

そのため、先ほどの実装のままでは、メール送信を非同期にしたとたんControllerからの値が失われてしまいます。

 
そこで、非同期に変更してもMailerに値を渡せるよう、呼び出し側で with を使って値を渡し、Mailerの中で params を使って受け取る実装へと変更します。

なお、 params については、Railsガイドより引用します。

with に渡されるキーの値は、メイラーアクションでは単なる params になります。つまり、 with(user: @user, account: @user.account) とすることでメイラーアクションで params[:user]params[:account] を使えるようになります。ちょうどコントローラのparamsと同じ要領です。

https://railsguides.jp/action_mailer_basics.html#%E3%83%A1%E3%82%A4%E3%83%A9%E3%83%BC%E3%82%92%E5%91%BC%E3%81%B3%E5%87%BA%E3%81%99

 

Active JobのバックエンドとしてDelayed Jobを使う

まずはActive Jobを使えるようにします。今回バックエンドは Delayed Job にします。
collectiveidea/delayed_job: Database based asynchronous priority queue system -- Extracted from Shopify

 
Gemfileに追加して bundle install した後、初期設定を行います。

# 初期設定
% bin/rails generate delayed_job:active_record
Running via Spring preloader in process 56881
      create  bin/delayed_job
       chmod  bin/delayed_job
      create  db/migrate/20210707145116_create_delayed_jobs.rb

# マイグレーション
% bin/rake db:migrate
Running via Spring preloader in process 57186
== 20210707145116 CreateDelayedJobs: migrating ================================
-- create_table(:delayed_jobs)
   -> 0.0023s
-- add_index(:delayed_jobs, [:priority, :run_at], {:name=>"delayed_jobs_priority"})
   -> 0.0008s
== 20210707145116 CreateDelayedJobs: migrated (0.0032s) =======================

 
また、Active JobのバックエンドとしてDelayed Jobを使えるよう設定します。

# config/environments/development.rb

config.active_job.queue_adapter = :delayed_job

 

Deviseからのメールを非同期化する

続いて、Deviseからのメールを非同期化します。

なお、Modelにそのまま書いてもよいのですが、今回はConcernに切り出します。

Concernに切り出すために変える部分としては

です。  

# app/models/concerns/devise_deliver_later.rb

module DeviseDeliverLater
  extend ActiveSupport::Concern

  included do
    after_commit :send_pending_devise_notifications
  end
# ...

 
あとは、ModelにConcernをincludeします。

class User < ApplicationRecord
  devise :database_authenticatable, :recoverable, :validatable

  # 追加
  include DeviseDeliverLater

  attr_accessor :controller_value

 

paramsにControllerからの値を渡す

先ほどのConcernに対し、修正を加えます。

render_and_send_devise_message メソッドの中でメールを送信しているため、

def render_and_send_devise_message(notification, *args)
  message = devise_mailer.with(controller_value: controller_value).send(notification, self, *args)

のように with(controller_value: controller_value) を使うことで、Controllerの値をMailerに渡せます。

なお、引数として渡している controller_value は Model の attr_accessor です。この時点の attr_accessor にはControllerの値が設定されています。

 

Controllerの修正

Deviseのコメントにある通り、一連のリクエストをトランザクションで囲う必要があります。

今回はControllerの create メソッドにトランザクションを実装します。

def create
  ApplicationRecord.transaction do
    params[:user][:controller_value] = 'from controller'
    super
  end

 
これで、メール送信を非同期化しつつ、Controllerからの値を渡せるようになっています。

 

ここまでのソースコード

以下のプルリクになります。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer/pull/3

なお、このプルリクでマージする先は main ではなく、非同期化していない Modelのattr_accessorのみで実装 のブランチです。

 

Mailer全体でControllerからの値を使えるようにする実装

ここまででは、DeviseのMailerだけでControllerの値を使えるようにしてきました。

ただ、1回のHTTPリクエストで

  • DeviseのMailerによる通知
  • 独自のカスタムMailerによる通知

の両方で、同じControllerからの値を引き渡す方法についても調べてみました。

 

Devise::Mailerの親をApplicationMailerにする

Devise::Mailer の実装を見ると、親は ActionMailer::Base となっています。

 
このままではMailerでの共通化がしづらいため、Devise::Mailer の親をカスタムMailerと同じ ApplicationMailer へと変更します。
ruby on rails - How do I configure devise to use a custom email layout? - Stack Overflow

# config/initializers/devise.rb

config.parent_mailer = 'ApplicationMailer'

 

before_actionを使って、paramsをインスタンス変数へ設定

続いて、paramsとして渡されたControllerの値をApplicationMailerのインスタンス変数として設定するよう、 before_action を使って実装します。
4 Action Mailerのコールバック | Action Mailer の基礎 - Railsガイド

また、メールの件名にControllerの値を設定できるよう、メソッド add_prefix も作っておきます。

class ApplicationMailer < ActionMailer::Base
  default from: 'from@example.com'
  layout 'mailer'

  before_action :set_controller_value, if: -> { params.present? }

  protected

  def add_prefix(subject)
    "[#{@controller_value}] #{subject}"
  end

  private

  def set_controller_value
    @controller_value = params&.fetch(:controller_value)
  end
end

 

DeviseMailerの不要な実装を削除

params からControllerの値を取り出してインスタンス変数に設定する」という処理がApplicationMailerに移動したため、不要になった実装をDeviseMailerから削除します。

# この実装を削除
def reset_password_instructions(record, token, opts = {})
  @controller_value = params&.fetch(:controller_value)

  super(record, token, opts)
end

 

独自のカスタムMailerによる通知を追加

Mailerを追加します。

# app/mailers/my_custom_mailer.rb

class MyCustomMailer < ApplicationMailer
  def notify_manager
    mail(to: params[:email], subject: add_prefix('hello'))
  end
end 

また、ここでは省略しますが、テンプレートも追加・修正します。

 

ControllerにカスタムMailerの処理を追加

ControllerにカスタムMailerを使ってメールを送信する処理を追加します。

class Users::PasswordsController < Devise::PasswordsController
  def create
    ApplicationRecord.transaction do
      params[:user][:controller_value] = 'from controller'
      super

      # 以下を追加
      MyCustomMailer.with(email: 'manager@example.com', controller_value: 'from controller').notify_manager.deliver_later
    end
  end
# ...

 
以上で、Mailer全体でControllerからの値を使えるようにする実装となりました。

 

ここまでのソースコード

以下のプルリクになります。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer/pull/4

 

ソースコード

今までの文中にもありましたが、Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer

各パターンでプルリクを分けています。