Action Mailer: concepts, trade-offs and production lessons
Email is one of those features that seems simple until you deploy it to production. Rails developers often underestimate the complexity of sending reliable, trackable email at scale. This guide covers the practical patterns, trade-offs and production lessons we've learned building transactional and marketing email systems across e-commerce, SaaS and ride-hailing platforms.
Mailers — Classes with methods per email
In Rails, mailers are classes that inherit from ActionMailer::Base. Each method represents a distinct email type. This design mirrors controllers, which makes the pattern intuitive for most teams.
Here's a transactional email for an order confirmation:
class OrderMailer < ApplicationMailer
default from: 'orders@shop.example.com'
def confirmation_email(order)
@order = order
@customer = order.customer
mail(
to: @customer.email,
subject: "Order ##{@order.id} confirmed",
reply_to: 'support@shop.example.com'
)
end
def shipment_notification(shipment)
@shipment = shipment
@order = shipment.order
mail(
to: @order.customer.email,
subject: "Your order is on the way"
)
end
endKey points: the mail method accepts to, cc, bcc, subject, reply_to and other headers. The default from: is set at the class level to avoid repetition. Instance variables like @order and @customer are automatically available in templates.
In production, we typically define a base mailer class to standardize headers, footer links and unsubscribe mechanisms:
class ApplicationMailer < ActionMailer::Base
default from: ENV['MAILER_FROM_ADDRESS']
layout 'mailer'
def unsubscribe_token(user)
"#{user.id}-#{user.email_preferences_token}"
end
protected
def set_reply_to
headers['Reply-To'] = ENV['SUPPORT_EMAIL']
end
endOne trade-off: mailer classes mix concerns. They handle both email logic and presentation. For complex email workflows with many conditional blocks, extraction into service objects keeps things cleaner. But for straightforward transactional emails, the built-in approach is sufficient.
In a ride-hailing system, we encountered a scenario where the same mailer method needed to send different content based on user preferences, trip status, and regional regulations. We extracted the logic into a service object:
class TripConfirmationEmailService
def initialize(trip, user)
@trip = trip
@user = user
end
def build_subject
case @trip.region
when 'eu'
"Votre trajet est confirmé"
when 'us'
"Your trip is confirmed"
else
"Trip confirmed"
end
end
def should_include_surge_pricing?
@user.preferences.show_pricing_details? && @trip.surge_multiplier > 1.0
end
def call
{
subject: build_subject,
include_surge: should_include_surge_pricing?,
include_driver_photo: @user.preferences.show_driver_info?
}
end
end
class TripMailer < ApplicationMailer
def confirmation(trip, user)
@trip = trip
@user = user
@email_config = TripConfirmationEmailService.new(trip, user).call
mail(to: @user.email, subject: @email_config[:subject])
end
endThis separation makes unit testing the business logic simple without needing to instantiate mailers or render templates.
Templates — HTML and text views
Rails renders both HTML and plain text versions of emails by default. Templates live in app/views/mailers/mailer_name/method_name.html.erb and .text.erb.
A simple confirmation email template:
<!-- app/views/order_mailer/confirmation_email.html.erb -->
<h1>Order confirmed</h1>
<p>Hi <%= @customer.first_name %>,</p>
<p>Your order #<%= @order.id %> has been confirmed.</p>
<table>
<tr>
<th>Item</th>
<th>Qty</th>
<th>Price</th>
</tr>
<% @order.line_items.each do |item| %>
<tr>
<td><%= item.product_name %></td>
<td><%= item.quantity %></td>
<td><%= number_to_currency(item.price) %></td>
</tr>
<% end %>
</table>
<p>Order total: <strong><%= number_to_currency(@order.total) %></strong></p>
<p><%= link_to 'View your order', order_url(@order) %></p>A few production lessons on templates:
- Always generate absolute URLs: Use
order_url(@order)notorder_path(@order). Email clients don't have a "current host" context. This is a common mistake that results in broken links. Setconfig.action_mailer.default_url_options = { host: 'shop.example.com', protocol: 'https' }for each environment. - Provide both HTML and text: Some email clients render text only. Rails does this automatically, but review the text version to ensure it's readable. A garbled plain text version damages user experience and deliverability.
- Inline critical CSS: Most email clients strip
<style>tags. Use thepremailer-railsgem or Tailwind's built-in email inlining to ensure styling survives. Without inlining, your carefully designed email becomes a wall of unstyled text. - Test across email clients: Litmus and Email on Acid are industry standards. A layout that looks fine in Gmail may break in Outlook or Apple Mail. We discovered that table layouts render inconsistently; cell padding needs explicit pixel values, not percentages.
- Keep images embedded or CDN-hosted: Avoid large attachments. Use image tags with CDN URLs. Large attachments slow down sends and can trigger spam filters. In a CRM system, we saw attachment-heavy emails consistently end up in junk folders.
- Escape user-generated content: If your email includes customer names, product descriptions or other user input, always escape it:
<%= sanitize(@product.description) %>. An unescaped string could inject HTML and break the email layout or create security issues.
For localization, call I18n.locale in the mailer method before rendering:
def confirmation_email(order)
@order = order
I18n.locale = order.customer.preferred_locale
mail(to: @order.customer.email, subject: t('.subject'))
endCreate locale-specific translation files. A travel platform we worked on needed emails in 15 languages. Forgetting to set locale before calling mail() resulted in emails being sent in the wrong language. We added a safety check in the ApplicationMailer:
class ApplicationMailer < ActionMailer::Base
before_action :log_locale
private
def log_locale
Rails.logger.info("Mailer #{self.class.name} using locale: #{I18n.locale}")
end
endThis minimal logging caught several instances where locale wasn't being set.
deliver_later — Send in background jobs
Calling mailer.confirmation_email(@order).deliver_now blocks the request. In production, you almost never do this. Use deliver_later to queue emails in the background:
class Order < ApplicationRecord
after_create :send_confirmation_email
private
def send_confirmation_email
OrderMailer.confirmation_email(self).deliver_later(wait: 1.minute)
end
endThe deliver_later call enqueues the email job and returns immediately. The actual send happens in your background job processor (Sidekiq, Delayed Job, etc.).
Trade-offs with background delivery:
- Resilience: If the mail server is temporarily down, retries are automatic. Synchronous delivery would fail the entire request. In an e-commerce system, a brief SMTP outage could cause checkout failures if emails were sent synchronously. Background queuing decouples order creation from email delivery.
- Latency: Emails arrive within seconds (or minutes with
wait:) rather than instantly. For critical alerts,deliver_nowmay be acceptable in a controller with timeout handling. We've found that most users expect emails within 30 seconds but are tolerant of delays up to 2 minutes. - Debugging: Background job failures are harder to trace. Use structured logging and monitoring. Sidekiq failures require explicit dead-letter queues and monitoring.
- Database coupling: If you pass a model to the mailer, Rails serializes it by ID. If the record is deleted before the job runs, you'll get a "record not found" error. Pass IDs explicitly when the model might be destroyed.
For critical emails (password resets, payment confirmations), consider a hybrid approach: send immediately but with a fallback queue:
def send_password_reset(user)
begin
timeout(5) do
UserMailer.password_reset(user).deliver_now
end
rescue Timeout::Error, StandardError => e
Rails.logger.error("Password reset email failed: #{e.message}")
UserMailer.password_reset(user).deliver_later(wait: 30.seconds)
end
endIn a SaaS platform, we implemented monitoring around password reset emails. If the immediate send failed, we tracked it and alerted on-call engineers. This caught an SMTP configuration issue that would have locked out users.
Track email delivery status. Most ESP integrations (Sendgrid, Postmark, Mailgun) provide webhooks for bounces, complaints and opens. Store these events in your database:
class EmailEvent < ApplicationRecord
belongs_to :user
validates :event_type, presence: true
validates :external_id, uniqueness: { scope: :event_type }
# event_type: 'delivered', 'bounced', 'complained', 'opened'
enum event_type: { delivered: 0, bounced: 1, complained: 2, opened: 3, clicked: 4 }
end
# In your webhook handler:
def handle_email_event
webhook_params = params.permit(:event, :message_id, :timestamp, :user_id, :email)
EmailEvent.find_or_create_by!(
external_id: webhook_params[:message_id],
event_type: webhook_params[:event]
) do |event|
event.user_id = webhook_params[:user_id]
event.email_address = webhook_params[:email]
event.occurred_at = Time.at(webhook_params[:timestamp].to_i)
event.metadata = webhook_params.to_h
end
# Handle permanent bounces
if webhook_params[:event] == 'bounced' && webhook_params[:permanent]
User.find(webhook_params[:user_id]).update(email_bounced: true, email_bounced_at: Time.current)
end
render json: { status: 'received' }
endA critical edge case: if a user's email bounces permanently, continuing to send to that address wastes resources and hurts your sender reputation. In a travel booking system, we didn't track hard bounces initially. We ended up on spam blacklists because we kept sending to invalid addresses. Webhooks saved us.
Previews — Preview emails in the browser
Rails provides a built-in email preview system accessible at /rails/mailers in development. Create a preview class for each mailer:
# app/mailers/previews/order_mailer_preview.rb
class OrderMailerPreview < ActionMailer::Preview
def confirmation_email
order = Order.includes(:customer, :line_items).first
OrderMailer.confirmation_email(order)
end
def shipment_notification
shipment = Shipment.first
OrderMailer.shipment_notification(shipment)
end
def refund_initiated
refund = Refund.first
OrderMailer.refund_initiated(refund)
end
endNow visit http://localhost:3000/rails/mailers/order_mailer and you'll see both the HTML and text versions. This is invaluable for checking that dynamic data renders correctly, links point to the right places, and styling survives.
Common mistake: preview classes load stale or non-existent records. Use factories or seeds to ensure consistent preview data:
class OrderMailerPreview < ActionMailer::Preview
def confirmation_email
order = Order.first || FactoryBot.create(:order_with_items)
OrderMailer.confirmation_email(order)
end
endIn production, disable preview access by default:
# config/environments/production.rb
config.action_mailer.preview_path = nilFor staging, keep previews enabled but require authentication. Create a simple middleware or route guard:
# config/routes.rb
if Rails.env.staging?
authenticate :user, ->(u) { u.admin? } do
namespace :rails do
resources :mailers, only: [:index, :show], as: :mailers
end
end
endCommon mistakes and edge cases
Hardcoding email addresses in mailers. Use instance variables and environment variables. Your test suite will thank you when it's not trying to send to hardcoded addresses. We've seen staging deploys accidentally email production customers because the mailer had a hardcoded address.
Sending large lists of emails in a loop without queueing. This blocks your request and can cause timeouts. Always use deliver_later or bulk email services with their APIs. If you need to send 10,000 order confirmation emails, batch them: Order.where(sent_confirmation_email: false).find_in_batches(batch_size: 100) { |orders| orders.each { |o| OrderMailer.confirmation_email(o).deliver_later } }
Forgetting to set the mail host in config. Without config.action_mailer.default_url_options = { host: 'example.com' }, links in email templates will be missing the domain. In development, this defaults to localhost:3000, which is correct. But in test environments, it defaults to example.com, which can cause test failures if not explicitly configured.
Not testing the text version of emails. Teams often only check HTML. Plain text versions get garbled and look unprofessional. Write tests that render both:
describe OrderMailer do
describe '#confirmation_email' do
let(:order) { create(:order) }
let(:mail) { OrderMailer.confirmation_email(order) }
it 'sends to the customer email' do
expect(mail.to).to include(order.customer.email)
end
it 'includes order details in HTML' do
expect(mail.body.encoded).to include(order.id.to_s)
end
it 'includes order details in text' do
# Force rendering of text part
text_part = mail.parts.find { |part| part.content_type.match?(/text\/plain/) }
expect(text_part.body.encoded).to include(order.id.to_s)
end
end
endStoring sensitive data in email templates. Email can be logged, stored in ESP systems and forwarded. Never include API keys, tokens or full payment card details in email body. A ride-hailing company accidentally included a temporary authorization token in a ride receipt email. Users forwarded it to friends, who could then access their rides.
Ignoring unsubscribe and preference links. CAN-SPAM and GDPR require them. Implement a preferences page tied to email addresses or tokens:
class UnsubscribeController < ApplicationController
skip_authentication
def show
@user = User.find_by!(email_unsubscribe_token: params[:token])
end
def update
@user = User.find_by!(email_unsubscribe_token: params[:token])
@user.update!(
unsubscribed_at: Time.current,
unsubscribe_reason: params[:reason]
)
render :confirmation
end
end
# In ApplicationMailer:
def unsubscribe_link(user)
unsubscribe_url(token: user.email_unsubscribe_token)
endNot monitoring sender reputation. Bounces, complaints and spam reports accumulate. If your bounce rate exceeds 5%, your IP may be blacklisted. Track these metrics and set up alerts:
class EmailHealthJob
def perform
bounced = EmailEvent.bounced.where('occurred_at > ?', 24.hours.ago).count
total = EmailEvent.where('occurred_at > ?', 24.hours.ago).count
bounce_rate = total > 0 ? (bounced.to_f / total) : 0
if bounce_rate > 0.05
AlertService.notify("High bounce rate: #{(bounce_rate * 100).round(2)}%")
end
end
endKey takeaways
- Mailers are controller-like classes with one method per email type. Use a base mailer to standardize headers and unsubscribe mechanisms. For complex logic, extract into service objects.
- Rails generates both HTML and plain text versions. Always verify the text output and use CSS inlining for HTML emails. Test across multiple email clients.
- Use
deliver_laterin production to avoid blocking requests. For critical emails, implement a synchronous-with-fallback pattern and monitor failures closely. - Leverage Rails previews in development to catch template issues early. Track delivery events (bounces, opens, complaints) via ESP webhooks to maintain deliverability and user engagement data.
- Design mailers to be testable: pass IDs instead of model instances when objects might be destroyed, avoid hardcoded addresses, avoid storing sensitive data, and implement unsubscribe mechanisms that comply with regulations.
- Monitor sender reputation continuously. High bounce rates, spam complaints and hard bounces accumulate and eventually damage your ability to deliver mail.






Comments
Loading comments…