Перейти к документации
Ruby

Rails и Rack

Инициализатор, задание, которое не может отправить дважды, контроллер вебхуков и тесты, которые никогда не обращаются к сети.

Настройка

У гема нет собственной интеграции с Rails: ни Railtie, ни метода доставки для ActionMailer. Вы вызываете API из задания или сервисного объекта через общий клиент или собранный вами, так же как из любой программы на Ruby.

config/initializers/openemail.rb
api_key = Rails.application.credentials.dig(:openemail, :api_key) OpenEmail.init(api_key:, timeout: 15) if api_key

Сохраните ключ через bin/rails credentials:edit в разделе openemail под именем api_key. После OpenEmail.init пространства имён OpenEmail.emails, OpenEmail.webhooks и все остальные используют этот клиент в каждом запросе, задании и сеансе консоли процесса.

Без этих учётных данных общий клиент вместо этого соберёт себя из OPENEMAIL_API_KEY при первом вызове, поэтому развёртыванию, которое хранит ключ в окружении, инициализатор вообще не нужен. OpenEmail.init выбрасывает ArgumentError, если нигде не находит учётных данных, а инициализатор запускается и для команд вроде assets:precompile, где ключ может быть не задан. Поэтому вызов выше защищён условием.

Отправка из задания

Отправляйте из задания, а не из запроса, чтобы медленная или неудачная отправка никогда не задерживала страницу. Выводите idempotency_key: из записи, к которой относится отправка. Тогда задание, которое запустилось снова, потому что очередь его повторила или воркер упал после ответа API, отправит с тем же ключом, и API воспроизведёт уже отправленное сообщение, а не отправит второе.

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 <[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])  endend

retry_on OpenEmail::NetworkError возвращает в очередь отправку, не получившую ответа, а тот же ключ превращает следующий запуск в воспроизведение, если первый всё же дошёл до API. discard_on OpenEmail::ValidationError отбрасывает сообщение, которое API отклонил в его текущем виде, поскольку повторная отправка не сможет пройти. К тому моменту, когда NetworkError доходит до задания, гем уже сам повторил отправку, по умолчанию дважды, с тем же ключом.

Держите ключ стабильным для записи и цели, как invoice:42:email. Ключ, построенный из метки времени или из SecureRandom, будет новым при каждом запуске, и тогда повторённое задание отправит дважды. Повторное использование ключа с другим телом, например со счётом, который изменился между двумя запусками, отклоняется с 422 idempotency_key_reuse, а не отправляется.

invoice.pdf.download возвращает байты из Active Storage как двоичную String, которую content вложения принимает как есть. Время из Active Support, например scheduledAt: 1.day.from_now, отправляется как момент в UTC, как любой Time, тогда как Date.tomorrow является Date из Ruby и отправляется как голая дата, которую API читает как полночь UTC, поэтому передавайте время, когда важен час.

Обычное задание Sidekiq работает так же. Выводите ключ из аргументов задания и позвольте собственным повторам Sidekiq воспроизводить отправку.

app/sidekiq/welcome_email_job.rb
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}"    )  endend

Из сервисного объекта

Сервисный объект, который принимает клиент аргументом, а по умолчанию использует общий, держит отправку в одном месте и позволяет тесту передать ему собственный клиент.

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 <[email protected]>",      to:,      subject: "Invoice #{number}",      text: "Your invoice #{number} is attached.",      idempotency_key: "invoice:#{number}:email"    )  endend

InvoiceEmail.new.deliver(number: "INV-2026-0042", to: "[email protected]") отправляет через общий клиент, а InvoiceEmail.new(client: test_client) через любой другой.

Приём вебхуков

Проверяйте каждую доставку, прежде чем действовать по ней. Rails разбирает тело JSON в params, но подпись покрывает исходные байты, поэтому передайте верификатору request.raw_post вместе с request.headers, из которых он читает заголовок подписи.

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  endend
config/routes.rb
Rails.application.routes.draw do  post "/webhooks/openemail", to: "open_email_webhooks#create"end

У контроллера ActionController::API нет защиты от подделки запросов, которую нужно обходить. В ActionController::Base или в построенном на нём ApplicationController добавьте skip_forgery_protection, иначе Rails отклонит POST ещё до запуска вашего действия, потому что доставка не несёт токена подлинности.

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  endend

Передайте событие заданию и ответьте сразу: доставка, не получившая ответа в течение 5 секунд, считается неудачной и позже отправляется снова. id события одинаков при каждом его повторе и воспроизведении, поэтому храните идентификаторы обработанных событий и пропускайте уже виденные.

head :bad_request отвечает на поддельную или устаревшую доставку. Отсутствующий секрет является другим сбоем: для него verify_webhook_signature выбрасывает ArgumentError, который контроллер не перехватывает, поэтому неправильно настроенное приложение отвечает 500, и доставка будет повторена, когда вы это исправите, а не каждое событие будет отвергнуто как поддельное.

Та же проверка работает в любом приложении Rack, если передать в headers: само окружение Rack, где заголовок подписи приходит как HTTP_X_OPENEMAIL_SIGNATURE. Такой эндпоинт Rack работает сам по себе с run OpenEmailWebhook.new в config.ru или внутри Rails с mount OpenEmailWebhook.new => "/webhooks/openemail" в маршрутах.

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"]]  endend

Параметры и загрузки

Телом запроса может быть любой объект, который отвечает на to_hash, поэтому разрешённые ActionController::Parameters проходят напрямую. Файл из формы, ActionDispatch::Http::UploadedFile, передаётся прямо в files.upload, который берёт из него имя и тип файла.

app/controllers/uploads_controller.rb
class UploadsController < ApplicationController  def create    file = OpenEmail.files.upload(params.require(:file))     render json: {id: file[:id]}  endend

Потоки и форки

Один клиент можно безопасно разделять между потоками, поэтому потоки Puma и воркеры Sidekiq могут одновременно отправлять через OpenEmail.emails. После сборки клиент заморожен, а пул соединений за ним берёт блокировку, как и OpenEmail.init и OpenEmail.client.

После форка, как в кластерном режиме Puma с preload_app!, в Unicorn или Resque, дочерний процесс забывает унаследованные соединения и открывает свои, поэтому клиент, собранный в инициализаторе до форка, безопасен в каждом воркере. В on_worker_boot ничего переподключать не нужно.

Каждый процесс держит до 8 простаивающих соединений на хост по 2 секунды каждое. Чтобы это изменить, передайте собственный OpenEmail::NetHttpAdapter.new(max_idle:, keep_alive_timeout:) как adapter:.

Тестирование

Дайте клиенту адаптер в тестах, и ничто не покинет машину. OpenEmail.init в setup заменяет общий клиент для всего процесса, а OpenEmail.reset_client в teardown сбрасывает его, так что следующий вызов снова соберёт клиент из окружения.

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  endend

Каждый записанный запрос является OpenEmail::HttpRequest с method, url, headers, body и timeout, а его body содержит JSON, который был бы отправлен, поэтому JSON.parse(request.body) показывает само сообщение. Верните статус ошибки с конвертом ошибки API, чтобы проверить, как ваш код обрабатывает отказ.

Общий клиент принадлежит всему процессу, поэтому тесты, которые одновременно выполняются в потоках, должны давать тестируемому коду собственный клиент, как это позволяет сервисный объект выше.