Developer Tutorial

How to Send Email with Ruby: Net::SMTP, Action Mailer, and the API Approach

Covers the standard library's Net::SMTP for scripts, Action Mailer for Rails apps, and a REST API for production services that need delivery webhooks and template management.

9 min read·Updated March 2026

Ruby's expressive syntax and Rails' convention-over-configuration make sending email deceptively easy to start — and surprisingly tricky to scale. This guide covers every real approach: Net::SMTP for plain scripts, Action Mailer for Rails apps, and a direct REST API call for production services where delivery tracking and bounce handling actually matter.

1. Net::SMTP vs Action Mailer vs REST API

Three approaches, each suited to a different context. The right one depends on whether you're writing a script, a Rails app, or a production service.

Net::SMTP (standard library)

Connects directly to an SMTP relay. Ships with Ruby, zero external gems required. Good for one-off scripts and admin tools.

+ Zero dependencies, built into Ruby
+ Works with any SMTP server
– Manual MIME construction for HTML email
– No delivery events, bounces, or tracking
– SMTP credentials to manage per environment

Action Mailer (Rails)

The Rails built-in. Uses ERB templates, integrates with Active Job for background delivery, and supports multiple delivery adapters. Great for Rails monoliths.

+ Native Rails integration with Active Job
+ ERB templates with layouts, partials, and helpers
+ Test mode captures emails in memory
– SMTP underneath — same operational issues
– Webhook delivery events require a separate adapter gem

REST API via Net::HTTP (recommended for apps)

POST JSON to an email API. One API key, webhook delivery events, managed deliverability. Works perfectly inside and outside Rails.

+ Webhooks for bounces, spam complaints, opens, clicks
+ No SMTP credentials to rotate per environment
+ Deliverability managed by provider (warm IPs, feedback loops)
+ Works in Rails, Sinatra, plain scripts, and background jobs

2. Sending email with Net::SMTP

Ruby's standard library approach. Net::SMTP handles the protocol; you compose the message as a raw string:

require 'net/smtp'

def send_email(to:, subject:, body:)
  from    = ENV.fetch('EMAIL_FROM')
  host    = ENV.fetch('SMTP_HOST')
  port    = ENV.fetch('SMTP_PORT', '587').to_i
  user    = ENV.fetch('SMTP_USER')
  pass    = ENV.fetch('SMTP_PASSWORD')

  message = <<~MESSAGE
    From: #{from}
    To: #{to}
    Subject: #{subject}
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8

    #{body}
  MESSAGE

  Net::SMTP.start(host, port, 'localhost', user, pass, :login) do |smtp|
    smtp.send_message message, from, to
  end
end

send_email(
  to:      '[email protected]',
  subject: 'Your order shipped',
  body:    "Hi, your order #1234 has shipped.\nTrack it at https://example.com/track/1234"
)

Gotchas with Net::SMTP in production:

  • TLS on port 587 uses STARTTLS (upgrade after connect). Port 465 uses SMTPS (TLS from the start). Use :plain or :login auth — :cram_md5 is rarely supported.
  • Building multipart HTML email manually requires correct Content-Type: multipart/alternative boundaries. One malformed boundary and the entire message is rejected. Use Action Mailer or the REST API instead.
  • Net::SMTP.start opens a new connection per call. For bulk sends, instantiate a Net::SMTP object directly and reuse it.

3. Action Mailer with Rails

Action Mailer is the Rails standard. Configure SMTP in config/environments/production.rb:

# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address:              ENV['SMTP_HOST'],
  port:                 587,
  user_name:            ENV['SMTP_USER'],
  password:             ENV['SMTP_PASSWORD'],
  authentication:       :login,
  enable_starttls_auto: true
}

Generate a mailer class and its template:

rails generate mailer UserMailer welcome_email password_reset
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  default from: ENV['EMAIL_FROM']

  def welcome_email(user)
    @user = user
    @login_url = 'https://app.example.com/login'
    mail(to: @user.email, subject: "Welcome to #{Rails.application.class.module_parent_name}")
  end

  def password_reset(user, token)
    @user = user
    @reset_url = "https://app.example.com/password/reset/#{token}"
    mail(to: @user.email, subject: 'Reset your password')
  end
end
# app/views/user_mailer/welcome_email.html.erb
<!DOCTYPE html>
<html>
<body>
  <h1>Welcome, <%= @user.first_name %>!</h1>
  <p>Your account is ready. <a href="<%= @login_url %>">Sign in</a></p>
</body>
</html>

# app/views/user_mailer/welcome_email.text.erb
Welcome, <%= @user.first_name %>!

Your account is ready. Sign in at: <%= @login_url %>

Deliver synchronously or in a background job with Active Job:

# In your controller or service
# Synchronous (blocks the request)
UserMailer.welcome_email(@user).deliver_now

# Background (non-blocking, via Active Job + Sidekiq/Solid Queue)
UserMailer.welcome_email(@user).deliver_later

# Scheduled delivery
UserMailer.welcome_email(@user).deliver_later(wait: 5.minutes)

Always use deliver_later in web requests. deliver_now blocks your Puma/Unicorn worker thread for the entire SMTP roundtrip — typically 100–500ms — which tanks throughput under load.

4. Sending email via REST API

No gems required — Ruby's standard Net::HTTP handles JSON POST requests. This works everywhere: Rails, Sinatra, Hanami, plain scripts:

require 'net/http'
require 'json'
require 'uri'

module EmailService
  API_URL = URI.parse('https://api.tinysend.co/v1/emails').freeze

  def self.send(to:, subject:, html:, text:)
    http = Net::HTTP.new(API_URL.host, API_URL.port)
    http.use_ssl = true
    http.open_timeout = 5
    http.read_timeout = 10

    request = Net::HTTP::Post.new(API_URL.path, {
      'Content-Type'  => 'application/json',
      'Authorization' => "Bearer #{ENV.fetch('TINYSEND_API_KEY')}"
    })
    request.body = JSON.generate({
      from:    ENV.fetch('EMAIL_FROM'),
      to:      to,
      subject: subject,
      html:    html,
      text:    text
    })

    response = http.request(request)

    unless response.is_a?(Net::HTTPSuccess)
      raise "Email API error #{response.code}: #{response.body}"
    end

    JSON.parse(response.body)
  end
end

# Usage
result = EmailService.send(
  to:      '[email protected]',
  subject: 'Your account is ready',
  html:    '<p>Hi Alice, <a href="https://app.example.com">sign in</a></p>',
  text:    'Hi Alice, sign in at https://app.example.com'
)
puts result['id']  # email ID for logging

For idempotent sends (password resets, receipts), pass an Idempotency-Key header. If the request times out and you retry, the API returns the original send instead of sending twice:

request['Idempotency-Key'] = "password-reset-#{user_id}-#{token}"

If you prefer a gem for cleaner syntax, Faraday or HTTParty work well. But for production services, the standard library is fine — one fewer dependency to update and audit.

5. HTML templates with ERB

Outside of Rails, render ERB templates manually and pass the output to the REST API:

require 'erb'
require 'ostruct'

WELCOME_HTML = <<~ERB
  <!DOCTYPE html>
  <html>
  <body style="font-family: sans-serif; color: #333;">
    <h1>Welcome, <%= first_name %>!</h1>
    <p>Your account is ready. <a href="<%= app_url %>" style="color: #6366f1;">Get started</a></p>
    <p style="font-size: 12px; color: #999;">If you didn't create an account, ignore this email.</p>
  </body>
  </html>
ERB

WELCOME_TEXT = <<~ERB
  Welcome, <%= first_name %>!

  Your account is ready. Sign in at: <%= app_url %>
ERB

def render_template(template, vars)
  context = OpenStruct.new(vars).instance_eval { binding }
  ERB.new(template).result(context)
end

html = render_template(WELCOME_HTML, first_name: 'Alice', app_url: 'https://app.example.com')
text = render_template(WELCOME_TEXT, first_name: 'Alice', app_url: 'https://app.example.com')

EmailService.send(
  to:      '[email protected]',
  subject: 'Your account is ready',
  html:    html,
  text:    text
)

Pre-parse templates once if your app sends at volume — ERB.new compiles the template to Ruby code each time it's called. Store the compiled ERB object in a constant and call .result on it per send. For editable templates without redeployment, store them in your email platform and pass variables at send time.

6. Error handling and retries

Network errors and 5xx API responses are transient — retry with backoff. 4xx errors (bad payload, invalid address) are permanent — fix the data, don't retry:

module EmailService
  class APIError < StandardError
    attr_reader :status_code, :retryable

    def initialize(msg, status_code:)
      super(msg)
      @status_code = status_code
      @retryable = status_code.to_s.start_with?('5')
    end
  end

  def self.send_with_retry(to:, subject:, html:, text:, max_retries: 3)
    retries = 0
    begin
      send(to: to, subject: subject, html: html, text: text)
    rescue APIError => e
      raise unless e.retryable
      raise if retries >= max_retries

      retries += 1
      sleep(2**retries)  # 2s, 4s, 8s
      retry
    rescue Net::OpenTimeout, Net::ReadTimeout => e
      raise if retries >= max_retries

      retries += 1
      sleep(2**retries)
      retry
    end
  end

  private_class_method def self.send(to:, subject:, html:, text:)
    # ... same as above, but raise APIError on non-2xx ...
    response = http.request(request)
    unless response.is_a?(Net::HTTPSuccess)
      raise APIError.new("Email API error #{response.code}: #{response.body}", status_code: response.code)
    end
    JSON.parse(response.body)
  end
end

In Sidekiq or Solid Queue, let the job framework handle retries instead of reimplementing it — configure retry: 3 on the job class and raise on failure. The exponential backoff pattern above is for synchronous contexts (scripts, webhooks) where you control the retry loop directly.

7. Deliverability checklist before you launch

SPF, DKIM, and DMARC are configured
Without these DNS records, Gmail and Outlook will reject or spam-folder your email. See the complete DKIM/SPF/DMARC guide.
API key is in environment variables, not committed to Git
Use git log -S TINYSEND_API_KEY to verify it's never appeared in your history before pushing to a public repo.
Bounce webhook handler is live before you send at volume
Hard bounces must be suppressed immediately. A bounce rate above 2% will get your sending account suspended by most providers.
Always include a plain-text body alongside HTML
HTML-only emails score worse in spam filters. Action Mailer sends both if you provide a .text.erb alongside your .html.erb.
Use deliver_later in Rails (not deliver_now) for web requests
Synchronous delivery blocks Puma workers. With Active Job + Solid Queue or Sidekiq, deliver_later is fire-and-forget from the request thread.

Action Mailer vs REST API for Rails apps

Action Mailer is idiomatic Rails and works well when you just need to send — it's hard to argue against it for a standard Rails app. The tradeoff is that SMTP delivery doesn't give you webhooks: you can't know when an email bounced or was marked as spam without adding a separate gem. A REST API gives you that data natively. See our SMTP vs email API comparison for more.

Send your first email from Ruby in 2 minutes

tinysend gives you a clean REST API, templates with variables, bounce handling, and BYOK — so you can use your existing SES or Postmark account as the backend. Works with Rails, Sinatra, and plain Ruby scripts.

Get your API key →