Back to Blog

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

14 min read

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:

  1. Action Mailer with a custom delivery method - keeps your existing mailer classes and views, swaps the transport layer
  2. 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

Gemfile
gem "httparty"
Gemfile
gem "resend"
Gemfile
gem "sendgrid-ruby"
bundle install

Add your API key to credentials or environment:

.env
SEQUENZY_API_KEY=sq_your_api_key_here
.env
RESEND_API_KEY=re_your_api_key_here
.env
SENDGRID_API_KEY=SG.your_api_key_here

Custom Action Mailer Delivery Method

The cleanest integration. Create a custom delivery method so all your existing mailers use the API provider automatically.

lib/sequenzy_delivery_method.rb
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
end
lib/resend_delivery_method.rb
class 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
end
lib/sendgrid_delivery_method.rb
class 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
end

Configure in your environment:

config/environments/production.rb
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"]
}
config/environments/production.rb
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"]
}
config/environments/production.rb
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    # synchronous

Direct API Calls (Skip Action Mailer)

If you want to call the API directly without Action Mailer, create a service object:

app/services/email_service.rb
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
end
app/services/email_service.rb
class 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
end
app/services/email_service.rb
class 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
end

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

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

Queue 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_later

Common SaaS Email Patterns

Password Reset

app/services/password_reset_service.rb
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
end
app/services/password_reset_service.rb
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",
    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
end
app/services/password_reset_service.rb
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",
    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
end

Stripe Webhook

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"]

    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
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"]

    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
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"]

    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
end

Going 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 = true

4. 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
end

Visit /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:

  1. Action Mailer with custom delivery methods for seamless provider integration
  2. Direct API calls with service objects
  3. Background jobs with Sidekiq and Active Job
  4. ERB templates with Action Mailer views
  5. Common SaaS patterns: password reset, Stripe webhooks
  6. 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.