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

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。把解析后的模型重新编码,几乎不可能还原出签名时的字节;拿重新序列化的内容去验签,结果要么是把合法事件全部拒掉,要么是验签悄悄失效、最后只能被关掉来让接口「能用」。