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から値を渡すにはどうしたらよいかを、同僚に教わりつつ調べた時のメモを残します。
目次
- 環境
- 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::Concern
included
ブロックの中に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
各パターンでプルリクを分けています。