Deviseの
recoverableconfirmablelockable
の各モジュールでは、各種確認のためにメールを送信します。
メール送信時に使われるのが 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から値を渡すにはどうしたらよいかを、同僚に教わりつつ調べた時のメモを残します。
目次
- 環境
- Deviseのモンキーパッチ + 各メソッドに引数を追加した実装
- Deviseのモンキーパッチ + Modelのattr_accessorを追加した実装
- Modelのattr_accessorのみで実装
- Modelのattr_accessor + Mailerのparamsによる、メールの非同期配信に対応した実装
- Mailer全体で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 モジュールを例にしますが、confirmable や lockable モジュールでも同じ方法が取れそうです。
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 については
ClassMethodとしておくとクラスメソッドが追加されるmodule_evalはclass_evalと同じで、モジュールの中身を定義
です。
また、 prepend DeviseMonkeyPatch については
- オープンクラスよりメソッドチェーン (
alias_method_chain) を使ったモンキーパッチの方が良い - ただ、Rails5.0で
alias_method_chainは廃止されたので、prependを使う
です。
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と同じ要領です。
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に切り出すために変える部分としては
extend ActiveSupport::Concernincludedブロックの中にafter_commit :send_pending_devise_notificationsを実装
です。
# 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 となっています。
- https://github.com/heartcombo/devise/blob/master/app/mailers/devise/mailer.rb#L4
- https://github.com/heartcombo/devise/blob/master/lib/generators/templates/devise.rb#L33
このままでは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
各パターンでプルリクを分けています。