문서로 건너뛰기
Ruby

Rails와 Rack

이니셜라이저, 두 번 발송할 수 없는 작업, 웹훅 컨트롤러, 그리고 네트워크에 닿지 않는 테스트.

설정하기

이 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는 두 번째 메시지를 보내지 않고 이미 보낸 메시지를 재생합니다.

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는 응답을 받지 못한 발송을 큐로 돌려보내며, 첫 실행이 실제로 API에 닿았다면 같은 키 덕분에 다음 실행은 재생이 됩니다. discard_on OpenEmail::ValidationError는 API가 작성된 그대로는 받아들일 수 없다고 거부한 메시지를 버립니다. 다시 보내도 성공할 수 없기 때문입니다. NetworkError가 작업에 도달할 즈음이면 gem은 이미 같은 키로 직접 발송을 다시 시도했으며, 기본값은 두 번입니다.

키는 invoice:42:email처럼 레코드와 목적에 대해 안정적으로 유지하세요. 타임스탬프나 SecureRandom으로 만든 키는 실행할 때마다 새로워지므로, 재시도된 작업이 두 번 발송하게 됩니다. 두 실행 사이에 바뀐 청구서처럼 다른 본문으로 키를 재사용하면, 발송되지 않고 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 자정으로 읽습니다. 시각이 중요할 때는 시각을 전달하세요.

일반 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

서비스 객체에서

클라이언트를 인자로 받고 공유 클라이언트를 기본값으로 두는 서비스 객체는 발송을 한곳에 모아 두며, 테스트에서 전용 클라이언트를 넘겨줄 수 있게 합니다.

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)는 그 밖의 어떤 클라이언트로든 발송합니다.

웹훅 받기

전달에 따라 무언가를 하기 전에 반드시 검증하세요. 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는 위조되었거나 오래된 전달에 응답합니다. 시크릿이 없는 것은 다른 종류의 실패입니다: verify_webhook_signature는 그 경우 ArgumentError를 발생시키고 컨트롤러는 이를 rescue하지 않으므로, 설정이 잘못된 앱은 500으로 응답하고 문제를 고친 뒤 전달이 다시 시도됩니다. 모든 이벤트가 위조로 거부되는 일은 없습니다.

같은 검사는 어떤 Rack 앱에서도 동작하며, Rack env 자체를 headers:로 전달합니다. 거기서 서명 헤더는 HTTP_X_OPENEMAIL_SIGNATURE로 도착합니다. 이런 Rack 엔드포인트는 config.ru의 run OpenEmailWebhook.new로 단독 실행하거나, 라우트의 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

클라이언트 하나를 스레드 간에 안전하게 공유할 수 있으므로, 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의 오류 봉투와 함께 오류 상태를 반환하세요.

공유 클라이언트는 프로세스 전체에 속하므로, 여러 스레드에서 동시에 실행되는 테스트는 위의 서비스 객체가 받는 것처럼, 테스트 대상 코드에 전용 클라이언트를 넘겨주어야 합니다.