unsent
unsent.dev
Guides

Ruby on Rails

Complete guide to integrating Unsent with Ruby on Rails for transactional and marketing emails

This guide shows you how to integrate Unsent into your Ruby on Rails application for sending transactional emails, managing contacts, and running email campaigns.

Prerequisites

Ruby 2.7+: Rails requires Ruby 2.7 or higher

Rails 6.0+: This guide assumes Rails 6.0 or newer

Unsent API Key: Generate one in your Unsent dashboard

Verified Domain: Set up a domain in the Domains section

Installation

Add the Unsent gem to your Gemfile:

Gemfile
gem 'unsent'

Then run:

bundle install

Configuration

Environment Variables

Add your Unsent credentials to your Rails credentials or environment variables:

.env
UNSENT_API_KEY=un_...
UNSENT_FROM_EMAIL=noreply@yourdomain.com

Never commit your .env file to version control. Add it to .gitignore and use Rails credentials or environment variables in production.

Rails Initializer

Create an initializer to set up the Unsent client as a singleton:

config/initializers/unsent.rb
require 'unsent'

module UnsentClient
  def self.instance
    @instance ||= Unsent::Client.new(
      Rails.application.credentials.dig(:unsent, :api_key) || ENV['UNSENT_API_KEY']
    )
  end
end

Now you can access the client anywhere in your Rails app:

UnsentClient.instance.emails.send(...)

Quick Start

Sending a Simple Email

Send a transactional email from a controller:

app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    
    if @user.save
      # Send welcome email
      data, error = UnsentClient.instance.emails.send({
        to: @user.email,
        from: ENV['UNSENT_FROM_EMAIL'],
        subject: 'Welcome to Our App!',
        html: "<h1>Welcome, #{@user.name}!</h1><p>Thanks for joining us.</p>",
        text: "Welcome, #{@user.name}! Thanks for joining us."
      })
      
      if error
        Rails.logger.error "Failed to send welcome email: #{error}"
      else
        Rails.logger.info "Welcome email sent: #{data['id']}"
      end
      
      redirect_to @user, notice: 'Account created successfully!'
    else
      render :new
    end
  end
end

Email Service Object Pattern

Create a dedicated service object for better organization:

app/services/email_service.rb
class EmailService
  def self.send_welcome_email(user)
    data, error = UnsentClient.instance.emails.send({
      to: user.email,
      from: ENV['UNSENT_FROM_EMAIL'],
      subject: 'Welcome to Our App!',
      html: ApplicationController.render(
        template: 'user_mailer/welcome',
        locals: { user: user }
      )
    })
    
    if error
      Rails.logger.error "Failed to send welcome email to #{user.email}: #{error}"
      raise "Email sending failed: #{error['message']}"
    end
    
    data
  end
  
  def self.send_password_reset(user, token)
    reset_url = Rails.application.routes.url_helpers.edit_password_reset_url(
      token,
      host: ENV['APP_HOST']
    )
    
    data, error = UnsentClient.instance.emails.send({
      to: user.email,
      from: ENV['UNSENT_FROM_EMAIL'],
      subject: 'Reset Your Password',
      html: ApplicationController.render(
        template: 'user_mailer/password_reset',
        locals: { user: user, reset_url: reset_url }
      )
    })
    
    handle_email_result(data, error, user.email)
  end
  
  private
  
  def self.handle_email_result(data, error, email)
    if error
      Rails.logger.error "Failed to send email to #{email}: #{error}"
      raise "Email sending failed"
    end
    data
  end
end

Use in your controller:

app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    
    if @user.save
      EmailService.send_welcome_email(@user)
      redirect_to @user, notice: 'Account created!'
    else
      render :new
    end
  end
end

Using Rails Views for Email Templates

Leverage Rails view templates for your emails:

Create Email Templates

app/views/user_mailer/welcome.html.erb
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
    .container { max-width: 600px; margin: 0 auto; padding: 20px; }
    .header { background-color: #0070f3; color: white; padding: 20px; text-align: center; }
    .content { padding: 20px; background-color: #f9f9f9; }
    .button { display: inline-block; padding: 12px 24px; background-color: #0070f3; color: white; text-decoration: none; border-radius: 6px; }
  </style>
</head>
<body>
  <div class="container">
    <div class="header">
      <h1>Welcome to <%= ENV['APP_NAME'] %>!</h1>
    </div>
    <div class="content">
      <p>Hi <%= user.name %>,</p>
      <p>Thanks for joining us! We're excited to have you on board.</p>
      <p>Here's what you can do next:</p>
      <ul>
        <li>Complete your profile</li>
        <li>Explore our features</li>
        <li>Connect with other users</li>
      </ul>
      <p style="text-align: center; margin-top: 30px;">
        <a href="<%= root_url %>" class="button">Get Started</a>
      </p>
      <p>If you have any questions, just reply to this email.</p>
      <p>Best regards,<br>The <%= ENV['APP_NAME'] %> Team</p>
    </div>
  </div>
</body>
</html>

Render Templates in Service

app/services/email_service.rb
class EmailService
  def self.send_welcome_email(user)
    html = ApplicationController.render(
      template: 'user_mailer/welcome',
      layout: false,
      locals: { user: user }
    )
    
    text = "Hi #{user.name},\n\nWelcome to #{ENV['APP_NAME']}! Thanks for joining us.\n\nBest regards,\nThe Team"
    
    data, error = UnsentClient.instance.emails.send({
      to: user.email,
      from: ENV['UNSENT_FROM_EMAIL'],
      subject: "Welcome to #{ENV['APP_NAME']}!",
      html: html,
      text: text
    })
    
    raise "Email failed: #{error}" if error
    data
  end
end

Background Jobs

Process emails asynchronously using ActiveJob:

Create Email Job

app/jobs/send_email_job.rb
class SendEmailJob < ApplicationJob
  queue_as :default
  
  retry_on StandardError, wait: :polynomially_longer, attempts: 3
  
  def perform(email_type, *args)
    case email_type
    when 'welcome'
      user = User.find(args[0])
      EmailService.send_welcome_email(user)
    when 'password_reset'
      user = User.find(args[0])
      token = args[1]
      EmailService.send_password_reset(user, token)
    else
      raise ArgumentError, "Unknown email type: #{email_type}"
    end
  end
end

Enqueue Jobs

app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    
    if @user.save
      # Send email asynchronously
      SendEmailJob.perform_later('welcome', @user.id)
      redirect_to @user, notice: 'Account created!'
    else
      render :new
    end
  end
end

With Sidekiq

If using Sidekiq, create a dedicated worker:

app/workers/welcome_email_worker.rb
class WelcomeEmailWorker
  include Sidekiq::Worker
  sidekiq_options retry: 3, queue: 'mailers'
  
  def perform(user_id)
    user = User.find(user_id)
    EmailService.send_welcome_email(user)
  end
end

Enqueue the worker:

WelcomeEmailWorker.perform_async(@user.id)

Email with Attachments

Send emails with file attachments:

app/services/invoice_service.rb
class InvoiceService
  def self.send_invoice(user, invoice)
    # Generate PDF (using Prawn, WickedPDF, etc.)
    pdf_content = generate_invoice_pdf(invoice)
    
    data, error = UnsentClient.instance.emails.send({
      to: user.email,
      from: ENV['UNSENT_FROM_EMAIL'],
      subject: "Invoice ##{invoice.number}",
      html: "<p>Please find your invoice attached.</p>",
      text: "Please find your invoice attached.",
      attachments: [
        {
          filename: "invoice_#{invoice.number}.pdf",
          content: Base64.strict_encode64(pdf_content),
          contentType: 'application/pdf'
        }
      ]
    })
    
    raise "Email failed: #{error}" if error
    data
  end
  
  private
  
  def self.generate_invoice_pdf(invoice)
    # Your PDF generation logic here
    # Example with Prawn:
    # require 'prawn'
    # Prawn::Document.new do |pdf|
    #   pdf.text "Invoice ##{invoice.number}"
    #   # ... more content
    # end.render
  end
end

Scheduled Emails

Schedule emails for future delivery:

app/services/reminder_service.rb
class ReminderService
  def self.schedule_reminder(user, event)
    # Schedule for 1 day before event
    scheduled_time = event.start_time - 1.day
    
    data, error = UnsentClient.instance.emails.send({
      to: user.email,
      from: ENV['UNSENT_FROM_EMAIL'],
      subject: "Reminder: #{event.title} tomorrow",
      html: "<p>Don't forget about #{event.title} tomorrow at #{event.start_time.strftime('%I:%M %p')}</p>",
      scheduledAt: scheduled_time.iso8601
    })
    
    if error
      Rails.logger.error "Failed to schedule reminder: #{error}"
      return nil
    end
    
    # Save email ID to cancel later if needed
    event.update(reminder_email_id: data['id'])
    data
  end
  
  def self.cancel_reminder(event)
    return unless event.reminder_email_id
    
    data, error = UnsentClient.instance.emails.cancel(event.reminder_email_id)
    
    if error
      Rails.logger.error "Failed to cancel reminder: #{error}"
    else
      event.update(reminder_email_id: nil)
    end
  end
end

Contact Management

Sync your Rails users with Unsent contact books:

app/models/concerns/unsent_syncable.rb
module UnsentSyncable
  extend ActiveSupport::Concern
  
  included do
    after_create :sync_to_unsent
    after_update :update_unsent_contact, if: :should_sync_to_unsent?
  end
  
  def sync_to_unsent
    return unless email.present?
    
    data, error = UnsentClient.instance.contacts.create(
      ENV['UNSENT_CONTACT_BOOK_ID'],
      {
        email: email,
        firstName: first_name,
        lastName: last_name,
        subscribed: subscribed_to_marketing?,
        metadata: {
          user_id: id.to_s,
          plan: subscription_plan,
          created_at: created_at.iso8601
        }
      }
    )
    
    if error
      Rails.logger.error "Failed to sync user #{id} to Unsent: #{error}"
    else
      update_column(:unsent_contact_id, data['id'])
    end
  end
  
  def update_unsent_contact
    return unless unsent_contact_id && email.present?
    
    data, error = UnsentClient.instance.contacts.update(
      ENV['UNSENT_CONTACT_BOOK_ID'],
      unsent_contact_id,
      {
        email: email,
        firstName: first_name,
        lastName: last_name,
        subscribed: subscribed_to_marketing?,
        metadata: {
          plan: subscription_plan,
          updated_at: updated_at.iso8601
        }
      }
    )
    
    Rails.logger.error "Failed to update Unsent contact: #{error}" if error
  end
  
  private
  
  def should_sync_to_unsent?
    saved_change_to_email? || saved_change_to_first_name? || 
    saved_change_to_last_name? || saved_change_to_subscription_plan?
  end
end

Include in your User model:

app/models/user.rb
class User < ApplicationRecord
  include UnsentSyncable
  
  # ... rest of your model
end

Campaigns

Send email campaigns to user segments:

app/services/campaign_service.rb
class CampaignService
  def self.create_newsletter(title, html_content)
    data, error = UnsentClient.instance.campaigns.create({
      name: title,
      subject: title,
      html: html_content,
      from: ENV['UNSENT_FROM_EMAIL'],
      contactBookId: ENV['UNSENT_CONTACT_BOOK_ID'],
      replyTo: ENV['UNSENT_REPLY_TO_EMAIL']
    })
    
    raise "Failed to create campaign: #{error}" if error
    data
  end
  
  def self.schedule_campaign(campaign_id, send_at)
    data, error = UnsentClient.instance.campaigns.schedule(
      campaign_id,
      { scheduledAt: send_at.iso8601 }
    )
    
    raise "Failed to schedule campaign: #{error}" if error
    data
  end
end

Webhooks

Handle Unsent webhook events in your Rails app:

Create Webhook Controller

app/controllers/webhooks/unsent_controller.rb
module Webhooks
  class UnsentController < ApplicationController
    skip_before_action :verify_authenticity_token
    
    def create
      event = params[:type]
      data = params[:data]
      
      case event
      when 'email.delivered'
        handle_email_delivered(data)
      when 'email.bounced'
        handle_email_bounced(data)
      when 'email.opened'
        handle_email_opened(data)
      when 'email.clicked'
        handle_email_clicked(data)
      when 'email.complained'
        handle_email_complained(data)
      else
        Rails.logger.info "Unhandled webhook event: #{event}"
      end
      
      head :ok
    rescue => e
      Rails.logger.error "Webhook error: #{e.message}"
      head :unprocessable_entity
    end
    
    private
    
    def handle_email_delivered(data)
      Rails.logger.info "Email #{data['emailId']} delivered to #{data['recipient']}"
      
      # Update your records
      # EmailLog.find_by(unsent_id: data['emailId'])&.update(status: 'delivered')
    end
    
    def handle_email_bounced(data)
      Rails.logger.warn "Email #{data['emailId']} bounced: #{data['bounceType']}"
      
      # Handle bounces
      user = User.find_by(email: data['recipient'])
      user&.update(email_bounced: true, bounce_reason: data['bounceType'])
    end
    
    def handle_email_opened(data)
      Rails.logger.info "Email #{data['emailId']} opened by #{data['recipient']}"
      
      # Track opens
      # EmailLog.find_by(unsent_id: data['emailId'])&.increment!(:open_count)
    end
    
    def handle_email_clicked(data)
      Rails.logger.info "Link clicked in email #{data['emailId']}: #{data['link']}"
      
      # Track clicks
      # EmailLog.find_by(unsent_id: data['emailId'])&.increment!(:click_count)
    end
    
    def handle_email_complained(data)
      Rails.logger.warn "Spam complaint for email #{data['emailId']}"
      
      # Unsubscribe user
      user = User.find_by(email: data['recipient'])
      user&.update(unsubscribed: true, unsubscribed_at: Time.current)
    end
  end
end

Add Route

config/routes.rb
Rails.application.routes.draw do
  namespace :webhooks do
    post 'unsent', to: 'unsent#create'
  end
end

Register Webhook

Create a Rake task to register your webhook:

lib/tasks/unsent.rake
namespace :unsent do
  desc 'Register webhook endpoint'
  task register_webhook: :environment do
    webhook_url = "#{ENV['APP_HOST']}/webhooks/unsent"
    
    data, error = UnsentClient.instance.webhooks.create({
      url: webhook_url,
      events: [
        'email.delivered',
        'email.bounced',
        'email.opened',
        'email.clicked',
        'email.complained'
      ]
    })
    
    if error
      puts "Failed to register webhook: #{error}"
    else
      puts "Webhook registered successfully: #{data['id']}"
      puts "URL: #{webhook_url}"
    end
  end
end

Run the task:

rails unsent:register_webhook

Testing

RSpec Examples

spec/services/email_service_spec.rb
require 'rails_helper'

RSpec.describe EmailService do
  let(:user) { create(:user, email: 'test@example.com', name: 'Test User') }
  let(:client) { instance_double(Unsent::Client) }
  let(:emails) { instance_double(Unsent::Emails) }
  
  before do
    allow(UnsentClient).to receive(:instance).and_return(client)
    allow(client).to receive(:emails).and_return(emails)
  end
  
  describe '.send_welcome_email' do
    context 'when email sends successfully' do
      before do
        allow(emails).to receive(:send).and_return([{ 'id' => 'email_123' }, nil])
      end
      
      it 'sends welcome email' do
        result = EmailService.send_welcome_email(user)
        
        expect(emails).to have_received(:send).with(hash_including(
          to: user.email,
          subject: anything
        ))
        expect(result['id']).to eq('email_123')
      end
    end
    
    context 'when email fails' do
      before do
        allow(emails).to receive(:send).and_return([nil, { 'message' => 'API Error' }])
      end
      
      it 'raises an error' do
        expect {
          EmailService.send_welcome_email(user)
        }.to raise_error(/Email sending failed/)
      end
    end
  end
end

MiniTest Examples

test/services/email_service_test.rb
require 'test_helper'

class EmailServiceTest < ActiveSupport::TestCase
  setup do
    @user = users(:one)
    @client = Minitest::Mock.new
    @emails = Minitest::Mock.new
    
    UnsentClient.stub :instance, @client do
      @client.expect :emails, @emails
    end
  end
  
  test 'sends welcome email successfully' do
    @emails.expect :send, [{ 'id' => 'email_123' }, nil], [Hash]
    
    UnsentClient.stub :instance, @client do
      @client.stub :emails, @emails do
        result = EmailService.send_welcome_email(@user)
        assert_equal 'email_123', result['id']
      end
    end
    
    @emails.verify
  end
end

Error Handling

Create a comprehensive error handling wrapper:

app/services/unsent_service.rb
class UnsentService
  class EmailError < StandardError; end
  
  def self.send_email(params, options = {})
    data, error = UnsentClient.instance.emails.send(params, options)
    
    if error
      handle_error(error, params[:to])
      raise EmailError, error['message']
    end
    
    data
  rescue Unsent::HTTPError => e
    Rails.logger.error "Unsent HTTP Error (#{e.status_code}): #{e.error}"
    raise EmailError, "Failed to send email: #{e.error['message']}"
  rescue => e
    Rails.logger.error "Unexpected error sending email: #{e.message}"
    raise EmailError, "Unexpected error: #{e.message}"
  end
  
  private
  
  def self.handle_error(error, recipient)
    case error['code']
    when 'RATE_LIMIT_EXCEEDED'
      Rails.logger.warn "Rate limit exceeded for #{recipient}"
    when 'DOMAIN_NOT_VERIFIED'
      Rails.logger.error "Domain not verified for #{recipient}"
    when 'INVALID_EMAIL'
      Rails.logger.warn "Invalid email address: #{recipient}"
    else
      Rails.logger.error "Email error for #{recipient}: #{error}"
    end
  end
end

Best Practices

1. Use Environment-Specific Configuration

config/initializers/unsent.rb
module UnsentClient
  def self.instance
    @instance ||= Unsent::Client.new(
      api_key,
      base_url: base_url
    )
  end
  
  def self.api_key
    Rails.application.credentials.dig(:unsent, :api_key) || ENV['UNSENT_API_KEY']
  end
  
  def self.base_url
    # Use local API in development if available
    if Rails.env.development? && ENV['UNSENT_LOCAL_API']
      'http://localhost:3004'
    else
      nil # Use default
    end
  end
end

2. Log Email Activity

Create an EmailLog model to track sent emails:

app/models/email_log.rb
class EmailLog < ApplicationRecord
  belongs_to :user, optional: true
  
  validates :unsent_id, :recipient, :subject, presence: true
  
  enum status: {
    pending: 0,
    sent: 1,
    delivered: 2,
    bounced: 3,
    complained: 4
  }
end

3. Graceful Degradation

Don't let email failures break your app:

def create
  @user = User.new(user_params)
  
  if @user.save
    begin
      SendEmailJob.perform_later('welcome', @user.id)
    rescue => e
      Rails.logger.error "Failed to queue welcome email: #{e.message}"
      # Continue anyway - user account was created successfully
    end
    
    redirect_to @user, notice: 'Account created!'
  else
    render :new
  end
end

Next Steps

Consider using ActionMailer with a custom delivery method that uses Unsent, giving you the best of both worlds: Rails conventions with Unsent's powerful API.