Developer Tutorial
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.
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.
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.
Connects directly to an SMTP relay. Ships with Ruby, zero external gems required. Good for one-off scripts and admin tools.
The Rails built-in. Uses ERB templates, integrates with Active Job for background delivery, and supports multiple delivery adapters. Great for Rails monoliths.
POST JSON to an email API. One API key, webhook delivery events, managed deliverability. Works perfectly inside and outside Rails.
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:
:plain or :login auth — :cram_md5 is rarely supported.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. 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.
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.
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.
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.
git log -S TINYSEND_API_KEY to verify it's never appeared in your history before pushing to a public repo..text.erb alongside your .html.erb.deliver_later is fire-and-forget from the request thread.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.
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 →