← 返回博客
用 Dart 通过 Rovela API 发送邮件
介绍如何用 Dart 的 http 包或 Dio 发送事务邮件,包含幂等键重试、Future.wait 并发批量发送,以及 Shelf 框架的常量时间 HMAC Webhook 验签。
更新于 2026年11月3日

本文介绍如何用 Dart 调用 Rovela API 发送事务邮件,涵盖 http 包、Dio、幂等键重试、Future.wait 并发批量发送,以及 Shelf 框架的 Webhook 验签。
前提条件
将 API 密钥设置为环境变量:
export ROVELA_API_KEY="re_..."
在 pubspec.yaml 中添加依赖:
dependencies:
http: ^1.2.0
用 http 包发送
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
Future<Map<String, dynamic>> sendEmail({
required String to,
required String subject,
required String html,
}) async {
final apiKey = Platform.environment['ROVELA_API_KEY']!;
final response = await http.post(
Uri.parse('https://api.rovela.dev/emails'),
headers: {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
body: jsonEncode({
'from': 'Acme <noreply@yourdomain.com>',
'to': to,
'subject': subject,
'html': html,
}),
);
if (response.statusCode >= 200 && response.statusCode < 300) {
return jsonDecode(response.body) as Map<String, dynamic>;
}
throw Exception('HTTP ${response.statusCode}: ${response.body}');
}
用 Dio 发送
Dio 支持拦截器和请求取消,适合规模较大的项目:
dependencies:
dio: ^5.4.0
import 'dart:io';
import 'package:dio/dio.dart';
final dio = Dio(BaseOptions(
baseUrl: 'https://api.rovela.dev',
headers: {
'Authorization': 'Bearer ${Platform.environment['ROVELA_API_KEY']}',
'Content-Type': 'application/json',
},
));
Future<Map<String, dynamic>> sendEmail({
required String to,
required String subject,
required String html,
}) async {
final response = await dio.post<Map<String, dynamic>>(
'/emails',
data: {
'from': 'Acme <noreply@yourdomain.com>',
'to': to,
'subject': subject,
'html': html,
},
);
return response.data!;
}
重试与幂等键
在循环之前生成一次幂等键,每次重试都复用同一个键,防止服务器接受请求后网络断开导致重复发送。只对 429 重试:
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:http/http.dart' as http;
String _randomKey() {
final rng = Random.secure();
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
Future<Map<String, dynamic>> sendWithRetry(
Map<String, dynamic> payload, {
int maxRetries = 4,
}) async {
final apiKey = Platform.environment['ROVELA_API_KEY']!;
final idempotencyKey = _randomKey(); // 循环前生成,重试时复用
for (var attempt = 0; attempt < maxRetries; attempt++) {
final response = await http.post(
Uri.parse('https://api.rovela.dev/emails'),
headers: {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: jsonEncode(payload),
);
if (response.statusCode == 429) {
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '');
final delay = retryAfter != null
? Duration(seconds: retryAfter)
: Duration(milliseconds: (pow(2, attempt) * 1000).toInt());
await Future.delayed(delay);
continue;
}
if (response.statusCode >= 200 && response.statusCode < 300) {
return jsonDecode(response.body) as Map<String, dynamic>;
}
throw Exception('HTTP ${response.statusCode}: ${response.body}');
}
throw Exception('超过最大重试次数 $maxRetries');
}
Future.wait 并发批量发送
用轻量信号量控制并发,避免同时发出过多请求:
import 'dart:async';
class Recipient {
final String email, subject, html;
Recipient(this.email, this.subject, this.html);
}
class Semaphore {
final int _max;
int _current = 0;
final _queue = <Completer<void>>[];
Semaphore(this._max);
Future<void> acquire() async {
if (_current < _max) { _current++; return; }
final c = Completer<void>();
_queue.add(c);
await c.future;
}
void release() {
if (_queue.isNotEmpty) {
_queue.removeAt(0).complete();
} else {
_current--;
}
}
}
Future<List<Map<String, dynamic>>> sendBatch(
List<Recipient> recipients, {
int concurrency = 5,
}) async {
final semaphore = Semaphore(concurrency);
return Future.wait(recipients.map((r) async {
await semaphore.acquire();
try {
final result = await sendEmail(to: r.email, subject: r.subject, html: r.html);
return {'email': r.email, 'result': result};
} catch (e) {
return {'email': r.email, 'error': e.toString()};
} finally {
semaphore.release();
}
}));
}
Shelf Webhook 验签
Rovela 用 HMAC-SHA256 对每个事件签名。签名在 x-resend-signature 请求头中,格式为 t=<时间戳>,v1=<十六进制签名>。对 <时间戳>.<原始 body> 用 Webhook 密钥计算 HMAC,再用常量时间比对防止时序攻击。
dependencies:
shelf: ^1.4.0
shelf_router: ^1.1.0
crypto: ^3.0.3
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
bool _constantTimeEqual(List<int> a, List<int> b) {
if (a.length != b.length) return false;
var result = 0;
for (var i = 0; i < a.length; i++) result |= a[i] ^ b[i];
return result == 0;
}
Future<Response> webhookHandler(Request request) async {
final secret = Platform.environment['ROVELA_WEBHOOK_SECRET']!;
final rawBody = await request.readAsString();
final sigHeader = request.headers['x-resend-signature'] ?? '';
final parts = <String, String>{};
for (final part in sigHeader.split(',')) {
final kv = part.split('=');
if (kv.length == 2) parts[kv[0]] = kv[1];
}
final timestamp = parts['t'];
final v1Hex = parts['v1'];
if (timestamp == null || v1Hex == null) {
return Response(400, body: 'missing signature fields');
}
late final List<int> v1Bytes;
try {
if (v1Hex.length.isOdd) throw const FormatException('odd-length hex');
v1Bytes = List.generate(
v1Hex.length ~/ 2,
(i) => int.parse(v1Hex.substring(i * 2, i * 2 + 2), radix: 16),
);
} on FormatException {
return Response(400, body: 'invalid hex in v1');
}
final expected = Hmac(sha256, utf8.encode(secret))
.convert(utf8.encode('$timestamp.$rawBody'))
.bytes;
if (!_constantTimeEqual(expected, v1Bytes)) {
return Response(400, body: 'invalid signature');
}
final payload = jsonDecode(rawBody) as Map<String, dynamic>;
switch (payload['type']) {
case 'email.delivered': break;
case 'email.bounced': break;
}
return Response.ok('ok');
}
_constantTimeEqual 逐字节异或后取 OR,全部通过才返回 true,防止通过响应时间推断签名内容。