Ir a la documentación
Ruby

Rails y Rack

Un inicializador, un trabajo que no puede enviar dos veces, un controlador de webhooks y pruebas que nunca llegan a la red.

Configuración inicial

La gema no tiene integración propia con Rails: ni Railtie ni método de entrega de ActionMailer. Llamas a la API desde un trabajo o un objeto de servicio, a través del cliente compartido o de uno que construyas, igual que desde cualquier programa Ruby.

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

Guarda la clave con bin/rails credentials:edit, bajo openemail y api_key. Después de OpenEmail.init, OpenEmail.emails, OpenEmail.webhooks y todos los demás espacios de nombres usan ese cliente en cada solicitud, trabajo y sesión de consola del proceso.

Sin la credencial, el cliente compartido se construye en su lugar a partir de OPENEMAIL_API_KEY en su primera llamada, así que un despliegue que guarda la clave en el entorno no necesita ningún inicializador. OpenEmail.init lanza ArgumentError cuando no encuentra ninguna credencial, y un inicializador también se ejecuta en comandos como assets:precompile, donde puede que la clave no esté definida, y por eso la llamada de arriba está protegida.

Enviar desde un trabajo

Envía desde un trabajo y no desde la solicitud, para que un envío lento o fallido nunca retrase una página. Deduce idempotency_key: del registro al que se refiere el envío. Así, un trabajo que se vuelve a ejecutar, porque la cola lo reintentó o porque un worker murió después de que la API respondiera, envía con la misma clave, y la API reproduce el mensaje que ya envió en lugar de enviar uno segundo.

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 devuelve a la cola un envío que no obtuvo respuesta, y la misma clave convierte la siguiente ejecución en una reproducción si la primera sí llegó a la API. discard_on OpenEmail::ValidationError descarta un mensaje que la API rechazó tal como estaba escrito, ya que volver a enviarlo no puede funcionar. Cuando un NetworkError llega al trabajo, la gema ya ha reintentado el envío por su cuenta, dos veces por defecto, con la misma clave.

Mantén la clave estable para el registro y el propósito, como invoice:42:email. Una clave construida a partir de una marca de tiempo o de SecureRandom es nueva en cada ejecución, y un trabajo reintentado enviaría entonces dos veces. Reutilizar una clave con un cuerpo distinto, como una factura que cambió entre dos ejecuciones, se rechaza con un 422 idempotency_key_reuse en lugar de enviarse.

invoice.pdf.download devuelve los bytes de Active Storage como String binaria, que el content de un adjunto acepta tal cual. Una hora de Active Support, como scheduledAt: 1.day.from_now, se envía como un instante UTC igual que cualquier Time, mientras que Date.tomorrow es una Date de Ruby, enviada como una fecha sin hora que la API lee como medianoche UTC, así que pasa una hora cuando la hora importe.

Un trabajo de Sidekiq normal funciona igual. Deduce la clave de los argumentos del trabajo y deja que los propios reintentos de Sidekiq reproduzcan el envío.

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

Desde un objeto de servicio

Un objeto de servicio que recibe su cliente como argumento, con el compartido por defecto, mantiene el envío en un solo sitio y permite que una prueba le pase un cliente propio.

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]") envía a través del cliente compartido, e InvoiceEmail.new(client: test_client) a través de cualquier otro.

Recibir webhooks

Verifica cada entrega antes de actuar en consecuencia. Rails analiza un cuerpo JSON en params, pero la firma cubre los bytes en bruto, así que pasa request.raw_post al verificador, junto con request.headers, de donde lee la cabecera de la firma.

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

Un controlador ActionController::API no tiene protección contra falsificación que sortear. Con ActionController::Base, o con un ApplicationController basado en él, añade skip_forgery_protection, o Rails rechazará el POST antes de que se ejecute tu acción, porque una entrega no lleva ningún token de autenticidad.

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

Pasa el evento a un trabajo y responde de inmediato: una entrega que no recibe respuesta en 5 segundos cuenta como fallida y se vuelve a enviar más tarde. El id del evento es el mismo en cada reintento y reproducción, así que guarda los ids que ya has procesado y omite los que ya hayas visto.

head :bad_request responde a una entrega falsificada o caducada. La falta del secreto es otro tipo de fallo: verify_webhook_signature lanza ArgumentError en ese caso, que el controlador no captura, así que una app mal configurada responde 500 y la entrega se vuelve a intentar una vez que lo corriges, en lugar de rechazarse todos los eventos como falsificados.

La misma comprobación funciona en cualquier app Rack, con el propio env de Rack como headers:, donde la cabecera de la firma llega como HTTP_X_OPENEMAIL_SIGNATURE. Un endpoint Rack como este funciona por sí solo con run OpenEmailWebhook.new en config.ru, o dentro de Rails con mount OpenEmailWebhook.new => "/webhooks/openemail" en las rutas.

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 y subidas de archivos

Un cuerpo de solicitud puede ser cualquier objeto que responda a to_hash, así que unos ActionController::Parameters permitidos pasan directamente. Un archivo de un formulario, un ActionDispatch::Http::UploadedFile, va directo a files.upload, que toma de él el nombre y el tipo del archivo.

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

Hilos de ejecución y forks

Un mismo cliente se puede compartir entre hilos de ejecución sin riesgo, así que los hilos de Puma y los workers de Sidekiq pueden enviar todos a la vez a través de OpenEmail.emails. El cliente queda congelado una vez construido, el pool de conexiones que hay detrás usa un bloqueo, y lo mismo hacen OpenEmail.init y OpenEmail.client.

Tras un fork, como en el modo clúster de Puma con preload_app!, en Unicorn o en Resque, el proceso hijo olvida las conexiones que heredó y abre las suyas, así que un cliente construido en un inicializador antes del fork es seguro en cada worker. No hay nada que reconectar en on_worker_boot.

Cada proceso mantiene hasta 8 conexiones inactivas por host, durante 2 segundos cada una. Pasa tu propio OpenEmail::NetHttpAdapter.new(max_idle:, keep_alive_timeout:) como adapter: para cambiarlo.

Pruebas

Dale al cliente un adaptador en tus pruebas y nada saldrá de la máquina. OpenEmail.init en setup sustituye el cliente compartido para todo el proceso, y OpenEmail.reset_client en teardown lo descarta, así que la siguiente llamada vuelve a construirlo a partir del entorno.

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

Cada solicitud registrada es un OpenEmail::HttpRequest con method, url, headers, body y timeout, y su body es el JSON que se habría enviado, así que JSON.parse(request.body) muestra el propio mensaje. Devuelve un status de error con el sobre de error de la API para probar cómo gestiona tu código un rechazo.

El cliente compartido pertenece a todo el proceso, así que las pruebas que se ejecutan en hilos a la vez deberían dar al código bajo prueba su propio cliente, como el que acepta el objeto de servicio de arriba.