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:
- Sign up a new user
- Click submit
- LetterOpener opens a new browser tab
- 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!