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.