← 返回博客

用 Ruby 通过 Rovela API 发送邮件

介绍如何用 Ruby 的 Net::HTTP、Faraday 和 HTTParty 发送事务邮件,包含幂等键重试、批量发送,以及 Sinatra 和 Rails 的 Webhook 验签。

更新于 2026年11月3日
用 Ruby 通过 Rovela API 发送邮件

本文介绍如何用 Ruby 调用 Rovela API 发送事务邮件。涵盖 Net::HTTP(零依赖)、Faraday、HTTParty,以及 Sinatra 和 Rails 的 Webhook 验签。

前提条件

需要 Rovela API 密钥,设置为环境变量:

export ROVELA_API_KEY="re_..."

用 Net::HTTP 发送

只需 Ruby 标准库,无需额外 gem。

require "net/http"
require "json"
require "uri"

def send_email(to:, subject:, html:, text: nil, idempotency_key: nil)
  uri = URI("https://api.rovela.dev/emails")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true

  body = { from: "Acme <noreply@yourdomain.com>", to: to, subject: subject, html: html }
  body[:text] = text if text
  body[:idempotency_key] = idempotency_key if idempotency_key

  request = Net::HTTP::Post.new(uri)
  request["Authorization"] = "Bearer #{ENV.fetch("ROVELA_API_KEY")}"
  request["Content-Type"]  = "application/json"
  request.body = body.to_json

  response = http.request(request)
  [response.code.to_i, JSON.parse(response.body)]
end

status, data = send_email(
  to: "user@example.com",
  subject: "欢迎加入",
  html: "<p>感谢注册!</p>"
)
puts "#{status}: #{data}"

用 Faraday 发送

Faraday 是流行的 Ruby HTTP 客户端,支持中间件扩展。

require "faraday"
require "json"

conn = Faraday.new("https://api.rovela.dev") do |f|
  f.request  :json
  f.response :json
  f.adapter  Faraday.default_adapter
end

response = conn.post("/emails") do |req|
  req.headers["Authorization"] = "Bearer #{ENV.fetch("ROVELA_API_KEY")}"
  req.body = {
    from:    "Acme <noreply@yourdomain.com>",
    to:      "user@example.com",
    subject: "欢迎加入",
    html:    "<p>感谢注册!</p>"
  }
end

puts response.status
puts response.body["id"]

用 HTTParty 发送

HTTParty 为 REST API 提供了便利的类级方法封装。

require "httparty"

class RovelaClient
  include HTTParty
  base_uri "https://api.rovela.dev"

  def initialize(api_key)
    @headers = {
      "Authorization" => "Bearer #{api_key}",
      "Content-Type"  => "application/json"
    }
  end

  def send_email(params)
    self.class.post("/emails", body: params.to_json, headers: @headers)
  end
end

client = RovelaClient.new(ENV.fetch("ROVELA_API_KEY"))
response = client.send_email(
  from:    "Acme <noreply@yourdomain.com>",
  to:      "user@example.com",
  subject: "欢迎加入",
  html:    "<p>感谢注册!</p>"
)
puts response.code
puts response["id"]

重试与幂等键

在循环之前生成一次幂等键,每次重试都复用同一个键。这样即使网络在服务器接受请求后断掉,也不会重复发送。

require "net/http"
require "json"
require "securerandom"

MAX_RETRIES   = 4
BASE_DELAY    = 1.0

def send_with_retry(payload)
  idempotency_key = SecureRandom.uuid  # 循环之前生成一次,重试时复用
  uri  = URI("https://api.rovela.dev/emails")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true

  MAX_RETRIES.times do |attempt|
    req = Net::HTTP::Post.new(uri)
    req["Authorization"]   = "Bearer #{ENV.fetch("ROVELA_API_KEY")}"
    req["Content-Type"]    = "application/json"
    req["Idempotency-Key"] = idempotency_key
    req.body = payload.to_json

    res = http.request(req)
    return [res.code.to_i, JSON.parse(res.body)] unless res.code.to_i == 429

    retry_after = res["Retry-After"]&.to_f || BASE_DELAY * (2 ** attempt)
    sleep retry_after
  end

  raise "超过最大重试次数 #{MAX_RETRIES}"
end

批量发送

用线程 + 计数信号量控制并发,无需额外依赖:

require "net/http"
require "json"
require "securerandom"

CONCURRENCY = 5

def send_batch(recipients)
  semaphore = Mutex.new
  available = CONCURRENCY
  cond      = ConditionVariable.new
  threads   = []

  recipients.each do |r|
    semaphore.synchronize { cond.wait(semaphore) while available.zero?; available -= 1 }

    threads << Thread.new(r) do |recipient|
      begin
        uri  = URI("https://api.rovela.dev/emails")
        http = Net::HTTP.new(uri.host, uri.port)
        http.use_ssl = true
        req = Net::HTTP::Post.new(uri)
        req["Authorization"]   = "Bearer #{ENV.fetch("ROVELA_API_KEY")}"
        req["Content-Type"]    = "application/json"
        req["Idempotency-Key"] = SecureRandom.uuid
        req.body = {
          from: "Acme <noreply@yourdomain.com>",
          to: recipient[:email], subject: recipient[:subject], html: recipient[:html]
        }.to_json
        res = http.request(req)
        puts "#{recipient[:email]}: #{res.code}"
      ensure
        semaphore.synchronize { available += 1; cond.signal }
      end
    end
  end

  threads.each(&:join)
end

Sinatra 验签 Webhook

Rovela 用 HMAC-SHA256 对每个事件签名。签名在 x-resend-signature 请求头中,格式为 t=<时间戳>,v1=<十六进制签名>。用 Webhook 密钥对 <时间戳>.<原始 body> 签名,再用常量时间比对。

require "sinatra"
require "openssl"
require "json"

WEBHOOK_SECRET = ENV.fetch("ROVELA_WEBHOOK_SECRET")

post "/webhooks/rovela" do
  raw_body  = request.body.read
  sig_header = request.env["HTTP_X_RESEND_SIGNATURE"] || ""

  parts     = sig_header.split(",").map { |p| p.split("=", 2) }.to_h
  timestamp = parts["t"]
  v1        = parts["v1"]

  halt 400, "缺少签名字段" if timestamp.nil? || v1.nil?

  signed_string = "#{timestamp}.#{raw_body}"
  expected      = OpenSSL::HMAC.hexdigest("SHA256", WEBHOOK_SECRET, signed_string)

  unless Rack::Utils.secure_compare(expected, v1)
    halt 400, "签名无效"
  end

  payload = JSON.parse(raw_body)
  case payload["type"]
  when "email.delivered"
    # 标记已送达
  when "email.bounced"
    # 处理退信
  end

  status 200
  "ok"
end

Rack::Utils.secure_compare 做常量时间字符串比对,防止时序攻击。

Rails 验签 Webhook

Rails 中通过 request.body.read 读取原始 body,注意要跳过 CSRF 验证:

# app/controllers/webhooks/rovela_controller.rb
class Webhooks::RovelaController < ApplicationController
  skip_before_action :verify_authenticity_token
  WEBHOOK_SECRET = ENV.fetch("ROVELA_WEBHOOK_SECRET")

  def create
    raw_body  = request.body.read
    sig_header = request.headers["X-Resend-Signature"] || ""

    parts     = sig_header.split(",").map { |p| p.split("=", 2) }.to_h
    timestamp = parts["t"]
    v1        = parts["v1"]

    return head :bad_request if timestamp.nil? || v1.nil?

    signed_string = "#{timestamp}.#{raw_body}"
    expected = OpenSSL::HMAC.hexdigest("SHA256", WEBHOOK_SECRET, signed_string)

    unless ActiveSupport::SecurityUtils.secure_compare(expected, v1)
      return head :bad_request
    end

    payload = JSON.parse(raw_body)
    case payload["type"]
    when "email.delivered"
      # 在数据库中标记已送达
    when "email.bounced"
      # 标记地址无效
    end

    head :ok
  end
end
# config/routes.rb
post "/webhooks/rovela", to: "webhooks/rovela#create"

ActiveSupport::SecurityUtils.secure_compare 是 Rails 内置的常量时间比对方法。