← 返回博客

用 C# 和 .NET 通过 Rovela API 发送邮件

介绍如何用 C# 的 HttpClient(依赖注入或独立使用)发送事务邮件,包含幂等键重试、SemaphoreSlim 批量发送,以及 ASP.NET Core Minimal API 和 MVC 控制器的 Webhook 验签。

更新于 2026年11月3日
用 C# 和 .NET 通过 Rovela API 发送邮件

本文介绍如何用 C# 和 .NET 调用 Rovela API 发送事务邮件,涵盖依赖注入 HttpClient、无 DI 的控制台场景、幂等键重试、批量发送,以及 ASP.NET Core Minimal API 和控制器的 Webhook 验签。

前提条件

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

export ROVELA_API_KEY="re_..."

用 HttpClient 发送(依赖注入)

通过 DI 注册 typed HttpClient,集中设置基址和鉴权头:

// Program.cs
using System.Net.Http.Headers;

builder.Services.AddHttpClient<RovelaClient>(client =>
{
    client.BaseAddress = new Uri("https://api.rovela.dev/");
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("ROVELA_API_KEY"));
});
// RovelaClient.cs
using System.Net.Http.Json;

public class RovelaClient
{
    private readonly HttpClient _http;
    public RovelaClient(HttpClient http) => _http = http;

    public async Task<EmailResult?> SendAsync(SendEmailRequest req, CancellationToken ct = default)
    {
        var response = await _http.PostAsJsonAsync("emails", req, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<EmailResult>(ct);
    }
}

public record SendEmailRequest(string From, string To, string Subject, string Html, string? Text = null);
public record EmailResult(string Id);

不使用依赖注入

控制台应用或脚本场景:

using System.Net.Http.Headers;
using System.Net.Http.Json;

var apiKey = Environment.GetEnvironmentVariable("ROVELA_API_KEY")!;
using var http = new HttpClient { BaseAddress = new Uri("https://api.rovela.dev/") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", apiKey);

var payload = new { from = "Acme <noreply@yourdomain.com>", to = "user@example.com",
                    subject = "欢迎加入", html = "<p>感谢注册!</p>" };
var response = await http.PostAsJsonAsync("emails", payload);
response.EnsureSuccessStatusCode();
Console.WriteLine(await response.Content.ReadAsStringAsync());

重试与幂等键

在循环之前生成一次幂等键,每次重试都复用同一个键,防止网络中断后重复发送:

public async Task<HttpResponseMessage> SendWithRetryAsync(HttpClient http, object payload, int maxRetries = 4)
{
    var idempotencyKey = Guid.NewGuid().ToString(); // 循环前生成,重试时复用

    for (int attempt = 0; attempt < maxRetries; attempt++)
    {
        var request = new HttpRequestMessage(HttpMethod.Post, "emails")
        {
            Content = JsonContent.Create(payload)
        };
        request.Headers.Add("Idempotency-Key", idempotencyKey);

        var response = await http.SendAsync(request);
        if (response.StatusCode != HttpStatusCode.TooManyRequests)
            return response;

        double delay = Math.Pow(2, attempt) * 1000;
        if (response.Headers.TryGetValues("Retry-After", out var values) &&
            double.TryParse(values.First(), out var seconds))
            delay = seconds * 1000;

        await Task.Delay(TimeSpan.FromMilliseconds(delay));
    }
    throw new Exception($"超过最大重试次数 {maxRetries}");
}

批量发送

用 SemaphoreSlim 限制并发:

public async Task SendBatchAsync(HttpClient http, IEnumerable<Recipient> recipients)
{
    var semaphore = new SemaphoreSlim(5);
    var tasks = recipients.Select(async r =>
    {
        await semaphore.WaitAsync();
        try
        {
            var response = await http.PostAsJsonAsync("emails", new
            {
                from = "Acme <noreply@yourdomain.com>",
                to = r.Email, subject = r.Subject, html = r.Html
            });
            Console.WriteLine($"{r.Email}: {(int)response.StatusCode}");
        }
        finally { semaphore.Release(); }
    });
    await Task.WhenAll(tasks);
}

ASP.NET Core Minimal API 验签

Rovela 用 HMAC-SHA256 对每个事件签名。签名在 x-resend-signature 请求头中,格式为 t=<时间戳>,v1=<十六进制签名>。用 Webhook 密钥对 <时间戳>.<原始 body> 签名,再用 CryptographicOperations.FixedTimeEquals 常量时间比对。

app.MapPost("/webhooks/rovela", async (HttpContext ctx) =>
{
    var secret = Environment.GetEnvironmentVariable("ROVELA_WEBHOOK_SECRET")!;

    using var ms = new MemoryStream();
    await ctx.Request.Body.CopyToAsync(ms);
    var rawBody = ms.ToArray();

    var sigHeader = ctx.Request.Headers["x-resend-signature"].FirstOrDefault() ?? "";
    var parts = sigHeader.Split(',')
        .Select(p => p.Split('=', 2)).Where(p => p.Length == 2)
        .ToDictionary(p => p[0], p => p[1]);

    if (!parts.TryGetValue("t", out var timestamp) || !parts.TryGetValue("v1", out var v1))
        return Results.BadRequest("invalid signature format");

    var signedString = $"{timestamp}.{Encoding.UTF8.GetString(rawBody)}";
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToHexString(
        hmac.ComputeHash(Encoding.UTF8.GetBytes(signedString))).ToLowerInvariant();

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(v1)))
        return Results.BadRequest("invalid signature");

    var payload = JsonSerializer.Deserialize<JsonElement>(rawBody);
    // 按 payload.GetProperty("type").GetString() 处理事件
    return Results.Ok("ok");
});

MVC 控制器验签

[ApiController]
[Route("webhooks")]
public class RovelaWebhookController : ControllerBase
{
    private readonly string _secret = Environment.GetEnvironmentVariable("ROVELA_WEBHOOK_SECRET")!;

    [HttpPost("rovela")]
    public async Task<IActionResult> Handle()
    {
        using var ms = new MemoryStream();
        await Request.Body.CopyToAsync(ms);
        var rawBody = ms.ToArray();

        var sigHeader = Request.Headers["x-resend-signature"].FirstOrDefault() ?? "";
        var parts = sigHeader.Split(',')
            .Select(p => p.Split('=', 2)).Where(p => p.Length == 2)
            .ToDictionary(p => p[0], p => p[1]);

        if (!parts.TryGetValue("t", out var timestamp) || !parts.TryGetValue("v1", out var v1))
            return BadRequest("invalid signature format");

        var signedString = $"{timestamp}.{Encoding.UTF8.GetString(rawBody)}";
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secret));
        var expected = Convert.ToHexString(
            hmac.ComputeHash(Encoding.UTF8.GetBytes(signedString))).ToLowerInvariant();

        if (!CryptographicOperations.FixedTimeEquals(
                Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(v1)))
            return BadRequest("invalid signature");

        return Ok("ok");
    }
}

CryptographicOperations.FixedTimeEquals 做常量时间字节比对,防止时序攻击。