← 返回博客

用 Clojure 通过 Rovela API 发送邮件

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

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

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 重新编码一遍,几乎不可能还原出同样的内容。