---
title: "Rails and Rack"
description: "An initializer, a job that cannot send twice, a webhook controller, and tests that never reach the network."
url: "https://openemail.uk/docs/ruby/rails"
area: "Ruby"
category: "Getting started"
---

# 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.

**config/initializers/openemail.rb**

```
api_key = Rails.application.credentials.dig(:openemail, :api_key)

OpenEmail.init(api_key:, timeout: 15) if api_key
```

Store 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.

**app/jobs/send_invoice_job.rb**

```
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 <billing@acme.com>",
      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])
  end
end
```

`retry_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.

**app/sidekiq/welcome_email_job.rb**

```
class WelcomeEmailJob
  include Sidekiq::Job

  def perform(user_id, email)
    OpenEmail.emails.send(
      from: "Acme <hello@acme.com>",
      to: email,
      subject: "Welcome to Acme",
      text: "Glad you are here.",
      idempotency_key: "welcome:#{user_id}"
    )
  end
end
```

## From 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.

**app/services/invoice_email.rb**

```
class InvoiceEmail
  def initialize(client: OpenEmail.client)
    @client = client
  end

  def deliver(number:, to:)
    @client.emails.send(
      from: "Acme Billing <billing@acme.com>",
      to:,
      subject: "Invoice #{number}",
      text: "Your invoice #{number} is attached.",
      idempotency_key: "invoice:#{number}:email"
    )
  end
end
```

`InvoiceEmail.new.deliver(number: "INV-2026-0042", to: "ada@example.com")` 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.

**app/controllers/open_email_webhooks_controller.rb**

```
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
  end
end
```

**config/routes.rb**

```
Rails.application.routes.draw do
  post "/webhooks/openemail", to: "open_email_webhooks#create"
end
```

An `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.

**app/controllers/open_email_webhooks_controller.rb**

```
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
  end
end
```

Hand 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.

**app/webhooks/open_email_webhook.rb**

```
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"]]
  end
end
```

## Params 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.

**app/controllers/uploads_controller.rb**

```
class UploadsController < ApplicationController
  def create
    file = OpenEmail.files.upload(params.require(:file))

    render json: {id: file[:id]}
  end
end
```

## Threads 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.

**test/jobs/send_invoice_job_test.rb**

```
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
  end
end
```

Each 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.
