← 返回博客
用 Scala 通过 Rovela API 发送邮件
介绍如何用 Scala 的 HttpURLConnection 或 sttp 发送事务邮件,包含幂等键重试、parTraverse 并发批量发送,以及 http4s 框架的常量时间 HMAC Webhook 验签。
更新于 2026年11月3日

本文介绍如何用 Scala 调用 Rovela API 发送事务邮件,涵盖标准库 HttpURLConnection、sttp 客户端、幂等键重试、parTraverse 并发批量发送,以及 http4s 服务端的 Webhook 验签。
前提条件
将 API 密钥设置为环境变量:
export ROVELA_API_KEY="re_..."
用 HttpURLConnection 发送
仅依赖 JDK:
import java.net.{HttpURLConnection, URL}
import scala.io.Source
import scala.util.{Try, Using}
object Rovela {
private val apiUrl = "https://api.rovela.dev/emails"
def sendEmail(to: String, subject: String, html: String): Try[String] = Try {
val apiKey = sys.env("ROVELA_API_KEY")
val conn = new URL(apiUrl).openConnection().asInstanceOf[HttpURLConnection]
conn.setRequestMethod("POST")
conn.setRequestProperty("Authorization", s"Bearer $apiKey")
conn.setRequestProperty("Content-Type", "application/json")
conn.setDoOutput(true)
val body = s"""{"from":"Acme <noreply@yourdomain.com>","to":"$to","subject":"$subject","html":"$html"}"""
Using.resource(conn.getOutputStream)(_.write(body.getBytes("UTF-8")))
val code = conn.getResponseCode
val stream = if (code >= 200 && code < 300) conn.getInputStream else conn.getErrorStream
Using.resource(Source.fromInputStream(stream))(_.mkString)
}
}
用 sttp 发送
sttp 是 Scala 常用的 HTTP 客户端,支持多种后端:
libraryDependencies ++= Seq(
"com.softwaremill.sttp.client3" %% "core" % "3.9.7",
"com.softwaremill.sttp.client3" %% "cats" % "3.9.7",
"io.circe" %% "circe-generic" % "0.14.9",
"org.typelevel" %% "cats-effect" % "3.5.4"
)
import sttp.client3._
import sttp.client3.httpclient.cats.HttpClientCatsBackend
import cats.effect.IO
import io.circe.generic.semiauto._
import io.circe.syntax._
import io.circe._
case class SendEmailRequest(from: String, to: String, subject: String, html: String)
object RovelaSttp {
implicit val encoder: Encoder[SendEmailRequest] = deriveEncoder
def sendEmail(req: SendEmailRequest): IO[Response[String]] = {
val apiKey = sys.env("ROVELA_API_KEY")
// resource() 会真正建立一个基于 HttpClient 的后端
HttpClientCatsBackend.resource[IO]().use { backend =>
basicRequest
.post(uri"https://api.rovela.dev/emails")
.header("Authorization", s"Bearer $apiKey")
.header("Content-Type", "application/json")
.body(req.asJson.noSpaces)
.send(backend)
}
}
}
重试与幂等键
在循环之前生成一次幂等键,每次重试都复用同一个键,防止服务器接受请求后网络断开导致重复发送。只对 429 重试:
import java.net.{HttpURLConnection, URL}
import java.security.SecureRandom
import scala.annotation.tailrec
import scala.io.Source
import scala.util.{Try, Using}
object RovelaRetry {
private val apiUrl = "https://api.rovela.dev/emails"
private val maxRetries = 4
private def randomKey(): String = {
val bytes = new Array[Byte](16)
new SecureRandom().nextBytes(bytes)
bytes.map(b => f"$b%02x").mkString
}
/** 2xx 返回 Right(body),其余返回 Left(状态码)。 */
private def postOnce(apiKey: String, key: String, payload: String): Either[Int, String] = {
val conn = new URL(apiUrl).openConnection().asInstanceOf[HttpURLConnection]
conn.setRequestMethod("POST")
conn.setRequestProperty("Authorization", s"Bearer $apiKey")
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Idempotency-Key", key)
conn.setDoOutput(true)
Using.resource(conn.getOutputStream)(_.write(payload.getBytes("UTF-8")))
val code = conn.getResponseCode
if (code >= 200 && code < 300)
Right(Using.resource(Source.fromInputStream(conn.getInputStream))(_.mkString))
else Left(code)
}
@tailrec
private def loop(attempt: Int, apiKey: String, key: String, payload: String): Try[String] =
postOnce(apiKey, key, payload) match {
case Right(body) => Try(body)
case Left(429) if attempt < maxRetries =>
Thread.sleep((math.pow(2, attempt) * 1000).toInt)
loop(attempt + 1, apiKey, key, payload)
case Left(code) =>
Try(throw new RuntimeException(s"HTTP $code"))
}
def sendWithRetry(payload: String): Try[String] = Try {
val apiKey = sys.env("ROVELA_API_KEY")
val key = randomKey() // 循环前生成,重试时复用
loop(0, apiKey, key, payload).get
}
}
Future 并发批量发送
parTraverse 并发执行发送,用 cats-effect 的 Semaphore 限制同时进行的请求数:
import cats.effect.{IO, Semaphore}
import cats.syntax.all._
case class Recipient(email: String, subject: String, html: String)
def sendOne(r: Recipient): String = {
import java.net.{HttpURLConnection, URL}
import scala.io.Source
import scala.util.Using
val conn = new URL("https://api.rovela.dev/emails")
.openConnection().asInstanceOf[HttpURLConnection]
conn.setRequestMethod("POST")
conn.setRequestProperty("Authorization", s"Bearer ${sys.env("ROVELA_API_KEY")}")
conn.setRequestProperty("Content-Type", "application/json")
conn.setDoOutput(true)
val body =
s"""{"from":"Acme <noreply@yourdomain.com>","to":"${r.email}","subject":"${r.subject}","html":"${r.html}"}"""
Using.resource(conn.getOutputStream)(_.write(body.getBytes("UTF-8")))
val code = conn.getResponseCode
val stream = if (code >= 200 && code < 300) conn.getInputStream else conn.getErrorStream
Using.resource(Source.fromInputStream(stream))(_.mkString)
}
def sendBatch(recipients: List[Recipient], concurrency: Int = 5): IO[List[String]] =
Semaphore[IO](concurrency).flatMap { sem =>
recipients.parTraverse { r =>
sem.permit.use(_ => IO.blocking(sendOne(r)))
}
}
不用 cats-effect 时,把列表按 concurrency 分块,每组用 Future.traverse 并发,前一组完成再启动下一组。
http4s Webhook 验签
Rovela 用 HMAC-SHA256 对每个事件签名。签名在 x-resend-signature 请求头中,格式为 t=<时间戳>,v1=<十六进制签名>。对 <时间戳>.<原始 body> 用 Webhook 密钥计算 HMAC,再用常量时间比对防止时序攻击。
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-dsl" % "0.23.26",
"org.http4s" %% "http4s-blaze-server" % "0.23.16"
)
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import cats.effect.{IO, IOApp}
import org.http4s._
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.dsl.io._
import org.http4s.implicits._
import org.http4s.server.Router
import org.typelevel.ci._
import scala.util.Try
object RovelaWebhook {
private def constantTimeEqual(a: Array[Byte], b: Array[Byte]): Boolean = {
if (a.length != b.length) return false
var result = 0; var i = 0
while (i < a.length) { result |= a(i) ^ b(i); i += 1 }
result == 0
}
private def parseSignature(header: String): Option[(String, String)] = {
val parts = header.split(",").flatMap { part =>
part.split("=", 2) match {
case Array(k, v) => Some(k -> v)
case _ => None
}
}.toMap
for {
t <- parts.get("t")
v1 <- parts.get("v1")
} yield (t, v1)
}
private def verify(secret: String, header: String, rawBody: String): Boolean =
parseSignature(header).exists { case (ts, v1Hex) =>
Try {
val mac = Mac.getInstance("HmacSHA256")
mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"))
val expected = mac.doFinal(s"$ts.$rawBody".getBytes("UTF-8"))
// 奇数长度的十六进制会在最后一段抛异常,被外层的 Try 捕获
val provided = v1Hex.grouped(2).map(Integer.parseInt(_, 16).toByte).toArray
constantTimeEqual(expected, provided)
}.getOrElse(false)
}
def routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ POST -> Root / "webhooks" / "rovela" =>
val secret = sys.env("ROVELA_WEBHOOK_SECRET")
val sigHead = req.headers.get(ci"x-resend-signature").map(_.head.value)
req.as[String].flatMap { rawBody =>
sigHead match {
case None => BadRequest("missing signature")
case Some(h) if !verify(secret, h, rawBody) => BadRequest("invalid signature")
case Some(_) =>
// 解析 body 并处理事件
Ok("ok")
}
}
}
}
object Main extends IOApp.Simple {
def run: IO[Unit] =
BlazeServerBuilder[IO]
.bindHttp(8080, "0.0.0.0")
.withHttpApp(Router("/" -> RovelaWebhook.routes).orNotFound)
.serve
.compile
.drain
}
constantTimeEqual 逐字节异或再 OR,全通过才返回 true,防止通过响应时间推断签名内容。