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

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:
- 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.
- 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
gem "faraday" # or httparty — both work finegem "resend"gem "sendgrid-ruby"bundle installAdd your API key. Rails has multiple options — use credentials for production secrets:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereFor 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_secretCreate a Custom Delivery Method
The cleanest integration. Build a custom delivery method so all your existing mailers route through the API provider automatically.
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
endmodule 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
endmodule 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
endConfigure the delivery method per environment:
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" }
endrequire_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" }
endrequire_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" }
endCreate 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
endBuild 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>© <%= 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_nowSend 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
endSend 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
endCommon Email Patterns for SaaS
Stripe Webhook Handler
For more on automating emails from Stripe events, see our Stripe email integration guide.
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
endmodule 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
endmodule 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
endAdd the route:
# config/routes.rb
namespace :webhooks do
post "stripe", to: "stripe#create"
endError Handling
Delivery Method Error Handling
Add retry logic to your custom delivery method:
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
endmodule 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
endmodule 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
endActive 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" }
endBackground 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 sidekiqNow 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
endInterceptors 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
endIntegration 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
endAction 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
endVisit 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 production4. 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 = :emails5. 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 = trueAdd the letter_opener gem for a great dev experience:
# Gemfile (development group)
gem "letter_opener", group: :developmentProduction 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
endFAQ
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
endHow do I test that an email was enqueued?
assert_enqueued_email_with UserMailer, :welcome, args: [user] do
post registrations_path, params: { user: valid_params }
endOr check the enqueued jobs directly:
assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
UserMailer.welcome(user).deliver_later
endHow 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
endVisit /rails/mailers to see all available previews.
Wrapping Up
Here's what we covered:
- Custom delivery methods to plug any provider into Action Mailer
- ERB templates with shared layouts for maintainable HTML emails
deliver_laterwith Sidekiq for non-blocking background sending- Interceptors for staging safety and development convenience
- Stripe webhooks with signature verification
- Testing with Action Mailer test helpers and integration tests
- 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.