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.