← 返回博客

用 Swift 通过 Rovela API 发送邮件

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

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

本文介绍如何用 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 字节,导致签名计算结果不正确。