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

Rails has Action Mailer built in. It's one of the best email abstractions in any framework. But it defaults to SMTP, and most tutorials stop at configuring Gmail credentials. That's fine for testing. It's not fine for production.
This guide covers how to send emails from Rails using API-based providers that handle deliverability, retries, and bounce processing. You'll get working examples for Action Mailer integration, direct API calls, background jobs, and production deployment.
Action Mailer vs Direct API Calls
Rails gives you two paths:
- Action Mailer with a custom delivery method - keeps your existing mailer classes and views, swaps the transport layer
- Direct API calls - bypass Action Mailer entirely, make HTTP calls to your provider
Action Mailer is the Rails way. It gives you mailer classes, view templates, previews, and interceptors. Use it unless you have a specific reason not to.
Pick a Provider
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management from one API. Has native Stripe integration. If you're building a SaaS product, this keeps everything in one place.
- Resend is developer-friendly with a clean API. Good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise option. Feature-rich, sometimes complex. Good for high volume.
Install
gem "httparty"gem "resend"gem "sendgrid-ruby"bundle installAdd your API key to credentials or environment:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereCustom Action Mailer Delivery Method
The cleanest integration. Create a custom delivery method so all your existing mailers use the API provider automatically.
class SequenzyDeliveryMethod
attr_accessor :settings
def initialize(settings)
@settings = settings
end
def deliver!(mail)
HTTParty.post(
"https://api.sequenzy.com/v1/transactional/send",
headers: {
"Authorization" => "Bearer #{settings[:api_key]}",
"Content-Type" => "application/json"
},
body: {
to: mail.to.first,
subject: mail.subject,
body: mail.html_part&.body&.to_s || mail.body.to_s
}.to_json
)
end
endclass ResendDeliveryMethod
attr_accessor :settings
def initialize(settings)
@settings = settings
Resend.api_key = settings[:api_key]
end
def deliver!(mail)
Resend::Emails.send({
from: mail.from.first,
to: mail.to.first,
subject: mail.subject,
html: mail.html_part&.body&.to_s || mail.body.to_s
})
end
endclass SendgridDeliveryMethod
attr_accessor :settings
def initialize(settings)
@settings = settings
end
def deliver!(mail)
sg = SendGrid::API.new(api_key: settings[:api_key])
from = SendGrid::Email.new(email: mail.from.first)
to = SendGrid::Email.new(email: mail.to.first)
content = SendGrid::Content.new(
type: "text/html",
value: mail.html_part&.body&.to_s || mail.body.to_s
)
mail_obj = SendGrid::Mail.new(from, mail.subject, to, content)
sg.client.mail._("send").post(request_body: mail_obj.to_json)
end
endConfigure in your environment:
require_relative "../../lib/sequenzy_delivery_method"
config.action_mailer.delivery_method = SequenzyDeliveryMethod
config.action_mailer.sequenzy_delivery_method_settings = {
api_key: ENV["SEQUENZY_API_KEY"]
}require_relative "../../lib/resend_delivery_method"
config.action_mailer.delivery_method = ResendDeliveryMethod
config.action_mailer.resend_delivery_method_settings = {
api_key: ENV["RESEND_API_KEY"]
}require_relative "../../lib/sendgrid_delivery_method"
config.action_mailer.delivery_method = SendgridDeliveryMethod
config.action_mailer.sendgrid_delivery_method_settings = {
api_key: ENV["SENDGRID_API_KEY"]
}Now all your mailers work through the API:
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
def welcome(user)
@user = user
mail(to: user.email, subject: "Welcome, #{user.name}")
end
def password_reset(user, token)
@user = user
@reset_url = "#{ENV['APP_URL']}/reset-password?token=#{token}"
mail(to: user.email, subject: "Reset your password")
end
end<!-- app/views/user_mailer/welcome.html.erb -->
<h1>Welcome, <%= @user.name %></h1>
<p>Your account is ready. Click below to get started.</p>
<a href="<%= root_url %>"
style="display:inline-block; background:#f97316; color:#fff; padding:12px 24px; border-radius:6px; text-decoration:none; font-weight:600;">
Go to Dashboard
</a>Send it:
UserMailer.welcome(user).deliver_later # background job
UserMailer.welcome(user).deliver_now # synchronousDirect API Calls (Skip Action Mailer)
If you want to call the API directly without Action Mailer, create a service object:
class EmailService
BASE_URL = "https://api.sequenzy.com/v1"
def self.send_email(to:, subject:, body:)
response = HTTParty.post(
"#{BASE_URL}/transactional/send",
headers: {
"Authorization" => "Bearer #{ENV['SEQUENZY_API_KEY']}",
"Content-Type" => "application/json"
},
body: { to: to, subject: subject, body: body }.to_json
)
raise "Email send failed: #{response.body}" unless response.success?
response.parsed_response
end
endclass EmailService
FROM_EMAIL = "Your App <noreply@yourdomain.com>"
def self.send_email(to:, subject:, html:)
Resend.api_key = ENV["RESEND_API_KEY"]
Resend::Emails.send({
from: FROM_EMAIL,
to: to,
subject: subject,
html: html
})
end
endclass EmailService
FROM_EMAIL = "noreply@yourdomain.com"
def self.send_email(to:, subject:, html:)
sg = SendGrid::API.new(api_key: ENV["SENDGRID_API_KEY"])
from = SendGrid::Email.new(email: FROM_EMAIL)
to_email = SendGrid::Email.new(email: to)
content = SendGrid::Content.new(type: "text/html", value: html)
mail = SendGrid::Mail.new(from, subject, to_email, content)
sg.client.mail._("send").post(request_body: mail.to_json)
end
endUse it in controllers:
# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
def create
@user = User.create!(user_params)
EmailService.send_email(
to: @user.email,
subject: "Welcome, #{@user.name}",
body: render_to_string("user_mailer/welcome", layout: "mailer", locals: { user: @user })
)
redirect_to dashboard_path
end
endBackground Jobs with Sidekiq
Never send emails in the request cycle. Use Sidekiq (or Active Job) to process them in the background.
# Gemfile
gem "sidekiq"# app/jobs/send_email_job.rb
class SendEmailJob < ApplicationJob
queue_as :emails
retry_on StandardError, wait: :polynomially_longer, attempts: 5
def perform(to:, subject:, body:)
EmailService.send_email(to: to, subject: subject, body: body)
end
endQueue from anywhere:
SendEmailJob.perform_later(
to: user.email,
subject: "Welcome, #{user.name}",
body: "<h1>Welcome!</h1><p>Your account is ready.</p>"
)Or use Action Mailer's built-in deliver_later:
# This uses Active Job under the hood
UserMailer.welcome(user).deliver_laterCommon SaaS Email Patterns
Password Reset
class PasswordResetService
def self.send_reset_email(user, token)
reset_url = "#{ENV['APP_URL']}/reset-password?token=#{token}"
EmailService.send_email(
to: user.email,
subject: "Reset your password",
body: <<~HTML
<h2>Password Reset</h2>
<p>Click 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;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
HTML
)
end
endclass PasswordResetService
def self.send_reset_email(user, token)
reset_url = "#{ENV['APP_URL']}/reset-password?token=#{token}"
EmailService.send_email(
to: user.email,
subject: "Reset your password",
html: <<~HTML
<h2>Password Reset</h2>
<p>Click 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;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
HTML
)
end
endclass PasswordResetService
def self.send_reset_email(user, token)
reset_url = "#{ENV['APP_URL']}/reset-password?token=#{token}"
EmailService.send_email(
to: user.email,
subject: "Reset your password",
html: <<~HTML
<h2>Password Reset</h2>
<p>Click 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;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
HTML
)
end
endStripe Webhook
module Webhooks
class StripeController < ApplicationController
skip_before_action :verify_authenticity_token
def create
payload = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
event = Stripe::Webhook.construct_event(
payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"]
)
case event.type
when "checkout.session.completed"
session = event.data.object
email = session.customer_email
EmailService.send_email(
to: email,
subject: "Payment confirmed",
body: "<h1>Thanks!</h1><p>Your subscription is now active.</p>"
)
# Also add as subscriber for marketing
HTTParty.post(
"https://api.sequenzy.com/v1/subscribers",
headers: {
"Authorization" => "Bearer #{ENV['SEQUENZY_API_KEY']}",
"Content-Type" => "application/json"
},
body: {
email: email,
tags: ["customer", "stripe"]
}.to_json
)
end
head :ok
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"]
event = Stripe::Webhook.construct_event(
payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"]
)
case event.type
when "checkout.session.completed"
session = event.data.object
EmailService.send_email(
to: session.customer_email,
subject: "Payment confirmed",
html: "<h1>Thanks!</h1><p>Your subscription is now active.</p>"
)
end
head :ok
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"]
event = Stripe::Webhook.construct_event(
payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"]
)
case event.type
when "checkout.session.completed"
session = event.data.object
EmailService.send_email(
to: session.customer_email,
subject: "Payment confirmed",
html: "<h1>Thanks!</h1><p>Your subscription is now active.</p>"
)
end
head :ok
end
end
endGoing to Production
1. Verify Your Domain
Add SPF, DKIM, and DMARC DNS records through your provider's dashboard. Without this, emails go to spam.
2. Use a Dedicated Sending Domain
Send from mail.yourapp.com instead of your root domain.
3. Configure Action Mailer Defaults
# config/environments/production.rb
config.action_mailer.default_url_options = { host: "yourapp.com", protocol: "https" }
config.action_mailer.perform_deliveries = true
config.action_mailer.raise_delivery_errors = true4. Use Action Mailer Previews
Rails has built-in email previews. Use them to catch template issues before they reach users:
# test/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
def welcome
UserMailer.welcome(User.first)
end
def password_reset
UserMailer.password_reset(User.first, "fake-token")
end
endVisit /rails/mailers/user_mailer/welcome in development to preview.
Beyond Transactional: Marketing and Automation
Once your Rails app sends transactional emails, you'll want onboarding sequences, marketing campaigns, and lifecycle automation. Most teams wire together Action Mailer with Mailchimp or ConvertKit. Two dashboards, subscriber sync headaches.
Sequenzy handles both from one API. Transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration.
# Add a subscriber when they sign up
HTTParty.post("https://api.sequenzy.com/v1/subscribers", {
headers: { "Authorization" => "Bearer #{ENV['SEQUENZY_API_KEY']}", "Content-Type" => "application/json" },
body: { email: user.email, firstName: user.name, tags: ["signed-up"] }.to_json
})
# Track events to trigger sequences
HTTParty.post("https://api.sequenzy.com/v1/subscribers/events", {
headers: { "Authorization" => "Bearer #{ENV['SEQUENZY_API_KEY']}", "Content-Type" => "application/json" },
body: { email: user.email, event: "onboarding.completed" }.to_json
})Set up sequences in the Sequenzy dashboard, and your Rails app triggers them based on user actions.
Wrapping Up
Here's what we covered:
- Action Mailer with custom delivery methods for seamless provider integration
- Direct API calls with service objects
- Background jobs with Sidekiq and Active Job
- ERB templates with Action Mailer views
- Common SaaS patterns: password reset, Stripe webhooks
- Production checklist: domain verification, mailer config, previews
The code in this guide is production-ready. Pick your provider, wire up the delivery method, and start sending.