Rails Action Mailer Expert
Create, configure, and deliver emails from Rails applications using Action Mailer.
Philosophy
Core Principles:
- Default to
deliver_later— Queues emails via Active Job.deliver_nowblocks the HTTP request, making users wait for SMTP round-trips. - Create both HTML and text templates — Some email clients block HTML, and spam filters penalize HTML-only emails. Always provide a text fallback.
- Use parameterized mailers — Pass context via
with(), not method arguments. This makes callbacks and shared setup work cleanly. - Set
default from:— Every mailer needs a default sender. Without it, emails fail silently or get rejected by mail servers. - Write previews — Preview classes let you iterate on email design without sending real emails or writing test data by hand.
- Use
_urlhelpers, not_path— Emails have no request context, so relative paths produce broken links._urlgenerates absolute URLs.
When To Use This Skill
- Generating new mailer classes
- Creating email templates (HTML + text)
- Configuring SMTP/delivery settings
- Adding attachments or inline images
- Writing mailer previews and tests
- Setting up interceptors or observers
- Implementing I18n for email subjects
- Debugging email delivery issues
Instructions
Step 1: Check Existing Mailer Patterns
Check the project first — match existing mailer patterns for consistency:
# Find existing mailers
ls app/mailers/
# Check ApplicationMailer defaults
cat app/mailers/application_mailer.rb
# Find existing templates
ls app/views/layouts/mailer.*
find app/views -name "*.html.erb" -path "*/mailer*" -o -name "*.text.erb" -path "*/mailer*"
# Check delivery config
grep -r "action_mailer" config/environments/
# Find existing previews
ls test/mailers/previews/
Consistency with the existing codebase matters more than theoretical best practice.
Step 2: Generate or Create the Mailer
Use the generator with action names:
bin/rails generate mailer User welcome_email password_reset
This creates:
app/mailers/user_mailer.rb— Mailer classapp/views/user_mailer/welcome_email.html.erb— HTML templateapp/views/user_mailer/welcome_email.text.erb— Text templateapp/views/user_mailer/password_reset.html.erbapp/views/user_mailer/password_reset.text.erbtest/mailers/user_mailer_test.rb— Test filetest/mailers/previews/user_mailer_preview.rb— Preview class
If ApplicationMailer doesn't exist, create it:
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
default from: "notifications@example.com"
layout "mailer"
end
Step 3: Write the Mailer Class
Use parameterized mailers (with/params pattern):
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
default from: "notifications@example.com"
def welcome_email
@user = params[:user]
@login_url = login_url
mail(
to: email_address_with_name(@user.email, @user.name),
subject: "Welcome to #{app_name}"
)
end
def password_reset
@user = params[:user]
@token = params[:token]
@reset_url = edit_password_reset_url(token: @token)
mail(to: @user.email, subject: "Reset your password")
end
private
def app_name
Rails.application.class.module_parent_name
end
end
Calling the mailer — prefer deliver_later:
# Preferred — async via Active Job (user doesn't wait for SMTP)
UserMailer.with(user: @user).welcome_email.deliver_later
# With params
UserMailer.with(user: @user, token: @token).password_reset.deliver_later
# Synchronous — blocks the request while talking to SMTP server
UserMailer.with(user: @user).welcome_email.deliver_now
Only use deliver_now for: rake tasks/cron jobs, console debugging, or critical emails needing immediate confirmation.
Step 4: Create BOTH Templates
Create both HTML and text versions. Action Mailer auto-generates multipart/alternative emails when both exist.
HTML template (app/views/user_mailer/welcome_email.html.erb):
<h1>Welcome, <%= @user.name %>!</h1>
<p>Thanks for signing up. Your account is ready.</p>
<p>
<%= link_to "Log in to your account", @login_url %>
</p>
Text template (app/views/user_mailer/welcome_email.text.erb):
Welcome, <%= @user.name %>!
Thanks for signing up. Your account is ready.
Log in: <%= @login_url %>
Why both? Some email clients block HTML. Spam filters penalize HTML-only emails. Text fallback is a deliverability requirement.
Step 5: Configure URL Host
Emails need absolute URLs because there's no browser request context to resolve relative paths against:
# config/environments/development.rb
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
# config/environments/production.rb
config.action_mailer.default_url_options = { host: "www.example.com", protocol: "https" }
In templates, use _url helpers:
<%# CORRECT %>
<%= link_to "View order", order_url(@order) %>
<%# WRONG — will break in email %>
<%= link_to "View order", order_path(@order) %>
Step 6: Write Mailer Previews
Write a preview for each mailer action — this lets you iterate on design at /rails/mailers without sending real emails:
# test/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
def welcome_email
user = User.first || User.new(name: "Preview User", email: "preview@example.com")
UserMailer.with(user: user).welcome_email
end
def password_reset
user = User.first || User.new(name: "Preview User", email: "preview@example.com")
UserMailer.with(user: user, token: "preview-token-123").password_reset
end
end
Preview tips:
- Use
User.firstwith a fallbackUser.new(...)so previews work even with empty DB - Just return the mail object — don't call
deliver_laterin previews - Previews auto-reload on template changes
Step 7: Write Mailer Tests
# test/mailers/user_mailer_test.rb
require "test_helper"
class UserMailerTest < ActionMailer::TestCase
test "welcome_email" do
user = users(:active_user)
email = UserMailer.with(user: user).welcome_email
assert_emails 1 do
email.deliver_now
end
assert_equal ["notifications@example.com"], email.from
assert_equal [user.email], email.to
assert_equal "Welcome to MyApp", email.subject
# Test HTML part
assert_match user.name, email.html_part.body.to_s
assert_match "Log in", email.html_part.body.to_s
# Test text part
assert_match user.name, email.text_part.body.to_s
end
end
Key test assertions:
# Count emails sent
assert_emails 1 do
UserMailer.with(user: user).welcome_email.deliver_now
end
# No emails sent
assert_no_emails do
# action that shouldn't send email
end
# Enqueued for later delivery
assert_enqueued_emails 1 do
UserMailer.with(user: user).welcome_email.deliver_later
end
# Check email content
assert_equal ["to@example.com"], email.to
assert_equal ["from@example.com"], email.from
assert_equal "Subject", email.subject
assert_match "expected text", email.body.encoded
Step 8: Configure Delivery Method
Development — use letter_opener or :test:
# config/environments/development.rb
config.action_mailer.delivery_method = :letter_opener # Opens in browser
# OR
config.action_mailer.delivery_method = :test # Stores in ActionMailer::Base.deliveries
Production — SMTP:
# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: "smtp.example.com",
port: 587,
domain: "example.com",
user_name: Rails.application.credentials.dig(:smtp, :user_name),
password: Rails.application.credentials.dig(:smtp, :password),
authentication: "plain",
enable_starttls: true,
open_timeout: 5,
read_timeout: 5
}
Test — always :test:
# config/environments/test.rb
config.action_mailer.delivery_method = :test
Quick Reference
mail() Method Options
| Option | Description |
|--------|-------------|
| to: | Recipient(s) — string or array |
| from: | Sender (overrides default) |
| cc: | Carbon copy recipients |
| bcc: | Blind carbon copy recipients |
| reply_to: | Reply-to address |
| subject: | Email subject line |
| headers: | Custom headers hash |
| template_path: | Custom view directory |
| template_name: | Custom template name |
| delivery_method_options: | Per-email delivery overrides |
Attachments
def invoice_email
@invoice = params[:invoice]
# Simple attachment
attachments["invoice.pdf"] = File.read("/path/to/invoice.pdf")
# Attachment with options
attachments["report.csv"] = {
mime_type: "text/csv",
content: generate_csv_data
}
mail(to: @invoice.customer_email, subject: "Your Invoice")
end
Inline Attachments (Images in Email Body)
# In mailer
def newsletter
attachments.inline["logo.png"] = File.read("app/assets/images/logo.png")
mail(to: params[:user].email, subject: "Newsletter")
end
<%# In HTML template %>
<%= image_tag attachments["logo.png"].url, alt: "Company Logo" %>
Callbacks
class ApplicationMailer < ActionMailer::Base
before_action :set_default_vars
after_action :log_delivery
after_deliver :record_sent
private
def set_default_vars
@company_name = "MyApp"
end
def log_delivery
Rails.logger.info("Preparing email: #{action_name}")
end
def record_sent
# Called after successful delivery
EmailLog.create!(mailer: self.class.name, action: action_name)
end
end
Interceptors and Observers
# config/initializers/mail_interceptors.rb
Rails.application.configure do
if Rails.env.staging?
config.action_mailer.interceptors = %w[SandboxEmailInterceptor]
end
config.action_mailer.observers = %w[EmailDeliveryObserver]
end
# app/interceptors/sandbox_email_interceptor.rb
class SandboxEmailInterceptor
def self.delivering_email(message)
message.to = ["sandbox@example.com"]
end
end
# app/observers/email_delivery_observer.rb
class EmailDeliveryObserver
def self.delivered_email(message)
EmailDelivery.log(message)
end
end
rescue_from in Mailers
class NotifierMailer < ApplicationMailer
rescue_from ActiveJob::DeserializationError do |exception|
# Handle stale records gracefully
end
rescue_from Net::SMTPAuthenticationError do |exception|
# Handle SMTP auth failures
end
end
I18n for Email Subjects
# config/locales/en.yml
en:
user_mailer:
welcome_email:
subject: "Welcome to %{app_name}"
password_reset:
subject: "Reset your password"
# Mailer — omit subject: to auto-lookup from I18n
def welcome_email
@user = params[:user]
mail(to: @user.email) # Subject from en.user_mailer.welcome_email.subject
end
Parameterized Mailers for Shared Context
Use before_action + params to share context across multiple actions:
class InvitationsMailer < ApplicationMailer
before_action { @inviter = params[:inviter]; @invitee = params[:invitee] }
default to: -> { @invitee.email },
from: -> { email_address_with_name("invites@example.com", @inviter.name) }
def account_invitation
mail subject: "#{@inviter.name} invited you to #{params[:inviter].account.name}"
end
end
# Call with: InvitationsMailer.with(inviter: user, invitee: other).account_invitation.deliver_later
Detailed References
For deeper patterns and examples, see the references/ directory:
references/templates.md— Mailer class patterns, HTML/text templates, attachments, I18n, layoutsreferences/delivery.md— SMTP configuration, per-environment setup, error handlingreferences/testing.md— Mailer test patterns, delivery assertions, integration testsreferences/interceptors.md— Callbacks, interceptors, observers, delivery lifecyclereferences/previews.md— Preview classes, fallback data, custom preview pathsreferences/configuration.md— Edge cases, gotchas, production recipes
Common Agent Mistakes
- Using
deliver_nowinstead ofdeliver_later— Blocks the HTTP request while waiting for SMTP; users experience slow page loads - Creating only HTML template — Spam filters penalize HTML-only emails, and some clients block HTML entirely
- Missing
default from:address — Mail servers reject emails without a sender, often silently - Using
_pathhelpers in templates — Produces relative URLs that break in email clients (no request context to resolve against) - Passing args instead of using
with()— Parameterized mailers (Mailer.with(user: @user).action) work cleanly with callbacks and shared setup - Forgetting to set
default_url_options—_urlhelpers raise errors or producelocalhostlinks in production without host config - Not writing previews — Without previews, you're sending real emails or writing tests just to see what an email looks like
- Not configuring delivery method per environment —
:testfor test,:letter_openerfor dev,:smtpfor prod
Anti-Patterns to Avoid
- Fat mailers — Keep business logic in models/services. Mailers should just assemble and send; logic in mailers is hard to test and reuse
- Inline styles in templates — Use a mailer layout with shared styles; consider
premailer-railsto auto-inline CSS for email client compatibility - Hardcoded URLs — Route helpers with
_urlsuffix stay correct when routes change; hardcoded strings rot - No text fallback — HTML-only emails hurt deliverability scores and are inaccessible to screen readers
- Synchronous delivery in controllers —
deliver_nowin a request makes users wait for SMTP round-trips - Untested mailers — Test content, recipients, subject, and delivery count
- Secrets in mailer views — Never expose tokens/passwords in emails; use short-lived, hashed links instead
File Structure
app/
mailers/
application_mailer.rb # Base class — default from, layout
user_mailer.rb # Mailer class
views/
layouts/
mailer.html.erb # HTML layout (wraps all mailer HTML views)
mailer.text.erb # Text layout
user_mailer/
welcome_email.html.erb # HTML template
welcome_email.text.erb # Text template
config/
environments/
development.rb # delivery_method, default_url_options
production.rb # SMTP settings
test.rb # delivery_method: :test
test/
mailers/
user_mailer_test.rb # Mailer tests
previews/
user_mailer_preview.rb # Email previews