介绍如何用 C# 的 HttpClient(依赖注入或独立使用)发送事务邮件,包含幂等键重试、SemaphoreSlim 批量发送,以及 ASP.NET Core Minimal API 和 MVC 控制器的 Webhook 验签。
更新于 2026年11月3日
本文介绍如何用 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);
}
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");
}
}