Saltar para a documentação
Ruby

Rails e Rack

Um inicializador, uma tarefa que não pode enviar duas vezes, um controlador de webhooks e testes que nunca chegam à rede.

Configuração inicial

A gem não tem integração própria com Rails: não tem Railtie nem método de entrega do ActionMailer. Chama a API a partir de uma tarefa ou de um objeto de serviço, através do cliente partilhado ou de um que construa, da mesma forma que a partir de qualquer programa Ruby.

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

Guarde a chave com bin/rails credentials:edit, em openemail e api_key. Depois de OpenEmail.init, OpenEmail.emails, OpenEmail.webhooks e todos os outros espaços de nomes usam esse cliente em todos os pedidos, tarefas e sessões de consola do processo.

Sem a credencial, o cliente partilhado constrói-se antes a partir de OPENEMAIL_API_KEY na sua primeira chamada, por isso um deploy que guarda a chave no ambiente não precisa de nenhum inicializador. OpenEmail.init lança ArgumentError quando não encontra nenhuma credencial, e um inicializador também é executado em comandos como assets:precompile, onde a chave pode não estar definida, e é por isso que a chamada acima está protegida.

Enviar a partir de uma tarefa

Envie a partir de uma tarefa e não a partir do pedido, para que um envio lento ou falhado nunca atrase uma página. Derive idempotency_key: do registo a que o envio diz respeito. Assim, uma tarefa que corre de novo, porque a fila a repetiu ou porque um worker morreu depois de a API responder, envia com a mesma chave, e a API reproduz a mensagem que já enviou em vez de enviar uma segunda.

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 devolve à fila um envio que não obteve resposta, e a mesma chave transforma a execução seguinte numa reprodução se a primeira chegou de facto à API. discard_on OpenEmail::ValidationError descarta uma mensagem que a API recusou tal como estava escrita, já que voltar a enviá-la não pode ter sucesso. Quando um NetworkError chega à tarefa, a gem já voltou a tentar o envio por si própria, duas vezes por omissão, com a mesma chave.

Mantenha a chave estável para o registo e para o propósito, como invoice:42:email. Uma chave construída a partir de uma marca temporal ou de SecureRandom é nova em cada execução, e uma tarefa repetida enviaria então duas vezes. Reutilizar uma chave com um corpo diferente, como uma fatura que mudou entre duas execuções, é recusado com um 422 idempotency_key_reuse em vez de enviado.

invoice.pdf.download devolve os bytes do Active Storage como String binária, que o content de um anexo aceita tal como está. Uma hora do Active Support, como scheduledAt: 1.day.from_now, é enviada como um instante UTC, como qualquer Time, enquanto Date.tomorrow é uma Date do Ruby, enviada como uma data simples que a API lê como meia-noite UTC, por isso passe uma hora quando a hora importa.

Uma tarefa simples do Sidekiq funciona da mesma forma. Derive a chave dos argumentos da tarefa e deixe que as próprias repetições do Sidekiq reproduzam o envio.

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

A partir de um objeto de serviço

Um objeto de serviço que recebe o seu cliente como argumento, com o partilhado por omissão, mantém o envio num único sítio e permite que um teste lhe entregue um cliente próprio.

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]") envia através do cliente partilhado, e InvoiceEmail.new(client: test_client) através de qualquer outro.

Receber webhooks

Verifique cada entrega antes de agir com base nela. O Rails analisa um corpo JSON para params, mas a assinatura cobre os bytes em bruto, por isso passe request.raw_post ao verificador, juntamente com request.headers, de onde ele lê o cabeçalho da assinatura.

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

Um controlador ActionController::API não tem proteção contra falsificação a contornar. Com ActionController::Base, ou com um ApplicationController baseado nele, acrescente skip_forgery_protection, ou o Rails recusa o POST antes de a sua ação correr, porque uma entrega não traz nenhum token de autenticidade.

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

Passe o evento a uma tarefa e responda de imediato: uma entrega que não recebe resposta em 5 segundos conta como falhada e é enviada de novo mais tarde. O id do evento é o mesmo em cada repetição e reenvio, por isso guarde os ids que já tratou e ignore os que já viu.

head :bad_request responde a uma entrega falsificada ou expirada. A falta do segredo é uma falha diferente: verify_webhook_signature lança ArgumentError nesse caso, que o controlador não apanha, por isso uma aplicação mal configurada responde 500 e a entrega é tentada de novo depois de a corrigir, em vez de todos os eventos serem rejeitados como falsificados.

A mesma verificação funciona em qualquer aplicação Rack, com o próprio env do Rack como headers:, onde o cabeçalho da assinatura chega como HTTP_X_OPENEMAIL_SIGNATURE. Um endpoint Rack como este corre sozinho com run OpenEmailWebhook.new em config.ru, ou dentro do Rails com mount OpenEmailWebhook.new => "/webhooks/openemail" nas rotas.

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 e carregamentos

Um corpo de pedido pode ser qualquer objeto que responda a to_hash, por isso uns ActionController::Parameters permitidos passam diretamente. Um ficheiro de um formulário, um ActionDispatch::Http::UploadedFile, vai diretamente para files.upload, que retira dele o nome e o tipo do ficheiro.

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

Threads e forks

Um mesmo cliente pode ser partilhado entre threads com segurança, por isso as threads do Puma e os workers do Sidekiq podem todos enviar através de OpenEmail.emails ao mesmo tempo. O cliente fica congelado depois de construído, o pool de ligações por trás dele usa um bloqueio, e o mesmo fazem OpenEmail.init e OpenEmail.client.

Depois de um fork, como no modo cluster do Puma com preload_app!, no Unicorn ou no Resque, o processo filho esquece as ligações que herdou e abre as suas, por isso um cliente construído num inicializador antes do fork é seguro em cada worker. Não há nada a voltar a ligar em on_worker_boot.

Cada processo mantém até 8 ligações inativas por host, durante 2 segundos cada. Passe o seu próprio OpenEmail::NetHttpAdapter.new(max_idle:, keep_alive_timeout:) como adapter: para alterar isso.

Testes

Dê ao cliente um adaptador nos seus testes e nada sai da máquina. OpenEmail.init em setup substitui o cliente partilhado para todo o processo, e OpenEmail.reset_client em teardown descarta-o, por isso a chamada seguinte volta a construí-lo a partir do ambiente.

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 pedido registado é um OpenEmail::HttpRequest com method, url, headers, body e timeout, e o seu body é o JSON que teria sido enviado, por isso JSON.parse(request.body) mostra a própria mensagem. Devolva um estado de erro com o envelope de erro da API para testar como o seu código trata uma recusa.

O cliente partilhado pertence a todo o processo, por isso os testes que correm em threads ao mesmo tempo devem dar ao código em teste um cliente próprio, como o que o objeto de serviço acima aceita.