Send email from Rails

You probably already have mailers and .html.erb views, so the honest integration replaces the delivery method and nothing else. This page gives you the delivery class, the initializer that registers it, and a mailer that goes out over HTTP instead of SMTP.

Setup

  1. 01

    Create a key

    https://emails.sh/dashboard issues a key starting with esh_.

  2. 02

    Store the key in credentials

    bin/rails credentials:edit and add emailssh: api_key: esh_... Rails encrypts it into config/credentials.yml.enc, which is safe to commit. ENV works too if your host injects it.

  3. 03

    Register the delivery method

    config/initializers/emailssh.rb below adds :emailssh to ActionMailer. Existing mailers keep their views and their deliver_later calls.

  4. 04

    Point the environment at it

    config.action_mailer.delivery_method = :emailssh in config/environments/production.rb, and :test in test so your specs never send.

Install
gem install emailssh
config/environments/production.rb
Rails.application.configure do
  config.action_mailer.delivery_method = :emailssh
  config.action_mailer.perform_deliveries = true

  # Raise instead of swallowing, so a broken key shows up in your error tracker
  # rather than as mail that quietly never arrives.
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.default_url_options = { host: 'acme.com', protocol: 'https' }
end

The delivery method

lib/emailssh/delivery_method.rb (Rails 7.1 or 8)

The delivery method
require 'net/http'
require 'json'
require 'uri'

module Emailssh
  # ActionMailer calls #deliver! with a Mail::Message. Everything ActionMailer
  # already does (views, layouts, attachments, multipart) happens before this,
  # so all that is left is turning the message into one HTTP request.
  class DeliveryMethod
    ENDPOINT = URI('https://emails.sh/v1/emails').freeze

    attr_accessor :settings

    def initialize(settings = {})
      self.settings = settings
    end

    def deliver!(message)
      payload = {
        from: message[:from].to_s,
        to: Array(message.to),
        subject: message.subject,
        html: html_part(message),
        text: text_part(message),
        cc: Array(message.cc),
        bcc: Array(message.bcc),
        reply_to: message[:reply_to]&.to_s
      }.compact

      request = Net::HTTP::Post.new(ENDPOINT)
      request['Authorization'] = "Bearer #{settings.fetch(:api_key)}"
      request['Content-Type'] = 'application/json'
      request.body = JSON.generate(payload)

      response = Net::HTTP.start(ENDPOINT.hostname, ENDPOINT.port, use_ssl: true, read_timeout: 15) do |http|
        http.request(request)
      end

      unless response.is_a?(Net::HTTPSuccess)
        raise "emails.sh returned #{response.code}: #{response.body}"
      end

      JSON.parse(response.body)
    end

    private

    def html_part(message)
      return message.html_part.body.decoded if message.html_part
      message.content_type&.start_with?('text/html') ? message.body.decoded : nil
    end

    def text_part(message)
      return message.text_part.body.decoded if message.text_part
      message.mime_type == 'text/plain' ? message.body.decoded : nil
    end
  end
end

Registering it

config/initializers/emailssh.rb

Registering it
require Rails.root.join('lib/emailssh/delivery_method')

ActionMailer::Base.add_delivery_method(
  :emailssh,
  Emailssh::DeliveryMethod,
  api_key: Rails.application.credentials.dig(:emailssh, :api_key) || ENV['EMAILSSH_API_KEY']
)

A mailer that uses it

app/mailers/user_mailer.rb, unchanged from any other Rails app

A mailer that uses it
class UserMailer < ApplicationMailer
  default from: 'Acme <hello@acme.com>'

  def welcome(user)
    @user = user
    @login_url = login_url

    mail(to: @user.email, subject: 'Welcome to Acme')
  end
end

# Call it from the controller. deliver_later hands it to Active Job so the HTTP
# request to emails.sh does not happen inside the user's request.
#   UserMailer.welcome(user).deliver_later

Worth knowing

01

deliver_now blocks the request

deliver_now makes an HTTP call inline, so a slow response is a slow signup. Use deliver_later with Active Job on Solid Queue, Sidekiq, or GoodJob, and make sure a worker is actually running.

02

Set delivery_method to :test in test

Otherwise your suite sends real mail. config.action_mailer.delivery_method = :test in config/environments/test.rb, and assert on ActionMailer::Base.deliveries.

03

The from address must be a verified domain

default from: in the mailer sets the envelope, and if that domain is not verified on your workspace the API returns 422 invalid_from_domain. Use onboarding@emails.sh until verification finishes.

04

Credentials over ENV where you can

config/credentials.yml.enc is encrypted with the master key, so the API key is not in plaintext on disk or in your shell history. ENV is fine when the host injects it, which is why the initializer falls back.

Questions

How do I use emails.sh with ActionMailer?

Add a custom delivery method, as above. Your mailers, views, layouts, and previews all keep working, only the transport changes.

Do I have to replace ActionMailer?

No, and you should not. Rails already renders multipart mail from ERB. Replacing only deliver! keeps everything else you have.

Can I skip ActionMailer and call the API directly?

Yes, if you have no existing mailers. Net::HTTP or Faraday against https://emails.sh/v1/emails with a Bearer token is the whole thing.

How do I handle bounces in Rails?

Subscribe to the webhook at https://emails.sh/dashboard/webhooks and add a controller action for email.bounced and email.complained that marks the address unsendable.

The rest of the API

POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.