← 返回博客

Flutter 发送邮箱验证码(2026):后端 HTTP 调事务邮件 API

用 Dart shelf 或云函数 BFF 为 Flutter 做注册/登录邮箱验证码:Random.secure + HMAC,带 Idempotency-Key 调 POST /emails——发送密钥永不进移动端;Rovela 只负责投递。

更新于 2026年11月4日
Flutter 发送邮箱验证码(2026):后端 HTTP 调事务邮件 API

为什么 Flutter 发邮箱验证码必须走后端

Flutter 很适合做注册/登录的 UI——邮箱输入框、焦点、六位数字码在 iOS/Android 上都很顺手。但它不适合存放 Rovela 的 sending_access 密钥。凡是打进 APK/IPA(或设备上能出网的 Dart isolate)的内容都可能被提取。把 ROVELA_API_KEY 塞进 --dart-define、.env 资源或 Firebase Remote Config,密钥仍然落在每台手机上。

你需要的是文档里的配方——邮箱验证码:由 服务端(Dart shelf、Cloud Functions、Cloud Run 或任意 BFF)用 CSPRNG 生成六位码,只存 HMAC(永不落明文),设过期与尝试次数,再 POST https://api.rovela.dev/emails,同时带上 html、text 和稳定的 Idempotency-Key。Flutter 应用只通过 HTTPS 调用你自己的 /auth/otp/send 与 /auth/otp/verify。Rovela 负责入队、DKIM、重试与事件。它不会生成验证码、校验验证码,也不会建会话。

Rovela 不提供 OTP 功能。 后端负责密码学、存储条目、重发节流、核销逻辑以及 session / JWT。Rovela 只负责投递。

本文面向 Flutter + Dart:一个用 shelf(或 Cloud Function)对接 Rovela 的 OTP API,以及永远看不到发送密钥的精简 Flutter 客户端。这不是 用 Dart 发邮件(通用 Dart/http 发送,没有 OTP 存储),也不是 Web/Node 的 OTP 姊妹篇(Express、Hono、Go)。数字码 vs 链接的产品选择见 邮箱验证:验证码 vs 链接。语言无关指南见 用邮件发 OTP。

你负责什么,Rovela 发什么

服务端负责:

  • CSPRNG → 六位、左侧补零(Dart 用 Random.secure()——不要用 Random())
  • 用 package:crypto 对 email + ":" + code 做 HMAC-SHA256,密钥是服务端 pepper(CODE_PEPPER)
  • 过期(文档示例约 10 分钟)、尝试计数、以及「同一地址一分钟只发一次」的重发闸
  • 按规范化邮箱 upsert,新发会使旧码失效(内存 map、Redis 或 SQL)
  • 核销时常量时间比较;成功后 delete / 失效
  • 对 /auth/otp/send 与 /auth/otp/verify 做限流
  • 验码成功后签发 session cookie / JWT

Flutter 应用负责:

  • 收集邮箱与验证码、调用你的 BFF、安全存储会话(flutter_secure_storage 或系统钥匙串)
  • UX:重发冷却计时、错误文案、离线处理

Rovela 负责:

  • POST https://api.rovela.dev/emails,Authorization: Bearer + sending_access 密钥(仅服务端环境变量——勿打进 Flutter 包,勿用会随商店包分发的 --dart-define 携带)
  • 入队、签名、重试、退信/投诉处理

发送接口返回 200 表示已接受并入队,不等于「已进收件箱」。

架构:手机 → 你的 API → Rovela

Flutter(UI)
   │  HTTPS JSON(没有 Rovela 密钥)
   ▼
Dart shelf / Cloud Function / Cloud Run(ROVELA_API_KEY + CODE_PEPPER)
   │  POST /emails + Idempotency-Key
   ▼
api.rovela.dev  →  收件箱

若其余栈已是 Dart,优先用薄 Dart BFF,让 OTP 密码学与移动端 API 同语言。Node / Go / Python BFF 同样可行——Flutter 侧不变。生产 OTP 不要在 Flutter 工程里用 package:http 直连 Rovela。

服务端环境变量(永远不要放进 App)

# 仅服务端 — Cloud Run / Function / 虚拟机密钥
export ROVELA_API_KEY="re_..."          # sending_access
export CODE_PEPPER="long-random-pepper"
export OTP_FROM="Acme <security@mail.acme.com>"
export OTP_TTL_MINUTES=10
export OTP_RESEND_COOLDOWN_SEC=60
export OTP_MAX_ATTEMPTS=5

按普通服务端凭证接入(Authentication、Quick Start)。生产发 OTP 用 sending_access 即可。

Dart 服务端:铸码、HMAC、POST /emails(shelf 草图)

// pubspec.yaml(服务端):shelf, shelf_router, http, crypto, uuid
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';

import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
import 'package:uuid/uuid.dart';

final _pepper = utf8.encode(Platform.environment['CODE_PEPPER']!);
final _apiKey = Platform.environment['ROVELA_API_KEY']!;
final _from = Platform.environment['OTP_FROM'] ?? 'Acme <security@mail.acme.com>';
final _ttl = Duration(minutes: int.parse(Platform.environment['OTP_TTL_MINUTES'] ?? '10'));
final _cooldown = Duration(seconds: int.parse(Platform.environment['OTP_RESEND_COOLDOWN_SEC'] ?? '60'));
const _maxAttempts = 5;

String normalizeEmail(String raw) => raw.trim().toLowerCase();

String mintCode() {
  final n = Random.secure().nextInt(1000000); // CSPRNG — 不是 Random()
  return n.toString().padLeft(6, '0');
}

String hashCode(String email, String code) {
  final mac = Hmac(sha256, _pepper);
  return mac.convert(utf8.encode('$email:$code')).toString(); // hex
}

bool safeEqualHex(String a, String b) {
  final aa = utf8.encode(a);
  final bb = utf8.encode(b);
  if (aa.length != bb.length) return false;
  var diff = 0;
  for (var i = 0; i < aa.length; i++) {
    diff |= aa[i] ^ bb[i];
  }
  return diff == 0;
}

class Record {
  Record({required this.codeHash, required this.expiresAt, required this.sentAt, this.attempts = 0});
  String codeHash;
  int attempts;
  DateTime expiresAt;
  DateTime sentAt;
}

final store = <String, Record>{}; // 仅演示 — 多实例请用 Redis/SQL

Future<Map<String, dynamic>> sendOtpEmail({
  required String to,
  required String code,
  required String idempotencyKey,
  required int ttlMinutes,
}) async {
  final body = {
    'from': _from,
    'to': to,
    'subject': 'Your Acme code: $code',
    'html':
        '<p>Your verification code is</p>'
        '<p style="font-size:28px;font-weight:600;letter-spacing:4px">$code</p>'
        '<p>It expires in $ttlMinutes minutes. If you did not ask for it, ignore this email.</p>',
    'text':
        'Your verification code is $code. It expires in $ttlMinutes minutes. '
        'If you did not ask for it, ignore this email.',
  };
  final res = await http.post(
    Uri.parse('https://api.rovela.dev/emails'),
    headers: {
      'Authorization': 'Bearer $_apiKey',
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
      'Accept': 'application/json',
    },
    body: jsonEncode(body),
  ).timeout(const Duration(seconds: 15));
  if (res.statusCode >= 300) {
    throw HttpException('rovela send failed: ${res.statusCode} ${res.body}');
  }
  return jsonDecode(res.body) as Map<String, dynamic>;
}

Future<Response> handleSend(Request req) async {
  final payload = jsonDecode(await req.readAsString()) as Map<String, dynamic>;
  final email = normalizeEmail('${payload['email'] ?? ''}');
  if (email.isEmpty || !email.contains('@')) {
    return Response(400, body: jsonEncode({'error': 'invalid_email'}));
  }
  final now = DateTime.now().toUtc();
  final existing = store[email];
  if (existing != null && now.difference(existing.sentAt) < _cooldown) {
    return Response(429, body: jsonEncode({'error': 'too_soon'}));
  }

  final code = mintCode();
  final codeHash = hashCode(email, code);
  store[email] = Record(
    codeHash: codeHash,
    expiresAt: now.add(_ttl),
    sentAt: now,
  );

  // 同一逻辑发送用稳定键:哈希前缀。新码 ⇒ 新键。
  final idem = 'otp-${codeHash.substring(0, 32)}';
  await sendOtpEmail(
    to: email,
    code: code,
    idempotencyKey: idem,
    ttlMinutes: _ttl.inMinutes,
  );

  // 统一文案 — 是否暴露账号存在性由产品决定
  return Response.ok(jsonEncode({
    'message': '若该地址可收信,验证码已在路上。',
  }));
}

Future<Response> handleVerify(Request req) async {
  final payload = jsonDecode(await req.readAsString()) as Map<String, dynamic>;
  final email = normalizeEmail('${payload['email'] ?? ''}');
  final code = '${payload['code'] ?? ''}'.trim();
  if (code.length != 6 || int.tryParse(code) == null) {
    return Response(400, body: jsonEncode({'error': 'invalid'}));
  }
  final rec = store[email];
  final now = DateTime.now().toUtc();
  if (rec == null) return Response(400, body: jsonEncode({'error': 'invalid'}));
  if (now.isAfter(rec.expiresAt)) {
    store.remove(email);
    return Response(400, body: jsonEncode({'error': 'expired'}));
  }
  if (rec.attempts >= _maxAttempts) {
    return Response(429, body: jsonEncode({'error': 'locked'}));
  }
  if (!safeEqualHex(hashCode(email, code), rec.codeHash)) {
    rec.attempts++;
    return Response(400, body: jsonEncode({'error': 'invalid'}));
  }
  store.remove(email);
  // 在此签发 session / JWT
  return Response.ok(jsonEncode({'ok': true, 'token': 'replace-with-jwt'}));
}

void main() async {
  final router = Router()
    ..post('/auth/otp/send', handleSend)
    ..post('/auth/otp/verify', handleVerify);
  final handler = const Pipeline()
      .addMiddleware(logRequests())
      // 生产环境请加按 IP 限流中间件
      .addHandler(router.call);
  final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 8080);
  print('OTP BFF on :${server.port}');
}

建议的表结构(每个邮箱一条有效码),对齐 验证码示例:

CREATE TABLE verification_codes (
  email      TEXT PRIMARY KEY,
  code_hash  TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  attempts   INT NOT NULL DEFAULT 0,
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Cloud Function 提示: 同一套 sendOtpEmail / HMAC 助手也可放进 Dart 或 Node 的 HTTPS 函数。密钥放函数的密钥管理——不要放进 Flutter。

把码放进 subject 有利于锁屏预览——多数产品可接受,共享大屏场景可省略。务必同时发送 html 与 text,并在 text 中带上数字(POST /emails)。

Flutter 客户端:只调你的 BFF

// lib/auth/otp_api.dart — 这里没有 Rovela 密钥
import 'dart:convert';
import 'package:http/http.dart' as http;

class OtpApi {
  OtpApi(this.baseUrl); // 例如 https://api.yourapp.com
  final String baseUrl;

  Future<void> sendCode(String email) async {
    final res = await http.post(
      Uri.parse('$baseUrl/auth/otp/send'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'email': email.trim()}),
    );
    if (res.statusCode == 429) {
      throw StateError('too_soon');
    }
    if (res.statusCode >= 300) {
      throw StateError('send_failed');
    }
  }

  Future<String> verifyCode(String email, String code) async {
    final res = await http.post(
      Uri.parse('$baseUrl/auth/otp/verify'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'email': email.trim(), 'code': code.trim()}),
    );
    if (res.statusCode >= 300) {
      throw StateError('invalid_or_locked');
    }
    final map = jsonDecode(res.body) as Map<String, dynamic>;
    return map['token'] as String; // 安全存储
  }
}
// lib/screens/otp_login.dart(草图)
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class OtpLoginScreen extends StatefulWidget {
  const OtpLoginScreen({super.key, required this.api});
  final OtpApi api;

  @override
  State<OtpLoginScreen> createState() => _OtpLoginScreenState();
}

class _OtpLoginScreenState extends State<OtpLoginScreen> {
  final emailCtrl = TextEditingController();
  final codeCtrl = TextEditingController();
  var step = 0; // 0 = 邮箱, 1 = 验证码
  var busy = false;
  String? error;

  Future<void> _send() async {
    setState(() { busy = true; error = null; });
    try {
      await widget.api.sendCode(emailCtrl.text);
      setState(() => step = 1);
    } catch (e) {
      setState(() => error = e.toString());
    } finally {
      setState(() => busy = false);
    }
  }

  Future<void> _verify() async {
    setState(() { busy = true; error = null; });
    try {
      final token = await widget.api.verifyCode(emailCtrl.text, codeCtrl.text);
      // 用 flutter_secure_storage 持久化 token;再跳转首页
      debugPrint('session ready (${token.length} chars)');
    } catch (e) {
      setState(() => error = e.toString());
    } finally {
      setState(() => busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('登录')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            if (step == 0) ...[
              TextField(
                controller: emailCtrl,
                keyboardType: TextInputType.emailAddress,
                autofillHints: const [AutofillHints.email],
                decoration: const InputDecoration(labelText: '邮箱'),
              ),
              const SizedBox(height: 16),
              FilledButton(
                onPressed: busy ? null : _send,
                child: Text(busy ? '发送中…' : '发送验证码'),
              ),
            ] else ...[
              TextField(
                controller: codeCtrl,
                keyboardType: TextInputType.number,
                inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                maxLength: 6,
                autofillHints: const [AutofillHints.oneTimeCode],
                decoration: const InputDecoration(labelText: '六位验证码'),
              ),
              const SizedBox(height: 16),
              FilledButton(
                onPressed: busy ? null : _verify,
                child: Text(busy ? '校验中…' : '验证'),
              ),
              TextButton(
                onPressed: busy ? null : _send,
                child: const Text('重新发送'),
              ),
            ],
            if (error != null) Text(error!, style: const TextStyle(color: Colors.red)),
          ],
        ),
      ),
    );
  }
}

使用 AutofillHints.oneTimeCode,方便 iOS/Android 从通知建议验证码。仍把短信自动填充库当作可选项——邮箱 OTP 落在邮件 App,不是 SMS Retriever 载荷。

Idempotency-Key:重试不会发两封码

Worker、Cloud Functions 重试与不稳定的移动网络都会重试。没有 Idempotency-Key,一次超时就可能让收件箱出现两封不一致的码。

  • Header ≤ 256 字符;Rovela 记住 24 小时,重放返回原始响应(API: emails)。
  • 由 code hash 派生(otp- + 前 32 位十六进制),使一次逻辑铸造对应一封入队邮件。
  • 冷却后的主动重发会铸造新码 → 新哈希 → 新键。不要用旧键发新码。
  • Flutter 客户端不会对 Rovela 设置该头;你的 BFF 在调 POST /emails 时设置。
curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-smoke-flutter-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "you@example.com",
    "subject": "Your Acme code: 482913",
    "html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
    "text": "Your verification code is 482913. Expires in 10 minutes."
  }'

为什么不要在 Flutter 里直连 Rovela

BFF → POST /emails(本文)Flutter → 直连 Rovela
sending_access 密钥服务端密钥管理可从二进制提取
OTP 铸码 + HMAC 存储服务端跨设备安全共享困难
限流边缘按 IP + 按邮箱攻击者把你的密钥当免费中继
幂等服务端按铸造稳定键移动端弱网重试易出错
会话签发验码后在服务端真会话仍需要后端

通用 Dart/http「从 CLI 或服务器发欢迎信」见 用 Dart 发邮件。那篇偏传输;本篇是带移动端边界的注册/登录 OTP。

认证数字码的投递注意

OTP 邮件短且敏感。优先用已验证子域,例如 security@mail.example.com,对齐 SPF/DKIM/DMARC,并保持 HTML 简洁。正文(以及常见的 subject)里的码不需要可点链接——点击追踪改写对魔法链接更关键——但打开/点击追踪仍应落在你控制的域名上。

若进垃圾箱,按 为什么 OTP / 魔法链接进垃圾箱 排查。产品该用数字码还是确认链接,见 验证码 vs 链接。

Flutter 里:验证码 vs 魔法链接

六位 OTP(本文)魔法链接
用户动作输入数字(适合手机键盘)打开 URL(Deep Link / App Link)
秘密熵低 → 必须 HMAC + pepperURL token 熵高 → 通常对 token 做 SHA-256 即可
文档形态验证码密码重置 / 链接模式
Flutter 表面邮箱 + PIN → 你的 BFFDeep link 处理 → 你的 BFF
点击追踪可选关注安全发信域上建议关闭

两者都从服务端调同一个 POST /emails。存储分开——不要把 OTP 记录当成魔法链接 token 复用。

上线前清单

  1. 服务端用 Random.secure()(或系统 CSPRNG)+ HMAC pepper;永不存明文码;不用非安全的 Random() 发 OTP。
  2. 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、BFF 上按 IP 限流。
  3. upsert / 覆盖会使旧码失效;验码成功后 delete。
  4. 同时有 html 和 text;可选把码放进 subject 方便预览。
  5. ROVELA_API_KEY(sending_access)只放服务端——不放 Flutter,不用会随商店包分发的 --dart-define。
  6. Idempotency-Key 对同一次逻辑发送保持稳定;新码 → 新键。
  7. 若隐藏账号存在性,公开文案保持一致。
  8. Flutter 只调你的 API;使用 AutofillHints.oneTimeCode;会话 token 放安全存储。
  9. 发信域已验证;进垃圾箱则按清单排查。

相关阅读

  1. 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
  2. 产品无关 OTP:用邮件发 OTP · 邮箱 OTP 安全。
  3. UX 选择:邮箱验证:验证码 vs 链接。
  4. 投递:为什么 OTP / 魔法链接进垃圾箱。
  5. 框架姊妹篇:Express OTP · Go OTP · Hono OTP · Django OTP。
  6. 仅 Dart 发送(非 OTP):用 Dart 发邮件。

一句话: 让 Flutter 只做 OTP UI;把铸码/HMAC/验码与 Rovela sending_access 密钥放在 Dart shelf(或任意)BFF 上,带 Idempotency-Key 调 POST /emails,永远不要把发送密钥打进移动客户端——Rovela 负责投递,认证仍归你的应用。