Action Mailer and Active Job sitting in a tree...

Elle Meredith

Almost every application I ever worked on had some requirement to send emails. Whenever I need to implement sending emails (using Action Mailer), I also implement a background job for it. Since version 4.2, Rails has built-in support for executing background jobs using Active Job.

Every time I need to start setting up Active Job for email sending, I find myself looking up the required syntax and suggested specs. This article solves this problem by gathering in one place all the answers I usually look for elsewhere.

Active Job

Active Job is a framework for declaring jobs and making them run on a variety of asynchronous queuing backends. It provides us with a consistent DSL, so we can easily switch queuing backends (for example between development and production environments) without having to rewrite our jobs.

Active Job has built-in adapters for popular queuing backends (Sidekiq, Resque, Delayed Job, and others). To get an up-to-date list of the adapters see the API Documentation for ActiveJob::QueueAdapters.

A good candidate for a background job is anything that involves external services or that might slow the responsiveness of the application, for example processing payments, sending emails, or external syntax highlighting services.

Getting started

If you are using Suspenders, most of the following setup will already be done for you, mainly around configuration of Action Mailer or Delayed Job.

If you are starting afresh, you will need to select and set up a queuing backend. In this case I am using Delayed Job.

The following is my configuration around Action Mailer and its testing set up. It adds a dotenv-rails gem and configures environments using .env variables locally. It adds mailer spec helpers, and it clears Action Mailer deliveries before each spec.

# Gemfile
group :development, :test do
  gem "dotenv-rails"
end

# .env
APPLICATION_HOST=localhost:3000
SMTP_ADDRESS=smtp.example.com
SMTP_DOMAIN=example.com
SMTP_PASSWORD=password
SMTP_USERNAME=username

# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: ENV.fetch("SMTP_ADDRESS"),
  authentication: :plain,
  domain: ENV.fetch("SMTP_DOMAIN"),
  enable_starttls_auto: true,
  password: ENV.fetch("SMTP_PASSWORD"),
  port: 587,
  user_name: ENV.fetch("SMTP_USERNAME")
 }
config.action_mailer.default_url_options = { host: ENV.fetch("APPLICATION_HOST") }

# config/environments/test.rb
config.action_mailer.delivery_method = :test
config.action_mailer.default_url_options = { host: "example.com" }

# spec/support/action_mailer.rb
RSpec.configure do |config|
  config.before(:each) do
    ActionMailer::Base.deliveries.clear
  end
end

# spec/support/mailer_helpers.rb
module MailerHelpers
  def emails
    ActionMailer::Base.deliveries
  end

  def last_email
    emails.last
  end
end

RSpec.configure do |config|
  config.include MailerHelpers, type: :mailer
end

Configuring Active Job is much simpler:

# config/application.rb
config.active_job.queue_adapter = :delayed_job

# config/environments/test.rb
config.active_job.queue_adapter = :inline

# spec/support/active_job.rb
RSpec.configure do |config|
  config.include ActiveJob::TestHelper

  config.before(:each) do
    clear_enqueued_jobs
  end
end

ActiveJob::Base.queue_adapter = :test

In our test suite set up, we set ActiveJob::Base.queue_adapter = :test. The :test adapter will not run jobs but will just store them, and will allow us to access the enqueued_jobs in our specs.

To load the files in spec/support, you need this line in your rails_helper.rb:

# spec/rails_helper.rb
Dir[Rails.root.join("spec/support/**/*.rb")].sort.each { |file| require file }

Now that we are ready to get started, let’s go through the feature that we would like to build. In this example, when a user signs up for a new user account, we will send them a welcome email with a link to confirm their email address.

Creating the mailer

$ rails g mailer user_mailer invite
  create  app/mailers/user_mailer.rb
  create  app/mailers/application_mailer.rb
  invoke  erb
  create    app/views/user_mailer
  create    app/views/layouts/mailer.text.erb
  create    app/views/layouts/mailer.html.erb
  create    app/views/user_mailer/invite.text.erb
  create    app/views/user_mailer/invite.html.erb
  invoke  rspec
  create    spec/mailers/user_mailer_spec.rb
  create    spec/fixtures/user_mailer/invite
  create    spec/mailers/previews/user_mailer_preview.rb

You will notice that we now have an ApplicationMailer where we can define defaults such as the from email address.

The default email layout lives at app/views/layouts/mailer.*.

We also have email previews, which are available at: http://localhost:3000/rails/mailers/user_mailer/invite*.

Email’s subject and body can be set using I18n so in our case, we can define:

# config/locales/en.yml
en:
  user_mailer:
    invite:
      subject: Welcome
      body: To confirm your email, please visit %{confirmation_url}

Our UserMailer specs might look something like:

# spec/mailers/user_mailer_spec.rb
require "rails_helper"

describe UserMailer  do
  describe "invite" do
    context "headers" do
      it "renders the subject" do
        user = build(:user, token: "abc")

        mail = described_class.invite(user)

        expect(mail.subject).to eq I18n.t("user_mailer.invite.subject")
      end

      it "sends to the right email" do
        user = build(:user, token: "abc")

        mail = described_class.invite(user)

        expect(mail.to).to eq [user.email]
      end

      it "renders the from email" do
        user = build(:user, token: "abc")

        mail = described_class.invite(user)

        expect(mail.from).to eq ["from@example.com"]
      end
    end

    it "includes the correct url with the user's token" do
      user = build(:user, token: "abc")

      mail = described_class.invite(user)

      expect(mail.body.encoded).to include confirmation_url(token: user.token)
    end
  end
end

Our UserMailer looks much simpler:

# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  def invite(user)
    @user = user

    mail to: @user.email
  end
end

We will also need to update our mailer views:

<%# app/views/user_mailer/invite.html.erb %>
<h1>Welcome <%= @user.email %></h1>

<%= t(".body", confirmation_url: confirmation_url(token: @user.token)) %>

And if we want to preview our email, we need to update the preview:

# spec/mailers/previews/user_mailer_preview.rb
def invite
  user = build(:user, token: "abc")
  UserMailer.invite(user)
end

Creating the Job

Now that we have a working mailer, we can create the background job and call on it from our user#invite method. We can use a Rails generator for that:

$ rails g job send_new_user_invitation
  invoke rspec
  create spec/jobs/send_new_user_invitation_job_spec.rb
  create app/jobs/send_new_user_invitation_job.rb

The Rails generator created a SendNewUserInvitationJob in app/jobs, which has one method #perform. To enqueue the job to be performed as soon as the queuing system is free, we can call SendNewUserInvitationJob.perform_later with any arguments we wish to pass it. If we wanted to run the job immediately, we could instantiate the job, and call .perform on that instance. We will see that in our specs shortly.

Again, let’s start with the specs:

# spec/jobs/send_new_user_invitation_job_spec.rb
require "rails_helper"

describe SendNewUserInvitationJob do
  describe "#perform" do
    it "calls on the UserMailer" do
      user = double("user", id: 1)
      allow(User).to receive(:find).and_return(user)
      allow(UserMailer).to receive_message_chain(:invite, :deliver_now)

      described_class.new.perform(user.id)

      expect(UserMailer).to have_received(:invite)
    end
  end

  describe ".perform_later" do
    it "adds the job to the queue :user_invites" do
      allow(UserMailer).to receive_message_chain(:invite, :deliver_now)

      described_class.perform_later(1)

      expect(enqueued_jobs.last[:job]).to eq described_class
    end
  end
end

And the corresponding code:

# app/jobs/send_new_user_invitation_job.rb
class SendNewUserInvitationJob < ActiveJob::Base
  queue_as :user_invites

  def perform(user_id)
    user = User.find(user_id)

    UserMailer.invite(user).deliver_now
  end
end

You will note that we pass user_id to the job. Many background jobs use Redis for their data store, which supports simple data structures and which is why we pass a simple string rather than a User object.

Active Job supports GlobalID for parameters. This makes it possible to pass live ActiveRecord objects to your job instead of class/id pairs, which you then have to manually deserialize. Previously you would pass an ActiveRecord id, run a query to find the object and then perform the task on hand. With GlobalID, this can be replaced with passing the ActiveRecord object as an argument directly to perform. Unfortunately this means that it is possible that the object record would be deleted after the job is enqueued but before the perform method is called, and thus the exception handling would need to be different. So best practice still suggests to use small and simple job params.

The last thing to do is get our User#invite to call on our flash new job:

# spec/models/user_spec.rb
describe User do
  # ...

  describe "#invite" do
    it "enqueues sending the invitation" do
      allow(SendNewUserInvitationJob).to receive(:perform_later)
      user = build(:user)

      user.invite

      expect(SendNewUserInvitationJob).to have_received(:perform_later)
    end
  end
end

# app/models/user.rb
class User < ActiveRecord::Base

  def invite
    SendNewUserInvitationJob.perform_later(id)
  end
end

Alternatively we can call UserMailer.invite(self).deliver_later, which will automatically send the invitation email asynchronously in the queue.

Recap

Let’s recap what we have done here. When we call on User#invite, it enqueues sending the invitation in the background. Once the queuing system is free, it will call on the UserMailer to deliver the invitation email.

You can check out a demo app for this tutorial at https://github.com/elle/active-job-action-mailer-demo

Active Job makes scheduling background jobs easier, without requiring you to change your code when changing queuing systems. Hopefully this tutorial makes setting up Active Job to work with Action Mailer a bit easier for you.