← 返回博客

用 F# 通过 Rovela API 发送邮件

介绍如何用 F# 的 HttpClient、FsHttp 或 FSharp.Data 发送事务邮件,包含幂等键重试、SemaphoreSlim 控制的并发批量发送,以及 ASP.NET Core 和 Giraffe 的 Webhook 验签。

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

F# 跑在 .NET 上,可以直接用平台的 HTTP 栈、加密原语和 task 工作流,不必离开这门语言。本文涵盖三种发送方式——原生 HttpClient、更贴近 F# 的 FsHttp,以及 FSharp.Data——外加幂等键重试、用 SemaphoreSlim 限制并发的批量发送,以及 ASP.NET Core 和 Giraffe 里的 Webhook 验签。

前提条件

将 API 密钥设置为环境变量:

export ROVELA_API_KEY="re_..."

创建项目并安装下面用到的库:

dotnet new console --language F# --name RovelaSender
cd RovelaSender
dotnet add package FsHttp
dotnet add package FSharp.Data
dotnet add package Giraffe

用 HttpClient 发送

HttpClient 不需要任何包,.NET 能跑的地方都能用。先处理一个 F# 特有的问题:to 是关键字,记录字段不能直接叫这个名字。

module Rovela.HttpClient

open System
open System.Net.Http
open System.Net.Http.Headers
open System.Text
open System.Text.Json
open System.Text.Json.Serialization

// `to` is a keyword in F#, so the record field carries the API's name.
type EmailPayload = {
    [<JsonPropertyName("from")>] Sender: string
    [<JsonPropertyName("to")>] Recipient: string
    [<JsonPropertyName("subject")>] Subject: string
    [<JsonPropertyName("html")>] Html: string
}

let apiKey = Environment.GetEnvironmentVariable "ROVELA_API_KEY"

let sendEmail (client: HttpClient) (payload: EmailPayload) =
    task {
        use request = new HttpRequestMessage(HttpMethod.Post, "https://api.rovela.dev/emails")
        request.Headers.Authorization <- AuthenticationHeaderValue("Bearer", apiKey)
        request.Content <-
            new StringContent(JsonSerializer.Serialize payload, Encoding.UTF8, "application/json")

        use! response = client.SendAsync request
        let! body = response.Content.ReadAsStringAsync()

        if not response.IsSuccessStatusCode then
            failwithf "HTTP %d: %s" (int response.StatusCode) body

        use document = JsonDocument.Parse body
        return document.RootElement.GetProperty("id").GetString()
    }

JsonPropertyName 让线上的字段名保持正确,同时 F# 侧的命名仍然自然。SendAsync 返回 Task,所以 sendEmail 可以放进 task { } 工作流里,像其它异步调用一样 await。

用 FsHttp 发送

FsHttp 把同一套东西包成计算表达式,三种写法里最短:

module Rovela.FsHttpClient

open System
open FsHttp

let apiKey = Environment.GetEnvironmentVariable "ROVELA_API_KEY"

let sendEmail (payload: obj) =
    let response =
        http {
            POST "https://api.rovela.dev/emails"
            AuthorizationBearer apiKey
            body
            jsonSerialize payload
        }
        |> Request.send

    let text = response |> Response.toText

    let code = int response.statusCode
    if code < 200 || code > 299 then
        failwithf "HTTP %d: %s" code text

    text

Request.send 是同步的。如果调用方本身是异步的,换成 Request.sendAsync 会返回 Task,其余代码不用改。

用 FSharp.Data 发送

FSharp.Data 的依赖最小:一个函数、一次调用,请求体直接传字符串。

module Rovela.FSharpData

open System
open System.Text.Json
open FSharp.Data
open FSharp.Data.HttpRequestHeaders

let apiKey = Environment.GetEnvironmentVariable "ROVELA_API_KEY"

/// `json` is the serialized payload, for example
/// {"from":"Acme <noreply@yourdomain.com>","to":"user@example.com",
///  "subject":"Welcome aboard","html":"<p>Thanks for signing up!</p>"}
let sendEmail (json: string) =
    let response =
        Http.Request(
            "https://api.rovela.dev/emails",
            httpMethod = HttpMethod.Post,
            headers = [
                Authorization("Bearer " + apiKey)
                ContentType HttpContentTypes.Json
            ],
            body = TextRequest json)

    match response.Body with
    | Text body when response.StatusCode >= 200 && response.StatusCode < 300 ->
        JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()
    | Text body ->
        failwithf "HTTP %d: %s" response.StatusCode body
    | _ ->
        failwith "Unexpected response body"

请求体传进来时已经序列化好了。这是有意的——下面的重试要保证每次尝试发出的字节完全一致。

用幂等键重试

幂等键存在的意义,正是那种「请求已经到了服务器、响应却没回到你手上」的情况。此时盲目重试会发出第二封邮件。在循环外只生成一次 key,重试就变安全了:API 认出重复的 key,直接返回第一次的结果。

module Rovela.Retry

open System
open System.Net
open System.Net.Http
open System.Net.Http.Headers
open System.Text
open System.Text.Json
open System.Threading.Tasks
open Rovela.HttpClient

let apiUrl = "https://api.rovela.dev/emails"

/// The key is generated once, before the loop, and reused on every attempt.
/// If the connection drops after the server has accepted the request, the
/// retry returns the first result instead of sending a second email.
let sendWithRetry (client: HttpClient) (maxRetries: int) (payload: EmailPayload) =
    task {
        let idempotencyKey = Guid.NewGuid().ToString("N")
        let body = JsonSerializer.Serialize payload

        let rec attempt (current: int) =
            task {
                use request = new HttpRequestMessage(HttpMethod.Post, apiUrl)
                request.Headers.Authorization <- AuthenticationHeaderValue("Bearer", apiKey)
                request.Headers.Add("Idempotency-Key", idempotencyKey)
                request.Content <- new StringContent(body, Encoding.UTF8, "application/json")

                use! response = client.SendAsync request
                let! text = response.Content.ReadAsStringAsync()

                if response.IsSuccessStatusCode then
                    return text
                elif response.StatusCode = HttpStatusCode.TooManyRequests && current < maxRetries then
                    let delayMs =
                        let retryAfter = response.Headers.RetryAfter
                        if isNull retryAfter || not retryAfter.Delta.HasValue then
                            pown 2 current * 1000
                        else
                            int retryAfter.Delta.Value.TotalMilliseconds
                    do! Task.Delay delayMs
                    return! attempt (current + 1)
                else
                    return failwithf "HTTP %d: %s" (int response.StatusCode) text
            }

        return! attempt 0
    }

只重试 429。API 返回 500,或者因为地址格式错误返回 422,再发一次也不会变好,重试只会放大问题。响应里带 Retry-After 时以它为准,否则延迟每次翻倍。

限制并发的批量发送

只靠 Task.WhenAll 会在同一瞬间发出所有请求。几百个收件人就是几百条并发连接,足以同时撞上 API 的限流和对端的连接上限。用信号量把在途请求数压住:

module Rovela.Batch

open System.Net.Http
open System.Threading
open System.Threading.Tasks
open Rovela.HttpClient

/// At most `concurrency` requests are in flight at any moment. `Task.WhenAll`
/// alone would start every one of them at once and lose to a rate limit.
let sendBatch (client: HttpClient) (concurrency: int) (recipients: EmailPayload list) =
    task {
        use gate = new SemaphoreSlim(concurrency, concurrency)

        let sendOne (payload: EmailPayload) =
            task {
                do! gate.WaitAsync()
                try
                    try
                        let! id = sendEmail client payload
                        return Ok(payload.Recipient, id)
                    with ex ->
                        return Error(payload.Recipient, ex.Message)
                finally
                    gate.Release() |> ignore
            }

        return! recipients |> List.map sendOne |> Task.WhenAll
    }

每个结果要么是 Ok (收件人, id),要么是 Error (收件人, 错误信息),所以一个坏地址不会毁掉整批。跑完之后把错误收集起来报告——一百个地址里有三个失效,剩下九十七个照样应该发出去。

验证 Webhook 签名

每个事件都用 HMAC-SHA256 签名。签名放在 x-resend-signature 头里,格式是 t=<unix 秒>,v1=<十六进制>,被签的内容是 <时间戳>.<原始请求体>。用你的 webhook 密钥算出同样的摘要,再用 CryptographicOperations.FixedTimeEquals 比较——它不会因为第一个不同字节的位置而提前返回。拒收超过五分钟的时间戳,能限制被截获请求的可用时长。

module Rovela.Webhook

open System
open System.Globalization
open System.Security.Cryptography
open System.Text

/// The header looks like "t=1717488123,v1=<hex>". The signed content is
/// "<t>.<raw body>", so a captured body cannot be replayed with a fresh
/// timestamp: changing either part invalidates the signature.
let verifySignature (secret: string) (header: string) (rawBody: string) (now: DateTimeOffset) =
    let parts =
        header.Split ','
        |> Array.choose (fun part ->
            match part.Split('=', 2) with
            | [| key; value |] -> Some(key.Trim(), value.Trim())
            | _ -> None)
        |> dict

    match parts.TryGetValue "t", parts.TryGetValue "v1" with
    | (true, timestamp), (true, signature) ->
        match Int64.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture) with
        | false, _ ->
            false
        | true, seconds ->
            let age = abs (now.ToUnixTimeSeconds() - seconds)
            if age > 300L then
                false
            else
                use hmac = new HMACSHA256(Encoding.UTF8.GetBytes secret)
                let expected =
                    hmac.ComputeHash(Encoding.UTF8.GetBytes $"{timestamp}.{rawBody}")
                    |> Convert.ToHexString
                    |> fun hex -> hex.ToLowerInvariant()
                CryptographicOperations.FixedTimeEquals(
                    Encoding.UTF8.GetBytes expected,
                    Encoding.UTF8.GetBytes(signature.ToLowerInvariant()))
    | _ ->
        false

在 ASP.NET Core 中验证 Webhook

最小 API 的处理器把请求体当流读出来,把原始字符串交给验签函数。它有意不绑定记录类型:模型绑定会消耗请求体并重新序列化,而签名覆盖的是当初发出去的那串字节。

module Program

open System
open System.IO
open System.Text
open System.Threading.Tasks
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Hosting
open Rovela.Webhook

let webhookHandler (ctx: HttpContext) : Task =
    task {
        use reader = new StreamReader(ctx.Request.Body, Encoding.UTF8)
        let! raw = reader.ReadToEndAsync()
        let header = string ctx.Request.Headers["x-resend-signature"]
        let secret = Environment.GetEnvironmentVariable "ROVELA_WEBHOOK_SECRET"

        if verifySignature secret header raw DateTimeOffset.UtcNow then
            ctx.Response.StatusCode <- 200
            do! ctx.Response.WriteAsync "ok"
        else
            ctx.Response.StatusCode <- 400
            do! ctx.Response.WriteAsync "invalid signature"
    }

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder(args)
    let app = builder.Build()

    app.MapPost("/webhooks/rovela", RequestDelegate webhookHandler) |> ignore
    app.Run()
    0

用 Giraffe 验证 Webhook

Giraffe 的处理器是同一个验签函数,外面套一层 HttpHandler:

module Rovela.GiraffeApp

open System
open System.IO
open System.Text
open Giraffe
open Microsoft.AspNetCore.Http
open Rovela.Webhook

let webhookHandler: HttpHandler =
    fun next ctx ->
        task {
            use reader = new StreamReader(ctx.Request.Body, Encoding.UTF8)
            let! raw = reader.ReadToEndAsync()
            let header = string ctx.Request.Headers["x-resend-signature"]
            let secret = Environment.GetEnvironmentVariable "ROVELA_WEBHOOK_SECRET"

            if verifySignature secret header raw DateTimeOffset.UtcNow then
                return! text "ok" next ctx
            else
                return! (setStatusCode 400 >=> text "invalid signature") next ctx
        }

两个处理器都是先读原始请求体、再碰 JSON。把解析后的模型重新编码,几乎不可能还原出签名时的字节;拿重新序列化的内容去验签,结果要么是把合法事件全部拒掉,要么是验签悄悄失效、最后只能被关掉来让接口「能用」。