Rails and Rack
An initializer, a job that cannot send twice, a webhook controller, and tests that never reach the network.
Setting up
The gem has no Rails integration of its own: no Railtie, and no ActionMailer delivery method. You call the API from a job or a service object, through the shared client or one you build, the same way as from any Ruby program.
api_key = Rails.application.credentials.dig(:openemail, :api_key) OpenEmail.init(api_key:, timeout: 15) if api_keyStore the key with bin/rails credentials:edit, under openemail and api_key. After OpenEmail.init, OpenEmail.emails, OpenEmail.webhooks and every other namespace use that client in every request, job and console session of the process.
Without the credential, the shared client builds itself from OPENEMAIL_API_KEY on its first call instead, so a deploy that keeps the key in the environment needs no initializer at all. OpenEmail.init raises ArgumentError when it finds no credential anywhere, and an initializer also runs for commands such as assets:precompile, where the key may not be set, which is why the call above is guarded.
Sending from a job
Send from a job rather than from the request, so a slow or failing send never holds up a page. Derive idempotency_key: from the record the send is about. A job that runs again, because the queue retried it or a worker died after the API answered, then sends with the same key, and the API replays the message it already sent instead of sending a second one.
class SendInvoiceJob < ApplicationJob queue_as :default retry_on OpenEmail::NetworkError, wait: 30.seconds, attempts: 5 discard_on OpenEmail::ValidationError def perform(invoice) sent = OpenEmail.emails.send( from: "Acme Billing <[email protected]>", to: invoice.customer_email, subject: "Invoice #{invoice.number}", html: "<p>Your invoice #{invoice.number} is attached.</p>", attachments: [{filename: "#{invoice.number}.pdf", content: invoice.pdf.download, contentType: "application/pdf"}], idempotency_key: "invoice:#{invoice.id}:email" ) invoice.update!(email_id: sent[:id], email_status: sent[:status]) endendretry_on OpenEmail::NetworkError hands a send that got no answer back to the queue, and the same key makes the next run a replay if the first one did reach the API. discard_on OpenEmail::ValidationError drops a message the API refused as written, since sending it again cannot succeed. By the time a NetworkError reaches the job, the gem has already tried the send again itself, twice by default, under the same key.
Keep the key stable for the record and the purpose, as invoice:42:email is. A key built from a timestamp or from SecureRandom is new on every run, and a retried job would then send twice. Reusing a key with a different body, such as an invoice that changed between two runs, is refused with a 422 idempotency_key_reuse rather than sent.
invoice.pdf.download returns the bytes from Active Storage as a binary String, which an attachment’s content takes as it is. A time from Active Support, such as scheduledAt: 1.day.from_now, is sent as a UTC instant like any Time, while Date.tomorrow is a Ruby Date, sent as a bare date that the API reads as midnight UTC, so pass a time when the hour matters.
A plain Sidekiq job works the same way. Derive the key from the job’s arguments and let Sidekiq’s own retries replay the send.
class WelcomeEmailJob include Sidekiq::Job def perform(user_id, email) OpenEmail.emails.send( from: "Acme <[email protected]>", to: email, subject: "Welcome to Acme", text: "Glad you are here.", idempotency_key: "welcome:#{user_id}" ) endendFrom a service object
A service object that takes its client as an argument, with the shared one as the default, keeps the send in one place and lets a test hand it a client of its own.
class InvoiceEmail def initialize(client: OpenEmail.client) @client = client end def deliver(number:, to:) @client.emails.send( from: "Acme Billing <[email protected]>", to:, subject: "Invoice #{number}", text: "Your invoice #{number} is attached.", idempotency_key: "invoice:#{number}:email" ) endendInvoiceEmail.new.deliver(number: "INV-2026-0042", to: "[email protected]") sends through the shared client, and InvoiceEmail.new(client: test_client) through any other.
Receiving webhooks
Verify every delivery before you act on it. Rails parses a JSON body into params, but the signature covers the raw bytes, so pass request.raw_post to the verifier, with request.headers, from which it reads the signature header.
class OpenEmailWebhooksController < ActionController::API def create event = OpenEmail.verify_webhook_signature( payload: request.raw_post, headers: request.headers, secret: Rails.application.credentials.dig(:openemail, :webhook_secret) ) HandleOpenEmailEventJob.perform_later(event) head :no_content rescue OpenEmail::WebhookSignatureError head :bad_request endendRails.application.routes.draw do post "/webhooks/openemail", to: "open_email_webhooks#create"endAn ActionController::API controller has no forgery protection to get past. Under ActionController::Base, or an ApplicationController built on it, add skip_forgery_protection, or Rails refuses the POST before your action runs, because a delivery carries no authenticity token.
class OpenEmailWebhooksController < ApplicationController skip_forgery_protection def create event = OpenEmail.verify_webhook_signature( payload: request.raw_post, headers: request.headers, secret: Rails.application.credentials.dig(:openemail, :webhook_secret) ) HandleOpenEmailEventJob.perform_later(event) head :no_content rescue OpenEmail::WebhookSignatureError head :bad_request endendHand the event to a job and answer at once: a delivery that gets no answer within 5 seconds counts as failed and is sent again later. The event’s id is the same on every retry and replay of it, so store the ids you have handled and skip one you have seen.
head :bad_request answers a forged or stale delivery. A missing secret is a different failure: verify_webhook_signature raises ArgumentError for it, which the controller does not rescue, so a misconfigured app answers 500 and the delivery is tried again once you fix it, rather than every event being turned away as forged.
The same check works in any Rack app, with the Rack env itself as headers:, where the signature header arrives as HTTP_X_OPENEMAIL_SIGNATURE. A Rack endpoint like this one runs on its own with run OpenEmailWebhook.new in config.ru, or inside Rails with mount OpenEmailWebhook.new => "/webhooks/openemail" in the routes.
class OpenEmailWebhook def call(env) request = Rack::Request.new(env) event = OpenEmail.verify_webhook_signature( payload: request.body.read, headers: env, secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET") ) HandleOpenEmailEventJob.perform_later(event) [204, {}, []] rescue OpenEmail::WebhookSignatureError [400, {"content-type" => "text/plain"}, ["bad signature"]] endendParams and uploads
A request body can be any object that responds to to_hash, so permitted ActionController::Parameters pass straight through. A file from a form, an ActionDispatch::Http::UploadedFile, goes straight to files.upload, which takes the file’s name and type from it.
class UploadsController < ApplicationController def create file = OpenEmail.files.upload(params.require(:file)) render json: {id: file[:id]} endendThreads and forks
One client is safe to share across threads, so Puma’s threads and Sidekiq’s workers can all send through OpenEmail.emails at once. The client is frozen once it is built, the connection pool behind it takes a lock, and so do OpenEmail.init and OpenEmail.client.
After a fork, as in Puma’s cluster mode with preload_app!, Unicorn or Resque, the child process forgets the connections it inherited and opens its own, so a client built in an initializer before the fork is safe in every worker. There is nothing to reconnect in on_worker_boot.
Each process keeps up to 8 idle connections per host, for 2 seconds each. Pass your own OpenEmail::NetHttpAdapter.new(max_idle:, keep_alive_timeout:) as adapter: to change that.
Testing
Give the client an adapter in your tests and nothing leaves the machine. OpenEmail.init in setup replaces the shared client for the whole process, and OpenEmail.reset_client in teardown drops it, so the next call builds from the environment again.
require "test_helper" class SendInvoiceJobTest < ActiveJob::TestCase FakeOpenEmail = Struct.new(:requests) do def call(request) requests << request OpenEmail::HttpResponse.new( status: 200, headers: {"content-type" => "application/json"}, body: JSON.generate({id: "msg_test", status: "sent"}) ) end end setup do @api = FakeOpenEmail.new([]) OpenEmail.init(api_key: "oe_test_fake", adapter: @api, max_retries: 0, disable_update_notice: true) end teardown do OpenEmail.reset_client end test "a job that runs twice sends under one key" do invoice = invoices(:september) 2.times { SendInvoiceJob.perform_now(invoice) } keys = @api.requests.map { |request| request.headers["Idempotency-Key"] } assert_equal ["invoice:#{invoice.id}:email"] * 2, keys assert_equal "/emails", URI(@api.requests.first.url).path endendEach recorded request is an OpenEmail::HttpRequest with method, url, headers, body and timeout, and its body is the JSON that would have been sent, so JSON.parse(request.body) shows the message itself. Return an error status with the API’s error envelope to test how your code handles a refusal.
The shared client belongs to the whole process, so tests that run in threads at the same time should give the code under test a client of its own, as the service object above accepts.