← 返回博客
用 Ruby 通过 Rovela API 发送邮件
介绍如何用 Ruby 的 Net::HTTP、Faraday 和 HTTParty 发送事务邮件,包含幂等键重试、批量发送,以及 Sinatra 和 Rails 的 Webhook 验签。
更新于 2026年11月3日

本文介绍如何用 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 内置的常量时间比对方法。