Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 031 / 100

Action Mailer: concepts, trade-offs and production lessons

Send emails from Rails.

Expona Admin11 min read

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:

ruby
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
end

Key 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:

ruby
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
end

One 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:

ruby
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
end

This 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:

erb
<!-- 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) not order_path(@order). Email clients don't have a "current host" context. This is a common mistake that results in broken links. Set config.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 the premailer-rails gem 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:

ruby
def confirmation_email(order)
  @order = order
  I18n.locale = order.customer.preferred_locale
  mail(to: @order.customer.email, subject: t('.subject'))
end

Create 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:

ruby
class ApplicationMailer < ActionMailer::Base
  before_action :log_locale

  private

  def log_locale
    Rails.logger.info("Mailer #{self.class.name} using locale: #{I18n.locale}")
  end
end

This 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:

ruby
class Order < ApplicationRecord
  after_create :send_confirmation_email

  private

  def send_confirmation_email
    OrderMailer.confirmation_email(self).deliver_later(wait: 1.minute)
  end
end

The 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_now may 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:

ruby
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
end

In 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:

ruby
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' }
end

A 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:

ruby
# 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
end

Now 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:

ruby
class OrderMailerPreview < ActionMailer::Preview
  def confirmation_email
    order = Order.first || FactoryBot.create(:order_with_items)
    OrderMailer.confirmation_email(order)
  end
end

In production, disable preview access by default:

ruby
# config/environments/production.rb
config.action_mailer.preview_path = nil

For staging, keep previews enabled but require authentication. Create a simple middleware or route guard:

ruby
# 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
end

Common 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:

ruby
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
end

Storing 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:

ruby
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)
end

Not 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:

ruby
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
end

Key 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_later in 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles