← 返回博客
用 Haskell 通过 Rovela API 发送邮件
介绍如何用 Haskell 的 http-conduit 或 req 发送事务邮件,包含幂等键重试、mapConcurrently 并发批量发送,以及 Scotty 框架用 cryptonite 实现常量时间 HMAC Webhook 验签。
更新于 2026年11月3日

本文介绍如何用 Haskell 调用 Rovela API 发送事务邮件,涵盖 http-conduit、req 库、幂等键重试、mapConcurrently 并发批量发送,以及 Scotty 框架用 cryptonite 实现常量时间 HMAC Webhook 验签。
前提条件
将 API 密钥设置为环境变量:
export ROVELA_API_KEY="re_..."
在 package.yaml 中添加依赖:
dependencies:
- http-conduit >= 2.3
- aeson >= 2.0
- bytestring
- text
用 http-conduit 发送
{-# LANGUAGE OverloadedStrings #-}
module Rovela where
import Data.Aeson (encode, object, (.=))
import qualified Data.ByteString.Char8 as BS
import Data.Text (Text)
import Network.HTTP.Conduit
import Network.HTTP.Types (statusIsSuccessful)
import System.Environment (getEnv)
sendEmail :: Text -> Text -> Text -> IO ()
sendEmail to subject html = do
apiKey <- getEnv "ROVELA_API_KEY"
manager <- newTlsManager
let payload = object
[ "from" .= ("Acme <noreply@yourdomain.com>" :: Text)
, "to" .= to
, "subject" .= subject
, "html" .= html
]
initReq <- parseRequest "https://api.rovela.dev/emails"
let req = initReq
{ method = "POST"
, requestBody = RequestBodyLBS (encode payload)
, requestHeaders =
[ ("Authorization", "Bearer " <> BS.pack apiKey)
, ("Content-Type", "application/json")
]
}
resp <- httpLbs req manager
if statusIsSuccessful (responseStatus resp)
then return ()
else ioError (userError ("HTTP 错误: " <> show (responseStatus resp)))
用 req 发送
req 提供更符合人体工程学的接口和类型安全的 URL 构建:
dependencies:
- req >= 3.13
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DataKinds #-}
module Rovela.Req where
import Data.Aeson (object, (.=))
import Data.Text (Text)
import qualified Data.Text as T
import Network.HTTP.Req
import System.Environment (getEnv)
sendEmail :: Text -> Text -> Text -> IO BsResponse
sendEmail to subject html = do
apiKey <- getEnv "ROVELA_API_KEY"
let payload = object
[ "from" .= ("Acme <noreply@yourdomain.com>" :: Text)
, "to" .= to
, "subject" .= subject
, "html" .= html
]
runReq defaultHttpConfig $
req POST
(https "api.rovela.dev" /: "emails")
(ReqBodyJson payload)
bsResponse
( header "Authorization" ("Bearer " <> TE.encodeUtf8 (T.pack apiKey))
<> header "Content-Type" "application/json"
)
重试与幂等键
在循环之前生成一次幂等键,每次重试都复用同一个键,防止服务器接受请求后网络断开导致重复发送。只对 429 重试:
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NumericUnderscores #-}
module Rovela.Retry where
import Control.Concurrent (threadDelay)
import Control.Exception (throwIO)
import Data.Aeson (Value, encode)
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as LBS
import Network.HTTP.Conduit
import Network.HTTP.Types (Status (statusCode))
import System.Environment (getEnv)
import System.Random (randomRIO)
import Text.Printf (printf)
randomHex :: Int -> IO String
randomHex n = do
bytes <- mapM (\_ -> randomRIO (0, 255 :: Int)) [1..n]
return $ concatMap (printf "%02x") bytes
sendWithRetry :: Value -> Int -> IO LBS.ByteString
sendWithRetry payload maxRetries = do
apiKey <- getEnv "ROVELA_API_KEY"
idempotencyKey <- randomHex 16 -- 循环前生成,重试时复用
manager <- newTlsManager
initReq <- parseRequest "https://api.rovela.dev/emails"
let baseReq = initReq
{ method = "POST"
, requestBody = RequestBodyLBS (encode payload)
, requestHeaders =
[ ("Authorization", "Bearer " <> BS.pack apiKey)
, ("Content-Type", "application/json")
, ("Idempotency-Key", BS.pack idempotencyKey)
]
}
let go attempt = do
resp <- httpLbs baseReq manager
let code = statusCode (responseStatus resp)
case code of
429 | attempt < maxRetries -> do
threadDelay (2 ^ attempt * 1_000_000)
go (attempt + 1)
_ | code >= 200 && code < 300 -> return (responseBody resp)
_ -> ioError (userError ("HTTP 错误: " <> show code))
go 0
mapConcurrently 并发批量发送
mapConcurrently 并发执行列表中的所有操作,用 QSem 信号量限制最大并发数:
dependencies:
- async >= 2.2
{-# LANGUAGE OverloadedStrings #-}
module Rovela.Batch where
import Control.Concurrent (newQSem, signalQSem, waitQSem)
import Control.Concurrent.Async (mapConcurrently)
import Control.Exception (bracket_)
import Data.Text (Text)
import Rovela (sendEmail)
data Recipient = Recipient
{ recipientEmail :: Text
, recipientSubject :: Text
, recipientHtml :: Text
}
sendBatch :: [Recipient] -> Int -> IO [Either String String]
sendBatch recipients concurrency = do
sem <- newQSem concurrency -- 信号量控制并发上限
mapConcurrently (withSem sem . sendOne) recipients
where
withSem sem action =
bracket_ (waitQSem sem) (signalQSem sem) action
sendOne r = do
_ <- sendEmail (recipientEmail r) (recipientSubject r) (recipientHtml r)
return (Right "sent" :: Either String String)
waitQSem 在计数器归零时阻塞,signalQSem 在任务完成后释放,确保最多 concurrency 个请求同时进行。
Scotty Webhook 验签
Rovela 用 HMAC-SHA256 对每个事件签名。签名在 x-resend-signature 请求头中,格式为 t=<时间戳>,v1=<十六进制签名>。对 <时间戳>.<原始 body> 用 Webhook 密钥计算 HMAC,再用 constEq 做常量时间比对防止时序攻击。
dependencies:
- scotty >= 0.20
- cryptonite >= 0.30
- memory >= 0.18
{-# LANGUAGE OverloadedStrings #-}
module Rovela.Webhook where
import qualified Crypto.Hash.Algorithms as Hash
import qualified Crypto.MAC.HMAC as HMAC
import Control.Monad.IO.Class (liftIO)
import qualified Data.ByteArray as BA
import Data.ByteArray (constEq)
import qualified Data.ByteArray.Encoding as BAE
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Lazy as TL
import Network.HTTP.Types (status400)
import System.Environment (getEnv)
import Web.Scottyimport Web.Scotty
parseSigHeader :: BS.ByteString -> Maybe (BS.ByteString, BS.ByteString)
parseSigHeader hdr = do
let parts = map (BSC.break (== '=')) (BSC.splitWith (== ',') hdr)
toKV (k, v)
| BS.null v = Nothing
| otherwise = Just (k, BS.tail v)
kvs = [kv | Just kv <- map toKV parts]
t <- lookup "t" kvs
v1 <- lookup "v1" kvs
return (t, v1)
verifySignature :: BS.ByteString -> BS.ByteString -> BS.ByteString -> BS.ByteString -> Bool
verifySignature secret timestamp rawBody v1Hex =
case BAE.convertFromBase BAE.Base16 v1Hex of
Left _ -> False
Right provided ->
let signingInput = timestamp <> "." <> rawBody
expected = HMAC.hmac secret signingInput :: HMAC.HMAC Hash.SHA256
-- constEq 逐字节比对,不提前返回,防止时序攻击
in constEq (BA.convert (HMAC.hmacGetDigest expected) :: BS.ByteString) provided
main :: IO ()
main = scotty 8080 $
post "/webhooks/rovela" $ do
secret <- liftIO (getEnv "ROVELA_WEBHOOK_SECRET")
rawBody <- body
sigHeader <- header "x-resend-signature"
let sigBS = maybe "" (TE.encodeUtf8 . TL.toStrict) sigHeader
case parseSigHeader sigBS of
Nothing -> do
status status400
text "缺少签名字段"
Just (ts, v1Hex) -> do
if verifySignature (BSC.pack secret) ts (LBS.toStrict rawBody) v1Hex
then do
-- 解析 body 并处理事件类型
text "ok"
else do
status status400
text "签名无效"
constEq 来自 memory 包,逐字节比对且不短路,确保比对时间不因哪些字节匹配而泄露信息。