Thin and maintainable Rails mailers: how we refactored Rails mailers at mkdev

Illustration of a relaxed man with hands behind head, an orange scarf, and a mug, pens, crumpled paper, and origami on the desk, suggesting a creative or brainstorming session. Illustration of a relaxed man with hands behind head, an orange scarf, and a mug, pens, crumpled paper, and origami on the desk, suggesting a creative or brainstorming session.

A new feature in Rails 5.1, parameterized mailers, has just been introduced lately. With them you can you use default filters and parameters in mailers. These two highly useful features allowed us to effectively and quickly refactor our Rails mailers. And now were going to talk about it.

Mailers before refactoring

There are lots of mailers that do not use parameterization. One of them is NotificationsMailer which notifies the students and the mentors about several important things, such as the subscription status, payments and stuff like that.

# app/mailers/notifications_mailer.rb

class NotificationsMailer < ApplicationMailer

  def student_inactive_notice(user)
    @user = user
    I18n.locale = @user.locale
    mail subject: default_i18n_subject,
         to: @user.email
  end

  def new_payment_for_mentor(user, amount)
    @user = user
    @amount = amount
    I18n.locale = @user.mentor.locale
    mail subject: t('.subject'),
         to: @user.mentor.email
  end

  # ...

end

There are two localizations (Russian and English) for the template of each method. You set the language in the body of any method with I18n.locale and also call the method mail(headers = {}, &block). We pass different arguments to this method, and set the default values for the most of them. In the aforementioned example we use :subject for an email subject, :to for a recipient mailing address, :from for a sender mailing address. The method default_i18n_subject(interpolations = {}) is used to set an email subject in most mailers. The same thing happens if you call t('.subject').

All mailer classes are inherited from ApplicationMailer, which will be a key pillar in our article. Before being refactored it looked that way:

# app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base
  default from: "\"mkdev\" <#{ENV['SUPPORT_EMAIL']}>"
end

There is also a model where we call an instance method of NotificationsMailer. All the emails are sent in the background with deliver_later(options = {}), hence using Active Job. If you used to use delay(options = {}) for this, now its time to change it, as its not compatible with parameterized mailers.

# app/models/user.rb

class User < ApplicationRecord

  ...
  class << self
    def notify_inactive
      students.where("last_active_at < ?", 2.days.ago).find_each do |user|
        NotificationsMailer.student_inactive_notice(user).deliver_later
        # ...
      end
    end
  end
  ...

end

It goes without saying that all mailers are tested. They are rather homogeneous so I'm not going to show the code here.

So lets proceed with our crazy (believe me:)) refactoring to behold the elegance of parameterized mailers!

Refactoring

Lets take headers from:, subject: and to: from NotificationsMailer and put them into the hash default(value = nil) inside ApplicationMailer, its options will be available for all the mailers by default.

Then lets define the filter before_action(names, block) to set the locale and a mail recipient. As the result well get a body positive ApplicationMailer:

# app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base

  default from: "\"mkdev\" <#{ENV['SUPPORT_EMAIL']}>",
          subject: -> { default_i18n_subject },
          to: -> { @recipient.email }

  before_action :load_recipient

  private

  def load_recipient
    @recipient = params[:recipient]

    I18n.locale = if @recipient.is_a?(User)
                    @recipient.locale
                  elsif @recipient.user
                    @recipient.user.locale
                  else
                    :en
                  end
  end

end

That's it, now we have the locale for all the mailers and in one place. Convenient? Well, its also DRY.

But the best thing is the way NotificationsMailer is now going to look. And the rest are not bad-looking too, believe me!

I think it wouldn't hurt to note that instance variables, defined from the hash params entry, are needed for the templates.

# app/mailers/notifications_mailer.rb

class NotificationsMailer < ApplicationMailer

  def student_inactive_notice
    mail
  end

  def new_student_for_mentor
    @student, @memo, @amount = params[:student], params[:memo], params[:amount]
    mail
  end

  # ...

end

You cant see it, but the code NotificationsMailer got 35% smaller and more legible and clear. There are loads of different mailers on mkdev, so you can imagine how the situation has been improved on the whole.

Now lets get back to the code, its time to correct mailer delivery in the models.

We just need to separate the key: value pairs with a comma in the with(params) method, which passes the parameters to the mailers.

NotificationsMailer.with(recipient: user).student_inactive_notice # create an ActionMailer::MessageDelivery object

As the result we have this kind of model:

# app/models/user.rb

class User < ApplicationRecord

  ...
  class << self
    def notify_inactive
      students.where("last_active_at < ?", 2.days.ago).find_each do |user|
        NotificationsMailer.with(recipient: user).student_inactive_notice.deliver_later
        # ...
      end
    end
  end
  ...

end

Since now we're done with refactoring, lets sum it all up.

Earlier all the parameters passed to Action Mailer were available only in the method body of the mailer. This problem is solved thanks to parameterized mailers. Now mailers can be called with, for instance, default behavior parameters, as was shown earlier. And this is amazing! Mailers become thinner and more legible, but remain as useful as before.

Notifications settings

I would also like to mention that this refactoring allowed us to implement the settings (enable/disable) email notifications with ease. Now we can just call policy in one place, which was impossible without parameterized mailers:) By the way, we use the same settings not just for one mailer.

Let me show you how it was put into action with the code.

Lets add a common mailer method mail_if_subscribed(*args).

# app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base

  ...
  private

  def mail_if_subscribed(*args)
    mail *args if NotificationsPolicy.new(@recipient, self.class, self.action_name).notify?
  end
  ...

end

We need the self.class parameter to point out on which mailer we can call policy and self.action_name to show on which method exactly. And *args is used to rewrite the default headers passed to mail.

After rewriting NotificationsMailer we have:

# app/mailers/notifications_mailer.rb

class NotificationsMailer < ApplicationMailer

  ...
  def student_subscription_expires
    mail_if_subscribed
  end

  def mentor_subscription_expires
    @student = params[:student]
    mail_if_subscribed
  end
  ...

end

Well, that's it for now, that's definitely it!