Thin and maintainable Rails mailers: how we refactored Rails mailers at mkdev
New parameterized mailers make it easier to share filters, defaults across actions in Rails mailers. Coming in 5.1! https://t.co/PXS63Ou9Kf
DHH (@dhh) 28. Januar 2017
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!