LaunchKit
← All posts
· 12 min read · by The LaunchKit team · 3 views

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:

Two lanes reach the same url helper from different starting points. A controller action always runs inside an open request, so it borrows the host from the Host: header on the wire. A mailer runs from a job, a rake task or a preview, none of which carry a Host: header, so default_url_options is its only source.

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

#rails #email

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.