Back to Blog

How to Send Emails in Ruby on Rails (2026 Guide)

18 min read

Most "how to send email in Rails" tutorials stop at configuring Mailtrap and calling deliver_now. That's fine for development. It's not fine when you need to send welcome emails, password resets, payment receipts, and onboarding sequences to real users.

This guide covers the full picture: custom delivery methods that plug into Action Mailer, ERB templates with layouts, background sending with Sidekiq, interceptors, previews, testing, and production-ready patterns. All code examples use Rails 7.1+ conventions.

Pick an Email Provider

You have three solid options. Each code example below lets you switch between them.

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one API. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. It also has built-in retries and native Stripe integration.
  • Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface.

Action Mailer vs Direct API

Rails gives you two paths:

  1. Action Mailer with a custom delivery method — keeps your mailer classes, ERB views, previews, and interceptors working. Swap the transport layer without changing application code. This is the Rails way.
  2. Direct API calls — bypass Action Mailer, call the provider's HTTP API from a service object.

Use the Action Mailer approach. You get mailer classes, layout templates, deliver_later, previews, interceptors, and you can swap providers by changing one config line.

Install Dependencies

Gemfile
gem "faraday"  # or httparty — both work fine
Gemfile
gem "resend"
Gemfile
gem "sendgrid-ruby"
bundle install

Add your API key. Rails has multiple options — use credentials for production secrets:

.env (development)
SEQUENZY_API_KEY=sq_your_api_key_here
.env (development)
RESEND_API_KEY=re_your_api_key_here
.env (development)
SENDGRID_API_KEY=SG.your_api_key_here

For production, use Rails encrypted credentials:

# Edit encrypted credentials
bin/rails credentials:edit
 
# Add:
# sequenzy:
#   api_key: sq_your_production_key
# stripe:
#   webhook_secret: whsec_your_secret

Create a Custom Delivery Method

The cleanest integration. Build a custom delivery method so all your existing mailers route through the API provider automatically.

lib/delivery_methods/sequenzy.rb
module DeliveryMethods
class Sequenzy
  attr_accessor :settings

  BASE_URL = "https://api.sequenzy.com/v1".freeze

  def initialize(settings)
    @settings = settings
    @conn = Faraday.new(url: BASE_URL) do |f|
      f.request :json
      f.response :json
      f.response :raise_error
      f.adapter Faraday.default_adapter
    end
  end

  def deliver!(mail)
    @conn.post("/transactional/send") do |req|
      req.headers["Authorization"] = "Bearer #{api_key}"
      req.body = {
        to: mail.to.first,
        subject: mail.subject,
        body: html_body(mail)
      }
    end
  rescue Faraday::Error => e
    raise DeliveryError, "Sequenzy API error: #{e.message}"
  end

  private

  def api_key
    settings[:api_key] || raise("SEQUENZY_API_KEY not configured")
  end

  def html_body(mail)
    mail.html_part&.body&.to_s || mail.body.to_s
  end

  class DeliveryError < StandardError; end
end
end
lib/delivery_methods/resend_delivery.rb
module DeliveryMethods
class ResendDelivery
  attr_accessor :settings

  def initialize(settings)
    @settings = settings
    Resend.api_key = api_key
  end

  def deliver!(mail)
    Resend::Emails.send({
      from: format_from(mail),
      to: mail.to.first,
      subject: mail.subject,
      html: html_body(mail)
    })
  end

  private

  def api_key
    settings[:api_key] || raise("RESEND_API_KEY not configured")
  end

  def format_from(mail)
    from = mail.from.first
    name = mail[:from]&.display_names&.first
    name ? "#{name} <#{from}>" : from
  end

  def html_body(mail)
    mail.html_part&.body&.to_s || mail.body.to_s
  end
end
end
lib/delivery_methods/sendgrid.rb
module DeliveryMethods
class Sendgrid
  attr_accessor :settings

  def initialize(settings)
    @settings = settings
    @sg = SendGrid::API.new(api_key: api_key)
  end

  def deliver!(mail)
    from = SendGrid::Email.new(email: mail.from.first)
    to = SendGrid::Email.new(email: mail.to.first)
    content = SendGrid::Content.new(
      type: "text/html",
      value: html_body(mail)
    )
    sg_mail = SendGrid::Mail.new(from, mail.subject, to, content)

    response = @sg.client.mail._("send").post(request_body: sg_mail.to_json)

    unless (200..299).include?(response.status_code.to_i)
      raise DeliveryError, "SendGrid error (#{response.status_code}): #{response.body}"
    end
  end

  private

  def api_key
    settings[:api_key] || raise("SENDGRID_API_KEY not configured")
  end

  def html_body(mail)
    mail.html_part&.body&.to_s || mail.body.to_s
  end

  class DeliveryError < StandardError; end
end
end

Configure the delivery method per environment:

config/environments/production.rb
require_relative "../../lib/delivery_methods/sequenzy"

Rails.application.configure do
config.action_mailer.delivery_method = DeliveryMethods::Sequenzy
config.action_mailer.sequenzy_settings = {
  api_key: Rails.application.credentials.dig(:sequenzy, :api_key) || ENV["SEQUENZY_API_KEY"]
}
config.action_mailer.perform_deliveries = true
config.action_mailer.raise_delivery_errors = true
config.action_mailer.default_url_options = { host: "yourapp.com", protocol: "https" }
end
config/environments/production.rb
require_relative "../../lib/delivery_methods/resend_delivery"

Rails.application.configure do
config.action_mailer.delivery_method = DeliveryMethods::ResendDelivery
config.action_mailer.resend_delivery_settings = {
  api_key: Rails.application.credentials.dig(:resend, :api_key) || ENV["RESEND_API_KEY"]
}
config.action_mailer.perform_deliveries = true
config.action_mailer.raise_delivery_errors = true
config.action_mailer.default_url_options = { host: "yourapp.com", protocol: "https" }
end
config/environments/production.rb
require_relative "../../lib/delivery_methods/sendgrid"

Rails.application.configure do
config.action_mailer.delivery_method = DeliveryMethods::Sendgrid
config.action_mailer.sendgrid_settings = {
  api_key: Rails.application.credentials.dig(:sendgrid, :api_key) || ENV["SENDGRID_API_KEY"]
}
config.action_mailer.perform_deliveries = true
config.action_mailer.raise_delivery_errors = true
config.action_mailer.default_url_options = { host: "yourapp.com", protocol: "https" }
end

Create a Mailer

Generate a mailer with Rails generators:

bin/rails generate mailer User welcome password_reset payment_receipt
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: "Your App <noreply@yourdomain.com>"
  layout "mailer"
end
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  def welcome(user)
    @user = user
    @dashboard_url = "#{root_url}dashboard"
 
    mail(
      to: user.email,
      subject: "Welcome, #{user.name}"
    )
  end
 
  def password_reset(user, token)
    @user = user
    @reset_url = "#{root_url}reset-password?token=#{token}"
 
    mail(
      to: user.email,
      subject: "Reset your password"
    )
  end
 
  def payment_receipt(user, amount:, plan:, invoice_url:)
    @user = user
    @amount = format("$%.2f", amount / 100.0)
    @plan = plan
    @invoice_url = invoice_url
 
    mail(
      to: user.email,
      subject: "Payment receipt - #{@amount}"
    )
  end
end

Build Email Templates with ERB

Create a shared email layout and per-email templates:

<!-- app/views/layouts/mailer.html.erb -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:sans-serif;">
  <div style="max-width:480px;margin:40px auto;background:#fff;border-radius:8px;padding:40px;">
    <%= yield %>
  </div>
  <div style="text-align:center;padding:20px;color:#9ca3af;font-size:12px;">
    <p>&copy; <%= Date.current.year %> <%= Rails.application.config.app_name %>. All rights reserved.</p>
  </div>
</body>
</html>
<!-- app/views/user_mailer/welcome.html.erb -->
<h1 style="font-size:24px;margin-bottom:16px;">
  Welcome, <%= @user.name %>
</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
  Your account is ready. Click below to get started.
</p>
<a href="<%= @dashboard_url %>"
   style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
          border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
          margin-top:16px;">
  Go to Dashboard
</a>
<!-- app/views/user_mailer/password_reset.html.erb -->
<h2 style="font-size:20px;">Password Reset</h2>
<p style="font-size:16px;line-height:1.6;color:#374151;">
  Click the link below to reset your password. This link expires in 1 hour.
</p>
<a href="<%= @reset_url %>"
   style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
          border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
          margin-top:16px;">
  Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
  If you didn't request this, ignore this email.
</p>
<!-- app/views/user_mailer/payment_receipt.html.erb -->
<h2 style="font-size:20px;">Payment Received</h2>
<p style="font-size:16px;color:#374151;">Thanks for your payment. Here's your receipt:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
  <tr>
    <td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
    <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;"><%= @plan %></td>
  </tr>
  <tr>
    <td style="padding:8px;font-weight:600;">Total</td>
    <td style="padding:8px;text-align:right;font-weight:600;"><%= @amount %></td>
  </tr>
</table>
<a href="<%= @invoice_url %>" style="color:#f97316;">View full invoice</a>

Send Emails

# Queue for background delivery (recommended)
UserMailer.welcome(user).deliver_later
 
# Synchronous delivery (blocks the request)
UserMailer.welcome(user).deliver_now

Send from Controllers

# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  def create
    @user = User.new(user_params)
 
    if @user.save
      # Queue welcome email (non-blocking)
      UserMailer.welcome(@user).deliver_later
 
      redirect_to dashboard_path, notice: "Account created!"
    else
      render :new, status: :unprocessable_entity
    end
  end
 
  private
 
  def user_params
    params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end
end

Send from Model Callbacks

# app/models/user.rb
class User < ApplicationRecord
  after_create_commit :send_welcome_email
 
  private
 
  def send_welcome_email
    UserMailer.welcome(self).deliver_later
  end
end

Common Email Patterns for SaaS

Stripe Webhook Handler

For more on automating emails from Stripe events, see our Stripe email integration guide.

app/controllers/webhooks/stripe_controller.rb
module Webhooks
class StripeController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.body.read
    sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
    secret = Rails.application.credentials.dig(:stripe, :webhook_secret) || ENV["STRIPE_WEBHOOK_SECRET"]

    begin
      event = Stripe::Webhook.construct_event(payload, sig_header, secret)
    rescue JSON::ParserError, Stripe::SignatureVerificationError => e
      Rails.logger.error("Stripe webhook error: #{e.message}")
      head :bad_request and return
    end

    case event.type
    when "checkout.session.completed"
      handle_checkout(event.data.object)
    when "invoice.payment_failed"
      handle_payment_failed(event.data.object)
    end

    head :ok
  end

  private

  def handle_checkout(session)
    email = session.customer_email
    user = User.find_by(email: email)

    # Send receipt via Action Mailer
    UserMailer.payment_receipt(
      user,
      amount: session.amount_total,
      plan: "Pro",
      invoice_url: session.invoice || "#"
    ).deliver_later

    # Add as Sequenzy subscriber for marketing
    SequenzyService.add_subscriber(email, tags: ["customer", "stripe"])

    Rails.logger.info("Checkout completed for #{email}")
  end

  def handle_payment_failed(invoice)
    email = invoice.customer_email
    user = User.find_by(email: email)

    UserMailer.payment_failed(user).deliver_later

    Rails.logger.warn("Payment failed for #{email}")
  end
end
end
app/controllers/webhooks/stripe_controller.rb
module Webhooks
class StripeController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.body.read
    sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
    secret = Rails.application.credentials.dig(:stripe, :webhook_secret) || ENV["STRIPE_WEBHOOK_SECRET"]

    begin
      event = Stripe::Webhook.construct_event(payload, sig_header, secret)
    rescue JSON::ParserError, Stripe::SignatureVerificationError => e
      Rails.logger.error("Stripe webhook error: #{e.message}")
      head :bad_request and return
    end

    case event.type
    when "checkout.session.completed"
      handle_checkout(event.data.object)
    when "invoice.payment_failed"
      handle_payment_failed(event.data.object)
    end

    head :ok
  end

  private

  def handle_checkout(session)
    user = User.find_by(email: session.customer_email)

    UserMailer.payment_receipt(
      user,
      amount: session.amount_total,
      plan: "Pro",
      invoice_url: session.invoice || "#"
    ).deliver_later

    Rails.logger.info("Checkout completed for #{session.customer_email}")
  end

  def handle_payment_failed(invoice)
    user = User.find_by(email: invoice.customer_email)
    UserMailer.payment_failed(user).deliver_later
    Rails.logger.warn("Payment failed for #{invoice.customer_email}")
  end
end
end
app/controllers/webhooks/stripe_controller.rb
module Webhooks
class StripeController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.body.read
    sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
    secret = Rails.application.credentials.dig(:stripe, :webhook_secret) || ENV["STRIPE_WEBHOOK_SECRET"]

    begin
      event = Stripe::Webhook.construct_event(payload, sig_header, secret)
    rescue JSON::ParserError, Stripe::SignatureVerificationError => e
      Rails.logger.error("Stripe webhook error: #{e.message}")
      head :bad_request and return
    end

    case event.type
    when "checkout.session.completed"
      handle_checkout(event.data.object)
    when "invoice.payment_failed"
      handle_payment_failed(event.data.object)
    end

    head :ok
  end

  private

  def handle_checkout(session)
    user = User.find_by(email: session.customer_email)

    UserMailer.payment_receipt(
      user,
      amount: session.amount_total,
      plan: "Pro",
      invoice_url: session.invoice || "#"
    ).deliver_later

    Rails.logger.info("Checkout completed for #{session.customer_email}")
  end

  def handle_payment_failed(invoice)
    user = User.find_by(email: invoice.customer_email)
    UserMailer.payment_failed(user).deliver_later
    Rails.logger.warn("Payment failed for #{invoice.customer_email}")
  end
end
end

Add the route:

# config/routes.rb
namespace :webhooks do
  post "stripe", to: "stripe#create"
end

Error Handling

Delivery Method Error Handling

Add retry logic to your custom delivery method:

lib/delivery_methods/sequenzy.rb
module DeliveryMethods
class Sequenzy
  attr_accessor :settings

  BASE_URL = "https://api.sequenzy.com/v1".freeze
  MAX_RETRIES = 3

  def initialize(settings)
    @settings = settings
    @conn = Faraday.new(url: BASE_URL) do |f|
      f.request :json
      f.request :retry,
        max: MAX_RETRIES,
        interval: 1,
        interval_randomness: 0.5,
        backoff_factor: 2,
        retry_statuses: [429, 500, 502, 503, 504]
      f.response :json
      f.adapter Faraday.default_adapter
    end
  end

  def deliver!(mail)
    response = @conn.post("/transactional/send") do |req|
      req.headers["Authorization"] = "Bearer #{api_key}"
      req.body = {
        to: mail.to.first,
        subject: mail.subject,
        body: html_body(mail)
      }
    end

    unless response.success?
      raise DeliveryError, "Sequenzy API error (#{response.status}): #{response.body}"
    end

    Rails.logger.info("Email sent via Sequenzy", to: mail.to.first, subject: mail.subject)
  rescue Faraday::Error => e
    Rails.logger.error("Sequenzy delivery failed: #{e.message}")
    raise DeliveryError, "Sequenzy delivery failed: #{e.message}"
  end

  private

  def api_key
    settings[:api_key] || raise("SEQUENZY_API_KEY not configured")
  end

  def html_body(mail)
    mail.html_part&.body&.to_s || mail.body.to_s
  end

  class DeliveryError < StandardError; end
end
end
lib/delivery_methods/resend_delivery.rb
module DeliveryMethods
class ResendDelivery
  attr_accessor :settings

  def initialize(settings)
    @settings = settings
    Resend.api_key = api_key
  end

  def deliver!(mail)
    result = Resend::Emails.send({
      from: format_from(mail),
      to: mail.to.first,
      subject: mail.subject,
      html: html_body(mail)
    })

    Rails.logger.info("Email sent via Resend", to: mail.to.first, id: result.dig("id"))
  rescue StandardError => e
    Rails.logger.error("Resend delivery failed: #{e.message}")
    raise
  end

  private

  def api_key
    settings[:api_key] || raise("RESEND_API_KEY not configured")
  end

  def format_from(mail)
    from = mail.from.first
    name = mail[:from]&.display_names&.first
    name ? "#{name} <#{from}>" : from
  end

  def html_body(mail)
    mail.html_part&.body&.to_s || mail.body.to_s
  end
end
end
lib/delivery_methods/sendgrid.rb
module DeliveryMethods
class Sendgrid
  attr_accessor :settings

  def initialize(settings)
    @settings = settings
    @sg = SendGrid::API.new(api_key: api_key)
  end

  def deliver!(mail)
    from = SendGrid::Email.new(email: mail.from.first)
    to = SendGrid::Email.new(email: mail.to.first)
    content = SendGrid::Content.new(type: "text/html", value: html_body(mail))
    sg_mail = SendGrid::Mail.new(from, mail.subject, to, content)

    response = @sg.client.mail._("send").post(request_body: sg_mail.to_json)

    unless (200..299).include?(response.status_code.to_i)
      raise DeliveryError, "SendGrid error (#{response.status_code}): #{response.body}"
    end

    Rails.logger.info("Email sent via SendGrid", to: mail.to.first)
  rescue StandardError => e
    Rails.logger.error("SendGrid delivery failed: #{e.message}")
    raise DeliveryError, e.message
  end

  private

  def api_key
    settings[:api_key] || raise("SENDGRID_API_KEY not configured")
  end

  def html_body(mail)
    mail.html_part&.body&.to_s || mail.body.to_s
  end

  class DeliveryError < StandardError; end
end
end

Active Job Retry Configuration

Configure retry behavior for email jobs:

# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: "Your App <noreply@yourdomain.com>"
  layout "mailer"
 
  # Called when delivery fails after all retries
  rescue_from StandardError, with: :handle_delivery_error
 
  private
 
  def handle_delivery_error(exception)
    Rails.logger.error(
      "Email delivery failed permanently",
      mailer: self.class.name,
      action: action_name,
      error: exception.message
    )
    # Optionally notify your error tracker
    # Sentry.capture_exception(exception)
  end
end
# config/application.rb
# Active Job retry for email delivery
config.action_mailer.deliver_later_queue_name = :emails
# config/initializers/sidekiq.rb (if using Sidekiq)
Sidekiq.configure_server do |config|
  config.redis = { url: ENV["REDIS_URL"] || "redis://localhost:6379/0" }
end
 
Sidekiq.configure_client do |config|
  config.redis = { url: ENV["REDIS_URL"] || "redis://localhost:6379/0" }
end

Background Sending with Sidekiq

deliver_later uses Active Job, which works with any backend (Sidekiq, Resque, Good Job, Solid Queue). Sidekiq is the most popular:

# Gemfile
gem "sidekiq"
# config/application.rb
config.active_job.queue_adapter = :sidekiq
# config/sidekiq.yml
:concurrency: 5
:queues:
  - [emails, 3]    # higher priority
  - [default, 1]
# Run Sidekiq
bundle exec sidekiq

Now deliver_later sends emails through Sidekiq with automatic retries:

# These all run in the background
UserMailer.welcome(user).deliver_later
UserMailer.password_reset(user, token).deliver_later
UserMailer.payment_receipt(user, amount: 4999, plan: "Pro", invoice_url: url).deliver_later
 
# Schedule for later
UserMailer.onboarding_followup(user).deliver_later(wait: 3.days)
 
# With specific queue
UserMailer.welcome(user).deliver_later(queue: :critical_emails)

Custom Job for Complex Email Logic

For emails that need more control than deliver_later provides:

# app/jobs/send_onboarding_sequence_job.rb
class SendOnboardingSequenceJob < ApplicationJob
  queue_as :emails
  retry_on StandardError, wait: :polynomially_longer, attempts: 5
  discard_on ActiveJob::DeserializationError
 
  def perform(user)
    return if user.onboarding_completed?
 
    case days_since_signup(user)
    when 0
      UserMailer.welcome(user).deliver_now
    when 1
      UserMailer.getting_started(user).deliver_now
    when 3
      UserMailer.feature_highlight(user).deliver_now
    when 7
      UserMailer.check_in(user).deliver_now unless user.active_recently?
    end
  end
 
  private
 
  def days_since_signup(user)
    (Date.current - user.created_at.to_date).to_i
  end
end

Interceptors and Observers

Rails has a powerful interceptor system for modifying or filtering emails before they're sent.

Staging Interceptor

Redirect all emails to your team in staging:

# lib/mail_interceptors/staging_interceptor.rb
class StagingInterceptor
  def self.delivering_email(message)
    message.to = [ENV["STAGING_EMAIL_REDIRECT"] || "dev-team@yourcompany.com"]
    message.cc = nil
    message.bcc = nil
    message.subject = "[STAGING] #{message.subject}"
  end
end
 
# config/environments/staging.rb
config.action_mailer.interceptors = ["StagingInterceptor"]

Logging Observer

Track all sent emails:

# lib/mail_observers/delivery_observer.rb
class DeliveryObserver
  def self.delivered_email(message)
    Rails.logger.info(
      "Email delivered",
      to: message.to,
      subject: message.subject,
      from: message.from
    )
  end
end
 
# config/environments/production.rb
config.action_mailer.observers = ["DeliveryObserver"]

Testing

Rails makes email testing excellent. Action Mailer has built-in test helpers.

# test/mailers/user_mailer_test.rb
require "test_helper"
 
class UserMailerTest < ActionMailer::TestCase
  test "welcome email has correct subject and recipient" do
    user = users(:jane)
    email = UserMailer.welcome(user)
 
    assert_emails 1 do
      email.deliver_now
    end
 
    assert_equal ["noreply@yourdomain.com"], email.from
    assert_equal [user.email], email.to
    assert_equal "Welcome, #{user.name}", email.subject
  end
 
  test "welcome email includes dashboard link" do
    user = users(:jane)
    email = UserMailer.welcome(user)
 
    assert_match "Go to Dashboard", email.html_part.body.to_s
    assert_match "/dashboard", email.html_part.body.to_s
  end
 
  test "password reset includes reset link with token" do
    user = users(:jane)
    token = "abc123"
    email = UserMailer.password_reset(user, token)
 
    assert_match "Reset Password", email.html_part.body.to_s
    assert_match "token=#{token}", email.html_part.body.to_s
    assert_match "expires in 1 hour", email.html_part.body.to_s
  end
 
  test "payment receipt formats amount correctly" do
    user = users(:jane)
    email = UserMailer.payment_receipt(user, amount: 4999, plan: "Pro", invoice_url: "#")
 
    assert_equal "Payment receipt - $49.99", email.subject
    assert_match "$49.99", email.html_part.body.to_s
    assert_match "Pro", email.html_part.body.to_s
  end
end

Integration Test

# test/integration/registration_flow_test.rb
require "test_helper"
 
class RegistrationFlowTest < ActionDispatch::IntegrationTest
  test "registration sends welcome email" do
    assert_enqueued_email_with UserMailer, :welcome do
      post registrations_path, params: {
        user: { name: "Jane", email: "jane@test.com", password: "password123", password_confirmation: "password123" }
      }
    end
  end
 
  test "registration does not send email on validation failure" do
    assert_no_enqueued_emails do
      post registrations_path, params: {
        user: { name: "", email: "invalid", password: "123" }
      }
    end
  end
end

Action Mailer Previews

Rails has built-in email previews. See your emails in the browser without sending them:

# test/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
  def welcome
    user = User.first || User.new(name: "Jane", email: "jane@example.com")
    UserMailer.welcome(user)
  end
 
  def password_reset
    user = User.first || User.new(name: "Jane", email: "jane@example.com")
    UserMailer.password_reset(user, "fake-token-123")
  end
 
  def payment_receipt
    user = User.first || User.new(name: "Jane", email: "jane@example.com")
    UserMailer.payment_receipt(user, amount: 4999, plan: "Pro", invoice_url: "#")
  end
end

Visit http://localhost:3000/rails/mailers/user_mailer/welcome to preview.

Going to Production

1. Verify Your Domain

Add SPF, DKIM, and DMARC DNS records through your provider's dashboard. Our email authentication guide covers the full setup. Without this, emails go straight to spam.

2. Use a Dedicated Sending Domain

Send from mail.yourapp.com instead of your root domain. If your email reputation takes a hit, your main domain stays clean.

3. Use Rails Credentials for Secrets

Never store API keys in plain .env files on production servers:

# Production credentials
RAILS_MASTER_KEY=xxx bin/rails credentials:edit --environment production

4. Configure Action Mailer Defaults

# config/environments/production.rb
config.action_mailer.perform_caching = false
config.action_mailer.perform_deliveries = true
config.action_mailer.raise_delivery_errors = false  # handled by job retry
config.action_mailer.default_url_options = { host: "yourapp.com", protocol: "https" }
config.action_mailer.deliver_later_queue_name = :emails

5. Development Configuration

# config/environments/development.rb
config.action_mailer.delivery_method = :letter_opener  # opens in browser
# Or use :test to capture without opening
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
config.action_mailer.raise_delivery_errors = true

Add the letter_opener gem for a great dev experience:

# Gemfile (development group)
gem "letter_opener", group: :development

Production Checklist

- [ ] Domain verified (SPF, DKIM, DMARC)
- [ ] Dedicated sending domain (mail.yourapp.com)
- [ ] API keys in Rails credentials (encrypted)
- [ ] All emails use deliver_later (not deliver_now)
- [ ] Sidekiq running with email queue
- [ ] Active Job retry configuration
- [ ] Staging interceptor to prevent real sends
- [ ] Action Mailer previews for all email types
- [ ] Tests for all mailer actions
- [ ] Error tracking (Sentry/Honeybadger) on delivery failures
- [ ] Letter Opener in development
- [ ] Logging observer for production
- [ ] CSRF skipped for webhook routes

Beyond Transactional: Marketing Emails and Sequences

At some point, you'll want more than one-off transactional emails. You'll want to:

  • Send onboarding sequences that guide new users through your product over several days
  • Run marketing campaigns to announce features or share updates
  • Automate lifecycle emails based on what users do (or don't do) in your app
  • Track engagement to see which emails get opened and clicked

Most teams stitch together Action Mailer with a separate marketing tool (Mailchimp, ConvertKit). That means two systems, subscriber sync issues, and fragmented analytics.

Sequenzy handles both from one platform. Same API, same dashboard. You get transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS-specific automations like trial conversion and churn prevention.

Create a service object for subscriber management:

# app/services/sequenzy_service.rb
class SequenzyService
  BASE_URL = "https://api.sequenzy.com/v1".freeze
 
  class << self
    def add_subscriber(email, first_name: nil, tags: [], custom_attributes: {})
      post("/subscribers", {
        email: email,
        firstName: first_name,
        tags: tags,
        customAttributes: custom_attributes
      })
    end
 
    def add_tag(email, tag)
      post("/subscribers/tags", { email: email, tag: tag })
    end
 
    def track_event(email, event, properties: {})
      post("/subscribers/events", {
        email: email,
        event: event,
        properties: properties
      })
    end
 
    private
 
    def post(path, body)
      conn.post(path) do |req|
        req.body = body
      end
    end
 
    def conn
      @conn ||= Faraday.new(url: BASE_URL) do |f|
        f.request :json
        f.request :authorization, "Bearer", api_key
        f.response :json
        f.adapter Faraday.default_adapter
      end
    end
 
    def api_key
      Rails.application.credentials.dig(:sequenzy, :api_key) || ENV["SEQUENZY_API_KEY"]
    end
  end
end
 
# Usage in your app
SequenzyService.add_subscriber(
  user.email,
  first_name: user.name,
  tags: ["signed-up"],
  custom_attributes: { plan: "free", source: "organic" }
)
 
SequenzyService.add_tag(user.email, "customer")
 
SequenzyService.track_event(user.email, "onboarding.completed", properties: {
  completed_steps: 5
})

Wire it up with model callbacks:

# app/models/user.rb
class User < ApplicationRecord
  after_create_commit :register_with_sequenzy
 
  private
 
  def register_with_sequenzy
    SequenzyService.add_subscriber(
      email,
      first_name: name,
      tags: ["signed-up"]
    )
  rescue StandardError => e
    Rails.logger.error("Failed to register subscriber: #{e.message}")
  end
end

FAQ

Should I use Action Mailer or direct API calls?

Use Action Mailer with a custom delivery method. You get ERB templates, layouts, deliver_later, previews, interceptors, observers, and you can swap providers by changing one config line. Direct API calls are only useful for provider-specific features that aren't email sending (like Sequenzy's subscriber management).

What's the difference between deliver_now and deliver_later?

deliver_now sends the email immediately (synchronous — blocks the request). deliver_later enqueues it as an Active Job (asynchronous — returns instantly). Always use deliver_later in controllers and model callbacks. The only time to use deliver_now is inside background jobs where blocking is fine.

Should I use Sidekiq, Good Job, or Solid Queue?

Sidekiq is the most popular and fastest (uses Redis). Good Job stores jobs in PostgreSQL (no Redis needed). Solid Queue is Rails 8's built-in queue backed by SQLite or any database. For most apps, Good Job or Solid Queue are simplest to deploy. Sidekiq is best if you need high throughput and already have Redis.

How do I prevent sending emails in development?

Use letter_opener gem — it opens emails in your browser instead of sending them. Or set config.action_mailer.delivery_method = :test to silently capture emails. For staging, use an interceptor to redirect all emails to your team's inbox.

How do I send multipart emails (HTML + plain text)?

Action Mailer does this automatically when you create both an .html.erb and .text.erb view:

app/views/user_mailer/
  welcome.html.erb   # HTML version
  welcome.text.erb   # Plain text version

Both get included in the email automatically.

How do I add attachments?

class UserMailer < ApplicationMailer
  def invoice(user, pdf_path)
    attachments["invoice.pdf"] = File.read(pdf_path)
 
    # Or inline attachments for images
    attachments.inline["logo.png"] = File.read("app/assets/images/logo.png")
 
    mail(to: user.email, subject: "Your invoice")
  end
end

How do I test that an email was enqueued?

assert_enqueued_email_with UserMailer, :welcome, args: [user] do
  post registrations_path, params: { user: valid_params }
end

Or check the enqueued jobs directly:

assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
  UserMailer.welcome(user).deliver_later
end

How do I use I18n for email subjects?

# app/mailers/user_mailer.rb
def welcome(user)
  @user = user
  mail(
    to: user.email,
    subject: default_i18n_subject(name: user.name)
  )
end
 
# config/locales/en.yml
en:
  user_mailer:
    welcome:
      subject: "Welcome, %{name}"

How do I preview emails with dynamic data?

Use Action Mailer previews with factory-created data:

class UserMailerPreview < ActionMailer::Preview
  def welcome
    UserMailer.welcome(FactoryBot.build(:user, name: "Preview User"))
  end
end

Visit /rails/mailers to see all available previews.

Wrapping Up

Here's what we covered:

  1. Custom delivery methods to plug any provider into Action Mailer
  2. ERB templates with shared layouts for maintainable HTML emails
  3. deliver_later with Sidekiq for non-blocking background sending
  4. Interceptors for staging safety and development convenience
  5. Stripe webhooks with signature verification
  6. Testing with Action Mailer test helpers and integration tests
  7. Production setup: Rails credentials, Sidekiq, Letter Opener, previews

The code in this guide is production-ready. Pick your provider, wire up the delivery method, and start shipping emails.

Frequently Asked Questions

Should I use Action Mailer or call an email API directly in Rails?

Use Action Mailer. It provides a clean abstraction with built-in support for templates, attachments, multipart emails, and Active Job queuing. Direct API calls bypass Rails' email infrastructure and make your code harder to test and maintain.

How do I send emails in the background in Rails?

Add deliver_later instead of deliver_now to your mailer call. Rails uses Active Job to queue the email. Configure a backend like Sidekiq, GoodJob, or Solid Queue in production. Background sending prevents slow API calls from blocking web requests.

How do I create email templates in Rails?

Generate a mailer with rails generate mailer UserMailer. Create views in app/views/user_mailer/ as .html.erb for HTML and .text.erb for plain text. Rails automatically sends multipart emails when both formats exist.

How do I test mailers in Rails?

Rails provides ActionMailer::TestHelper with assertions like assert_emails(1) and assert_enqueued_emails(1). Use ActionMailer::Base.deliveries to inspect sent emails in tests. Test both the mailer itself and the code that triggers it.

How do I preview email templates during development?

Create preview classes in test/mailers/previews/ (or spec/mailers/previews/). Visit /rails/mailers in your browser to see rendered previews. This lets you iterate on email design without sending actual emails.

How do I configure different email providers per environment in Rails?

Set delivery method in environment config files. Use config.action_mailer.delivery_method = :letter_opener for development, :test for testing, and your provider's delivery method for production. Credentials go in Rails encrypted credentials.

How do I store email API keys in Rails?

Use Rails encrypted credentials: rails credentials:edit. Store keys under a namespace (e.g., email: { api_key: "..." }). Access with Rails.application.credentials.dig(:email, :api_key). This encrypts keys at rest and keeps them out of plain text config.

Can I use Action Mailer callbacks for email tracking?

Action Mailer supports before_action, after_action, and around_action callbacks in mailer classes. Use them for logging, header manipulation, or conditional logic. For delivery tracking (opens, clicks), you need your email provider's webhook integration.

How do I send emails with attachments in Rails?

Use attachments['filename.pdf'] = File.read('/path/to/file') in your mailer action. For inline images, use attachments.inline['logo.png'] and reference them in templates with image_tag(attachments['logo.png'].url). Rails handles MIME encoding automatically.

Should I use Sidekiq or GoodJob for email queue processing?

Sidekiq (Redis-based) is the most popular and performant option. GoodJob (PostgreSQL-based) is simpler if you want to avoid Redis as a dependency. Solid Queue (Rails 8+ default) is the newest option with database-backed queuing. All work well for email processing.