← 返回博客

用 Elixir 通过 Rovela API 发送邮件

介绍如何用 Elixir 的 HTTPoison 或 Finch 发送事务邮件,包含幂等键重试、Task.async_stream 并发批量发送,以及 Plug 和 Phoenix 的常量时间 HMAC Webhook 验签。

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

本文介绍如何用 Elixir 调用 Rovela API 发送事务邮件,涵盖 HTTPoison、Finch、幂等键重试、Task.async_stream 并发批量发送,以及 Plug 和 Phoenix 的 Webhook 验签。

前提条件

将 API 密钥设置为环境变量:

export ROVELA_API_KEY="re_..."

在 mix.exs 中添加依赖:

defp deps do
  [
    {:httpoison, "~> 2.0"},
    {:jason, "~> 1.4"}
  ]
end

用 HTTPoison 发送

defmodule Rovela do
  @api_url "https://api.rovela.dev/emails"

  def send_email(to, subject, html) do
    api_key = System.fetch_env!("ROVELA_API_KEY")

    headers = [
      {"Authorization", "Bearer #{api_key}"},
      {"Content-Type", "application/json"}
    ]

    body = Jason.encode!(%{
      from: "Acme <noreply@yourdomain.com>",
      to: to, subject: subject, html: html
    })

    case HTTPoison.post(@api_url, body, headers) do
      {:ok, %{status_code: s, body: b}} when s in 200..299 -> {:ok, Jason.decode!(b)}
      {:ok, %{status_code: s, body: b}}                    -> {:error, {s, b}}
      {:error, %{reason: r}}                               -> {:error, r}
    end
  end
end

用 Finch 发送

Finch 基于连接池,适合生产环境。先在 Application Supervisor 中启动:

# application.ex
children = [{Finch, name: RovelaFinch}]
defmodule Rovela.Finch do
  @api_url "https://api.rovela.dev/emails"

  def send_email(to, subject, html) do
    api_key = System.fetch_env!("ROVELA_API_KEY")

    headers = [
      {"authorization", "Bearer #{api_key}"},
      {"content-type", "application/json"}
    ]

    body = Jason.encode!(%{
      from: "Acme <noreply@yourdomain.com>",
      to: to, subject: subject, html: html
    })

    :post
    |> Finch.build(@api_url, headers, body)
    |> Finch.request(RovelaFinch)
    |> case do
      {:ok, %{status: s, body: b}} when s in 200..299 -> {:ok, Jason.decode!(b)}
      {:ok, %{status: s, body: b}}                    -> {:error, {s, b}}
      {:error, reason}                                 -> {:error, reason}
    end
  end
end

重试与幂等键

在循环(递归)之前生成一次幂等键,每次重试都复用同一个键,防止服务器接受请求后网络断开导致重复发送。只对 429 重试:

defmodule Rovela.Retry do
  @api_url "https://api.rovela.dev/emails"
  @max_retries 4

  def send_with_retry(payload, attempt \\ 0, idempotency_key \\ nil) do
    key = idempotency_key || (:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower))
    api_key = System.fetch_env!("ROVELA_API_KEY")

    headers = [
      {"Authorization", "Bearer #{api_key}"},
      {"Content-Type", "application/json"},
      {"Idempotency-Key", key}
    ]

    case HTTPoison.post(@api_url, Jason.encode!(payload), headers) do
      {:ok, %{status_code: 429, headers: h}} when attempt < @max_retries ->
        delay =
          case List.keyfind(resp_headers, "retry-after", 0) do
            {_, v} ->
              # Retry-After 也可能是 HTTP 日期,解析不了就退回指数退避
              case Integer.parse(v) do
                {secs, _} -> secs * 1000
                :error    -> trunc(:math.pow(2, attempt) * 1000)
              end

            nil ->
              trunc(:math.pow(2, attempt) * 1000)
          end
        Process.sleep(delay)
        send_with_retry(payload, attempt + 1, key)

      {:ok, %{status_code: s, body: b}} when s in 200..299 -> {:ok, Jason.decode!(b)}
      {:ok, %{status_code: s, body: b}}                    -> {:error, {s, b}}
      {:error, %{reason: r}}                               -> {:error, r}
    end
  end
end

Task.async_stream 并发批量发送

Task.async_stream/3 的 max_concurrency 参数直接限制并发数,不需要手动管理信号量:

defmodule Rovela.Batch do
  def send_batch(recipients, concurrency \\ 5) do
    recipients
    |> Task.async_stream(
      fn %{email: email, subject: subject, html: html} ->
        case Rovela.send_email(email, subject, html) do
          {:ok, result} -> {:ok, email, result}
          {:error, err} -> {:error, email, err}
        end
      end,
      max_concurrency: concurrency,
      timeout: 30_000,
      on_timeout: :kill_task
    )
    |> Enum.map(fn
      {:ok, result}   -> result
      {:exit, reason} -> {:error, :timeout, reason}
    end)
  end
end

结果保持原始顺序,单个任务失败不会中断其余任务。

Plug 验签

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

defmodule MyApp.RovelaWebhookPlug do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    secret = System.fetch_env!("ROVELA_WEBHOOK_SECRET")
    {:ok, raw_body, conn} = Plug.Conn.read_body(conn)
    sig_header = get_req_header(conn, "x-resend-signature") |> List.first("")

    with {:ok, timestamp, v1_hex} <- parse_signature(sig_header),
         {:ok, v1_bytes}          <- Base.decode16(v1_hex, case: :lower),
         :ok                      <- verify(secret, timestamp, raw_body, v1_bytes) do
      payload = Jason.decode!(raw_body)
      conn |> handle_event(payload) |> send_resp(200, "ok")
    else
      {:error, reason} -> conn |> send_resp(400, inspect(reason)) |> halt()
    end
  end

  defp parse_signature(header) do
    parts =
      header
      |> String.split(",")
      |> Enum.flat_map(fn part ->
        case String.split(part, "=", parts: 2) do
          [k, v] -> [{k, v}]
          _      -> []
        end
      end)
      |> Map.new()

    case {parts["t"], parts["v1"]} do
      {t, v1} when not is_nil(t) and not is_nil(v1) -> {:ok, t, v1}
      _ -> {:error, :malformed_signature}
    end
  end

  defp verify(secret, timestamp, raw_body, v1_bytes) do
    expected = :crypto.mac(:hmac, :sha256, secret, "#{timestamp}.#{raw_body}")
    if :crypto.hash_equals(expected, v1_bytes), do: :ok, else: {:error, :invalid_signature}
  end

  defp handle_event(conn, _payload), do: conn
end

在路由中挂载:

plug "/webhooks/rovela", MyApp.RovelaWebhookPlug

Phoenix 验签

Phoenix 默认的 JSON 解析器会消费 body,需要先缓存原始字节,再在控制器里验签:

# lib/my_app/cache_body_reader.ex
defmodule MyApp.CacheBodyReader do
  def read_body(conn, opts) do
    {:ok, body, conn} = Plug.Conn.read_body(conn, opts)
    conn = Plug.Conn.put_private(conn, :raw_body, body)
    {:ok, body, conn}
  end
end

在 endpoint.ex 中把 Plug.Parsers 的 body_reader 换成上面的模块:

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  body_reader: {MyApp.CacheBodyReader, :read_body, []},
  json_decoder: Jason

控制器:

defmodule MyAppWeb.WebhookController do
  use MyAppWeb, :controller

  def rovela(conn, _params) do
    secret     = System.fetch_env!("ROVELA_WEBHOOK_SECRET")
    raw_body   = conn.private[:raw_body] || ""
    sig_header = get_req_header(conn, "x-resend-signature") |> List.first("")

    with {:ok, ts, v1_hex} <- parse_sig(sig_header),
         {:ok, v1}         <- Base.decode16(v1_hex, case: :lower),
         expected          = :crypto.mac(:hmac, :sha256, secret, "#{ts}.#{raw_body}"),
         true              <- :crypto.hash_equals(expected, v1) do
      handle_event(conn.body_params["type"])
      send_resp(conn, 200, "ok")
    else
      _ -> send_resp(conn, 400, "invalid signature")
    end
  end

  defp parse_sig(header) do
    parts = for kv <- String.split(header, ","),
                [k, v] <- [String.split(kv, "=", parts: 2)],
                into: %{}, do: {k, v}
    case {parts["t"], parts["v1"]} do
      {t, v} when not is_nil(t) and not is_nil(v) -> {:ok, t, v}
      _ -> {:error, :malformed}
    end
  end

  defp handle_event(_type), do: :ok
end

路由:

scope "/webhooks" do
  post "/rovela", WebhookController, :rovela
end

:crypto.hash_equals/2 做常量时间比对,防止时序攻击。