Ruby and Rails: send transactional email with the Inboxili API

Updated

There is no official Ruby gem for Inboxili. This page shows a small client built on Net::HTTP that you can copy into your project.

Rails apps usually send mail through Action Mailer over SMTP. Inboxili is an HTTPS API, so you call it from a service object instead. Ruby's standard library is enough.

The client

app/services/inboxili.rb (or lib/inboxili.rb):

require "json"
require "net/http"
require "uri"

class Inboxili
  class Error < StandardError
    attr_reader :status, :code

    def initialize(status, code, message)
      super(message)
      @status = status
      @code = code
    end
  end

  class ConnectionError < StandardError; end

  BASE_URL = "https://api.inboxili.com/api/v1".freeze

  def initialize(api_key: ENV.fetch("INBOXILI_API_KEY"), base_url: BASE_URL, timeout: 10, max_retries: 2)
    @api_key = api_key
    @base_url = base_url.chomp("/")
    @timeout = timeout
    @max_retries = max_retries
  end

  # Sends one email. Returns the parsed body, for example {"status" => "sent", "message_id" => "..."}.
  def send_email(to:, from_email:, subject: nil, html_body: nil, text_body: nil, template_id: nil, template_data: nil, from_name: nil)
    raise ArgumentError, "provide html_body or template_id" if html_body.to_s.empty? && template_id.to_s.empty?
    raise ArgumentError, "subject is required without a template" if template_id.to_s.empty? && subject.to_s.empty?

    payload = {
      to: to, from_email: from_email, from_name: from_name, subject: subject,
      html_body: html_body, text_body: text_body, template_id: template_id, template_data: template_data
    }.reject { |_, v| v.nil? }

    attempt = 0
    loop do
      status, body = post("/transactional/send", payload)
      return body if status.between?(200, 299)

      # Only 429 is retried: the request was rejected before sending, so a retry cannot duplicate the email.
      if status == 429 && attempt < @max_retries
        sleep((2**attempt) + rand * 0.25)
        attempt += 1
        next
      end
      error = body.is_a?(Hash) ? body["error"] || {} : {}
      raise Error.new(status, error["code"] || "http_error", error["message"] || "HTTP #{status}")
    end
  end

  private

  def post(path, payload)
    uri = URI("#{@base_url}#{path}")
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = uri.scheme == "https"
    http.open_timeout = @timeout
    http.read_timeout = @timeout

    request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{@api_key}")
    request.body = JSON.generate(payload)

    response = http.request(request)
    body = begin
      JSON.parse(response.body.to_s)
    rescue JSON::ParserError
      {}
    end
    [response.code.to_i, body]
  rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
    raise ConnectionError, "No response from Inboxili (#{e.class}). The email may or may not have been sent."
  end
end

Send an email

Inboxili.new.send_email(
  to: "ada@example.com",
  from_email: "hello@yourdomain.com",
  from_name: "Acme",
  subject: "Welcome, {{first_name}}",
  html_body: "<p>Hi {{first_name}}, your account is ready.</p>",
  template_data: { first_name: "Ada" }
)

From a Rails job

class WelcomeEmailJob < ApplicationJob
  queue_as :default
  discard_on Inboxili::Error # 4xx will fail the same way again

  def perform(user_id)
    user = User.find(user_id)
    Inboxili.new.send_email(
      to: user.email,
      from_email: "hello@yourdomain.com",
      subject: "Welcome, {{first_name}}",
      html_body: "<p>Hi {{first_name}}, your account is ready.</p>",
      template_data: { first_name: user.first_name }
    )
  end
end

Do not add retry_on Inboxili::ConnectionError: a timeout may mean the email was already sent, and the API has no idempotency key. Record a welcome_sent_at column and check it first if you do want retries.

Error handling

| Exception | Meaning | Do | |---|---|---| | Inboxili::Error status 401 | Bad, revoked or expired key | Fix configuration. | | Inboxili::Error status 403 | Missing scope or IP not allowed | Fix the key's scope or IP rules. | | Inboxili::Error status 422, code sender_not_verified | from_email not on a verified domain | Verify the domain. | | Inboxili::Error status 429 | Over 120 requests per minute | Retried twice already; pace bulk sends. | | Inboxili::ConnectionError | Timeout or network failure | Outcome unknown. Check your sent log. |

Testing

require "minitest/autorun"
require "webmock/minitest"

class InboxiliTest < Minitest::Test
  def test_maps_errors
    stub_request(:post, "https://api.inboxili.com/api/v1/transactional/send")
      .to_return(status: 422, body: { error: { code: "sender_not_verified", message: "nope" } }.to_json)

    error = assert_raises(Inboxili::Error) do
      Inboxili.new(api_key: "k").send_email(to: "a@b.co", from_email: "x@y.co", subject: "s", html_body: "<p>x</p>")
    end
    assert_equal "sender_not_verified", error.code
  end
end

(webmock is a development dependency.) Webhook signatures are the hex HMAC-SHA256 of the raw body: OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body), compared with Rack::Utils.secure_compare.

Frequently asked questions

Can I use Action Mailer with Inboxili?
Not directly. Action Mailer delivers through SMTP or a delivery method. Inboxili has no SMTP endpoint, so call the API from a job or service object.
Is there a gem?
Not yet. The client below is about forty lines and covers sending, errors and retries on rate limits.

Official SDKs

Build with Inboxili

Create a workspace, verify a domain, and make your first API call.

Related