← 返回博客
用 Kotlin 通过 Rovela API 发送邮件
介绍如何用 Kotlin 的 HttpURLConnection、OkHttp 或 Ktor 客户端发送事务邮件,包含幂等键重试、协程 Semaphore 批量发送,以及 Ktor 服务端和 Spring Boot 的 Webhook 验签。
更新于 2026年11月3日

本文介绍如何用 Kotlin 调用 Rovela API 发送事务邮件,涵盖标准库 HttpURLConnection、OkHttp、Ktor 客户端、幂等键重试、协程批量发送,以及 Ktor 服务端和 Spring Boot 的 Webhook 验签。
前提条件
将 API 密钥设置为环境变量:
export ROVELA_API_KEY="re_..."
用 HttpURLConnection 发送
仅依赖 JDK,无需额外库:
import java.net.HttpURLConnection
import java.net.URL
fun sendEmail(to: String, subject: String, html: String): String {
val apiKey = System.getenv("ROVELA_API_KEY")
val url = URL("https://api.rovela.dev/emails")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Authorization", "Bearer $apiKey")
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
val body = """{"from":"Acme <noreply@yourdomain.com>","to":"$to","subject":"$subject","html":"$html"}"""
conn.outputStream.use { it.write(body.toByteArray()) }
return conn.inputStream.use { it.readBytes().decodeToString() }
}
用 OkHttp 发送
OkHttp 提供连接池和简洁的 API:
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
val client = OkHttpClient()
val JSON = "application/json".toMediaType()
fun sendEmail(to: String, subject: String, html: String): String {
val body = """{"from":"Acme <noreply@yourdomain.com>","to":"$to","subject":"$subject","html":"$html"}"""
.toRequestBody(JSON)
val request = Request.Builder()
.url("https://api.rovela.dev/emails")
.addHeader("Authorization", "Bearer ${System.getenv("ROVELA_API_KEY")}")
.post(body)
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "HTTP ${response.code}" }
return response.body!!.string()
}
}
用 Ktor HttpClient 发送
协程项目中推荐使用 Ktor 客户端:
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
@Serializable
data class SendEmailRequest(val from: String, val to: String, val subject: String, val html: String)
val httpClient = HttpClient(CIO)
suspend fun sendEmail(req: SendEmailRequest) =
httpClient.post("https://api.rovela.dev/emails") {
header(HttpHeaders.Authorization, "Bearer ${System.getenv("ROVELA_API_KEY")}")
contentType(ContentType.Application.Json)
setBody(req)
}
重试与幂等键
在循环之前生成一次幂等键,每次重试都复用同一个键,防止服务器接受请求后网络断开导致重复发送:
import java.util.UUID
import kotlin.math.pow
fun sendWithRetry(payload: String, maxRetries: Int = 4): String {
val idempotencyKey = UUID.randomUUID().toString() // 循环前生成,重试时复用
for (attempt in 0 until maxRetries) {
val request = Request.Builder()
.url("https://api.rovela.dev/emails")
.addHeader("Authorization", "Bearer ${System.getenv("ROVELA_API_KEY")}")
.addHeader("Idempotency-Key", idempotencyKey)
.post(payload.toRequestBody(JSON))
.build()
client.newCall(request).execute().use { response ->
if (response.code != 429) {
check(response.isSuccessful) { "HTTP ${response.code}" }
return response.body!!.string()
}
val retryAfter = response.header("Retry-After")?.toLongOrNull()
val delayMs = retryAfter?.times(1000) ?: (2.0.pow(attempt) * 1000).toLong()
Thread.sleep(delayMs)
}
}
throw RuntimeException("超过最大重试次数 $maxRetries")
}
协程批量发送
用 kotlinx.coroutines 的 Semaphore 控制并发:
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
data class Recipient(val email: String, val subject: String, val html: String)
suspend fun sendBatch(recipients: List<Recipient>) = coroutineScope {
val semaphore = Semaphore(5)
val httpClient = HttpClient(CIO)
recipients.map { r ->
async {
semaphore.withPermit {
val response = httpClient.post("https://api.rovela.dev/emails") {
header(HttpHeaders.Authorization, "Bearer ${System.getenv("ROVELA_API_KEY")}")
contentType(ContentType.Application.Json)
setBody("""{"from":"Acme <noreply@yourdomain.com>","to":"${r.email}","subject":"${r.subject}","html":"${r.html}"}""")
}
println("${r.email}: ${response.status.value}")
}
}
}.awaitAll()
httpClient.close()
}
Ktor 服务端验签
Rovela 用 HMAC-SHA256 对每个事件签名。签名在 x-resend-signature 请求头中,格式为 t=<时间戳>,v1=<十六进制签名>。用 Webhook 密钥对 <时间戳>.<原始 body> 签名,再用 MessageDigest.isEqual 常量时间比对。
import io.ktor.server.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.security.MessageDigest
fun Route.rovelaWebhook() {
val secret = System.getenv("ROVELA_WEBHOOK_SECRET")!!
post("/webhooks/rovela") {
val rawBody = call.receive<ByteArray>()
val sigHeader = call.request.header("x-resend-signature") ?: ""
val parts = sigHeader.split(",")
.mapNotNull { it.split("=", limit = 2).takeIf { p -> p.size == 2 }?.let { p -> p[0] to p[1] } }
.toMap()
val timestamp = parts["t"] ?: return@post call.respond(io.ktor.http.HttpStatusCode.BadRequest, "missing timestamp")
val v1 = parts["v1"] ?: return@post call.respond(io.ktor.http.HttpStatusCode.BadRequest, "missing v1")
val signedString = "$timestamp.${rawBody.decodeToString()}"
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
val expected = mac.doFinal(signedString.toByteArray()).joinToString("") { "%02x".format(it) }
if (!MessageDigest.isEqual(expected.toByteArray(), v1.toByteArray()))
return@post call.respond(io.ktor.http.HttpStatusCode.BadRequest, "invalid signature")
call.respond("ok")
}
}
Spring Boot 验签
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.security.MessageDigest
@RestController
@RequestMapping("/webhooks")
class RovelaWebhookController {
private val secret = System.getenv("ROVELA_WEBHOOK_SECRET")!!
@PostMapping("/rovela")
fun handle(
@RequestHeader("x-resend-signature") sigHeader: String,
@RequestBody rawBody: ByteArray,
): ResponseEntity<String> {
val parts = sigHeader.split(",")
.mapNotNull { it.split("=", limit = 2).takeIf { p -> p.size == 2 }?.let { p -> p[0] to p[1] } }
.toMap()
val timestamp = parts["t"] ?: return ResponseEntity.badRequest().body("missing timestamp")
val v1 = parts["v1"] ?: return ResponseEntity.badRequest().body("missing v1")
val signedString = "$timestamp.${rawBody.decodeToString()}"
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
val expected = mac.doFinal(signedString.toByteArray()).joinToString("") { "%02x".format(it) }
if (!MessageDigest.isEqual(expected.toByteArray(), v1.toByteArray())) {
return ResponseEntity.badRequest().body("invalid signature")
}
return ResponseEntity.ok("ok")
}
}
MessageDigest.isEqual 做常量时间字节比对,防止时序攻击。