Rails ActionMailer Production Guide: Email Deliverability, Modern APIs, and Bulletproof Testing
Rails ActionMailer production setup: Resend, Postmark, or SendGrid, inbox-reliable delivery, bounce handling, deliver_later queuing, and mailer testing.
The startup had raised its Series A two months earlier, decided to run a reactivation campaign to eight thousand lapsed contacts, and came to me when the open rate came in at 0.3 percent. One engineer told me, with visible pride, that they had set up Rails ActionMailer in an afternoon. The SMTP server was a cheap shared hosting plan with no SPF record and no DKIM keys. Their sending domain was four months old with no sending history. Their From address was notifications@the-app-name.io. They were landing in the spam folder for nearly every recipient, and the deliverability dashboard they had not set up would have told them that immediately.
After nineteen years of shipping Rails apps, I can tell you that ActionMailer itself is excellent. The framework is mature, the API is clean, and the default behavior is sensible. The part teams consistently get wrong is everything that happens after ActionMailer hands the message off: provider choice, DNS authentication records, bounce handling, suppression lists, and testing against real inbox behavior. This post covers all of it in the order that actually matters.
What Rails ActionMailer Does Under the Hood
ActionMailer is a framework for building and delivering email messages from a Rails application. It works structurally like controllers: you define a mailer class with action methods, each action builds a Mail::Message object, and the framework handles multipart MIME rendering, attachment encoding, headers, and delivery.
The delivery layer is pluggable. ActionMailer ships with four delivery methods: smtp (sends through any SMTP server), sendmail (pipes to a local binary), file (writes messages to disk), and test (accumulates messages in memory for assertions). In production you will use smtp, pointing at a transactional email provider rather than running your own mail server.
The fundamental model: ActionMailer builds the message, the delivery method transmits it, and Active Job optionally queues the transmission as a background job. All three layers are independently configurable, which is where most of the useful complexity lives.
Choosing a Transactional Email Provider
The most important email decision you make is provider selection. Running your own SMTP server is not a realistic option for anything with deliverability requirements — the IP reputation game is too expensive and too slow to win from a cold start. Pick a transactional email provider and use their infrastructure.
Three providers I recommend in 2026, matched to different use cases:
Resend is the right answer for most new Rails apps. The API is clean, the SMTP credentials work out of the box, the dashboard shows you what happened to every message, and the free tier is generous enough for development and staging. If you are starting a project today and have no existing provider contracts, use Resend.
Postmark is the answer when deliverability is business-critical and you need enterprise-grade support. Postmark’s IP reputation is the best in the industry. Transactional messages reliably land in primary inboxes. Their bounce and complaint processing is the most thorough of any provider I have used. The cost is higher than Resend, but for anything touching billing confirmation, authentication flows, or customer-facing communications at scale, it is worth it.
SendGrid is the answer when you need transactional and marketing email from one platform, have existing enterprise pricing agreements, or need to send at very high volume under an account with established reputation.
Configure any of them via SMTP in your environment config:
# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.perform_deliveries = true
config.action_mailer.raise_delivery_errors = true
config.action_mailer.default_url_options = { host: "app.example.com", protocol: "https" }
config.action_mailer.smtp_settings = {
address: "smtp.resend.com",
port: 587,
user_name: "resend",
password: Rails.application.credentials.resend_api_key!,
authentication: :login,
enable_starttls_auto: true
}
raise_delivery_errors = true in production is non-negotiable. The default is false, which means SMTP failures silently disappear into your log stream. You want exceptions, because exceptions get captured by your error tracker and appear in your alerting dashboard. Silent email failures are the worst kind — you find out about them three days later from a customer wondering why they never got an invoice.
See Rails credentials and secrets management for how to store API keys so they do not end up in the repository.
Building a Mailer Class That Does Not Embarrass You
Most Rails tutorials show a three-line mailer and move on. Production mailers need more thought. Here is the base class pattern I use:
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
default from: email_address_with_name("no-reply@example.com", "Example App")
layout "mailer"
rescue_from StandardError, with: :handle_delivery_exception
private
def handle_delivery_exception(exception)
Sentry.capture_exception(exception, extra: {
mailer: self.class.name,
action: action_name,
recipient: message.to&.first
})
raise
end
end
And a representative collection of mailer actions:
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
def welcome_email(user)
@user = user
@login_url = login_url
mail(
to: email_address_with_name(user.email, user.full_name),
subject: "Welcome to Example App"
)
end
def password_reset(user, token)
@user = user
@reset_url = edit_password_reset_url(token, email: user.email)
@expires_in = "2 hours"
mail(to: user.email, subject: "Reset your password")
end
def monthly_summary(user, report)
@user = user
@report = report
attachments["summary-#{Date.current.strftime('%Y-%m')}.pdf"] = {
mime_type: "application/pdf",
content: report.to_pdf
}
mail(to: user.email, subject: "Your #{Date.current.strftime('%B')} summary")
end
end
email_address_with_name formats addresses as "Full Name <email@example.com>". This changes how your messages appear in inbox clients and meaningfully improves open rates — it takes five seconds and almost no one does it.
Always define both an HTML template and a plain-text template. ActionMailer sends multipart by default when both exist. Spam filters inspect the text part and penalize messages with no text alternative. Your app/views/user_mailer/welcome_email.html.erb needs a corresponding welcome_email.text.erb with the same content rendered as plain text.
Rails ActionMailer Deliverability: The Part Teams Always Skip
Your email can be perfectly formatted and still land in spam. Deliverability is a combination of DNS configuration, sending reputation, list hygiene, and content quality. The first three are mechanical and entirely within your control.
SPF (Sender Policy Framework) is a DNS TXT record that tells receiving servers which IP addresses are authorized to send email from your domain. If you send via Resend, add their SPF include:
TXT v=spf1 include:_spf.resend.com ~all
DKIM (DomainKeys Identified Mail) adds a cryptographic signature that receiving servers verify against a public key in your DNS. Every provider gives you a CNAME or TXT record when you set up a sending domain. Add it immediately. Messages without DKIM claiming to come from your domain look suspicious.
DMARC ties SPF and DKIM together and tells receiving servers what to do on authentication failure. Start with a monitoring-only policy:
TXT v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com
p=none means “report but take no action.” Run it for thirty days, read the aggregate reports, confirm SPF and DKIM are passing for all your legitimate sending sources, then move to p=quarantine and eventually p=reject. Never jump straight to p=reject — you will lose legitimate mail.
The single most important deliverability factor after DNS is sending only to people who actually expect your email. Confirmed opt-in lists, immediate bounce and complaint processing, and unsubscribe handling on first request. The List-Unsubscribe header is now required by Gmail and Yahoo for bulk senders:
# app/mailers/marketing_mailer.rb
class MarketingMailer < ApplicationMailer
def newsletter(user)
@user = user
@unsubscribe_url = unsubscribe_url(token: user.email_token)
headers["List-Unsubscribe"] = "<#{@unsubscribe_url}>"
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
mail(to: user.email, subject: "This week at Example")
end
end
Queuing Email Delivery with deliver_later
deliver_now sends synchronously and blocks the request thread while SMTP negotiates — typically 200ms to two seconds under normal conditions, and indefinitely if the provider is down. deliver_later enqueues a background job via Active Job and returns immediately. Use deliver_later for almost everything.
# In a controller, service object, or callback
UserMailer.welcome_email(user).deliver_later
# Scheduled delivery
UserMailer.monthly_summary(user, report).deliver_later(wait_until: 1.week.from_now)
# High-priority queue for security-critical mail
UserMailer.password_reset(user, token).deliver_later(queue: :critical, priority: 0)
The default Active Job queue for mailers is mailers. In Solid Queue, give it a dedicated worker with appropriate concurrency and keep it isolated from low-priority bulk jobs:
# config/solid_queue.yml
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "critical"
threads: 5
- queues: "mailers"
threads: 10
- queues: "default"
threads: 5
Password resets and security alerts belong on critical so they are not delayed behind a newsletter run that is grinding through forty thousand addresses. Newsletters and digest emails belong on a low-priority queue with per-second rate limiting so you do not exhaust your provider API quota in a burst and trigger automatic account suspension.
See Rails Active Job retries and circuit breakers for configuring retry behavior on transient SMTP failures, and Solid Queue background jobs with Postgres for queue architecture.
Testing Rails ActionMailer Properly
Rails sets config.action_mailer.delivery_method = :test in the test environment by default. In that mode, every delivered message accumulates in ActionMailer::Base.deliveries as a Mail::Message object. Reset it in before blocks and assert on it afterward:
# spec/mailers/user_mailer_spec.rb
RSpec.describe UserMailer do
describe "#welcome_email" do
let(:user) { create(:user, email: "test@example.com", full_name: "Test User") }
let(:mail) { described_class.welcome_email(user) }
it "renders the headers correctly" do
expect(mail.subject).to eq("Welcome to Example App")
expect(mail.to).to eq(["test@example.com"])
expect(mail.from).to include("no-reply@example.com")
end
it "includes the user name in the body" do
expect(mail.body.encoded).to include("Test User")
end
it "sends a multipart message" do
expect(mail.parts.map(&:content_type)).to include(
match(/text\/plain/),
match(/text\/html/)
)
end
end
end
In integration and request specs, use ActionMailer::TestHelper to assert on enqueued mailer jobs without executing them:
# spec/requests/registrations_spec.rb
RSpec.describe "POST /registrations", type: :request do
include ActionMailer::TestHelper
it "enqueues a welcome email" do
expect {
post registrations_path, params: {
user: { email: "new@example.com", password: "secret123" }
}
}.to have_enqueued_mail(UserMailer, :welcome_email)
end
it "addresses the welcome email to the new user" do
post registrations_path, params: {
user: { email: "new@example.com", password: "secret123" }
}
expect(enqueued_mails.last.to).to include("new@example.com")
end
end
have_enqueued_mail is more precise than checking ActionMailer::Base.deliveries in a request spec — it asserts on what was queued rather than what was delivered synchronously, which is the correct boundary for a test that exercises a controller action.
Email Previews in Development
ActionMailer previews let you inspect rendered emails in your browser during development without sending them. Define a preview class alongside your mailer specs:
# spec/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
def welcome_email
user = User.first || User.new(email: "preview@example.com", full_name: "Preview User")
UserMailer.welcome_email(user)
end
def password_reset
user = User.first
token = user.generate_password_reset_token
UserMailer.password_reset(user, token)
end
def monthly_summary
user = User.first
report = MonthlyReport.generate_for(user, Date.current.beginning_of_month)
UserMailer.monthly_summary(user, report)
end
end
Navigate to http://localhost:3000/rails/mailers/user_mailer and every preview renders with your actual templates, real data, and all inline CSS. You see broken layouts, missing template variables, and rendering errors immediately rather than after a real send. Configure the preview path in development if your specs live in spec/ rather than test/:
# config/environments/development.rb
config.action_mailer.preview_paths = [Rails.root.join("spec/mailers/previews")]
This is the highest-ROI change you can make to your email development workflow. Teams that do not use previews spend hours doing test sends to throw-away inboxes. Teams that do use previews catch every visual regression in under a minute.
Handling Bounces and Complaints
This is where most Rails apps fail. They send email, never process bounces, their list quality degrades, and their sender reputation decays until they start landing in spam. Every provider delivers bounce and complaint events to a webhook URL you configure. Handle them.
# app/controllers/email_events_controller.rb
class EmailEventsController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_webhook_signature
def resend
events = JSON.parse(request.body.read)
events.each { |event| EmailEventProcessor.call(event) }
head :ok
end
private
def verify_webhook_signature
signature = request.headers["Resend-Signature"]
expected = OpenSSL::HMAC.hexdigest(
"SHA256",
Rails.application.credentials.resend_webhook_secret!,
request.raw_post
)
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
end
end
# app/services/email_event_processor.rb
class EmailEventProcessor
def self.call(event)
case event["type"]
when "email.bounced"
address = event.dig("data", "to")
bounce_type = event.dig("data", "bounce", "type")
EmailSuppression.upsert(
{ address: address, reason: "bounce", bounce_type: bounce_type, suppressed_at: Time.current },
unique_by: :address
)
when "email.complained"
address = event.dig("data", "to")
EmailSuppression.upsert(
{ address: address, reason: "complaint", suppressed_at: Time.current },
unique_by: :address
)
end
end
end
Check the suppression table before every delivery using a mailer before_action:
class ApplicationMailer < ActionMailer::Base
before_action :check_suppression
private
def check_suppression
recipient = message.to&.first
return unless recipient
return unless EmailSuppression.exists?(address: recipient)
message.perform_deliveries = false
end
end
This is also how you comply with CAN-SPAM, GDPR, and Canada’s CASL — not through legal language in a footer, but through actually not sending to addresses that have hard-bounced or complained.
Structured Logging for Rails ActionMailer
When emails do not arrive, the investigation always follows the same path: confirm the job ran, confirm the provider received the message, check the provider’s delivery log for rejection reasons, check the spam folder, check SPF/DKIM/DMARC pass status. Add structured logging so you have the audit trail without digging through provider dashboards:
# app/mailers/application_mailer.rb (updated)
class ApplicationMailer < ActionMailer::Base
around_action :log_delivery
private
def log_delivery
start = Time.current
yield
Rails.logger.info(JSON.generate(
event: "email_delivered",
mailer: self.class.name,
action: action_name,
recipient: message.to&.first,
subject: message.subject,
duration_ms: ((Time.current - start) * 1000).round
))
rescue => e
Rails.logger.error(JSON.generate(
event: "email_delivery_failed",
mailer: self.class.name,
action: action_name,
recipient: message.to&.first,
error: e.message
))
raise
end
end
With this in place, a log search on email_delivered and email_delivery_failed gives you a complete audit trail of every message attempt — exactly what you need when a customer says they never got the payment confirmation.
For the other direction — receiving email and routing it back into your app — the complementary post is Rails Action Mailbox for inbound email, which covers the same providers used as inbound processors.
FAQ
What is the difference between deliver_now and deliver_later in Rails ActionMailer?
deliver_now sends the email synchronously, blocking the current thread until the SMTP conversation completes. In a web request, this adds 200ms to two seconds of latency under normal conditions and blocks the entire request if the provider is unavailable. deliver_later serializes the mail arguments into a background job via Active Job and returns immediately. Use deliver_later for everything. For time-sensitive mail like password resets, deliver_later(queue: :critical) queues with no delay while still avoiding the request-blocking behavior.
How do I test emails in Rails without actually sending them?
The default test environment sets delivery_method = :test, which accumulates messages in ActionMailer::Base.deliveries without transmitting them. In RSpec, use have_enqueued_mail from ActionMailer::TestHelper in request and controller specs to assert that deliver_later enqueued the correct job. Use mailer unit specs to assert on subject, recipients, body content, and headers directly from the return value of the mailer action method. Use browser previews for visual HTML verification during development.
Should I use SMTP or a provider’s Ruby API gem for Rails ActionMailer?
SMTP for almost everything transactional. Every provider supports SMTP, configuration is identical across providers, and switching providers later is a one-line change in smtp_settings. HTTP API gems (Resend’s resend gem, SendGrid’s sendgrid-ruby) offer features like template IDs, scheduling, and batch sending that have no SMTP equivalent — reach for them when you need those specific capabilities, not by default. The coupling to a specific provider’s SDK is a real cost.
How do I add working unsubscribe functionality to Rails mailers?
Generate a signed user-specific token using Rails.application.message_verifier or an HMAC over the user’s ID and a rotation key, store it on the user record or derive it on demand, and build an unsubscribe URL around it. Set both List-Unsubscribe and List-Unsubscribe-Post headers so Gmail and Yahoo’s one-click unsubscribe button works without the user having to visit a page. The unsubscribe controller action must work without session authentication — it is visited via a link in an email client — and must process the request with the token from the URL. Store the preference in your suppression table and check it via a before_action in ApplicationMailer before every send.
Need to fix a deliverability problem or build a reliable transactional email pipeline into your Rails app? TTB Software has been building and debugging production email systems in Rails for nineteen years. We have seen every way this can go wrong, and usually at three in the morning.
Related Articles
Rails Phlex: Ruby-First View Components That Beat ERB and ViewComponent on Speed
Rails Phlex writes views in pure Ruby — no templates, no DSL surprises. Faster than ERB, smaller than ViewComponent, ...
Rails Pessimistic Locking: SELECT FOR UPDATE, with_lock, and Preventing Race Conditions
Rails pessimistic locking with SELECT FOR UPDATE, lock! and with_lock — prevent race conditions on balances, inventor...
Rails Strong Migrations: Catch Unsafe Database Changes Before They Lock Production
Rails Strong Migrations: catch unsafe Postgres changes — NOT NULL adds, renames, non-CONCURRENTLY indexes — before th...