Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Railsの技: Action Mailerを活用する良い書き方を改めておさらいする(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Railsの技: Action Mailerを活用する良い書き方を改めておさらいする(翻訳)

メーラーは、文字通りあらゆるRailsアプリケーションで使われる機能です。しかしメーラーは後から導入されることが多く、Railsアプリケーションを正しく書く鉄則が放ったらかしにされがちです。

メーラーを書くという作業は、コードベースで「一度設定したら後は忘れる」部分になります。しかし最近自分のアプリケーションのメーラーを見返したときに、2つの点でショックを受けました。自分が昔書いたメーラーのひどさと、そして自分の知らない素晴らしいメーラー機能がRailsに豊富に揃っているという事実です。

私はもう10年以上Railsアプリケーションを書き続けていますが、メーラーについて今週私が得た学びがいくつもあったので、今後新しいデフォルトとして使っていくつもりです。

お知らせ

ソフトウェアについて考えるのが好きな方、当"Boring Rails"スタイルでコードを書くのが好きな方へ: Arrowsではプロダクトエンジニアを募集しています。私と一緒に働いてみませんか?

🔗 メールの表示名は専用ヘルパーでやろう

Railsには、メールクライアントで表示される表示名(display name)をフォーマットする組み込みヘルパーがあります。

# 表示名なし
ActionMailer::Base.email_address_with_name("help@arrows.to", "Arrows HQ")
=> "Arrows HQ <help@arrows.to>"
# 表示名あり
ActionMailer::Base.email_address_with_name(user.email, user.display_name)
=> "Matt Swanson <matt@boringrails.com>"

大した機能には見えないかもしれませんが、このdisplay_nameメソッドはnil値も扱えますし、引用符もエスケープしてくれます。アプリから配信するメールを洗練させるのにとても良い小技です。

このヘルパーについては過去記事でも書いたことがありますが、この小技を紹介するたびに「これ知らなかった!」というお便りをくださる方が何人もいるので、ここでも紹介しておきたいと思います。

🔗 メーラーのビューフォルダを変更する

メーラーで使うビューのファイル構造はいつも悩みのタネのひとつでした。デフォルトでは、たとえばNotificationMailer.welcome_emailapp/views/notification_mailer/welcome_email.html.erbファイルに置かれます。

このファイル構造には、Railsのコントローラが動作するときの仕組みが反映されています。しかしこのままでは、すべてのメールテンプレートを一度に見ようとするときに面倒です。メールの表示方法を変更するたびに、変更に問題がなかったかどうかを確かめるために、あちこちにあるメールのテンプレートを片っ端からチェックしなければなりませんでした。

Andy Croll氏による以下の記事で、メーラーのビューテンプレートを1箇所にまとめる方法があることを知りました。ApplicationMailerに少し手を加えるだけで、メールのテンプレートを探しやすいフォルダ構造になります。

参考: All Your Mailer Views in One Place - Andy Croll

class ApplicationMailer < ActionMailer::Base
  prepend_view_path "app/views/mailers"
end

これで、メーラーのビューをapp/views/mailers/notification_mailer/welcome_email.html.erbに置けるようになりました。フォルダのネストが1階層増えはしますが、app/views/mailersフォルダに置くようにしたことで、メーラーのビューとコントローラのビューを同じapp/views/フォルダに置かずに済みます。

🔗 1個のメーラーで複数のメール送信を扱う

1個のメーラーで複数のメール送信を扱えることをご存知でしたか?

Railsのドキュメントには、1個のメーラーで複数のメール送信を扱ってはいけないとは書かれていませんが、明示的に推奨されているわけでもありません(インターネット上では不特定の人々から明示的に許諾を得なければならなくなることもあったりしますが、それを思えば幸いです)。

私がこれまで手がけてきたどのRailsアプリも、何らかの理由でMailerクラスとメール送信メソッドが1対1対応していました。これでは、システムで扱っているメール送信を把握するのが面倒になるだけでなく、命名に奇妙な軋轢が生じてコードの臭いも漂ってきます。

以前の私は、以下のようなメーラーを書いていました。

class CommentReplyMailer < ApplicationMailer
  layout "minimal"

  def comment_reply_email(user, comment)
    # mail(to: ...)
  end
end
class UserMentionedMailer < ApplicationMailer
  layout "minimal"

  def mentioned_email(mentionee, comment)
    # mail(to: ...)
  end
end

当時そのようにした理由は自分でもよくわかりませんが、分割する理由を正当化できません。

代わりに、以下のようにメール送信機能を1個のメーラーでグループ化すればよいのです。

class NotificationMailer < ApplicationMailer
  layout "minimal"

  def comment_reply(user, comment)
    # mail(to: ...)
  end

  def mentioned(mentionee, comment)
    # mail(to: ...)
  end
end

これで、1個のファイルですべてのメール通知を見渡せるようになりました。配信されるメールがあることを見落として、アプリに新機能を追加したときに更新がユーザーに通知されなかった、といったことが起きがちですよね。

🔗 命名に"email"という語は不要

メーラーはメールを送信するものなので、メソッド名やビュー名の末尾に_emailを追加する必要はありません。前述の小技を用いてビューをapp/views/mailers/フォルダに置く場合は特にそうです。

# このコードがメールを送信することはわかりきっている
NotificationMailer.comment_reply_email(@user, @comment).deliver_later

# なので、名前の末尾に_mailは不要!
NotificationMailer.comment_reply(@user, @comment).deliver_later

🔗 "パラメータ化"メーラー

さて、ここまで紹介した小技はどれも気が利いていますが、驚くほどの技ではありませんでした。

最近の私は、メールを自分たちの送信ドメインからトランザクショナルに送信できる機能を構築中です。この機能では、ユーザーが受け取るシステムメールの送信元をhello@arrows.toではなくonboarding@acme-saas.comに変えます。舞台裏ではDNS関連の動作も関わってきますが、今はこの機能のメーラー部分に注目したいと思います。

ここで難しいのは、あるアカウントがカスタム送信ドメインをセットアップしている場合、メーラーでFromアドレスが確実に上書きされるようにすることです。アプリケーションには多数のメーラーがあるのですが、この条件コードをいちいちメーラーごとに追加したくありません。こんな調子では、いつかそのうち新しいメール送信を追加するときに、ユーザーがカスタム送信ドメインを使っていることを忘れるのではないかと心配でした。

手始めに、中身がぎっしり詰まっているメーラーの1つで基本的な実装を行ってみました。

class NotificationMailer < ApplicationMailer
  def comment_reply(user, comment)
    # ...
    mail(
      to: user.email,
      from: build_from_address(comment.account)
    )
  end

  private

  def build_from_address(account)
    if account.custom_email_sender?
      email_address_with_name(
        account.custom_email_address,
        account.custom_email_name
      )
    else
      email_address_with_name("hello@arrows.to", account.name)
    end
  end
end

最初に思いついたのは、 Current.accountをチェックするコードを少しばかりApplicationMailerに追加するという方法でしたが、この方法は行き詰まってしまいました。このメーラーは( deliver_later経由で)バックグラウンドジョブから送信すべきなので、カレントリクエスト(そしてCurrent属性)のコンテキストが失われてしまうのです。

回避方法はいろいろ考えられるのですが(Current属性をジョブに渡すミドルウェアなど)、どうもしっくり来ません。

もっといい方法でこれを実装できないかと思って探し回るうちに、RailsガイドのAction Mailerドキュメントに立ち戻り、そこで1つ発見がありました。どのサンプルコードも、データをメーラーに直接渡していないのです。その代わり、あらゆるものにparams経由でアクセスしています。

NotificationMailer
  .comment_reply(user, comment)
  .deliver_later

つまり、上のように書くのではなく、withで以下のように書くということです。

NotificationMailer
  .with(user: user, comment: comment)
  .comment_reply
  .deliver_later

このパターンはまったく使ったことがありませんでした。最初にRailsを学んで以来(2.xの時代です!)、私のメーラーの書き方は何ひとつ変わっていなかったのですが、実はRails 5.1から"パラメータ化"メーラーという概念が導入されているのです。

第一印象では、どの点が嬉しいのかが今ひとつ理解できませんでした。私が一般的に好んでいるのは、一般的なparamsハッシュよりも、メーラーメソッドに明示的にメソッド引数を渡す方法です。

しかし今回は、最終的に「なるほど!」と膝を打ちました。

withとパラメータ化メーラーを使えば、メーラーにbefore_actionコールバックを追加することで、カスタム送信ドメインのようなオプションをメーラーメソッドのコンテキストの外で設定できるというメリットも得られるのです。

このコンセプトに沿って以下のように少し手を加えてみました。

class NotificationMailer < ApplicationMailer
  before_action { @account = params[:account] }
  before_action { @from = build_from_address }

  def comment_reply(user, comment)
    # ...
    mail(to: user.email, subject: "New reply", from: @from)
  end

  def mentioned(mentionee, comment)
    # ...
    mail(to: mentionee.email, subject: "You were mentioned", from: @from)
  end

  private

  def build_from_address
    if @account.custom_email_sender?
      email_address_with_name(
        @account.custom_email_address,
        @account.custom_email_name
      )
    else
      email_address_with_name("hello@arrows.to", @account.name)
    end
  end
end

この時点ではまだ改良の余地はあるものの、各メーラーからこの設定を切り出せるようにする準備が整いました。

🔗 動的なdefaultオプション

次なる小技は、メーラーのdefaultオプションの活用方法です。皆さんはおそらく、このdefaultオプションにlambdaを渡して値を動的に設定できるという技をご存知ないでしょう(私も知りませんでした...)。

class NotificationMailer < ApplicationMailer
  # 固定値を渡すことはもちろん可能
  default from: "hello@arrows.to"

  # しかし...動的な値を渡せばもっと便利になる
  default from: -> { build_default_from_address }

  private

  def build_default_from_address
    # ここでメールアドレスからデフォルト値を構成する
  end
end

defaultの嬉しい点は、必要に応じて個別のメーラーでfromオプションオーバーライドできる点です。しかもデフォルト値を設定しておけば、完全に省略できます。

例のカスタム送信ドメイン機能の私の実装では、この動的なデフォルト値とパラメータ化メーラーを合わせ技にしてデータを渡すのが重要な突破口になりました。

🔗 点と点をつなぎあわせる1

動的なデフォルト値と before_action コールバックを組み合わせることで、以下のようにデフォルト値を設定する際に params ハッシュを利用できるようになります。

class NotificationMailer < ApplicationMailer
  default from: -> { build_default_from_address }

  before_action { @account = params[:account] }

  def comment_reply(user, comment)
    # ...
    mail(to: user.email, subject: "New reply")
  end

  def mentioned(mentionee, comment)
    # ...
    mail(to: mentionee.email, subject: "You were mentioned")
  end

  private

  def build_default_from_address
    if @account.custom_email_sender?
      email_address_with_name(
        @account.custom_email_address,
        @account.custom_email_name
      )
    else
      email_address_with_name("hello@arrows.to", @account.name)
    end
  end
end

NotificationMailer.with(account: @account).comment_reply(@user, @comment).deliver_later

例のカスタム送信アドレスのロジックが、メーラーのメソッドから完全に消えました。おかげで、この振る舞いがNotificationMailerに縛られないことも明確になりました。このコードはコールバックとパラメータ化オプションに移動したので、メーラーの新しいベースクラスで使えるようになります。

これによって、"Accountを対象とするメール送信"(特定のAccountをコンテキストとして送信されるメール)という概念の導入にも成功しました。継承先で独自の設定や機能を追加することも可能です。

class AccountMailer < ApplicationMailer
  layout "minimal"
  default from: -> { build_default_from_address }

  before_action { @account = params.fetch(:account) }

  private

  def build_default_from_address
    if @account.custom_email_sender?
      email_address_with_name(
        @account.custom_email_address,
        @account.custom_email_name
      )
    else
      email_address_with_name("hello@arrows.to", @account.name)
    end
  end
end
class NotificationMailer < AccountMailer
  # ...
end
class DigestMailer < AccountMailer
  # ...
end
class ParticipationMailer < AccountMailer
  # ...
end

さらに、今後メーラーを新たに作るときにwith(account: @account) 設定をうっかり省略してしまうこともなくなるので、ささやかながら開発エクスペリエンスが向上しました。

class AccountMailer < ApplicationMailer
  # ...

  before_action { @account = params.fetch(:account) }
end

paramsが省略されるとbefore_action がNoMethodErrorで失敗します。
呼び出し側がaccountを渡し忘れると、それを利用しているfetchKeyError: :accountで失敗します。

🔗 最後に

Railsアプリケーションで書くメーラーは、品質面において最も質が落ちる部分です。Railsフレームワークのメール送信には極めて強力かつエレガントな概念が凝縮されているにもかかわらず、アプリケーションコードにあるメーラーは一度書いたらそれっきり放置されがちです。

メーラーがシステムの隅っこに位置しているせいで、メーラーのコードが複雑になったり重複しまくったりしていても見落とされがちです。メールのHTMLビューのレンダリングが難しいこともあって、「一度書いたらそれっきり」という悪い習慣がさらに後押しされてしまいます。

しかし、コードベースの他の部分と同様に、メーラーにもコードを整理整頓するためのツールがちゃんとあるのです。

私のメーラーは自分が最初にRailsを学んで以来ホコリをかぶったままでしたが、ささやかな変更をいくつか加えるだけでコードがこれほど目覚ましく改善されたことに驚きました。

特に私の場合、横断的な振る舞い(送信者アドレスのカスタマイズ)をベースのメーラークラスで抽象化することに成功しました。動的なデフォルト値とパラメータ化メーラーを合わせて使うことで、今後書かれるコードで"正しいこと"を楽に実現できるという快適な開発エクスペリエンスを提供できるようになったのです。

ひと手間かけて命名やフォルダ構造にも工夫を加えたおかげで、メーラーのメソッドが読みやすくなり、アプリのメールビューの全体像もわかりやすくなりました。いくつかのメール送信をグループ化して1個のメーラークラスにしたことで、処理内容の近いコードを凝縮した形でまとめておけるようになりました。

今後は、アプリケーション固有のメーラーコンテキストの定義を増やしていくつもりです。たとえば、AccountMailerベースクラスはアカウントを対象にメールを生成し、SystemMailerベースクラスは、ログインやパスワードリセットのメールをさまざまな設定オプションで送信する、といった具合です。

皆さんのメーラーも、少し手を加えてあげるだけでコードベースの模範になるでしょう!


本記事が役に立つと思った方は、ぜひフォームでニュースレターの登録をお願いします。余分な文章を削ぎ落とした読みやすく価値ある情報だけをニュースレターで配信いたします。

関連記事

Railsの技: Active Recordの結果のenum値ソートをSQLで実行する(翻訳)


  1. 訳注: "Connecting the dots"はスティーブ・ジョブスの有名な言葉です。参考: Steve Jobs - Connecting The Dots - Motivational Video - YouTube 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。