用 Clojure 通过 Rovela API 发送邮件
介绍如何用 Clojure 的 clj-http、JDK HttpClient 或 http-kit 发送事务邮件,包含幂等键重试、ExecutorService 并发批量发送,以及 Ring 和 http-kit 的 Webhook 验签。

Clojure 跑在 JVM 上,既能直接用成熟的 HTTP 和加密库,代码又比对应的 Java 短很多。本文涵盖 clj-http、JDK 自带的 HttpClient 和 http-kit 三种发送方式,幂等键重试、用 ExecutorService 限制并发的批量发送,以及 Ring 和 http-kit 的 Webhook 验签。
前提条件
将 API 密钥设置为环境变量:
export ROVELA_API_KEY="re_..."
在 deps.edn 中加入依赖:
{:deps {clj-http/clj-http {:mvn/version "3.13.0"}
cheshire/cheshire {:mvn/version "5.13.0"}
http-kit/http-kit {:mvn/version "2.8.0"}
compojure/compojure {:mvn/version "1.7.1"}
ring/ring-core {:mvn/version "1.12.2"}
ring/ring-jetty-adapter {:mvn/version "1.12.2"}}}
用 clj-http 发送
clj-http 是最常用的选择,搭配 cheshire 处理 JSON:
(ns rovela.core
(:require [clj-http.client :as http]
[cheshire.core :as json]))
(defn send-email [to subject html]
(let [resp (http/post "https://api.rovela.dev/emails"
{:headers {"Authorization" (str "Bearer " (System/getenv "ROVELA_API_KEY"))
"Content-Type" "application/json"}
:body (json/generate-string {:from "Acme <noreply@yourdomain.com>"
:to to
:subject subject
:html html})
:throw-exceptions false})]
(when-not (<= 200 (:status resp) 299)
(throw (ex-info (str "HTTP " (:status resp) ": " (:body resp))
{:status (:status resp)})))
(json/parse-string (:body resp) true)))
(println (:id (send-email "user@example.com"
"Welcome aboard"
"<p>Thanks for signing up!</p>")))
用 java.net.http 发送
不想引入依赖的话,JDK 11 自带的客户端只需要几行 interop:
(ns rovela.jdk
(:require [cheshire.core :as json])
(:import (java.net URI)
(java.net.http HttpClient HttpRequest
HttpRequest$BodyPublishers
HttpResponse$BodyHandlers)))
(defn send-email [to subject html]
(let [body (json/generate-string {:from "Acme <noreply@yourdomain.com>"
:to to
:subject subject
:html html})
client (HttpClient/newHttpClient)
req (-> (HttpRequest/newBuilder (URI/create "https://api.rovela.dev/emails"))
(.header "Authorization" (str "Bearer " (System/getenv "ROVELA_API_KEY")))
(.header "Content-Type" "application/json")
(.POST (HttpRequest$BodyPublishers/ofString body))
(.build))
resp (.send client req (HttpResponse$BodyHandlers/ofString))]
(when-not (<= 200 (.statusCode resp) 299)
(throw (ex-info (str "HTTP " (.statusCode resp) ": " (.body resp)) {})))
(json/parse-string (.body resp) true)))
用 http-kit 发送
http-kit 返回的是 promise,和异步为主的程序组合起来很自然:
(ns rovela.async
(:require [cheshire.core :as json]
[org.httpkit.client :as http]))
(defn send-email [to subject html]
(let [{:keys [status body error]}
@(http/post "https://api.rovela.dev/emails"
{:headers {"Authorization" (str "Bearer " (System/getenv "ROVELA_API_KEY"))
"Content-Type" "application/json"}
:body (json/generate-string {:from "Acme <noreply@yourdomain.com>"
:to to
:subject subject
:html html})})]
(when error
(throw (ex-info "request failed" {:error error})))
(when-not (<= 200 status 299)
(throw (ex-info (str "HTTP " status ": " body) {:status status})))
(json/parse-string body true)))
重试与幂等键
在循环之前生成一次幂等键,每次重试都复用同一个键,防止服务器已经接受请求后连接断开导致重复发送。只对 429 重试:
(ns rovela.retry
(:require [cheshire.core :as json]
[clj-http.client :as http])
(:import (java.util UUID)))
(def ^:private api-url "https://api.rovela.dev/emails")
(defn- post-once [api-key key payload]
(http/post api-url
{:headers {"Authorization" (str "Bearer " api-key)
"Content-Type" "application/json"
"Idempotency-Key" key}
:body payload
:throw-exceptions false}))
(defn send-with-retry [payload & {:keys [max-retries] :or {max-retries 4}}]
(let [api-key (System/getenv "ROVELA_API_KEY")
key (str (UUID/randomUUID)) ; 循环前生成一次,重试时复用
body (json/generate-string payload)]
(loop [attempt 0]
(let [{:keys [status headers body]} (post-once api-key key body)]
(cond
(<= 200 status 299)
(json/parse-string body true)
(and (= 429 status) (< attempt (dec max-retries)))
(let [retry-after (some-> (get headers "retry-after") parse-long)
delay-ms (long (* 1000 (or retry-after (Math/pow 2 attempt))))]
(Thread/sleep delay-ms)
(recur (inc attempt)))
:else
(throw (ex-info (str "HTTP " status ": " body) {:status status})))))))
(println (:id (send-with-retry {:from "Acme <noreply@yourdomain.com>"
:to "user@example.com"
:subject "Welcome aboard"
:html "<p>Thanks for signing up!</p>"})))
用 ExecutorService 并发批量发送
pmap 的线程数跟着 CPU 核数走,对网络请求来说这个上限并不合适。固定大小的线程池才能真正限制同时在途的请求数:
(ns rovela.batch
(:require [rovela.core :as rovela])
(:import (java.util.concurrent Callable Executors Future TimeUnit)))
(defn- submit ^Future [pool f]
(.submit pool ^Callable f))
(defn send-batch [recipients concurrency]
(let [pool (Executors/newFixedThreadPool concurrency)]
(try
(->> recipients
(mapv (fn [r]
(submit pool
(fn []
(try
{:email (:email r)
:result (rovela/send-email (:email r) (:subject r) (:html r))}
(catch Exception e
{:email (:email r) :error (.getMessage e)}))))))
(mapv (fn [^Future f] (.get f))))
(finally
(.shutdown pool)
(.awaitTermination pool 30 TimeUnit/SECONDS)))))
线程池负责限制并发,awaitTermination 给在途请求留出完成的时间再退出 JVM。
Ring Webhook 验签
Rovela 用 HMAC-SHA256 对每个事件签名。签名在 x-resend-signature 请求头中,格式为 t=<时间戳>,v1=<十六进制签名>。对 <时间戳>.<原始 body> 用 Webhook 密钥计算 HMAC,再用 MessageDigest/isEqual 做常量时间比对。
(ns rovela.webhook
(:require [cheshire.core :as json]
[clojure.string :as str]
[compojure.core :refer [POST defroutes]]
[ring.adapter.jetty :refer [run-jetty]])
(:import (java.security MessageDigest)
(javax.crypto Mac)
(javax.crypto.spec SecretKeySpec)))
(defn hmac-hex [secret data]
(let [mac (doto (Mac/getInstance "HmacSHA256")
(.init (SecretKeySpec. (.getBytes ^String secret "UTF-8") "HmacSHA256")))]
(->> (.doFinal mac (.getBytes ^String data "UTF-8"))
(map #(format "%02x" (bit-and % 0xff)))
(apply str))))
(defn parse-signature [header]
(->> (str/split (or header "") #",")
(keep (fn [part]
(let [[k v] (str/split part #"=" 2)]
(when v [k v]))))
(into {})))
(defn verify [secret header raw-body]
(let [parts (parse-signature header)
ts (get parts "t")
v1 (get parts "v1")]
(when (and ts v1)
(let [expected (hmac-hex secret (str ts "." raw-body))]
(MessageDigest/isEqual (.getBytes ^String expected "UTF-8")
(.getBytes ^String v1 "UTF-8"))))))
(defroutes app
(POST "/webhooks/rovela" request
(let [raw-body (slurp (:body request))
header (get-in request [:headers "x-resend-signature"])
secret (System/getenv "ROVELA_WEBHOOK_SECRET")]
(if (verify secret header raw-body)
{:status 200 :headers {"Content-Type" "text/plain"} :body "ok"}
{:status 400 :headers {"Content-Type" "text/plain"} :body "invalid signature"}))))
(defn -main [& _]
(run-jetty app {:port 8080 :join? true}))
http-kit Webhook 验签
http-kit 传给 handler 的 map 里,:body 已经是字符串,所以同一个校验函数可以原样复用:
(ns rovela.webhook-async
(:require [org.httpkit.server :as server]
[rovela.webhook :refer [verify]]))
(defn handler [request]
(let [raw-body (:body request)
header (get-in request [:headers "x-resend-signature"])
secret (System/getenv "ROVELA_WEBHOOK_SECRET")]
(if (verify secret header raw-body)
{:status 200 :headers {"Content-Type" "text/plain"} :body "ok"}
{:status 400 :headers {"Content-Type" "text/plain"} :body "invalid signature"})))
(defn -main [& _]
(server/run-server handler {:port 8080}))
两个 handler 都在碰 JSON 之前先读原始 body。hmac-hex 必须对发送方签名时那串完全相同的字节计算,把解析后的 map 重新编码一遍,几乎不可能还原出同样的内容。