Isometric illustration of a Sinatra server sending envelopes through the Enkimail API to two inboxes with green checkmarks

Sending transactional emails in Sinatra doesn’t need ActionMailer, an SMTP server or a pile of configuration. Sinatra has no mailer of its own, so you add one gem, enkimail. It brings in the mail gem to build the message and delivers it through the Enkimail API. Then you set one environment variable, ENKIMAIL_API_KEY. This guide takes you from an empty Gemfile to a welcome email in a real inbox in about five minutes. It covers HTML and plain-text templates, tests, background delivery, and the errors you’ll actually see.


TL;DR

  • Add gem "enkimail" to your Gemfile. It installs the mail gem as a dependency, but you still need require "mail" before require "enkimail".
  • Point Mail.defaults at Enkimail::DeliveryMethod with api_key: ENV.fetch("ENKIMAIL_API_KEY"). That’s the whole integration.
  • The gem sends over HTTPS (port 443) to https://api.enkimail.com/api/v1/transactional_emails, so it keeps working on hosts that block outbound SMTP ports 25, 465 and 587.
  • Your from address must be a verified sender on a verified domain in Enkimail, or the API rejects the email.
  • Every failure raises Enkimail::Error: rescue it in the route, or send from a Sidekiq job so retries happen automatically.

Table of Contents

How do you send transactional emails in Sinatra?

You build the message with the mail gem and hand it to a delivery method that talks to an email provider. With Enkimail, that delivery method is Enkimail::DeliveryMethod, and it only needs your API key.

Transactional emails are the ones your app sends to one user because of something that user did: a welcome email, a password reset, a receipt, a login code. They aren’t newsletters. They have to arrive in seconds and not land in spam.

Rails ships ActionMailer for this. Sinatra ships nothing, which is part of its charm. The enkimail gem registers itself with ActionMailer in Rails, but its delivery method is a plain Ruby class with initialize(settings) and deliver!(mail). The mail gem (the library ActionMailer is built on) accepts exactly that, so it plugs straight into Sinatra. The gem’s README documents this setup:

Flow diagram: a Sinatra route calls Mail.deliver, the mail gem passes the message to Enkimail::DeliveryMethod, which converts it to JSON and POSTs it over HTTPS to /api/v1/transactional_emails, and Enkimail delivers it to the inbox with SPF, DKIM and DMARC

The gem converts each message into a JSON payload with from, to, cc, bcc, subject, body (plain text), html_body and attachments (Base64-encoded). It sends that payload with Faraday and a Bearer token. There’s no SMTP, no Net::SMTP and no open port 587.

Pro Tip: if you’ve been running your own Postfix server, compare the setup with configuring Postfix and OpenDKIM on Debian. An API-based provider replaces all of that with one environment variable.

Prerequisites

You need a running Sinatra app and an Enkimail account with three things set up.

Requirement Why Where
Ruby 3.3+ and Sinatra 4.x The examples use Sinatra 4.2 and Ruby 3.3 syntax .ruby-version, Gemfile
An Enkimail account The Free plan is enough to follow along enkimail.com
A verified domain Enkimail adds SPF and DKIM for it, which deliverability depends on Enkimail dashboard: add the copy-paste DNS records
A verified sender The from address must be one of your sender identities Enkimail dashboard
An API key Authenticates every request as Authorization: Bearer <key> Enkimail dashboard

The DNS records take the longest because you wait for propagation. Start with them, and write the code while they verify.

Step 1: Add the enkimail and mail gems

Add enkimail to your Gemfile. It depends on faraday and mail (>= 2.8), so both are installed for you.

# Gemfile
source "https://rubygems.org"

gem "sinatra", "~> 4.1"
gem "rackup", "~> 2.2"
gem "puma", "~> 6.6"
gem "enkimail", "~> 0.1.6"

group :test do
  gem "minitest", "~> 5.25"
  gem "rack-test", "~> 2.2"
end
bundle install

At the time of writing, this resolves to enkimail 0.1.6, mail 2.9, faraday 2.14 and sinatra 4.2.

Step 2: Configure the API key

The API key is the only setting the gem needs. Put it in an environment variable and give it to Mail.defaults. Never commit the key.

# .env (keep it out of git) or your host's environment settings
ENKIMAIL_API_KEY=your_enkimail_api_key

Then create a small mailer file. Both the routes and the background jobs will use it:

# mailer.rb
require "erb"
require "mail"
require "enkimail"

Mail.defaults do
  if ENV["RACK_ENV"] == "test"
    delivery_method :test
  else
    delivery_method Enkimail::DeliveryMethod,
                    api_key: ENV.fetch("ENKIMAIL_API_KEY"),
                    base_url: ENV["ENKIMAIL_BASE_URL"] # optional, defaults to https://api.enkimail.com
  end
end

module Mailer
  TEMPLATES = File.expand_path("views/emails", __dir__)

  module_function

  def welcome(email:, name:)
    text = render("welcome.text", name: name)
    html = render("welcome.html", name: name)

    Mail.deliver do
      from    "hello@yourdomain.com" # must be a verified sender in Enkimail
      to      email
      subject "Welcome aboard, #{name}!"

      text_part { body text }
      html_part do
        content_type "text/html; charset=UTF-8"
        body html
      end
    end
  end

  def render(template, locals)
    ERB.new(File.read(File.join(TEMPLATES, "#{template}.erb"))).result_with_hash(locals)
  end
end

Three details in this file matter:

  • The from address is plain code, not configuration. The gem doesn’t read it from anywhere. It must be one of your verified senders, or the API rejects the email.
  • ENV.fetch instead of ENV[]. A missing key crashes the app at boot with KeyError, not later on the first signup.
  • delivery_method :test in tests. The mail gem stores messages in Mail::TestMailer.deliveries instead of calling the API, so your test suite never sends a real email.
  • Plain ERB, not Sinatra’s erb helper. The erb helper only exists inside a request. Plain ERB also works from a Sidekiq job, where there’s no request.

base_url is the gem’s only other option. Leave ENKIMAIL_BASE_URL unset in production, and the gem uses https://api.enkimail.com. Set it only to point a staging or development app at another API host.

Pro Tip: if you’re managing more than a couple of secrets, the patterns in managing environment variables in Rails (.env for development, the platform’s settings for production, fail fast at boot) apply to Sinatra unchanged.

Step 3: Write the email templates

Send both an HTML part and a plain-text part. Clients that can’t render HTML show the text, and spam filters treat a text alternative as a good sign.

<%# views/emails/welcome.text.erb %>
Hi <%= name %>,

Thanks for signing up. Your account is ready.

The Team
<%# views/emails/welcome.html.erb %>
<!doctype html>
<html>
  <body style="font-family: sans-serif; color: #27272a;">
    <h1 style="color: #ea580c;">Hi <%= ERB::Util.h(name) %>,</h1>
    <p>Thanks for signing up. Your account is ready.</p>
    <p>The Team</p>
  </body>
</html>

Plain ERB doesn’t escape output. ERB::Util.h stops a user called Ada <script> from injecting HTML into your email. Use inline styles: most email clients ignore <style> blocks and external stylesheets.

When the message has both parts, the gem sends the text as body and the HTML as html_body. For a single-part message, it uses the Content-Type to decide which of the two fields to fill.

Step 4: Send the email from a Sinatra route

Call the mailer from the route and rescue Enkimail::Error, the one exception the gem raises for API errors, connection failures and a missing key.

# app.rb
require "sinatra"
require_relative "mailer"

post "/signup" do
  Mailer.welcome(email: params.fetch("email"), name: params.fetch("name"))
  status 201
  "Welcome email sent to #{params["email"]}\n"
rescue Enkimail::Error => e
  logger.error(e.message)
  halt 502, "We couldn't send the welcome email. Please try again.\n"
end

In a real app, you’d create the user first and send the email after the record is saved. If the email fails, the account still exists, and the user can ask for the email again.

Attachments work the same way as in any mail message. The gem Base64-encodes them for the API:

# mailer.rb, inside the Mailer module
def invoice(email:, pdf_path:)
  Mail.deliver do
    from    "billing@yourdomain.com" # must be a verified sender in Enkimail
    to      email
    subject "Your invoice"
    body    "Your invoice is attached."
    add_file filename: File.basename(pdf_path), content: File.binread(pdf_path)
  end
end

Step 5: Verify it works

Check it in two ways: an automated test that never touches the API, and one real email to your own inbox.

Automated test

# test/mailer_test.rb
ENV["RACK_ENV"] = "test"

require "minitest/autorun"
require "rack/test"
require_relative "../app"

class SignupTest < Minitest::Test
  include Rack::Test::Methods

  def app = Sinatra::Application

  def setup = Mail::TestMailer.deliveries.clear

  def test_signup_sends_welcome_email
    post "/signup", email: "ada@example.com", name: "Ada"

    assert_equal 201, last_response.status
    mail = Mail::TestMailer.deliveries.last
    assert_equal ["ada@example.com"], mail.to
    assert_equal "Welcome aboard, Ada!", mail.subject
    assert_includes mail.html_part.body.decoded, "Hi Ada"
  end
end
bundle exec ruby test/mailer_test.rb
# 1 runs, 5 assertions, 0 failures, 0 errors, 0 skips

A real email

Replace hello@yourdomain.com in mailer.rb with one of your verified senders. Then start the app with your real key and sign up with your own address:

ENKIMAIL_API_KEY=your_enkimail_api_key bundle exec ruby app.rb -p 4567
curl -i -X POST http://localhost:4567/signup -d email=you@example.com -d name=Ada
# HTTP/1.1 201 Created
# Welcome email sent to you@example.com

The email should arrive within seconds. In Gmail, open it and choose Show original. SPF: PASS and DKIM: PASS confirm that the domain verification worked. If the request returns 502 instead, the app log has the exact Enkimail::Error message. See Troubleshooting.

Should you send emails in the request or in a background job?

For a side project, sending in the request is fine. In production, send from a background job. The gem doesn’t set an HTTP timeout, so a slow API call holds a Puma thread until Ruby’s Net::HTTP gives up after 60 seconds. A failed send is also simply lost unless something retries it.

  In the request In a Sidekiq job
Setup None Redis and a worker process
Response time Includes the API round trip Immediate
Failed send User sees an error, email is lost Retried automatically with backoff
Best for Prototypes, admin tools, low traffic Signups, password resets, receipts

Sidekiq works with plain Ruby. Because the mailer from Step 2 doesn’t depend on Sinatra, the job can reuse it:

# jobs/welcome_email_job.rb
require "sidekiq"
require_relative "../mailer"

class WelcomeEmailJob
  include Sidekiq::Job
  sidekiq_options retry: 5

  def perform(email, name)
    Mailer.welcome(email: email, name: name)
  end
end
# app.rb: enqueue instead of sending inline
require_relative "jobs/welcome_email_job"

post "/signup" do
  WelcomeEmailJob.perform_async(params.fetch("email"), params.fetch("name"))
  status 202
  "Welcome email queued\n"
end
bundle add sidekiq
bundle exec sidekiq -r ./jobs/welcome_email_job.rb

Sidekiq reads REDIS_URL from the environment, and any Enkimail::Error raised in perform triggers a retry. For the trade-offs between Sidekiq and Solid Queue, and for running workers in production, see Rails background jobs in production. Most of it applies to Sinatra too.

Pro Tip: on the Free plan, Enkimail allows 50 emails per hour. A job queue spreads a signup spike over time. Sending inline would hit the limit and fail for the users who sign up after it.

How Heroku, Render, Fly.io, Upsun and Enkihost handle transactional email

None of the five sends your app’s transactional email for you in a way you’d use for production. You bring a provider, and on some platforms SMTP isn’t even an option, which makes an HTTPS API the safer default.

Platform Built-in email SMTP from the app What they recommend
Heroku No Allowed Attach an email add-on from Elements (Mailer To Go, CloudMailin…), which injects SMTP config vars
Render No Blocked on free web services (ports 25, 465, 587) since September 2025 Use the provider’s HTTP API, or upgrade to a paid instance
Fly.io No Allowed, but shared IPs have no reverse DNS Relay through a transactional email service, ideally over HTTPS
Upsun SMTP proxy (PLATFORM_SMTP_HOST), SendGrid-backed Port 25 only to the proxy; 465/587 to external servers Built-in proxy is “best-effort”, on for production only, and can’t be white-labeled
Enkihost No; the included services are PostgreSQL and Redis Not documented Enkimail over HTTPS with one environment variable

Render’s change is the one that catches people out: code that sends fine locally times out in production with ETIMEDOUT, and changing SMTP providers doesn’t help. Render support’s own advice is to switch to the provider’s API. Upsun’s proxy is handy for a contact form, but the docs say emails “aren’t guaranteed to be deliverable”, so you’d still want a dedicated provider for password resets.

The enkimail gem avoids all of this, because it only needs outbound HTTPS, which every platform allows.

Troubleshooting transactional emails in Sinatra

These are the errors you’ll see, with the messages the gem and Ruby actually print.

uninitialized constant Mail (NameError)

The mail gem is installed with enkimail, but require "enkimail" doesn’t load it, and neither does Sinatra. Add require "mail" before require "enkimail", as in mailer.rb above.

key not found: "ENKIMAIL_API_KEY" (KeyError)

The variable isn’t set in the process environment. Check how the app starts. A .env file is only read if something loads it, such as dotenv or your process manager. On your host, set it in the app’s environment settings and restart the app.

Enkimail API Key is missing. Please set config.action_mailer.enkimail_settings = { api_key: '...' }

The variable exists but is empty (ENKIMAIL_API_KEY=). The message mentions ActionMailer because the gem was written for Rails. In Sinatra, the fix is still to set a non-empty value.

Enkimail API Error (401): ...

The API rejected the key. Make sure it was copied completely, with no trailing space or newline from echo or a copy-paste, and that it wasn’t revoked in the dashboard.

Enkimail API Error (4xx): ... about the sender or domain

The from address isn’t a verified sender, or its domain isn’t verified yet. Emails from unverified domains or senders are rejected. Check the from address in your mailer against the sender identities in the dashboard, and wait for the DNS records to verify.

Enkimail Connection Error: Failed to open TCP connection to api.enkimail.com:443 ...

The app couldn’t reach the API: no outbound network, DNS failure, or a firewall. Run curl -I https://api.enkimail.com from the same server or container. Inside a background job this is the error a retry fixes.

The email arrives in spam

Check the SPF and DKIM results in the original message headers. If they fail, the DNS records from the Enkimail dashboard aren’t published exactly as given. Also send a plain-text part, avoid an image-only body, and make sure the from domain matches the verified domain.

Author Perspective: email should be the boring part

I built Enkimail because I kept rebuilding the same thing. Every new Sinatra or Rails side project needed a welcome email and a password reset. Every time, I was choosing between a provider whose pricing jumps once a project gets real and running my own Postfix with OpenDKIM. I’ve done the second one. It works, and I’m proud of the 9.9/10 mail-tester score. But it’s a server you have to patch, an IP reputation you have to watch, and a weekend you don’t get back.

What I want from transactional email is to be unable to tell it’s there: one gem, one key, an exception when something goes wrong, and a dashboard showing what was sent. In Sinatra that matters even more, because I swapped Rails for Sinatra so I wouldn’t carry framework weight I don’t use. Five lines of Mail.defaults is about the right amount of email infrastructure for a small app.

Sinatra and Enkimail on Enkihost

Enkihost hosts Sinatra apps alongside Rails and Jekyll. It pairs well with an API-based email provider like Enkimail because neither needs SMTP plumbing.

Enkihost

  • Ruby hosting built by Rubyists: deploy the Sinatra app from this guide as is. ENKIMAIL_API_KEY is just one more environment variable.
  • Redis add-on injects REDIS_URL, which Sidekiq reads automatically, so the background-job version of the mailer works without extra configuration.
  • PostgreSQL add-on injects DATABASE_URL for the users your signup route creates.
  • Zero-downtime deploys, so rotating the API key or changing a template doesn’t drop a request halfway through a signup.
  • Per-app resource isolation: a burst of email jobs in another app doesn’t slow down yours.

A Sinatra app runs on Ignite at 9 EUR per month after a 14-day free trial, with PostgreSQL and Redis included, or on Blaze at 29 EUR. Combined with Enkimail’s Free plan (1,200 emails a day), sending its transactional email costs nothing extra. See enkihost.com.

FAQ

Does the enkimail gem require Rails?

No. The gem registers itself with ActionMailer when Rails is loaded, but its delivery method is a plain Ruby class. In Sinatra or any Ruby script, pass Enkimail::DeliveryMethod and your API key to Mail.defaults from the mail gem and send messages as usual.

Can I send through Enkimail with SMTP instead of the API?

Yes. Enkimail provides SMTP credentials as well as the REST API, and the mail gem can deliver over SMTP. The API is the better default because it uses HTTPS on port 443, which works on hosts that block outbound SMTP ports, such as Render’s free web services.

How many transactional emails can I send for free?

The Enkimail Free plan allows 1,200 emails per day and 50 per hour, with 1 verified domain and 1 sender identity. The Pro plan costs 2.99 EUR per month and raises that to 5,000 per day and 200 per hour, with 10 domains, 15 sender identities and webhooks.

How do I test Sinatra emails without sending real ones?

Set the mail gem’s delivery method to :test when RACK_ENV is test. Messages are then stored in Mail::TestMailer.deliveries instead of being sent, so your tests can check the recipient, subject and body without calling the Enkimail API.

Can I use the same setup in Hanami, Roda or a plain Ruby script?

Yes. The setup only depends on the mail gem, not on Sinatra. Require mail and enkimail, configure Mail.defaults with your API key once at boot, and call Mail.deliver wherever you need to send an email.

Sources