Action Mailer in production
Type "rails action mailer" into a search box and the autocomplete reads like a bug report. Nine
suggestions, and four of them are configuration: smtp settings, preview, config, and
default_url_options twice over. Nobody is stuck on how to write a mailer class. People are stuck
on the handful of lines that sit between a mailer that renders and an email that arrives.
What follows walks those lines in the order you hit them, on Rails 8.1.3.1, against the six mailers
in the Rails boilerplate this site sells: ApplicationMailer, ConfirmationMailer,
PasswordsMailer, SequenceMailer, SetupMailer and SupportTicketMailer.
Why a mailer needs a host and a controller does not
A controller never asks you for a host because it is already holding one. url_for in a controller
goes through ActionController::Metal::UrlFor, which fills the host in from the request that is
currently open:
# actionpack-8.1.3.1/lib/action_controller/metal/url_for.rb
def url_options
@_url_options ||= {
host: request.host,
port: request.optional_port,
protocol: request.protocol,
_recall: request.path_parameters
}.merge!(super).freeze
ActionMailer::Base reaches url_for through a different door. The railtie mixes in
AbstractController::UrlFor, which includes ActionDispatch::Routing::UrlFor, whose entire
implementation is this:
# actionpack-8.1.3.1/lib/action_dispatch/routing/url_for.rb
def url_options
default_url_options
end
There is no request in a mailer. A job runs it, a rake task runs it, a preview runs it, and none of
those have a Host: header to borrow. So default_url_options is not a convenience, it is the only
source of the host, and the failure is unmissable when the key is absent:
# actionpack-8.1.3.1/lib/action_dispatch/http/url.rb
unless host
raise ArgumentError, "Missing host to link to! Please provide the :host parameter, " \
"set default_url_options[:host], or set :only_path to true"
end
Path helpers do not rescue you. A mail client has no base URL to resolve /passwords/abc123
against, so mailer views use the _url form, which is why app/views/confirmation_mailer/confirm.html.erb
reads email_confirmation_url(@token) and app/views/sequence_mailer/welcome.html.erb reads
dashboard_url. Every one of those goes through full_url_for.
The boilerplate sets the option in all three environments, and the three lines are not the same line:
# config/environments/development.rb
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
# config/environments/test.rb
config.action_mailer.default_url_options = { host: "example.com" }
# config/environments/production.rb
config.action_mailer.default_url_options = { host: ENV.fetch("APP_HOST", "example.com") }
The production line is the interesting one, and it comes back below.
Action Mailer SMTP settings, and where the password lives
Action Mailer SMTP settings are a plain hash handed to the :smtp delivery method, and the question
worth answering is not what the keys are but where their values come from. In this codebase they
come from the encrypted credentials, never from the environment:
# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = AppConfig.smtp_settings
AppConfig.smtp_settings is one method, and it takes a reader lambda so the same builder can be
pointed at a different environment's credentials:
# config/app_config.rb
def smtp_settings(get = ->(key) { Rails.application.credentials.dig(:smtp, key) })
{
address: get.call(:address),
port: (get.call(:port).presence || 587).to_i,
domain: get.call(:domain).presence,
user_name: get.call(:user_name).presence,
password: get.call(:password).presence,
authentication: (get.call(:authentication).presence || "plain").to_sym,
enable_starttls_auto: to_bool(get.call(:enable_starttls_auto), default: true),
ssl: to_bool(get.call(:ssl), default: false),
tls: to_bool(get.call(:tls), default: false),
openssl_verify_mode: get.call(:openssl_verify_mode).presence,
open_timeout: (get.call(:open_timeout).presence || 5).to_i,
read_timeout: (get.call(:read_timeout).presence || 5).to_i
}
end
Two defaults in there are decisions rather than boilerplate. port falls back to 587, which is
submission with STARTTLS and the port every provider in Setup::SMTP_PROVIDERS publishes:
smtp.resend.com, smtp.postmarkapp.com, smtp.sendgrid.net, smtp.mailgun.org and
email-smtp.us-east-1.amazonaws.com, all on 587. The timeouts default to 5 seconds each, so a
provider having a bad afternoon costs a worker five seconds and a raised exception instead of a
thread parked on a socket.
The wizard that writes those credentials is Admin::SetupController, gated with
before_action :require_local so a deployed console cannot rewrite secrets at runtime. Its live
check is the sixth mailer, and it overrides delivery per message rather than per process:
# app/controllers/admin/setup_controller.rb
mail = SetupMailer.connection_test(to)
mail.delivery_method(:smtp, smtp_settings(setup)) if setup.value("smtp.address").present?
mail.deliver_now
Credentials over environment variables is a position with a bill attached. Rotating an SMTP password
means editing config/credentials/production.yml.enc, committing it and deploying, which you cannot
do from a phone at 2am the way you can set a dyno config var. And the secret is not actually gone
from the environment: RAILS_MASTER_KEY still lives there, so what you bought is one indirection,
not invisibility. What you bought it for is that the settings are version controlled and reviewable,
and that a fresh clone has one key to obtain rather than eleven.
Previews, letter_opener, and the cheapest review loop in Rails
An Action Mailer preview is a class, not a route you enable. Subclass ActionMailer::Preview, give
it a method per mail, return the message:
# test/mailers/previews/confirmation_mailer_preview.rb
class ConfirmationMailerPreview < ActionMailer::Preview
def confirm
ConfirmationMailer.confirm(User.first)
end
end
The railtie supplies the rest. config.action_mailer.preview_paths starts empty and gets
"#{Rails.root}/test/mailers/previews" appended, show_previews defaults to
Rails.env.development?, and if it is on, three routes are prepended after initialization:
# actionmailer-8.1.3.1/lib/action_mailer/railtie.rb
get "/rails/mailers" => "rails/mailers#index", internal: true
get "/rails/mailers/download/*path" => "rails/mailers#download", internal: true
get "/rails/mailers/*path" => "rails/mailers#preview", internal: true
ConfirmationMailerPreview#confirm is then at /rails/mailers/confirmation_mailer/confirm. The
loop is cheap for the obvious reason, that editing a template and reloading a browser tab beats
replaying a signup flow, and for a less obvious one: the preview renders through the same
default_url_options, so a wrong host is a link you can hover over rather than a bug report in
three weeks.
This codebase ships none of that, which is worth saying plainly rather than describing a directory
that is not there. test/mailers/previews does not exist, the suite is RSpec under spec/, and
there are zero ActionMailer::Preview subclasses. What it ships instead is letter_opener_web 3.0.0,
MIT, last released 2024-05-14, wired in two places:
# config/environments/development.rb
config.action_mailer.delivery_method = :letter_opener_web
# config/routes.rb
mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development?
The two tools answer different questions and a real project wants both. A preview renders a mailer
on demand from data you invented, so it proves the template compiles and nothing about whether your
application ever calls it. letter_opener catches what the app actually sent, with the real User
row and the real token, so it proves the code path ran, and it cannot tell you anything about a
template until you have triggered the flow that sends it. The maintenance cost of previews is the
fake data: User.first on a machine where the first user has no name renders a mail nobody will
ever receive, and preview fixtures drift from the model quietly.
The email that renders in preview and fails in production
Look again at the production line: ENV.fetch("APP_HOST", "example.com").
Deploy without setting APP_HOST and nothing raises. full_url_for has a host, it is
"example.com", and email_confirmation_url(@token) renders as a complete, well formed,
correctly signed link to a domain you do not own. Every mailer spec still passes, because
config/environments/test.rb sets that same host, so an assertion that the body contains
example.com/email_confirmations is green in CI and green in a broken production. That is the test
that stays green while production is broken, and the only signal it produces is users who cannot
confirm their address. Two deploy docs in the boilerplate name the variable for exactly this reason:
deploy-kamal.md lists APP_HOST: app.yourproduct.com with the comment "used by
confirmation/reset email links", and deploy-heroku.md has the matching heroku config:set. The
full picture of getting those env vars to the box is in
Deploying Rails with Kamal.
The second silent failure is this codebase's own doing, an interceptor registered globally:
# config/initializers/mail_delivery_guard.rb
class SmtpDeliveryGuard
def self.delivering_email(message)
return unless Rails.env.production?
return if AppConfig.smtp_configured?
message.perform_deliveries = false
Rails.logger.warn("[Mail] SMTP isn't configured yet; skipped sending '#{message.subject}'.")
end
end
ActionMailer::Base.register_interceptor(SmtpDeliveryGuard)
AppConfig.smtp_configured? is smtp_address.present? and nothing more. With no smtp.address
credential in production, every send is rendered, intercepted, marked undeliverable and dropped. The
controller returns 200, the job completes, no row lands in solid_queue_failed_executions, and the
entire record of the event is one warn line.
The intent is defensible and stated in the file: a fresh clone must boot and reach the setup wizard
without a connection error on the first password reset. The cost is that the guard's success case
and its failure case look identical from outside, and it keys off one credential, so an address
present with a wrong password takes the other path and raises properly. If you keep an interceptor
like this, the honest version escalates: warn while the app has no users, and something that pages
you once it does.
What deliver_later actually puts on the queue
deliver_later does not enqueue an email. It enqueues a description of how to build one:
# actionmailer-8.1.3.1/lib/action_mailer/message_delivery.rb
@mailer_class.delivery_job.set(options).perform_later(
@mailer_class.name, @action.to_s, delivery_method.to_s, args: @args)
The job is ActionMailer::MailDeliveryJob, and its perform constantizes the mailer name, calls
the action with the deserialized arguments, and only then renders and sends. Nothing about the
rendered message crosses the queue. The arguments do, through Global ID, so
ConfirmationMailer.confirm(user) puts a reference to that user on the queue and the mail is
composed at delivery time against whatever the row says then.
Queue placement has a default most people never see. MailDeliveryJob declares
queue_as { mailer_class.deliver_later_queue_name }, and that class attribute defaults to
:mailers. Five of the six mailers here go through it: ConfirmationMailer from
registrations_controller.rb and email_confirmations_controller.rb, PasswordsMailer from
passwords_controller.rb, SupportTicketMailer from app/services/support/create_ticket.rb, and
SequenceMailer from EmailSequences::Step#deliver_to. Only the setup wizard's connection test
uses deliver_now, correctly, because its whole purpose is to report success or failure in the HTTP
response.
Production runs Solid Queue, and config/queue.yml here declares queues: "*", so the mailers
queue is covered by accident of the wildcard. Name your queues explicitly, as many people do to give
mail its own worker, and mailers is the one that gets forgotten: the jobs enqueue fine, sit in
solid_queue_ready_executions, and nothing fails, because an unclaimed row is not an error.
Solid Queue vs Sidekiq covers what that table is doing and why a
poll loop rather than a blocking read.
Two failures are worth knowing before you meet them. The first is a raise Rails wrote on purpose,
triggered by reading the message before deferring it, as in mail.subject for a log line or a
mail.to in an if:
You've accessed the message before asking to deliver it later, so you may have made local
changes that would be silently lost if we enqueued a job to deliver it. Why? Only the mailer
method *arguments* are passed with the delivery job!
The second is the flip side of Global ID. Destroy the record between enqueue and run and the job
raises ActiveJob::DeserializationError, which is not catchable inside the mailer method because
it happens before the method runs. rescue_from ActiveJob::DeserializationError at the mailer class
level is where it belongs, and for a user deleting their account mid sequence, discarding is usually
the right answer.
SPF, DKIM and DMARC are not Rails settings
Deliverability is where Rails stops helping, and the search results lie about it. Searching for rails email deliverability returns Action Mailer tutorials, which is the trap: every line of configuration above can be perfect and your mail still lands in spam, because the receiving server is not asking your application anything. It is asking DNS.
Three records, three specifications. SPF (RFC 7208) is a TXT record naming the servers allowed to send for your domain. DKIM (RFC 6376) publishes a public key so the receiver can verify a signature your provider adds to each message. DMARC tells receivers what to do when both fail, and it moved onto the standards track in May 2026 as RFC 9989, which obsoletes the informational RFC 7489 and is compatible with the records you already have.
Gmail turned this from advice into a gate. The sender guidelines require, for a domain sending
roughly 5,000 messages a day or more to personal Gmail accounts, that you "set up SPF and DKIM email
authentication for your domain" and "set up DMARC email authentication for your sending domain",
where "your DMARC enforcement policy can be set to none". Alongside that: spam rates "below 0.30%"
in Postmaster Tools, and one-click unsubscribe on marketing and subscribed messages, meaning the
List-Unsubscribe-Post: List-Unsubscribe=One-Click header and not only a link in the body.
Transactional senders read that threshold and conclude it does not apply to them. Reputation is scored per domain, so if the same domain sends your product announcements, those announcements decide whether the password resets arrive.
The boilerplate does not do this for you and cannot. No gem writes DNS records for a domain you
bought somewhere else. What it ships is a Sending email documentation page, which is a written
procedure rather than a feature. Budget for propagation of up to 48 hours, remember that a domain
gets exactly one SPF record so multiple senders merge their include: terms into one line, and
check the result the only way that counts: send yourself a message, open the original, and read
SPF=pass, DKIM=pass, DMARC=pass off the headers.
What this post does not cover
Inbound mail is absent entirely. Action Mailbox is a separate subsystem with its own ingress configuration, and nothing above applies to it.
Also missing: HTML email that survives Outlook, which is a CSS inlining problem rather than a Rails
one, and visible in this codebase as the empty <style> block in
app/views/layouts/mailer.html.erb; bounce and complaint webhooks, which every provider exposes and
none of these six mailers consume; provider HTTP APIs as an alternative to SMTP, which trade a
delivery_method for a gem and better error reporting; and dedicated IP warmup, which matters at a
volume where you have a deliverability consultant rather than a blog post. One detail sits on the
line: five of the six mailers here ship a .text.erb next to the .html.erb, and
app/views/setup_mailer/connection_test.html.erb does not, so that one goes out single part. Fine
for a wizard's connection test, not fine for anything a customer receives.
Comments
No comments yet. Be the first.