← 返回博客

用 Kotlin 通过 Rovela API 发送邮件

介绍如何用 Kotlin 的 HttpURLConnection、OkHttp 或 Ktor 客户端发送事务邮件,包含幂等键重试、协程 Semaphore 批量发送,以及 Ktor 服务端和 Spring Boot 的 Webhook 验签。

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

本文介绍如何用 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 做常量时间字节比对,防止时序攻击。