用 Swift 通过 Rovela API 发送邮件
介绍如何用 Swift 的 URLSession 或 async/await 发送事务邮件,包含幂等键重试、TaskGroup 并发批量发送,以及用 Apple CryptoKit 做常量时间 Webhook 验签,附 Vapor 服务端示例。

本文介绍如何用 Swift 调用 Rovela API 发送事务邮件,涵盖 URLSession 回调、现代 async/await 写法、幂等键重试、TaskGroup 并发批量发送,以及用 Apple CryptoKit 进行 Webhook 验签——纯 Swift 和 Vapor 服务端两种场景。
前提条件
将 API 密钥设置为环境变量:
export ROVELA_API_KEY="re_..."
用 URLSession 发送
URLSession 在全平台(iOS、macOS、Linux Swift)均可用:
import Foundation
func sendEmail(
to: String,
subject: String,
html: String,
completion: @escaping (Result<Data, Error>) -> Void
) {
guard let apiKey = ProcessInfo.processInfo.environment["ROVELA_API_KEY"] else {
completion(.failure(URLError(.userAuthenticationRequired)))
return
}
var request = URLRequest(url: URL(string: "https://api.rovela.dev/emails")!)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [
"from": "Acme <noreply@yourdomain.com>",
"to": to, "subject": subject, "html": html,
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error { completion(.failure(error)); return }
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode), let data = data else {
completion(.failure(URLError(.badServerResponse))); return
}
completion(.success(data))
}.resume()
}
用 async/await 发送
Swift 5.5 起,URLSession.data(for:) 有原生 async 重载,和结构化并发配合更简洁:
import Foundation
struct EmailPayload: Encodable {
let from: String
let to: String
let subject: String
let html: String
}
struct EmailResult: Decodable {
let id: String
}
func sendEmail(_ payload: EmailPayload) async throws -> EmailResult {
let apiKey = ProcessInfo.processInfo.environment["ROVELA_API_KEY"]!
var request = URLRequest(url: URL(string: "https://api.rovela.dev/emails")!)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(payload)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(EmailResult.self, from: data)
}
重试与幂等键
在循环之前生成一次幂等键,每次重试都复用同一个键,防止服务器接受请求后网络断开导致重复发送:
import Foundation
func sendWithRetry(payload: some Encodable, maxRetries: Int = 4) async throws -> Data {
let apiKey = ProcessInfo.processInfo.environment["ROVELA_API_KEY"]!
let idempotencyKey = UUID().uuidString // 循环前生成,重试时复用
var request = URLRequest(url: URL(string: "https://api.rovela.dev/emails")!)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key")
request.httpBody = try JSONEncoder().encode(payload)
for attempt in 0..<maxRetries {
let (data, response) = try await URLSession.shared.data(for: request)
let http = response as! HTTPURLResponse
if http.statusCode != 429 {
guard (200...299).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return data
}
let wait = http.value(forHTTPHeaderField: "Retry-After")
.flatMap(Double.init) ?? pow(2.0, Double(attempt))
try await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
}
throw URLError(.cannotConnectToHost)
}
TaskGroup 并发批量发送
用 withThrowingTaskGroup 按窗口切片来控制并发,每批最多同时跑 N 个请求:
import Foundation
struct Recipient {
let email: String
let subject: String
let html: String
}
func sendBatch(recipients: [Recipient], concurrency: Int = 5) async throws {
let apiKey = ProcessInfo.processInfo.environment["ROVELA_API_KEY"]!
for chunk in stride(from: 0, to: recipients.count, by: concurrency)
.map({ recipients[$0..<min($0 + concurrency, recipients.count)] })
{
try await withThrowingTaskGroup(of: Void.self) { group in
for r in chunk {
group.addTask {
var req = URLRequest(url: URL(string: "https://api.rovela.dev/emails")!)
req.httpMethod = "POST"
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: [
"from": "Acme <noreply@yourdomain.com>",
"to": r.email, "subject": r.subject, "html": r.html,
])
let (_, response) = try await URLSession.shared.data(for: req)
print("\(r.email): \((response as! HTTPURLResponse).statusCode)")
}
}
try await group.waitForAll()
}
}
}
stride 把列表按 5 个一窗口切片,当前窗口全部完成后再开下一批。如果需要更精细地「始终保持 N 个任务在跑」,可以改用基于 actor 的令牌池。
用 CryptoKit 验签
Rovela 用 HMAC-SHA256 对每个事件签名。签名在 x-resend-signature 请求头中,格式为 t=<时间戳>,v1=<十六进制签名>。对 <时间戳>.<原始 body> 用 Webhook 密钥计算 HMAC,再用 CryptoKit 的 HMAC.isValidAuthenticationCode 做常量时间比对:
import CryptoKit
import Foundation
enum WebhookError: Error {
case malformedSignature, invalidSignature
}
func verifyRovelaSignature(
secret: String,
body: Data,
signatureHeader: String
) throws {
var timestamp: String?
var v1Hex: String?
for part in signatureHeader.split(separator: ",") {
let kv = part.split(separator: "=", maxSplits: 1)
guard kv.count == 2 else { continue }
switch kv[0] {
case "t": timestamp = String(kv[1])
case "v1": v1Hex = String(kv[1])
default: break
}
}
guard let ts = timestamp, let hex = v1Hex else {
throw WebhookError.malformedSignature
}
// 十六进制字符串转原始字节
guard hex.count % 2 == 0 else { throw WebhookError.malformedSignature }
let v1Bytes = stride(from: 0, to: hex.count, by: 2).compactMap { i -> UInt8? in
let s = hex.index(hex.startIndex, offsetBy: i)
let e = hex.index(s, offsetBy: 2)
return UInt8(hex[s..<e], radix: 16)
}
guard v1Bytes.count == hex.count / 2 else { throw WebhookError.malformedSignature }
let signingInput = Data("\(ts).".utf8) + body
let key = SymmetricKey(data: Data(secret.utf8))
// isValidAuthenticationCode 内部做常量时间比对,防止时序攻击
guard HMAC<SHA256>.isValidAuthenticationCode(
Data(v1Bytes),
authenticating: signingInput,
using: key
) else {
throw WebhookError.invalidSignature
}
}
Vapor 服务端验签
在 Vapor 中,先 collect() 原始 body,再调用上面的验签函数:
import Vapor
import CryptoKit
func routes(_ app: Application) throws {
app.post("webhooks", "rovela") { req async throws -> HTTPStatus in
let secret = Environment.get("ROVELA_WEBHOOK_SECRET")!
let sigHeader = req.headers.first(name: "x-resend-signature") ?? ""
// collect() 确保整个 body 已在内存中,再读取
let body = try await req.body.collect(max: 1 << 20).map(Data.init) ?? Data()
try verifyRovelaSignature(secret: secret, body: body, signatureHeader: sigHeader)
let event = try req.content.decode(RovelaEvent.self)
switch event.type {
case "email.delivered": break
case "email.bounced": break
default: break
}
return .ok
}
}
struct RovelaEvent: Content {
let type: String
}
务必在 req.content.decode() 之前调用 req.body.collect(),否则流式传输可能已消费掉 body 字节,导致签名计算结果不正确。