Railsで、Action Mailerの callback・interceptor・observer の実行タイミングを調べてみた

RailsのAction Mailerには

などのフィルターやフックがあります。

ただ、これらの実行タイミングが分からなかったため、調べた時のメモです。

なお、今回は

  • メール送信が同期/非同期
  • メールサーバへの送信が成功/失敗

を組み合わせた4パターンで調べてみます。

 
目次

 

環境

  • Rails 6.1.4
  • letter opener 1.7.0
  • delayed_job_active_record 4.1.6
    • メールを非同期で送信するために、Active Jobのバックエンドとして利用

 

Action Mailerを使うアプリの準備

今回は

  • /hook/now にアクセスすると、メールを同期送信
  • /hook/later にアクセスすると、メールを非同期送信

とします。

また、メールサーバへの通信は

  • letter_opener を使うことで、メールサーバへの通信が成功したとみなせる
  • デフォルトのSMTP設定を使うことで、メールサーバへの通信が失敗しなとみなせる
    • デフォルトでは正しいSMTP設定になっていないため

として、 config/environments/development.rb へ設定することとします。

 

Railsアプリの生成とgem追加

Railsアプリを作成します。

% bundle exec rails new rails_mailer_app --skip-javascript --skip-turbolinks --skip-sprockets --skip-test

今回使うものをGemfileに追加します。

group :development do
  gem 'letter_opener'
end

gem 'delayed_job_active_record'

 

Delayed Jobのセットアップ

Delayed JobのREADMEに従ってセットアップします。
collectiveidea/delayed_job: Database based asynchronous priority queue system -- Extracted from Shopify

% bin/rails generate delayed_job:active_record
Running via Spring preloader in process 83213
      create  bin/delayed_job
       chmod  bin/delayed_job
      create  db/migrate/20210723085718_create_delayed_jobs.rb


% bin/rails db:migrate
== 20210723085718 CreateDelayedJobs: migrating ================================
-- create_table(:delayed_jobs)
   -> 0.0033s
-- add_index(:delayed_jobs, [:priority, :run_at], {:name=>"delayed_jobs_priority"})
   -> 0.0008s
== 20210723085718 CreateDelayedJobs: migrated (0.0043s) =======================

 

Mailerのセットアップ

Mailerを生成します。

% bin/rails g mailer MyMailer hello

 
生成されたMailerに対し、

  • before_action
  • after_action

を追加します。

 
今回は動作したことを確認するため、 logger.info を使ってログへ出力します。

Mailerなので、 logger で動作します。
2.3 メッセージ送信 | Rails アプリケーションのデバッグ - Railsガイド

class MyMailer < ApplicationMailer
  before_action :log_at_before_action
  after_action :log_at_after_action

  def hello
    @greeting = 'Hi'

    mail to: 'to@example.org'

   logger.info('<====== [Run hello] =======>')
  end

  private

  def log_at_before_action
   logger.info('======= [Before] =======>')
  end

  def log_at_after_action
   logger.info('<====== [After] ========')
  end
end

 

Controllerのセットアップ

Mailerができたので、Controllerを生成します。

% bin/rails g controller hook now later --no-helper --no-assets

 
Controllerの中でMailerを起動します。

now アクションは同期送信、laterアクションは非同期送信としています。

class HookController < ApplicationController
  def now
    MyMailer.hello.deliver_now
  end

  def later
    MyMailer.hello.deliver_later
  end
end

 

Interceptorの作成

app/mailers/my_mailer_interceptor.rb として作成します。

Interceptorとして動作するよう、クラスメソッド delivering_email を定義し、その中でログを出力しています。

class MyMailerInterceptor
  def self.delivering_email(mail)
    Rails.logger.info('<====== [Interceptor] =======>')
  end
end

 

Observerの作成

app/mailers/my_mailer_observer.rb として作成します。

こちらもクラスメソッド delivered_email を定義します。

なお、引数 message のクラス名も確認できるよう、ログに出力します。

class MyMailerObserver
  def self.delivered_email(message)
    Rails.logger.info('======= [Observer] =======>')
    Rails.logger.info(message.class.to_s)
    Rails.logger.info('<====== [Observer] ========')
  end
end

 

config/environments/development.rb への追記

メールまわりの設定を追記します。

なお、パターンごとの設定はコメントアウトしておき、動作確認する時にアンコメントしていきます。

# 追加
# メールの動作確認
# interceptorは常に実行
config.action_mailer.interceptors = ['MyMailerInterceptor']
# observerは常に実行
config.action_mailer.observers = ['MyMailerObserver']
# メール送信時のエラーは無視しない
config.action_mailer.raise_delivery_errors = true
# メール配信を行う
config.action_mailer.perform_deliveries = true

# パターン1. 同期送信、letter_openerを使用
# config.action_mailer.delivery_method = :letter_opener

# パターン2. 同期送信、存在しないSMTPサーバを使用
# config.action_mailer.delivery_method = :smtp

# パターン3. 非同期送信、letter openerの設定
# config.active_job.queue_adapter = :delayed_job
# config.action_mailer.delivery_method = :letter_opener

# パターン4. 非同期送信、存在しないSMTPサーバ
# config.active_job.queue_adapter = :delayed_job
# config.action_mailer.delivery_method = :smtp

 
以上で、確認するための準備ができました。

 

パターン1. 同期送信、letter_openerを使用

config/environments/development.rb

config.action_mailer.delivery_method = :letter_opener

を有効にして実行すると、

Started GET "/hook/now" for 127.0.0.1 at 2021-07-23 20:53:17 +0900
Processing by HookController#now as HTML
======= [Before] =======>
  Rendered layout layouts/mailer.text.erb (Duration: 0.1ms | Allocations: 118)
<====== [Run hello] =======>
<====== [After] ========
MyMailer#hello: processed outbound mail in 5.2ms
<====== [Interceptor] =======>
Delivered mail 60faadad696f5_15d7e43e41150@...
======= [Observer] =======>
Mail::Message
<====== [Observer] ========

と、

  1. before_action
  2. Mailerのhelloメソッド
  3. after_action
  4. interceptor
  5. observer

の順で実行されました。

 

パターン2. 同期送信、存在しないSMTPサーバを使用

config/environments/development.rb

config.action_mailer.delivery_method = :smtp

を有効にして実行すると、画面に Connection refused - connect(2) for "localhost" port 25 エラーが表示されました。

また、ログには

Started GET "/hook/now" for 127.0.0.1 at 2021-07-23 20:58:06 +0900
======= [Before] =======>
  Rendered layout layouts/mailer.text.erb (Duration: 0.5ms | Allocations: 287)
<====== [Run hello] =======>
<====== [After] ========
MyMailer#hello: processed outbound mail in 12.9ms
<====== [Interceptor] =======>
Delivered mail 60faaece837ed_16bcd2a30984@...
...
Completed 500 Internal Server Error in 75ms (ActiveRecord: 0.0ms | Allocations: 42200)

Errno::ECONNREFUSED (Connection refused - connect(2) for "localhost" port 25):

と出力されました。

  1. before_action
  2. Mailerのhelloメソッド
  3. after_action
  4. interceptor

までは実行されたものの、 observer は実行されませんでした。

 

パターン3. 非同期送信、letter openerを使用

config/environments/development.rb

config.active_job.queue_adapter = :delayed_job
config.action_mailer.delivery_method = :letter_opener

を有効にして hook/later にアクセスした時は

Started GET "/hook/later" for 127.0.0.1 at 2021-07-23 21:02:34 +0900
...
[ActiveJob] Enqueued ActionMailer::MailDeliveryJob (Job ID: 922d375d-04eb-4f3e-bd9c-f9340ddce72f) to DelayedJob(default) with arguments: "MyMailer", "hello", "deliver_now", {:args=>[]}

と、Mailerまわりのログが出力されませんでした。

その後、

% bin/rails jobs:work

を実行したところ

[ActiveJob] [ActionMailer::MailDeliveryJob] [...] ======= [Before] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...]   Rendered layout layouts/mailer.text.erb (Duration: 0.4ms | Allocations: 242)
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [Run hello] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [After] ========
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] MyMailer#hello: processed outbound mail in 23.9ms
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [Interceptor] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Delivered mail 60fab03938356_16f61e247966d@
...
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] ======= [Observer] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Mail::Message
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [Observer] ========
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Performed ActionMailer::MailDeliveryJob (Job ID: 922d375d-04eb-4f3e-bd9c-f9340ddce72f) from DelayedJob(default) in 101.14ms

と、Active Jobの方で

  1. before_action
  2. Mailerのhelloメソッド
  3. after_action
  4. interceptor
  5. observer

の順に実行されました。

 

パターン4. 非同期送信、存在しないSMTPサーバを使用

config/environments/development.rb

config.active_job.queue_adapter = :delayed_job
config.action_mailer.delivery_method = :smtp

を有効にして hook/later にアクセスした時は

Started GET "/hook/later" for 127.0.0.1 at 2021-07-23 21:12:48 +0900
...
[ActiveJob] Enqueued ActionMailer::MailDeliveryJob (Job ID: a73c673e-63e6-412b-802d-59108998efd6) to DelayedJob(default) with arguments: "MyMailer", "hello", "deliver_now", {:args=>[]}

と、Mailerまわりのログが出力されませんでした。

その後、

% bin/rails jobs:work

を実行したところ、ジョブのログに

[Worker(host:*** pid:95801)] 1 jobs processed at 22.9310 j/s, 1 failed

と出力されました。

また、サーバのログにも

2021-07-23T21:14:40+0900: [Worker(host:*** pid:95801)] Starting job worker
...
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Performing ActionMailer::MailDeliveryJob (Job ID: 28ee4556-11de-4f8e-bf39-6caa02f56533) from DelayedJob(default) enqueued at 2021-07-23T12:14:27Z with arguments: "MyMailer", "hello", "deliver_now", {:args=>[]}
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] ======= [Before] =======>
...
[ActiveJob] [ActionMailer::MailDeliveryJob] [...]   Rendered layout layouts/mailer.text.erb (Duration: 0.6ms | Allocations: 243)
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [Run hello] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [After] ========
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] MyMailer#hello: processed outbound mail in 32.0ms
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] <====== [Interceptor] =======>
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Delivered mail 60fab2b0b41fc_17639e2445447@
...
[ActiveJob] [ActionMailer::MailDeliveryJob] [...] Error performing ActionMailer::MailDeliveryJob (Job ID: 28ee4556-11de-4f8e-bf39-6caa02f56533) from DelayedJob(default) in 107.6ms: Errno::ECONNREFUSED (Connection refused - connect(2) for "localhost" port 25):

と、

  1. before_action
  2. Mailerのhelloメソッド
  3. after_action
  4. interceptor

までは実行されたものの、 observer は実行されませんでした。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_mailer_app

関係するプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_mailer_app/pull/1

Rails + Active Adminで、Active Admin向けのテストコードを request spec で書いてみた

Active AdminのControllerに手を加えた際、テストコードがほしくなりました。

Wikiを見たところ、controller specでの実装でした。
Testing your ActiveAdmin controllers with RSpec · activeadmin/activeadmin Wiki

ただ、現在では controller spec よりも request spec が推奨されています。
Rails: Rails 5 のサポート | RSpec 3.5 がリリースされました!

 
そこで、Active Admin向けのテストコードを request spec で書いてみました。

 
目次

 

環境

  • Ruby 3.0.1
  • Rails 6.1.4
  • Active Admin 2.9.0
  • Devise 4.8.0
    • Active Adminの管理ページをログイン必須にするために使用
  • rspec-rails 5.0.1

 

プロダクションコードの準備

前回の記事のコードを一部修正してプロダクションコードとします。

 

Modelにバリデーションを追加

name を入力必須にします。

# app/models/fruit.rb

class Fruit < ApplicationRecord
  validates :name, presence: true
end

 

Active Adminで Model を作成する時にトランザクションを追加

オーバーライドしていた controllerの create メソッドを変更し、

を追加します。

なお、flashについては、

  • renderのときは flash.now
  • リダイレクトのときは flash

を使います。
5.2 Flash | Action Controller の概要 - Railsガイド

ActiveAdmin.register Fruit do
  permit_params :name, :color

  controller do
    def create
      ApplicationRecord.transaction do
        super do |format|
          if @fruit.valid?
            call_api_with_params(permitted_params[:fruit][:name])
            # redirectするので flash
            flash[:notice] = 'success'
          else
            # バリデーションエラー時はrenderされるので flash.now
            flash.now[:alert] = 'wrong!'
          end
        end
      end

    rescue StandardError
      flash.now[:error] = 'exception!'
      render :new
    end

    private

    def call_api_with_params(name)
      logger.info("======> call api with #{name}")
    end
end

 

画面での動作確認
正常

 

バリデーションエラー

 

APIエラー

今のプロダクションコードでは発生し得ないので、メソッド call_api_with_params で例外が出るように修正した時の表示となります。

 

テストコードの準備

プロダクションコードができたので、次は request spec を書きます。まずは準備です。

 

rspecまわり

Gemfileに

を追加し、bundle installします。

group :development, :test do
  gem 'factory_bot_rails'
  gem 'rspec-rails'
end

 
続いて、rspecの初期設定とrequest specの雛形を生成します。

% bin/rails generate rspec:install
Running via Spring preloader in process 37340
      create  .rspec
       exist  spec
      create  spec/spec_helper.rb
      create  spec/rails_helper.rb

% bin/rails generate rspec:request fruit
Running via Spring preloader in process 37150
      create  spec/requests/fruits_spec.rb

 

rexmlの追加

rspecを実行したところ、以下のエラーが発生しました。

% bin/rails spec spec/requests/admin
...
An error occurred while loading ./spec/requests/admin/fruits_spec.rb. - Did you mean?
                    rspec ./spec/factories/admin_user.rb

Failure/Error: require File.expand_path('../config/environment', __dir__)

LoadError:
  cannot load such file -- rexml/document

 
原因は、Ruby3.0.1 の場合、rexml gemが不足しているためでした。
Rails 6.1, Ruby 3.0.0: tests error as they cannot load rexml - Stack Overflow

そこで、 rexml もGemfileに追加してインストールします。

gem 'rexml'

 

factory_botによる admin user 作成

今回のActive AdminはDeviseによる認証を行っています。

そのため、テストコード中も admin user でログインする必要があります。

そこで、factory_bot を使って admin userを作成できるようにします。

 
まずは、spec/rails_helper.rbFactoryBot::Syntax::Methods を追加します。
Configure your test suite | Setup | factory_bot/GETTING_STARTED.md at master · thoughtbot/factory_bot

config.include FactoryBot::Syntax::Methods

 
続いて、生成する admin user の設定を行います。

admin_userのfactoryは、 spec/factories/admin_user.rb に作成します。

複数のadmin userをfactory_botで生成しても問題が起こらないよう、メールアドレスはシーケンスにします。
https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md#inline-sequences

また、Deviseで生成した admin userは、パスワード項目として passwordpassword_confirmation の2つが必要になるため、それぞれ設定します。
How To: Test controllers with Rails (and RSpec) · heartcombo/devise Wiki)

FactoryBot.define do
  factory :admin_user do
    sequence(:email) { |n| "person#{n}@example.com" }
    password { 'password' }
    password_confirmation { 'password' }
  end
end

 

テストコード中で sign_in できるようにする

テストコード中でのログインを容易にするため、spec/rails_helper.rb にDeviseの Devise::Test::IntegrationHelpers を追加します。
heartcombo/devise: Flexible authentication solution for Rails with Warden.

config.include Devise::Test::IntegrationHelpers

 
以上で準備ができました。

 

正常系の request spec

spec/requests/admin/fruit_spec.rb に作成します。

テストコードでは

あたりを頭に置いて実装します。

require 'rails_helper'

RSpec.describe 'Admin::Fruits', type: :request do
  let(:admin_user) { create(:admin_user) }

  before do
    sign_in admin_user
  end

  describe '#create' do
    let(:name) { 'りんご' }
    let(:color) { '#000000' }
    let(:params) { { fruit: { name: name, color: color } } }

    context '登録に成功した場合' do
      before { post admin_fruits_path, params: params } # pathは複数形

      it 'Fruitが登録されていること' do
        fruit = Fruit.find_by(name: name)
        expect(fruit).not_to eq nil
        expect(fruit.color).to eq nil
        expect(fruit.start_of_sales).to eq nil
      end

      it '作成したFruitの詳細画面へリダイレクトしていること' do
        expect(response).to have_http_status '302'
        fruit = Fruit.find_by(name: name)
        expect(response).to redirect_to(admin_fruit_path(fruit)) # pathは単数形
      end

      it 'リダイレクト先の画面にflashが表示されていること' do
        follow_redirect!

        expect(response.body).to include 'success'
      end
    end

# ...

 

nameを入力しない場合の request spec

こちらも同様な形で検証します。

なお、contextの中で name を上書きしているため、このcontextの中では name の値が nil になっています。

context 'nameが未入力でエラーの場合' do
  # describeで定義した name を上書き
  let(:name) { nil }

  before { post admin_fruits_path, params: params }

  it 'Fruitが登録されていないこと' do
    expect(Fruit.find_by(name: name)).to eq nil
  end

  it '作成したFruitの登録画面のままであること' do
    expect(response).to have_http_status '200'
  end

  it 'エラーが表示されていること' do
    expect(response.body).to include 'be blank'
  end

  it 'エラーのflashが表示されていること' do
    expect(response.body).to include 'wrong!'
  end
end

 

外部APIの呼び出しで例外が発生した場合の request spec

現在のプロダクションコードでは、外部APIの呼び出し時には例外が発生しません。

そこで、外部APIを呼び出しているメソッド call_api_with_params で例外が発生するよう、メソッドを差し替えます。

また、Active AdminのControllerは、デフォルトでは Admin::<Model名>Controller という名前になるため、今回は Admin::FruitsController に対して差し替えを行います。

 
なお、request specではControllerのインスタンスをどのように差し替えるのが適切か分からなかったため、 expect_any_instance_of でControllerのどのインスタンスでも例外が発生するようにしています。

もし、より良い方法をご存じの方がいれば、教えていただけるとありがたいです。

context 'nameを含むリクエストを送ったものの、APIでエラーになった場合' do
  before do
    expect_any_instance_of(Admin::FruitsController).to receive(:call_api_with_params)
                                                         .with(name)
                                                         .once
                                                         .and_raise(StandardError)
    post admin_fruits_path, params: params
  end

  it 'Fruitが登録されていないこと' do
    expect(Fruit.find_by(name: name)).to eq nil
  end

  it '作成したFruitの登録画面のままであること' do
    expect(response).to have_http_status '200'
  end

  it 'エラーのflashが表示されていること' do
    expect(response.body).to include 'exception!'
  end
end

 
以上のように、Active Admin向けのテストコードを request spec で書けることが分かりました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_with_active_admin_app

 
関係するプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_with_active_admin_app/pull/3

Active AdminのControllerでは、Strong Parameters のメソッド名は permitted_params だった

Rails の Controller で Strong Parametersを使う場合、 ***_params というプライベートメソッドを定義しています。

一方、Active Admin の Controller で Strong Parameters を使う時は、デフォルトだとどんな名前になるのかを調べた時のメモです。

 
目次

 

環境

  • Rails 6.1.4
  • Active Admin 2.9.0

 

permitted_params で取り出せた

公式ドキュメントにありました。
Setting up Strong Parameters | Active Admin | The administration framework for Ruby on Rails

permit_params で定義した項目は、 permitted_params から取り出せます。

 
そこで、前回の記事で使ったActive Adminのコードを一部修正し、

ActiveAdmin.register Fruit do
  permit_params :name, :color  # 定義

  # コントローラのcreate/update/destroyをオーバーライド
  controller do
    def create
      super do |format|
        # Strong Parameters から取り出し
        call_api_with_params(permitted_params[:fruit][:name])
      end
    end

    def call_api_with_params(name)
      logger.info("======> call api with #{name}")
    end
  end
end

のように、

  • create メソッドをオーバーライド
  • createメソッドの中で、StrongParametersの name を使ってAPIを呼ぶ
    • 今回はログに出力するだけ

な実装としました。

そして、Active Adminの画面で

  • name: ぶどう
  • color: #b46fe2
  • start_of_sales: 2021/7/19 00:04

の値を入力して保存します。

すると、ログには

Started POST "/admin/fruits" for 127.0.0.1 at 2021-07-19 00:04:11 +0900
Processing by Admin::FruitsController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "fruit"=>{"name"=>"ぶどう", "color"=>"#b46fe2", "start_of_sales(1i)"=>"2021", "start_of_sales(2i)"=>"7", "start_of_sales(3i)"=>"19", "start_of_sales(4i)"=>"00", 
...
Unpermitted parameters: :start_of_sales(1i), :start_of_sales(2i), :start_of_sales(3i), :start_of_sales(4i), :start_of_sales(5i)
======> call api with ぶどう

と出力されました。

permitted_params[:fruit][:name]ぶどう が取り出せていました。

 

permitted_paramsのオーバーライド

Active Adminのデフォルトでは permit_params にて制御しています。

ただ、何らかの理由によりStrong Paramterの値を変更したい場合は、 permitted_params メソッドをオーバーライドします。

例えば

ActiveAdmin.register Fruit do
  permit_params :name, :color

  # コントローラのcreate/update/destroyをオーバーライド
  controller do
    # ...

    def permitted_params
      params.permit(:fruit => [:name])

      # 以下の書き方だとエラーになる
      # param is missing or the value is empty: fruit
      # params.require(:fruit).permit(:name)
    end

# ...

のようにすると、ログには

Unpermitted parameters: :color, :start_of_sales(1i), :start_of_sales(2i), :start_of_sales(3i), :start_of_sales(4i), :start_of_sales(5i)
Unpermitted parameters: :authenticity_token, :commit
{"fruit"=>#<ActionController::Parameters {"name"=>"ぶどう"} permitted: true>}
======> call api with ぶどう

のように出力されました。

Active Adminの結果も、以下のように color の値が入りませんでした。

 
なお、上記以外の書き方については、Active Adminのドキュメントや、Inherited ResourcesのREADMEにも記載がありました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_with_active_admin_app

関係するプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_with_active_admin_app/pull/2

Rails + Active Adminで、Active Admin Controllerのcreate/update/destroyをオーバーライドする

Railsでは、Active Adminを使うことで管理者画面を容易に作ることができます。
Active Admin | The administration framework for Ruby on Rails

 
そんな中、Active AdminでModelの作成・更新・削除を行うと同時に、Modelとは関係ないAPIを呼ぶ処理を追加したくなったため、調べた時のメモを残します。

 
目次

 

環境

  • Rails 6.1.4
  • Active Admin 2.9.0
  • Devise 4.8.0
    • Active Adminの管理ページをログイン必須にするために使用

 

ベースとなるActive Adminアプリの作成

Active Adminのセットアップ

まずは、追加する前のActive Adminアプリを作成します。

今回はRails6.1系のため、assets generatorにWebpackerを使ったアプリを作成します。

 
rails new します。

% rails new rails_with_active_admin_app

 
Gemfileにactive adminとdeviseを追加して、 bundle install します。

gem 'activeadmin'
gem 'devise'

 
このまま起動してもWebpackerまわりでエラーになるため、Webpackerのセットアップを行います。

% bin/rails webpacker:install
      create  config/webpacker.yml
Copying webpack core config
      create  config/webpack
      create  config/webpack/development.js
      create  config/webpack/environment.js
      create  config/webpack/production.js
      create  config/webpack/test.js
Copying postcss.config.js to app root directory
      create  postcss.config.js
Copying babel.config.js to app root directory
      create  babel.config.js
Copying .browserslistrc to app root directory
      create  .browserslistrc
The JavaScript app source directory already exists
       apply  path/to/rails_with_active_admin_app/vendor/bundle/ruby/3.0.0/gems/webpacker-5.4.0/lib/install/binstubs.rb
  Copying binstubs
       exist    bin
      create    bin/webpack
      create    bin/webpack-dev-server
      append  .gitignore
Installing all JavaScript dependencies [5.4.0]
         run  yarn add @rails/webpacker@5.4.0 from "."
...
✨  Done in 6.49s.
Webpacker successfully installed 🎉 🍰

 
Active Adminの初期化を行います。

Webpackerはオプトインのため、 use_webpacker オプションを追加してWebpackerを使うようにします。
webpacker | Installation | Active Admin | The administration framework for Ruby on Rails

% bin/rails g active_admin:install --use_webpacker
Running via Spring preloader in process 11165
      create  config/initializers/active_admin.rb
      create  app/admin
      create  app/admin/dashboard.rb
       route  ActiveAdmin.routes(self)
    generate  active_admin:webpacker
       rails  generate active_admin:webpacker 
Running via Spring preloader in process 11173
      create  app/javascript/packs/active_admin.js
      create  app/javascript/stylesheets/active_admin.scss
      create  app/javascript/packs/active_admin/print.scss
      create  config/webpack/plugins/jquery.js
      insert  config/webpack/environment.js
      insert  config/webpack/environment.js
         run  yarn add @activeadmin/activeadmin from "."
yarn add v1.22.10
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
success Saved 4 new dependencies.
info Direct dependencies
└─ @activeadmin/activeadmin@2.9.0
info All dependencies
├─ @activeadmin/activeadmin@2.9.0
├─ jquery-ui@1.12.1
├─ jquery-ujs@1.2.3
└─ jquery@3.6.0
✨  Done in 6.09s.
      create  db/migrate/20210715221110_create_active_admin_comments.rb

 
ちなみに、今回はDeviseを使っているため関係ないですが、Deviseを使っていない場合は --skip-users オプションを追加してActiveAdminを初期化します。

bin/rails g active_admin:install --use_webpacker --skip-users

 
続いてDBまわりのセットアップを行います。
Setting up Active Admin | Active Admin | The administration framework for Ruby on Rails

% bin/rails db:migrate

% bin/rails db:seed

 
ここまでで準備ができたため、Railsを起動します。

% bin/rails s

この状態で http://localhost:3000/admin にアクセスし、以下の情報でログインできればOKです。

 

Modelを作成

今回は以下の項目を持つ Fruit モデルを用意します。

論理名 物理名 制約
名前 name string unique
color string -
販売開始日時 start_of_sales datetime -

 
Model Fruit を作成します。

% bin/rails g model Fruit name:string:unique color:string start_of_sales:datetime

 
マイグレーションします。

% bin/rails db:migrate

 

Active Adminで管理できるようにする

Modelができたため、次はActive Adminで管理できるようにします。

app/admin/fruit.rb を作成します。

今回、Active Adminの画面では

  • name
  • color

のみ編集可能とするため、 permit_params を使って指定します。
Setting up Strong Parameters | Working with Resources | Active Admin | The administration framework for Ruby on Rails

ActiveAdmin.register Fruit do
  permit_params :name, :color
end

これで、Controller Admin::Fruit が生成されます。
Content rendering API · activeadmin/activeadmin Wiki

 

動作確認

http://localhost:3000/admin/fruits/new にアクセスします。

color は color pickerになっています。もし color pickerを使いたくない場合は、以下のようにしてカスタマイズできそうです。
ruby on rails - ActiveAdmin: how to have a text field instead a color picker to input a color? - Stack Overflow

また、permit_paramsで指定していない start_of_sales も表示されています。

 
入力して作成します。

 
作成した後です。 start_of_sales は入力したはずですが、登録されていません。

 

Controllerのcreate/update/destroyをオーバーライドする

本題になります。APIを呼ぶ処理を追加するために、Controllerのメソッドをオーバーライドしていきます。

まず、上で見たController Admin::Fruit の親の定義を見たところ、以下にありました。
https://github.com/activeadmin/activeadmin/blob/v2.9.0/lib/active_admin/base_controller.rb#L7

module ActiveAdmin
  # BaseController for ActiveAdmin.
  # It implements ActiveAdmin controllers core features.
  class BaseController < ::InheritedResources::Base

 
さらに、 InheritedResources::Base は、別のgem inherited_resources にて定義してあります。
https://github.com/activeadmin/inherited_resources/blob/v1.13.0/app/controllers/inherited_resources/base.rb#L11

さらにこの中でincludeされているのが InheritedResources:: Actions です。
https://github.com/activeadmin/inherited_resources/blob/v1.13.0/lib/inherited_resources/actions.rb

そのため、 InheritedResources:: Actions の各メソッドをオーバーライドすることで、APIを呼ぶ処理を追加できそうです。

 
オーバーライドは controller メソッドのブロックに定義します。

 
今回は inherited_resources でのオーバーライド方法にならい、 super do な形にしてみます。
Overwriting actions | activeadmin/inherited_resources

ActiveAdmin.register Fruit do
  permit_params :name, :color

  # コントローラのcreate/update/destroyをオーバーライド
  controller do
    def create
      super do |format|
        call_api(:create)
      end
    end

    def update
      super do |format|
        call_api(:update)
      end
    end

    def destroy
      super do |format|
        call_api(:destroy)
      end
    end

    private

    def call_api(method)
      # 外部APIを呼んだつもり
      # (今回はログに出力する)
      logger.info("======> called api by #{method}")
    end
  end
end

 

動作確認

Fruitに対して作成・更新・削除を行ったところ、以下のようなログが出力されていました。

メソッドをオーバーライドできました。

# 作成
Started POST "/admin/fruits" for 127.0.0.1 at 2021-07-17 21:34:04 +0900
...
  Fruit Create (1.3ms)  INSERT INTO "fruits" ("name", "color", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "みかん"], ["color", "#f2ad18"], ["created_at", "2021-07-17 12:34:04.759054"], ["updated_at", "2021-07-17 12:34:04.759054"]]
  ↳ app/admin/fruit.rb:7:in `create'
  TRANSACTION (1.3ms)  commit transaction
  ↳ app/admin/fruit.rb:7:in `create'
======> called api by create


# 更新
Started PATCH "/admin/fruits/9" for 127.0.0.1 at 2021-07-17 21:34:13 +0900
...
  Fruit Update (0.5ms)  UPDATE "fruits" SET "name" = ?, "updated_at" = ? WHERE "fruits"."id" = ?  [["name", "夏みかん"], ["updated_at", "2021-07-17 12:34:13.249457"], ["id", 9]]
  ↳ app/admin/fruit.rb:13:in `update'
  TRANSACTION (0.9ms)  commit transaction
  ↳ app/admin/fruit.rb:13:in `update'
======> called api by update


# 削除
Started DELETE "/admin/fruits/9" for 127.0.0.1 at 2021-07-17 21:34:16 +0900
Processing by Admin::FruitsController#destroy as HTML
...
  ↳ app/admin/fruit.rb:19:in `destroy'
======> called api by destroy
Redirected to http://localhost:3000/admin/fruits

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_with_active_admin_app

 
関係するプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_with_active_admin_app/pull/1

Railsで、同一のbefore_actionを別オプションで複数定義した場合、最後の定義が有効になる

Railsbefore_action などのフィルタでは、 only などのオプションを追加することで対象のActionを制限できます。
8 フィルタ | Action Controller の概要 - Railsガイド

 
そんな中、同一の before_action を別オプションで複数定義してしまったことがあったため、挙動をメモしておきます。

 
目次

 

環境

 

Controllerの場合

Controllerで before_action を実装する時、

class BeforeActionFilterController < ApplicationController
  before_action :set_template_value, except: [:index3]
  before_action :set_template_value
  before_action :set_template_value, only: [:index3]

  def index1
  end

  def index2
  end

  def index3
  end

  private

  def set_template_value
    @template_value = 'foo'
  end
end

と定義してしまったとします。

この場合は、 index3 のみ before_action が動作します。

index1の場合

index2の場合

index3の場合

 
この挙動は

同じフィルタを異なるオプションで複数回呼び出しても期待どおりに動作しません。最後に呼び出されたフィルタ定義によって、それまでのフィルタ定義は上書きされます。

8 フィルタ | Action Controller の概要 - Railsガイド

のためです。

 

Mailerの場合

Mailerにも before_action などのコールバックがあります。

こちらも挙動は同じです。

そのため、

class BeforeActionCallbackMailer < ApplicationMailer
  before_action :set_template_value, except: [:welcome3]
  before_action :set_template_value
  before_action :set_template_value, only: [:welcome3]

  def welcome1
    mail(to: 'welcome1@example.com')
  end

  def welcome2
    mail(to: 'welcome2@example.com')
  end

  def welcome3
    mail(to: 'welcome3@example.com')
  end

  private

  def set_template_value
    @template_value = 'bar'
  end
end

というMailerを使い

class BeforeActionFilterController < ApplicationController
  def send_mail
    BeforeActionCallbackMailer.welcome1.deliver_now
    BeforeActionCallbackMailer.welcome2.deliver_now
    BeforeActionCallbackMailer.welcome3.deliver_now
  end

のようにメールを送信したとします。

この場合も、 welcome3 のみ before_action が動作しテンプレートに値が渡されます。

 
welcome1の場合

welcome2の場合

welcome3の場合

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_miscellaneous_app

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_miscellaneous_app/pull/1

Rails + 名前空間付きのActionMailerを使う場合に、件名をi18nのロケールファイルで指定してみた

Railsガイドによると、Action Mailer の件名は

mailメソッドに件名が渡されなかった場合、Action Mailerは既存の訳文の利用を試みます。「<mailer_scope>.<action_name>.subject」というパターンでキーが構築されます。

4.6 Action Mailerメールの件名を訳文に置き換える | Rails 国際化 (i18n) API - Railsガイド

となります。

ただ、

module Foo
  class BarMailer < ApplicationMailer
# ...

のような名前空間付きのAction Mailerの場合、ロケールファイルではどのように指定すればよいか迷ったためメモを残します。

 

目次

 

環境

 

名前空間付きのAction Mailerに対するロケールファイルの書き方

例えば

module Foo
  class BarMailer < ApplicationMailer
    def hello
      mail(to: 'baz@example.com')
    end
  end
end

というAction Mailerに hello というメソッドがあり、Controllerで

class HomeController < ApplicationController
  def index
    Foo::BarMailer.hello.deliver_now
  end
end

のように呼ぶとします。

 
この場合は、 config/locales/en.yml などのロケールファイルに

en:
  hello: "Hello world"
  foo:
    bar_mailer:
      hello:
        subject: My Subject

と、module Foo に対して1階層 foo を用意します。

 
ちなみに、Controllerでの呼び出しでは

::Foo::BarMailer.hello.deliver_now

でも良いです。

先頭の :: については、以下が参考になりました。

 
さらに、以降も余談を続けます。

 

Mailerのメソッドがネストしている場合

例えば、Action Mailerが

module Foo
  class BarMailer < ApplicationMailer
    def hey
      send_mail
    end

    def send_mail
      mail(to: 'send_mail@example.com')
    end
  end
end

と、 hey メソッドの中で別のpublicなメソッド send_mail を呼び出しているとします。

また、Controllerでは

class HomeController < ApplicationController
  def index
    Foo::BarMailer.hey.deliver_now
  end
end

hey メソッドを呼ぶとします。

 
この時、件名に対応するロケールファイルのキーは

  • hey
  • send_mail

のどちらになるか迷いました。

 
試してみたところ、ロケールファイルでは

en:
  hello: "Hello world"
  foo:
    bar_mailer:
      hey:
        subject: Hey subject

と、Controllerで呼ぶ時のメソッド名 hey をキーとして作れば良さそうです。

 

Mailerに対するロケールファイルのキーが存在しない場合

例えば

module Foo
  class BarMailer < ApplicationMailer
    def no_subject
      mail(to: 'no_subject@example.com')
    end
  end
end

というメソッド no_subject があるものの、ロケールファイルにキーが存在しない場合はどうなるか気になりました。

 
この時はメソッドの名前が件名に使われるようです。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_i18n_app

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

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