Rails et Rack
Un initializer, un job qui ne peut pas envoyer deux fois, un contrôleur de webhooks, et des tests qui n'atteignent jamais le réseau.
Mise en place
La gem n'a pas d'intégration Rails propre : pas de Railtie, ni de méthode de livraison ActionMailer. Vous appelez l'API depuis un job ou un objet de service, via le client partagé ou un client que vous construisez, comme depuis n'importe quel programme Ruby.
api_key = Rails.application.credentials.dig(:openemail, :api_key) OpenEmail.init(api_key:, timeout: 15) if api_keyStockez la clé avec bin/rails credentials:edit, sous openemail et api_key. Après OpenEmail.init, OpenEmail.emails, OpenEmail.webhooks et tous les autres espaces de noms utilisent ce client dans chaque requête, job et session de console du processus.
Sans les credentials, le client partagé se construit à la place à partir de OPENEMAIL_API_KEY lors de son premier appel : un déploiement qui garde la clé dans l'environnement n'a donc besoin d'aucun initializer. OpenEmail.init lève ArgumentError quand il ne trouve d'identifiant nulle part, et un initializer s'exécute aussi pour des commandes comme assets:precompile, où la clé n'est peut-être pas définie : c'est pourquoi l'appel ci-dessus est protégé.
Envoyer depuis un job
Envoyez depuis un job plutôt que depuis la requête, pour qu'un envoi lent ou en échec ne bloque jamais une page. Dérivez idempotency_key: de l'enregistrement que concerne l'envoi. Un job qui s'exécute à nouveau, parce que la file l'a réessayé ou qu'un worker est mort après la réponse de l'API, envoie alors avec la même clé, et l'API rejoue le message déjà envoyé au lieu d'en envoyer un second.
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 renvoie à la file un envoi resté sans réponse, et la même clé fait de l'exécution suivante un rejeu si la première a bien atteint l'API. discard_on OpenEmail::ValidationError abandonne un message que l'API a refusé tel qu'il est écrit, puisque le renvoyer ne peut pas réussir. Au moment où une NetworkError atteint le job, la gem a déjà retenté l'envoi elle-même, deux fois par défaut, sous la même clé.
Gardez la clé stable pour l'enregistrement et l'objectif, comme l'est invoice:42:email. Une clé construite à partir d'un horodatage ou de SecureRandom est nouvelle à chaque exécution, et un job réessayé enverrait alors deux fois. Réutiliser une clé avec un corps différent, comme une facture qui a changé entre deux exécutions, est refusé avec un 422 idempotency_key_reuse au lieu d'être envoyé.
invoice.pdf.download renvoie les octets d'Active Storage sous forme de String binaire, que le content d'une pièce jointe accepte tel quel. Une heure d'Active Support, comme scheduledAt: 1.day.from_now, est envoyée comme un instant UTC, comme tout Time, alors que Date.tomorrow est une Date Ruby, envoyée comme une date nue que l'API lit comme minuit UTC : passez donc une heure quand l'heure compte.
Un job Sidekiq ordinaire fonctionne de la même façon. Dérivez la clé des arguments du job et laissez les réessais propres à Sidekiq rejouer l'envoi.
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}" ) endendDepuis un objet de service
Un objet de service qui prend son client en argument, avec le client partagé par défaut, garde l'envoi en un seul endroit et permet à un test de lui fournir son propre client.
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]") envoie via le client partagé, et InvoiceEmail.new(client: test_client) via n'importe quel autre.
Recevoir des webhooks
Vérifiez chaque livraison avant d'agir en conséquence. Rails analyse un corps JSON dans params, mais la signature couvre les octets bruts : passez donc request.raw_post au vérificateur, avec request.headers, dont il lit l'en-tête de signature.
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"endUn contrôleur ActionController::API n'a pas de protection contre la falsification à contourner. Sous ActionController::Base, ou un ApplicationController construit dessus, ajoutez skip_forgery_protection, sinon Rails refuse le POST avant l'exécution de votre action, car une livraison ne porte aucun jeton d'authenticité.
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 endendConfiez l'événement à un job et répondez immédiatement : une livraison qui ne reçoit pas de réponse dans les 5 secondes compte comme un échec et est renvoyée plus tard. L'id de l'événement est le même à chaque réessai et à chaque rejeu : stockez donc les ids que vous avez traités et ignorez ceux que vous avez déjà vus.
head :bad_request répond à une livraison falsifiée ou périmée. Un secret manquant est un échec différent : verify_webhook_signature lève ArgumentError dans ce cas, que le contrôleur n'intercepte pas. Une application mal configurée répond donc 500, et la livraison est retentée une fois le problème corrigé, au lieu que chaque événement soit rejeté comme falsifié.
La même vérification fonctionne dans toute application Rack, avec l'env Rack lui-même comme headers:, où l'en-tête de signature arrive sous la forme HTTP_X_OPENEMAIL_SIGNATURE. Un endpoint Rack comme celui-ci s'exécute seul avec run OpenEmailWebhook.new dans config.ru, ou dans Rails avec mount OpenEmailWebhook.new => "/webhooks/openemail" dans les 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 et uploads
Un corps de requête peut être n'importe quel objet qui répond à to_hash : des ActionController::Parameters autorisés passent donc directement. Un fichier venant d'un formulaire, un ActionDispatch::Http::UploadedFile, va directement à files.upload, qui en tire le nom et le type du fichier.
class UploadsController < ApplicationController def create file = OpenEmail.files.upload(params.require(:file)) render json: {id: file[:id]} endendThreads et forks
Un même client peut être partagé sans risque entre threads : les threads de Puma et les workers de Sidekiq peuvent donc tous envoyer via OpenEmail.emails en même temps. Le client est gelé une fois construit, le pool de connexions qui le sous-tend prend un verrou, tout comme OpenEmail.init et OpenEmail.client.
Après un fork, comme en mode cluster de Puma avec preload_app!, avec Unicorn ou Resque, le processus enfant oublie les connexions héritées et ouvre les siennes : un client construit dans un initializer avant le fork est donc sûr dans chaque worker. Il n'y a rien à reconnecter dans on_worker_boot.
Chaque processus garde jusqu'à 8 connexions inactives par hôte, 2 secondes chacune. Passez votre propre OpenEmail::NetHttpAdapter.new(max_idle:, keep_alive_timeout:) comme adapter: pour modifier cela.
Tests
Donnez un adaptateur au client dans vos tests, et rien ne quitte la machine. OpenEmail.init dans setup remplace le client partagé pour tout le processus, et OpenEmail.reset_client dans teardown l'abandonne : le prochain appel le reconstruit donc à partir de l'environnement.
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 endendChaque requête enregistrée est une OpenEmail::HttpRequest avec method, url, headers, body et timeout, et son body est le JSON qui aurait été envoyé : JSON.parse(request.body) montre donc le message lui-même. Renvoyez un statut d'erreur avec l'enveloppe d'erreur de l'API pour tester comment votre code gère un refus.
Le client partagé appartient à tout le processus : des tests qui s'exécutent dans des threads en même temps devraient donc donner au code testé son propre client, comme l'accepte l'objet de service ci-dessus.