Writing better Action Mailers: Revisiting a core Rails concept

swanson

matt swanson

Posted on January 17, 2023

Writing better Action Mailers: Revisiting a core Rails concept

Mailers are a feature used in literally every Rails application. But they are often an after thought where we throw out the rules of well-written applications.

Writing mailers is a “set it and forget it” part of your codebase. But recently, I’ve revisited the handful of mailers in my application and I was shocked at both how bad things were and also how many nice mailer features in Rails I wasn’t aware of.

I’ve been writing Rails applications for over 10 years and there were things I figured out just this week about mailers that I will be using as my new defaults going forward.

Psst! If you like thinking about software and writing code in the "Boring Rails" style, we are hiring Product Engineers at Arrows. Come work with me!

Better display names

Rails has a built-in helper for formatting a display name that shows in email clients.

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>"
Enter fullscreen mode Exit fullscreen mode

This might seem trivial but it handles nil values and escaping quotes for you. And it’s a really nice touch for making emails from your app feel more polished.

I’ve written about this helper before but every time I mention it, someone replies telling them this is the first they’ve heard of it, so I will keep repeating it!

Changing the view folders

One thing that also bugs me is the file structure for mailer views. By default, the view for, e.g. NotificationMailer.welcome_email will be located at app/views/notification_mailer/welcome_email.html.erb.

This structure mirrors how controllers in Rails work. But it makes it difficult for you to see all your email templates at once. Often times, when we make a change to how we display emails, I need to scan all of the templates to make sure we aren’t doing anything funky.

I came across this post by Andy Croll that shows you how to put all your mailer views in one place. If you make one small tweak to your ApplicationMailer, you can achieve a more scannable folder structure.

class ApplicationMailer < ActionMailer::Base
  prepend_view_path "app/views/mailers"
end
Enter fullscreen mode Exit fullscreen mode

Now you can put your mailer view in app/views/mailers/notification_mailer/welcome_email.html.erb. It is one extra level of folder nesting, but now you have a specific app/views/mailers folder instead of mixing in mailer views with controller views.

Multiple emails per mailer

Did you know you can put multiple emails inside of a single mailer?

There is nothing in the Rails documentation that says you can’t have multiple emails from one mailer, but it also isn’t explicitly encouraged. Sometimes you just need explicit permission from a random person on the internet and I am happy to be that person!

For whatever reason, every Rails app I’ve worked in has a one-to-one relationship between Mailer classes and email methods. Not only does this make it harder to grok the emails in your system, but it presents weird friction in naming that should jump out as a code smell.

Previously, I would make mailers like:

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
Enter fullscreen mode Exit fullscreen mode

Why? I don’t really know. I can’t defend separating them.

Instead, you can group functionality into one mailer.

class NotificationMailer < ApplicationMailer
  layout "minimal"

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

  def mentioned(mentionee, comment)
    # mail(to: ...)
  end
end
Enter fullscreen mode Exit fullscreen mode

Now you can see all the notifications in one file. There are so many times when I forget that we have a certain email going out and it doesn’t get updated when a new feature is added to the app.

You don’t need to write the word “email”

Mailers send emails. You don’t need to append _email to your methods/views – especially if you follow the above tip to put the views into app/views/mailers.

# No one is going to be surprised that this code sends an email
NotificationMailer.comment_reply_email(@user, @comment).deliver_later

# So don't prefix everything with `email`!
NotificationMailer.comment_reply(@user, @comment).deliver_later
Enter fullscreen mode Exit fullscreen mode

Parameterized mailers

Okay, so far all of these tips are neat but nothing too wild.

Recently, I’ve been building a feature that allows you to send transactional from your own sending domain. So instead of getting a system email from hello@arrows.to, it would come from onboarding@acme-saas.com. There is some DNS related stuff that needs to happen in the background but I want to focus on the mailer portion of this feature.

One difficulty was making sure that, if an account had setup the custom sending domain, we overwrite the From address in the mailer. The problem was that there are many mailers in the application and I didn’t want to put this conditional code in every mailer. I was worried that – down the line – I would add a new email and then forget to consider the case where the account is using a custom sending domain.

I started with this basic implementation in one of the impacted mailers.

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
Enter fullscreen mode Exit fullscreen mode

My first thought was that I could add some code to my ApplicationMailer to check the Current.account. But I hit a snag because mailers should be sent in a background job (via deliver_later) and we lose the context of the current request (and thus, the Current attributes). There are ways around this – like a middleware to pass along the Current attributes to the job – but something felt off to me.

When looking for a better way to implement this, I went back to the Action Mailer documentation and I noticed something: none of the example code was passing in data to the mailer methods directly. Instead there were accessing everything via params.

So instead of:

NotificationMailer
  .comment_reply(user, comment)
  .deliver_later
Enter fullscreen mode Exit fullscreen mode

You would write:

NotificationMailer
  .with(user: user, comment: comment)
  .comment_reply
  .deliver_later
Enter fullscreen mode Exit fullscreen mode

I had never used this pattern before. Mailers have not changed since I first learned Rails (in the 2.x days!) but in Rails 5.1, the concept of a “parameterized” mailer was introduced.

My first impression was that I didn’t quite understand the point of this. I generally prefer having the explicit method arguments on the mailer method compared to a generic params hash.

But, this time it finally clicked!

The extra benefit of using with and parameterized mailers is that you can add before_action callbacks to your mailers to configure options like the custom sending domain outside of the context of the mailer method.

I used this concept to make a small change:

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
Enter fullscreen mode Exit fullscreen mode

So far, this isn’t really much better but this is setting the stage for being able to pull this configuration out of each mailer.

Dynamic defaults

The next piece of the puzzle is making use of mailer default options. One thing you might not realize (because I didn’t either…) is that you can pass a lambda to default to set the value dynamically.

class NotificationMailer < ApplicationMailer
  # You can pass in a static value
  default from: "hello@arrows.to"

  # But...it's probably more useful to use dynamic values
  default from: -> { build_default_from_address }

  private

  def build_default_from_address
    # Construct the default from address here
  end
end
Enter fullscreen mode Exit fullscreen mode

The nice part about using default is that individual mailers can override the from option if they need to, but if you set the default, you can omit it completely.

The key breakthrough for my implementation of the custom sending domain feature comes from combining the dynamic defaults with the parameterized mailer option for passing in data.

Connecting the dots

By using dynamic defaults and before_action callbacks, you can have access to the params hash when configuring the defaults.

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_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
Enter fullscreen mode Exit fullscreen mode

Now the logic for the custom sending address has been completely removed from the mailer methods. And it now clear that this behavior is not specific to just the NotificationMailer. Since we pulled the code into callbacks and parameterized options, we can hoist it up to a new mailer base class.

This also allows me to introduce the concept of an “Account scoped email” – an email sent in the context of a specific Account, which may have additional configuration or features.

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

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

  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

class NotificationMailer < AccountMailer
  # ...
end

class DigestMailer < AccountMailer
  # ...
end

class ParticipationMailer < AccountMailer
  # ...
end
Enter fullscreen mode Exit fullscreen mode

There is one subtle change that also improves the developer experience and ensures that future mailers don’t omit the with(account: @account) configuration.

class AccountMailer < ApplicationMailer
  # ...

  before_action { @account = params.fetch(:account) }
end
Enter fullscreen mode Exit fullscreen mode

The before_action will fail with a NoMethodError if the params are omitted and by using fetch it will fail with KeyError: :account if the caller does not pass in the account.

Wrap it up

Mailers are the worst part of a Rails application in terms of quality. While the framework provides an extremely powerful and elegant conceptual compression around sending emails, we often write them once and never touch them in our application code.

We frequently ignore complex code and duplication in mailers because they are at the edge of the system. The difficulty in rendering HTML email views does not help and further encourages “get it working and never touch it again” behavior.

But you the tools for organizing mailers just like the rest of your codebase.

I hadn’t brushed up mailers since I first learned Rails and I was surprised by how much I could improve them with a couple of small changes.

In my specific case, I was able to abstract a cross-cutting behavior (customizing the sender address) into a base mailer class. By using dynamic defaults and parameterized mailers, I can provide a pleasant developer experience that makes it easy to do “the right thing” for future code.

By applying some extra thought on naming and folder structure, I was able to make the mailer methods read better and make it easier to see the full scope of email views in the app. And by grouping multiple emails into single mailer classes, you can keep things that are similar close together in the code.

As I move forward, I will be working to define more application specific mailer contexts: for example an AccountMailer base class for emails generated within the scope of an account and SystemMailer base class for things like login and password reset emails that have different configuration options.

Your mailers can be an exemplary part of your codebase with a little bit of work!


💖 💪 🙅 🚩
swanson
matt swanson

Posted on January 17, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related