You're building a Rails app. You add signup. Users create accounts. You want to send them a welcome email.

But how do you test that email without spamming real inboxes?

Most developers skip email testing. They send one test email manually and call it done. Then weeks later, they discover the password reset email has a broken link. Or the invoice attachment is missing. Or the confirmation link goes to localhost instead of production.

Users notice these things. They don't come back.

Let me show you how to test every email your app sends. All of it. Without paid services. Without complex setup. Using tools that come with Rails or are completely free.


Why Email Testing Matters

Email is often the only direct communication channel between your app and your users.

When it breaks, trust breaks.

Real Failures I've Seen

  • Welcome emails going to spam because of missing SPF records
  • Password reset links with the wrong domain (localhost in production)
  • Invoices attached as empty PDFs
  • Dynamic content that works for some users and breaks for others
  • Emails that simply never send because of background job failures

Each of these cost the company time, money, and user trust.

What Proper Testing Gives You

Benefit Why It Matters
Confidence Deploy without manually testing every email
Speed Catch broken emails before users do
Documentation Tests show what emails should contain
Regression prevention Never break the same email twice

The Tools (All Free)

You don't need paid email services for testing. Rails gives you everything.

Tool Purpose Built into Rails?
ActionMailer previews See emails in browser during development Yes
LetterOpener Open triggered emails in browser instead of sending No (gem)
RSpec matchers Test email content, subject, recipients No (gem)
deliver_later testing Verify background email delivery Yes
Capybara email Test full user flows with email interactions No (gem)

No external APIs. No credit cards. No monthly fees. Just clean, reliable testing.


Part 1: ActionMailer Previews (Built into Rails)

Rails includes email previews out of the box. Most developers don't know they exist.

Basic Setup

Create a preview file:

# test/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
  def welcome_email
    UserMailer.welcome_email(User.first)
  end

  def password_reset
    UserMailer.password_reset(User.first)
  end

  def invoice_email
    UserMailer.invoice_email(User.first, Invoice.last)
  end
end

Now visit http://localhost:3000/rails/mailers in your browser.

You'll see a list of all your email previews. Click one. It opens in your browser exactly as it would appear in an email client.

No email server needed. No sending. Just instant visual feedback.

Handling Missing Data

What if your database is empty? Create data on the fly:

class UserMailerPreview < ActionMailer::Preview
  def welcome_email
    user = User.first || User.create!(
      email: "preview@example.com",
      name: "Preview User"
    )
    UserMailer.welcome_email(user)
  end

  def password_reset
    user = User.first || User.create!(
      email: "reset@example.com",
      name: "Reset User"
    )
    UserMailer.password_reset(user)
  end
end

Previewing Different Scenarios

Preview edge cases:

class UserMailerPreview < ActionMailer::Preview
  def welcome_email_new_user
    user = User.new(email: "new@example.com", name: "New User")
    UserMailer.welcome_email(user)
  end

  def welcome_email_long_name
    user = User.new(email: "long@example.com", name: "A" * 100)
    UserMailer.welcome_email(user)
  end

  def welcome_email_special_characters
    user = User.new(email: "special@example.com", name: "Jürgen Müller")
    UserMailer.welcome_email(user)
  end

  def invoice_email_with_discount
    invoice = Invoice.new(total: 99.99, discount_applied: true)
    UserMailer.invoice_email(User.first, invoice)
  end

  def invoice_email_without_discount
    invoice = Invoice.new(total: 149.99, discount_applied: false)
    UserMailer.invoice_email(User.first, invoice)
  end
end

Customizing Preview Styles

Match your production email styling:

/* app/assets/stylesheets/rails_mailers.css */
.rails_mailers .email_container {
  max-width: 600px;
  margin: 0 auto;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}

.rails_mailers .email_content {
  background: white;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 2rem;
}

Part 2: LetterOpener for Development

ActionMailer previews show you emails you manually create. But what about emails triggered by user actions? Signup, purchase, password reset.

You don't want to send real emails during development. Use LetterOpener.

Installation

# Gemfile
group :development do
  gem "letter_opener"
  gem "letter_opener_web"  # Optional: browse emails in browser
end
bundle install

Configuration

# config/environments/development.rb
Rails.application.configure do
  # Don't send real emails
  config.action_mailer.delivery_method = :letter_opener

  # Still perform deliveries (just to letter_opener)
  config.action_mailer.perform_deliveries = true

  # Default URL for email links
  config.action_mailer.default_url_options = { host: "localhost:3000" }
end

How It Works

Now when your app sends an email:

  1. Sign up a new user
  2. Click submit
  3. LetterOpener opens a new browser tab
  4. The email appears instantly

No spam folder. No waiting. No email server configuration.

LetterOpener Web (Optional)

Browse all sent emails in one place:

# config/routes.rb
if Rails.env.development?
  mount LetterOpenerWeb::Engine, at: "/letter_opener"
end

Visit http://localhost:3000/letter_opener to see a list of every email sent during development.

Real-World Example

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)

    if @user.save
      # This will open in your browser automatically
      UserMailer.welcome_email(@user).deliver_later
      redirect_to @user, notice: "User created. Check your email!"
    else
      render :new
    end
  end
end

No configuration. No environment variables. No risk of accidentally emailing real people.


Part 3: RSpec Matchers for Email

Testing email content with RSpec is straightforward once you know the patterns.

Setup

# Gemfile
group :test do
  gem "rspec-rails"
  gem "factory_bot_rails"
  gem "faker"
end
bundle install
rails generate rspec:install

Basic Email Testing

# spec/mailers/user_mailer_spec.rb
require "rails_helper"

RSpec.describe UserMailer, type: :mailer do
  describe "#welcome_email" do
    let(:user) { create(:user, name: "Alice", email: "alice@example.com") }
    let(:mail) { UserMailer.welcome_email(user) }

    it "renders the subject" do
      expect(mail.subject).to eq("Welcome to MyApp, Alice!")
    end

    it "renders the receiver email" do
      expect(mail.to).to eq(["alice@example.com"])
    end

    it "renders the sender email" do
      expect(mail.from).to eq(["noreply@myapp.com"])
    end

    it "assigns the user's name" do
      expect(mail.body.encoded).to include("Hello, Alice")
    end

    it "includes a link to the dashboard" do
      expect(mail.body.encoded).to include(dashboard_url)
    end
  end
end

Testing HTML vs Plain Text

Most emails have both versions.

describe "#welcome_email" do
  let(:mail) { UserMailer.welcome_email(user) }

  it "has both HTML and plain text parts" do
    expect(mail.parts.length).to eq(2)
    expect(mail.parts.first.content_type).to include("text/html")
    expect(mail.parts.last.content_type).to include("text/plain")
  end

  it "contains different content in each part" do
    html_part = mail.parts.find { |p| p.content_type.include?("text/html") }
    text_part = mail.parts.find { |p| p.content_type.include?("text/plain") }

    expect(html_part.body.encoded).to include("<strong>Welcome</strong>")
    expect(text_part.body.encoded).to include("Welcome")
  end
end

Testing Links

Links in emails are critical. A broken password reset link means a locked-out user.

describe "#password_reset" do
  let(:user) { create(:user) }
  let(:mail) { UserMailer.password_reset(user) }

  it "includes the password reset link" do
    expect(mail.body.encoded).to include(edit_password_reset_url(user.reset_token))
  end

  it "includes the reset token in the link" do
    expect(mail.body.encoded).to include(user.reset_token)
  end

  it "does not include the token in plain text" do
    # Security: token should be in URL, not visible as plain text
    text_part = mail.parts.find { |p| p.content_type.include?("text/plain") }
    expect(text_part.body.encoded).not_to include(user.reset_token)
  end
end

Testing Attachments

describe "#invoice_email" do
  let(:invoice) { create(:invoice, :with_pdf) }
  let(:mail) { InvoiceMailer.invoice_email(invoice.user, invoice) }

  it "attaches the PDF" do
    expect(mail.attachments.count).to eq(1)
    expect(mail.attachments.first.content_type).to include("application/pdf")
    expect(mail.attachments.first.filename).to eq("invoice_#{invoice.id}.pdf")
  end

  it "sets the correct attachment content" do
    attachment = mail.attachments.first
    expect(attachment.body.encoded).to include("INVOICE")
  end
end

Testing Dynamic Content

describe "#order_confirmation" do
  let(:order) { create(:order, total_cents: 4999, items_count: 3) }
  let(:mail) { OrderMailer.confirmation(order) }

  it "displays the correct total" do
    expect(mail.body.encoded).to include("$49.99")
  end

  it "displays the item count" do
    expect(mail.body.encoded).to include("3 items")
  end

  it "lists each item" do
    order.items.each do |item|
      expect(mail.body.encoded).to include(item.name)
      expect(mail.body.encoded).to include(item.price.to_s)
    end
  end
end

Testing Conditional Content

describe "#weekly_summary" do
  context "when user has activity" do
    let(:user) { create(:user, :with_recent_activity) }
    let(:mail) { SummaryMailer.weekly_summary(user) }

    it "shows activity summary" do
      expect(mail.body.encoded).to include("You had 5 new interactions")
    end

    it "does not show the inactive message" do
      expect(mail.body.encoded).not_to include("We miss you!")
    end
  end

  context "when user has no activity" do
    let(:user) { create(:user, :inactive) }
    let(:mail) { SummaryMailer.weekly_summary(user) }

    it "shows re-engagement message" do
      expect(mail.body.encoded).to include("We miss you!")
    end

    it "does not show activity summary" do
      expect(mail.body.encoded).not_to include("You had")
    end
  end
end

Part 4: Testing Background Email Delivery

Your app likely sends emails asynchronously with deliver_later. Test that too.

Testing Job Enqueueing

# spec/mailers/user_mailer_spec.rb
require "rails_helper"

RSpec.describe UserMailer, type: :mailer do
  describe "#welcome_email" do
    let(:user) { create(:user) }

    it "enqueues the email as a background job" do
      expect {
        UserMailer.welcome_email(user).deliver_later
      }.to have_enqueued_job(ActionMailer::MailDeliveryJob)
    end
  end
end

Testing Controller Triggers

# spec/requests/users_spec.rb
RSpec.describe "Users", type: :request do
  describe "POST /users" do
    let(:valid_params) { { user: { email: "new@example.com", name: "New User" } } }

    it "enqueues a welcome email" do
      expect {
        post users_path, params: valid_params
      }.to have_enqueued_email(UserMailer, :welcome_email)
    end

    it "enqueues email with correct arguments" do
      expect {
        post users_path, params: valid_params
      }.to have_enqueued_email(UserMailer, :welcome_email).with(instance_of(User))
    end
  end
end

Testing Delivery Later with Custom Queue

class ImportantMailer < ApplicationMailer
  def notification(user)
    mail(to: user.email, subject: "Important!").deliver_later(queue: "high_priority")
  end
end

# spec/mailers/important_mailer_spec.rb
it "enqueues on the high priority queue" do
  expect {
    ImportantMailer.notification(user).deliver_later
  }.to have_enqueued_job.on_queue("high_priority")
end

Testing Delivery Now vs Delivery Later

describe "delivery method" do
  let(:user) { create(:user) }

  it "delivers immediately with deliver_now" do
    expect {
      UserMailer.welcome_email(user).deliver_now
    }.to change(ActionMailer::Base.deliveries, :count).by(1)
  end

  it "enqueues with deliver_later" do
    expect {
      UserMailer.welcome_email(user).deliver_later
    }.to have_enqueued_job
    expect(ActionMailer::Base.deliveries.count).to eq(0)
  end
end

Part 5: Testing Email in System Tests

Sometimes you need to test the entire flow: user signs up, receives email, clicks link.

Setup Capybara Email

# Gemfile
group :test do
  gem "capybara"
  gem "capybara-email"
  gem "selenium-webdriver"
end
bundle install
# spec/rails_helper.rb
require "capybara/email/rspec"

RSpec.configure do |config|
  config.include Capybara::Email::RSpecMatchers
end
# spec/support/capybara.rb
Capybara.configure do |config|
  config.server = :puma
  config.default_driver = :selenium_chrome_headless
  config.javascript_driver = :selenium_chrome_headless
end

Testing Full Signup Flow

# spec/system/user_signup_spec.rb
RSpec.describe "User signup", type: :system do
  scenario "user receives welcome email and clicks confirmation link" do
    # Clear emails before test
    clear_emails

    # Visit signup page
    visit signup_path

    # Fill form
    fill_in "Email", with: "alice@example.com"
    fill_in "Name", with: "Alice"
    fill_in "Password", with: "password123"
    fill_in "Password confirmation", with: "password123"
    click_button "Sign Up"

    # Should redirect to confirmation page
    expect(page).to have_content("Please check your email to confirm your account")

    # Open the email
    open_email("alice@example.com")
    expect(current_email).to have_subject("Welcome to MyApp, Alice!")

    # Click the confirmation link
    current_email.click_link "Confirm my account"

    # Should redirect to login page with success message
    expect(page).to have_content("Account confirmed! Please log in.")

    # Log in
    fill_in "Email", with: "alice@example.com"
    fill_in "Password", with: "password123"
    click_button "Log In"

    # Should be logged in
    expect(page).to have_content("Welcome back, Alice!")
  end
end

Testing Password Reset Flow

# spec/system/password_reset_spec.rb
RSpec.describe "Password reset", type: :system do
  let(:user) { create(:user, email: "alice@example.com") }

  scenario "user resets their password" do
    # Visit forgot password page
    visit new_password_reset_path

    # Enter email
    fill_in "Email", with: user.email
    click_button "Send reset instructions"

    # Should show confirmation
    expect(page).to have_content("Check your email for reset instructions")

    # Open email
    open_email(user.email)
    expect(current_email).to have_subject("Reset your password")

    # Click reset link
    current_email.click_link "Reset Password"

    # Should go to reset form
    expect(page).to have_content("Choose a new password")

    # Set new password
    fill_in "New password", with: "newpassword123"
    fill_in "Confirm new password", with: "newpassword123"
    click_button "Reset Password"

    # Should redirect to login with success
    expect(page).to have_content("Password reset successfully. Please log in.")

    # Log in with new password
    fill_in "Email", with: user.email
    fill_in "Password", with: "newpassword123"
    click_button "Log In"

    # Should be logged in
    expect(page).to have_content("Welcome back!")
  end

  scenario "reset link can only be used once" do
    # Request reset
    visit new_password_reset_path
    fill_in "Email", with: user.email
    click_button "Send reset instructions"

    # Get the reset token from the email
    open_email(user.email)
    reset_link = current_email.body.encoded.match(/reset_password_token=[^"]+/)[0]

    # Use it once
    visit reset_password_path + "?#{reset_link}"
    fill_in "New password", with: "newpassword123"
    fill_in "Confirm new password", with: "newpassword123"
    click_button "Reset Password"

    # Try to use it again
    visit reset_password_path + "?#{reset_link}"
    expect(page).to have_content("Token has expired or is invalid")
  end
end

Testing Invoice Email with Attachment

# spec/system/invoice_email_spec.rb
RSpec.describe "Invoice email", type: :system do
  let(:user) { create(:user) }
  let(:order) { create(:order, user: user) }

  scenario "user receives invoice with PDF attachment" do
    # Trigger invoice generation
    visit order_path(order)
    click_button "Send Invoice"

    # Open email
    open_email(user.email)
    expect(current_email).to have_subject("Your invoice for order ##{order.id}")

    # Check attachment
    expect(current_email.attachments.count).to eq(1)
    expect(current_email.attachments.first.filename).to match(/\.pdf$/)

    # Click download link in email
    current_email.click_link "Download PDF"

    # Should download PDF (browser behavior varies, check response)
    expect(page.response_headers["Content-Type"]).to include("application/pdf")
  end
end

Part 6: Testing Edge Cases

Testing with Factory Data

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    email { Faker::Internet.email }
    name { Faker::Name.name }
    created_at { 1.day.ago }
    trait :with_subscription do
      after(:create) { |user| create(:subscription, user: user) }
    end
    trait :with_recent_purchases do
      after(:create) { |user| create_list(:order, 3, user: user, created_at: 1.day.ago) }
    end
  end
end

# spec/mailers/user_mailer_spec.rb
let(:user) { create(:user, :with_subscription, :with_recent_purchases) }

Testing Long Names and Unicode

describe "user with long name" do
  let(:user) { create(:user, name: "A" * 100) }
  let(:mail) { UserMailer.welcome_email(user) }

  it "handles long names without breaking" do
    expect(mail.body.encoded).to include(user.name)
    # Should not truncate or crash
  end
end

describe "user with unicode characters" do
  let(:user) { create(:user, name: "Jürgen Müller 陈") }
  let(:mail) { UserMailer.welcome_email(user) }

  it "handles unicode correctly" do
    expect(mail.body.encoded).to include("Jürgen")
    expect(mail.body.encoded).to include("陈")
  end
end

Testing Email Delivery Failures

describe "when email fails to send" do
  let(:user) { create(:user) }

  before do
    allow(UserMailer).to receive(:welcome_email).and_raise(Net::SMTPError)
  end

  it "logs the error but doesn't crash" do
    expect(Rails.logger).to receive(:error).with(/SMTPError/)
    expect {
      UserMailer.welcome_email(user).deliver_now
    }.to raise_error(Net::SMTPError)
  end

  it "still creates the user" do
    expect {
      post users_path, params: { user: attributes_for(:user) }
    }.to change(User, :count).by(1)
  end
end

Testing Rate Limiting

describe "email rate limiting" do
  let(:user) { create(:user) }

  it "prevents sending too many emails" do
    5.times { UserMailer.welcome_email(user).deliver_now }

    expect {
      UserMailer.welcome_email(user).deliver_now
    }.to raise_error("Rate limit exceeded")
  end
end

Part 7: Common Pitfalls and Solutions

Absolute URLs in Emails

Emails need absolute URLs, not relative ones.

# Wrong
link_to "Reset password", edit_password_reset_path(token)

# Right
link_to "Reset password", edit_password_reset_url(token)

Test it:

it "uses absolute URLs" do
  expect(mail.body.encoded).to include("http://")
  expect(mail.body.encoded).not_to include("edit_password_reset_path")
end

Environment-Specific Config

# config/environments/development.rb
config.action_mailer.default_url_options = { host: "localhost: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["APP_HOST"] }

Email Previews Not Showing

If previews don't appear:

# config/environments/development.rb
config.action_mailer.preview_paths << Rails.root.join("test/mailers/previews")

LetterOpener Not Opening

# config/environments/development.rb
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true

Test Emails Being Sent in Production

Never happen with correct config:

# config/environments/production.rb
config.action_mailer.delivery_method = :smtp

Part 8: Complete Configuration Files

Development Environment

# config/environments/development.rb
Rails.application.configure do
  # Preview emails in browser
  config.action_mailer.preview_paths << Rails.root.join("test/mailers/previews")

  # Open emails in browser instead of sending
  config.action_mailer.delivery_method = :letter_opener

  # Still perform deliveries (just to letter_opener)
  config.action_mailer.perform_deliveries = true

  # Default URL for email links
  config.action_mailer.default_url_options = { host: "localhost:3000" }

  # Raise errors for missing deliveries
  config.action_mailer.raise_delivery_errors = true
end

Test Environment

# config/environments/test.rb
Rails.application.configure do
  # Don't send real emails in tests
  config.action_mailer.delivery_method = :test

  # Default URL for email links
  config.action_mailer.default_url_options = { host: "example.com", port: 3000 }

  # Raise errors for missing deliveries
  config.action_mailer.raise_delivery_errors = true
end

Production Environment

# config/environments/production.rb
Rails.application.configure do
  # Use SMTP for real emails
  config.action_mailer.delivery_method = :smtp

  # SMTP configuration (use environment variables)
  config.action_mailer.smtp_settings = {
    address: ENV["SMTP_ADDRESS"],
    port: ENV["SMTP_PORT"],
    domain: ENV["SMTP_DOMAIN"],
    user_name: ENV["SMTP_USERNAME"],
    password: ENV["SMTP_PASSWORD"],
    authentication: :plain,
    enable_starttls_auto: true
  }

  # Default URL for email links
  config.action_mailer.default_url_options = { host: ENV["APP_HOST"] }

  # Raise errors for missing deliveries
  config.action_mailer.raise_delivery_errors = true
end

The Complete Testing Checklist

Use this checklist to ensure your emails are fully tested.

Mailer Tests

  • [ ] Subject line is correct
  • [ ] Recipient email is correct
  • [ ] Sender email is correct
  • [ ] HTML body contains expected content
  • [ ] Plain text body contains expected content
  • [ ] Links use absolute URLs
  • [ ] Links point to correct paths
  • [ ] Attachments are present (if any)
  • [ ] Attachment filenames are correct
  • [ ] Conditional content works for all cases

Integration Tests

  • [ ] Emails are enqueued as background jobs
  • [ ] Emails are sent when actions are triggered
  • [ ] Emails are NOT sent when actions fail
  • [ ] Email delivery failures are handled gracefully

System Tests

  • [ ] Users can click links in emails
  • [ ] Confirmation flows work end-to-end
  • [ ] Password reset flows work end-to-end
  • [ ] Links expire correctly
  • [ ] Links can only be used once (if designed that way)

Edge Cases

  • [ ] Long names don't break formatting
  • [ ] Unicode characters display correctly
  • [ ] Empty data doesn't cause errors
  • [ ] Rate limiting works (if implemented)

Summary

Testing emails in Rails doesn't require paid services or complex setup.

Tool What It Does When To Use
ActionMailer previews See emails in browser Development, visual checking
LetterOpener Open triggered emails in browser Development, testing flows
RSpec matchers Test subject, recipients, content Automated testing
deliver_later testing Verify background email delivery Automated testing
Capybara email Test full user flows with email System/integration testing

Set these up once. You'll never send a broken email again.

Your users will thank you. Their inboxes will be cleaner. Your support tickets will decrease.

And you'll sleep better knowing that when someone clicks Reset Password, they'll actually get where they need to go.


This is the final post in this series. Happy testing!