Zur Dokumentation springen
Ruby

Rails und Rack

Ein Initializer, ein Job, der nicht zweimal senden kann, ein Webhook-Controller und Tests, die nie das Netzwerk erreichen.

Einrichtung

Das Gem hat keine eigene Rails-Integration: kein Railtie und keine Zustellmethode für ActionMailer. Sie rufen die API aus einem Job oder einem Service-Objekt auf, über den gemeinsamen Client oder einen selbst erzeugten, genauso wie aus jedem anderen Ruby-Programm.

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

Speichern Sie den Schlüssel mit bin/rails credentials:edit unter openemail und api_key. Nach OpenEmail.init verwenden OpenEmail.emails, OpenEmail.webhooks und jeder andere Namespace diesen Client in jeder Anfrage, jedem Job und jeder Konsolensitzung des Prozesses.

Ohne die Credentials baut sich der gemeinsame Client bei seinem ersten Aufruf stattdessen aus OPENEMAIL_API_KEY. Ein Deployment, das den Schlüssel in der Umgebung hält, braucht also gar keinen Initializer. OpenEmail.init löst einen ArgumentError aus, wenn es nirgends Zugangsdaten findet, und ein Initializer läuft auch bei Befehlen wie assets:precompile, bei denen der Schlüssel womöglich nicht gesetzt ist. Deshalb ist der Aufruf oben abgesichert.

Aus einem Job senden

Senden Sie aus einem Job statt aus der Anfrage, damit ein langsamer oder fehlschlagender Versand nie eine Seite aufhält. Leiten Sie idempotency_key: aus dem Datensatz ab, um den es beim Versand geht. Ein Job, der erneut läuft, weil die Queue ihn wiederholt hat oder ein Worker nach der Antwort der API abgestürzt ist, sendet dann mit demselben Key, und die API spielt die bereits gesendete Nachricht erneut ab, statt eine zweite zu senden.

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 gibt einen Versand, der keine Antwort bekam, an die Queue zurück, und derselbe Key macht den nächsten Lauf zu einem erneuten Abspielen, falls der erste die API doch erreicht hat. discard_on OpenEmail::ValidationError verwirft eine Nachricht, die die API in dieser Form abgelehnt hat, denn ein erneutes Senden kann nicht gelingen. Wenn ein NetworkError den Job erreicht, hat das Gem den Versand bereits selbst erneut versucht, standardmäßig zweimal, unter demselben Key.

Halten Sie den Key für den Datensatz und den Zweck stabil, so wie invoice:42:email. Ein Key aus einem Zeitstempel oder aus SecureRandom ist bei jedem Lauf neu, und ein wiederholter Job würde dann zweimal senden. Ein Key, der mit einem anderen Body wiederverwendet wird, etwa bei einer Rechnung, die sich zwischen zwei Läufen geändert hat, wird mit einem 422 idempotency_key_reuse abgelehnt statt gesendet.

invoice.pdf.download gibt die Bytes aus Active Storage als binären String zurück, den content eines Anhangs unverändert übernimmt. Eine Zeit aus Active Support, etwa scheduledAt: 1.day.from_now, wird wie jedes Time als UTC-Zeitpunkt gesendet, während Date.tomorrow ein Ruby-Date ist, das als bloßes Datum gesendet wird, das die API als Mitternacht UTC liest. Übergeben Sie also eine Zeit, wenn die Uhrzeit wichtig ist.

Ein einfacher Sidekiq-Job funktioniert genauso. Leiten Sie den Key aus den Argumenten des Jobs ab und lassen Sie die eigenen Wiederholungen von Sidekiq den Versand erneut abspielen.

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

Aus einem Service-Objekt

Ein Service-Objekt, das seinen Client als Argument nimmt, mit dem gemeinsamen als Standard, hält den Versand an einer Stelle und erlaubt einem Test, ihm einen eigenen Client zu übergeben.

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]") sendet über den gemeinsamen Client und InvoiceEmail.new(client: test_client) über jeden anderen.

Webhooks empfangen

Verifizieren Sie jede Zustellung, bevor Sie darauf reagieren. Rails parst einen JSON-Body in params, die Signatur deckt aber die rohen Bytes ab. Übergeben Sie der Prüfung daher request.raw_post zusammen mit request.headers, woraus sie den Signatur-Header liest.

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

Ein ActionController::API-Controller hat keinen Fälschungsschutz, der zu umgehen wäre. Unter ActionController::Base oder einem darauf aufbauenden ApplicationController fügen Sie skip_forgery_protection hinzu, sonst lehnt Rails den POST ab, bevor Ihre Action läuft, weil eine Zustellung kein Authenticity-Token trägt.

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

Übergeben Sie das Event an einen Job und antworten Sie sofort: Eine Zustellung, die innerhalb von 5 Sekunden keine Antwort bekommt, gilt als fehlgeschlagen und wird später erneut gesendet. Die id des Events ist bei jeder Wiederholung und jedem erneuten Abspielen dieselbe. Speichern Sie also die bearbeiteten ids und überspringen Sie eine, die Sie schon gesehen haben.

head :bad_request beantwortet eine gefälschte oder veraltete Zustellung. Ein fehlendes Secret ist ein anderer Fehlschlag: verify_webhook_signature löst dafür einen ArgumentError aus, den der Controller nicht abfängt. Eine falsch konfigurierte App antwortet also mit 500, und die Zustellung wird erneut versucht, sobald Sie das behoben haben, statt dass jedes Event als gefälscht abgewiesen wird.

Dieselbe Prüfung funktioniert in jeder Rack-App, mit dem Rack-env selbst als headers:, wo der Signatur-Header als HTTP_X_OPENEMAIL_SIGNATURE ankommt. Ein Rack-Endpunkt wie dieser läuft eigenständig mit run OpenEmailWebhook.new in config.ru oder innerhalb von Rails mit mount OpenEmailWebhook.new => "/webhooks/openemail" in den Routen.

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

Params und Uploads

Ein Request-Body kann jedes Objekt sein, das auf to_hash antwortet, freigegebene ActionController::Parameters gehen also direkt durch. Eine Datei aus einem Formular, ein ActionDispatch::Http::UploadedFile, geht direkt an files.upload, das Name und Typ der Datei daraus übernimmt.

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

Threads und Forks

Ein Client kann gefahrlos von mehreren Threads gemeinsam genutzt werden, die Threads von Puma und die Worker von Sidekiq können also alle gleichzeitig über OpenEmail.emails senden. Der Client ist nach dem Erzeugen eingefroren, der Verbindungspool dahinter arbeitet mit einer Sperre, und OpenEmail.init und OpenEmail.client ebenso.

Nach einem Fork, wie im Cluster-Modus von Puma mit preload_app!, bei Unicorn oder Resque, vergisst der Kindprozess die geerbten Verbindungen und öffnet eigene. Ein Client, der vor dem Fork in einem Initializer erzeugt wurde, ist daher in jedem Worker sicher. In on_worker_boot muss nichts neu verbunden werden.

Jeder Prozess hält bis zu 8 ungenutzte Verbindungen pro Host offen, jeweils 2 Sekunden lang. Übergeben Sie einen eigenen OpenEmail::NetHttpAdapter.new(max_idle:, keep_alive_timeout:) als adapter:, um das zu ändern.

Testen

Geben Sie dem Client in Ihren Tests einen Adapter, dann verlässt nichts den Rechner. OpenEmail.init in setup ersetzt den gemeinsamen Client für den ganzen Prozess, und OpenEmail.reset_client in teardown verwirft ihn, sodass der nächste Aufruf wieder aus der Umgebung baut.

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

Jede aufgezeichnete Anfrage ist ein OpenEmail::HttpRequest mit method, url, headers, body und timeout, und ihr body ist das JSON, das gesendet worden wäre. JSON.parse(request.body) zeigt also die Nachricht selbst. Geben Sie einen Fehlerstatus mit dem Fehlerumschlag der API zurück, um zu testen, wie Ihr Code mit einer Ablehnung umgeht.

Der gemeinsame Client gehört dem ganzen Prozess. Tests, die gleichzeitig in Threads laufen, sollten dem getesteten Code daher einen eigenen Client geben, wie ihn das Service-Objekt oben annimmt.