← 返回博客

用 Dart 通过 Rovela API 发送邮件

介绍如何用 Dart 的 http 包或 Dio 发送事务邮件,包含幂等键重试、Future.wait 并发批量发送,以及 Shelf 框架的常量时间 HMAC Webhook 验签。

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

本文介绍如何用 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,防止通过响应时间推断签名内容。