ドキュメント本文へスキップ
Ruby

Rails と Rack

イニシャライザー、二重送信しないジョブ、Webhook コントローラー、そしてネットワークに出ないテスト。

セットアップ

この gem には独自の 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 は 2 通目を送らずに送信済みのメッセージを再生します。

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 は応答のなかった送信をキューに戻し、1 回目が実際に API に届いていた場合は、同じキーによって次の実行が再生になります。discard_on OpenEmail::ValidationError は、API が書かれたとおりの内容では受け付けないと拒否したメッセージを破棄します。もう一度送っても成功しないからです。NetworkError がジョブに届く時点で、gem はすでに同じキーで送信を自分でリトライしており、既定では 2 回です。

キーは invoice:42:email のように、レコードと目的に対して安定させてください。タイムスタンプや SecureRandom から作ったキーは実行のたびに新しくなるため、リトライされたジョブが 2 回送信してしまいます。2 回の実行の間に変わった請求書のように、異なるボディでキーを再利用すると、送信されずに 422 idempotency_key_reuse で拒否されます。

invoice.pdf.download は Active Storage からバイト列をバイナリの String として返し、添付ファイルの content はそれをそのまま受け取ります。scheduledAt: 1.day.from_now のような Active Support の時刻は、他の Time と同じく UTC の時刻として送られます。一方 Date.tomorrow は Ruby の Date で、日付だけの値として送られ、API はそれを UTC の午前 0 時として読むため、時刻が重要なときは時刻を渡してください。

素の 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

サービスオブジェクトから

クライアントを引数で受け取り、既定値を共有クライアントにしたサービスオブジェクトなら、送信処理を 1 か所にまとめられ、テストから専用のクライアントを渡すこともできます。

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) は任意の別のクライアントで送信します。

Webhook の受信

配信に基づいて何かをする前に、必ず検証してください。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 を追加してください。そうしないと、配信には authenticity token がないため、アクションが実行される前に 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 はそのリトライや再生のたびに同じなので、処理済みの id を保存し、見たことのあるものはスキップしてください。

head :bad_request は偽造された配信や古い配信に応答します。secret がないのは別の種類の失敗です:その場合 verify_webhook_signature は ArgumentError を送出し、コントローラーはそれを rescue しないため、設定を誤ったアプリは 500 を返し、修正後に配信が再試行されます。すべてのイベントが偽造として拒否されることはありません。

同じチェックは任意の Rack アプリでも使えます。Rack の env そのものを headers: として渡し、そこでは署名ヘッダーが HTTP_X_OPENEMAIL_SIGNATURE として届きます。このような Rack エンドポイントは、config.ru の run OpenEmailWebhook.new で単独で動かすことも、routes の mount OpenEmailWebhook.new => "/webhooks/openemail" で Rails の中で動かすこともできます。

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

スレッドと fork

1 つのクライアントをスレッド間で安全に共有できるため、Puma のスレッドも Sidekiq のワーカーも、同時に OpenEmail.emails から送信できます。クライアントは作成後に凍結され、その背後の接続プールはロックを取ります。OpenEmail.init と OpenEmail.client も同様です。

preload_app! を使った Puma のクラスターモード、Unicorn、Resque のように fork した後は、子プロセスは継承した接続を捨てて自分の接続を開くため、fork 前にイニシャライザーで作ったクライアントはどのワーカーでも安全です。on_worker_boot で再接続すべきものはありません。

各プロセスはホストごとに最大 8 個のアイドル接続を、それぞれ 2 秒間保持します。変更するには、独自の OpenEmail::NetHttpAdapter.new(max_idle:, keep_alive_timeout:) を adapter: として渡してください。

テスト

テストでクライアントにアダプターを与えれば、何もマシンの外に出ません。setup の OpenEmail.init はプロセス全体の共有クライアントを置き換え、teardown の OpenEmail.reset_client はそれを破棄するため、次の呼び出しは再び環境変数からクライアントを作ります。

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

記録された各リクエストは method、url、headers、body、timeout を持つ OpenEmail::HttpRequest で、その body は送られるはずだった JSON なので、JSON.parse(request.body) でメッセージそのものを確認できます。コードが拒否をどう扱うかをテストするには、API のエラーエンベロープを付けたエラーステータスを返してください。

共有クライアントはプロセス全体のものなので、複数のスレッドで同時に実行するテストでは、上のサービスオブジェクトが受け付けるように、テスト対象のコードに専用のクライアントを渡してください。