Writing better Action Mailers: Revisiting a core Rails concept
Tags: ruby, rails, webdev
Published: 2023-01-17 14:14:47

---
title: Writing better Action Mailers: Revisiting a core Rails concept
date: 2023-01-16 13:00:00 UTC
published: true
tags: ruby,rails,webdev
series: Boring Rails
canonical_url: https://boringrails.com/articles/writing-better-action-mailers/
---

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](https://www.notion.so/arrows/Product-Engineer-Ruby-on-Rails-b59c7c818d4c447095d54e9171a32bc3). Come work with me!

## Better display names

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

```ruby
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>"
```

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](https://boringrails.com/tips/rails-mailers-email-address-with-name) 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](https://andycroll.com/ruby/all-your-mailer-views-in-one-place/). If you make one small tweak to your `ApplicationMailer`, you can achieve a more scannable folder structure.

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

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:

```ruby
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
```

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

Instead, you can group functionality into one mailer.

```ruby
class NotificationMailer < ApplicationMailer
  layout "minimal"

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

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

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`.

```ruby
# 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
```

## 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](mailto:hello@arrows.to), it would come from [onboarding@acme-saas.com](mailto: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.

```ruby
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
```

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](https://guides.rubyonrails.org/action_mailer_basics.html) 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:

```ruby
NotificationMailer
  .comment_reply(user, comment)
  .deliver_later
```

You would write:

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

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](https://github.com/rails/rails/blob/8a7a9d0a0b27e113e6d0a6087299c4bd41d29738/guides/source/5_1_release_notes.md#parameterized-mailers).

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:

```ruby
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
```

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.

```ruby
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
```

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.

```ruby
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
```

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.

```ruby
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
```

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

```ruby
class AccountMailer < ApplicationMailer
  # ...

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

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!

* * *
[![](https://boringrails.com/images/twitter-banner.png)](https://twitter.com/_swanson)