RUBY ON RAILS · 21 MIN READ ·

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.

Rails ActionMailer Production Guide: Email Deliverability, Modern APIs, and Bulletproof 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.

#rails-actionmailer #rails-email-deliverability #rails-mailer-testing #resend-rails #rails-email-production #actionmailer-smtp-configuration

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software