← 返回博客

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

用 Node Express BFF 为 React Native / Expo 做注册邮箱验证码:crypto.randomInt + HMAC,带 Idempotency-Key 调 POST /emails——发送密钥永不进移动端;Rovela 只负责投递。

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

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

React Native(以及 Expo)很适合做注册/登录的 UI——TextInput、键盘类型、自动填充,以及在 iOS/Android 上都很顺手的六位输入。但它不适合存放 Rovela 的 sending_access 密钥。凡是打进 IPA/APK(或写进 expo.extra、app.config.js、被 Metro 打包的 .env)的内容都可能被提取。把 ROVELA_API_KEY 塞进 EXPO_PUBLIC_*、AsyncStorage 或「远程配置里的秘密」,密钥仍然落在每台手机上。

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

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

本文面向 React Native / Expo:一个对接 Rovela 的薄 Express(或 Node)OTP BFF,以及永远看不到发送密钥的精简 RN 客户端。这不是 Flutter OTP(Dart shelf + Flutter 组件),也不是纯 Web 的 Express OTP(同样的 Node 密码学,没有移动端边界)。数字码 vs 链接的产品选择见 邮箱验证:验证码 vs 链接。语言无关指南见 用邮件发 OTP。

你负责什么,Rovela 发什么

Node BFF负责:

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

React Native / Expo 应用负责:

  • 收集邮箱与验证码、用 fetch 调用你的 BFF、安全存储会话(expo-secure-store 或系统钥匙串)
  • UX:重发冷却计时、错误文案、离线 / 飞行模式处理
  • 可选 textContentType="oneTimeCode" / autoComplete="sms-otp" 提示(邮箱 OTP 仍落在邮件 App,不是 SMS Retriever)

Rovela 负责:

  • POST https://api.rovela.dev/emails,Authorization: Bearer + sending_access 密钥(仅服务端环境变量——勿打进 RN 包,勿放进 EXPO_PUBLIC_*)
  • 入队、签名、重试、退信/投诉处理

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

架构:手机 → 你的 API → Rovela

React Native / Expo(UI)
   │  HTTPS JSON(没有 Rovela 密钥)
   ▼
Node Express / Fastify / Nest BFF(ROVELA_API_KEY + CODE_PEPPER)
   │  POST /emails + Idempotency-Key
   ▼
api.rovela.dev  →  收件箱

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

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

# 仅服务端 — Fly.io / Railway / Cloud Run / VPS 密钥
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 即可。Expo 的 EXPO_PUBLIC_ 前缀用于会下发到客户端的非机密配置;切勿把 Rovela 密钥放进去。

Node BFF:铸码、HMAC、POST /emails(Express 草图)

// server/src/otp.ts — CommonJS 或 ESM;TypeScript 友好
import crypto from "node:crypto";
import express from "express";

const PEPPER = process.env.CODE_PEPPER!;
const API_KEY = process.env.ROVELA_API_KEY!; // sending_access
const FROM = process.env.OTP_FROM ?? "Acme <security@mail.acme.com>";
const TTL_MS = Number(process.env.OTP_TTL_MINUTES ?? 10) * 60_000;
const COOLDOWN_MS = Number(process.env.OTP_RESEND_COOLDOWN_SEC ?? 60) * 1000;
const MAX_ATTEMPTS = Number(process.env.OTP_MAX_ATTEMPTS ?? 5);

type Rec = { codeHash: string; expiresAt: number; sentAt: number; attempts: number };
const store = new Map<string, Rec>(); // 仅演示 — 多实例请用 Redis/SQL

function normalizeEmail(raw: string) {
  return raw.trim().toLowerCase();
}

function mintCode() {
  return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
}

function hashCode(email: string, code: string) {
  return crypto.createHmac("sha256", PEPPER).update(`${email}:${code}`).digest("hex");
}

async function sendOtpEmail(opts: {
  to: string;
  code: string;
  idempotencyKey: string;
  ttlMinutes: number;
}) {
  const res = await fetch("https://api.rovela.dev/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": opts.idempotencyKey,
    },
    body: JSON.stringify({
      from: FROM,
      to: opts.to,
      subject: `Your Acme code: ${opts.code}`,
      html:
        `<p>Your verification code is</p>` +
        `<p style="font-size:28px;font-weight:600;letter-spacing:4px">${opts.code}</p>` +
        `<p>It expires in ${opts.ttlMinutes} minutes. If you did not ask for it, ignore this email.</p>`,
      text:
        `Your verification code is ${opts.code}. It expires in ${opts.ttlMinutes} minutes. ` +
        `If you did not ask for it, ignore this email.`,
    }),
  });
  if (!res.ok) {
    const errText = await res.text();
    throw new Error(`rovela send failed: ${res.status} ${errText}`);
  }
  return res.json() as Promise<{ object: string; id: string }>;
}

export const otpRouter = express.Router();

otpRouter.post("/auth/otp/send", express.json(), async (req, res) => {
  const email = normalizeEmail(String(req.body?.email ?? ""));
  if (!email.includes("@")) return res.status(400).json({ error: "invalid_email" });

  const now = Date.now();
  const existing = store.get(email);
  if (existing && now - existing.sentAt < COOLDOWN_MS) {
    return res.status(429).json({ error: "too_soon" });
  }

  const code = mintCode();
  const codeHash = hashCode(email, code);
  store.set(email, {
    codeHash,
    expiresAt: now + TTL_MS,
    sentAt: now,
    attempts: 0,
  });

  // 同一逻辑发送用稳定键:哈希前缀。新码 ⇒ 新键。
  const idem = `otp-${codeHash.slice(0, 32)}`;
  await sendOtpEmail({
    to: email,
    code,
    idempotencyKey: idem,
    ttlMinutes: Math.round(TTL_MS / 60_000),
  });

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

otpRouter.post("/auth/otp/verify", express.json(), async (req, res) => {
  const email = normalizeEmail(String(req.body?.email ?? ""));
  const code = String(req.body?.code ?? "").trim();
  if (!/^\d{6}$/.test(code)) return res.status(400).json({ error: "invalid" });

  const rec = store.get(email);
  const now = Date.now();
  if (!rec) return res.status(400).json({ error: "invalid" });
  if (now > rec.expiresAt) {
    store.delete(email);
    return res.status(400).json({ error: "expired" });
  }
  if (rec.attempts >= MAX_ATTEMPTS) {
    return res.status(429).json({ error: "locked" });
  }

  const expected = Buffer.from(rec.codeHash, "hex");
  const actual = Buffer.from(hashCode(email, code), "hex");
  if (
    expected.length !== actual.length ||
    !crypto.timingSafeEqual(expected, actual)
  ) {
    rec.attempts += 1;
    return res.status(400).json({ error: "invalid" });
  }

  store.delete(email);
  // 在此签发 session / JWT
  return res.json({ ok: true, token: "replace-with-jwt" });
});

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

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()
);

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

React Native 客户端:只调你的 BFF

// src/api/otp.ts — 这里没有 Rovela 密钥
const BASE = process.env.EXPO_PUBLIC_API_BASE!; // 你的 BFF,例如 https://api.yourapp.com

export async function sendCode(email: string) {
  const res = await fetch(`${BASE}/auth/otp/send`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: email.trim() }),
  });
  if (res.status === 429) throw new Error("too_soon");
  if (!res.ok) throw new Error("send_failed");
}

export async function verifyCode(email: string, code: string) {
  const res = await fetch(`${BASE}/auth/otp/verify`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: email.trim(), code: code.trim() }),
  });
  if (!res.ok) throw new Error("invalid_or_locked");
  const data = (await res.json()) as { token: string };
  return data.token; // 用 expo-secure-store 存储
}
// src/screens/OtpLogin.tsx(草图)
import { useState } from "react";
import {
  View,
  TextInput,
  Button,
  Text,
  ActivityIndicator,
  Platform,
} from "react-native";
import * as SecureStore from "expo-secure-store";
import { sendCode, verifyCode } from "../api/otp";

export function OtpLogin() {
  const [email, setEmail] = useState("");
  const [code, setCode] = useState("");
  const [step, setStep] = useState<0 | 1>(0);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function onSend() {
    setBusy(true);
    setError(null);
    try {
      await sendCode(email);
      setStep(1);
    } catch (e) {
      setError(String(e));
    } finally {
      setBusy(false);
    }
  }

  async function onVerify() {
    setBusy(true);
    setError(null);
    try {
      const token = await verifyCode(email, code);
      await SecureStore.setItemAsync("session", token);
      // 跳转首页
    } catch (e) {
      setError(String(e));
    } finally {
      setBusy(false);
    }
  }

  return (
    <View style={{ padding: 24, gap: 12 }}>
      {step === 0 ? (
        <>
          <TextInput
            value={email}
            onChangeText={setEmail}
            autoCapitalize="none"
            keyboardType="email-address"
            autoComplete="email"
            textContentType="emailAddress"
            placeholder="邮箱"
          />
          {busy ? (
            <ActivityIndicator />
          ) : (
            <Button title="发送验证码" onPress={onSend} />
          )}
        </>
      ) : (
        <>
          <TextInput
            value={code}
            onChangeText={setCode}
            keyboardType="number-pad"
            maxLength={6}
            autoComplete={Platform.OS === "android" ? "sms-otp" : "one-time-code"}
            textContentType="oneTimeCode"
            placeholder="六位验证码"
          />
          {busy ? (
            <ActivityIndicator />
          ) : (
            <>
              <Button title="验证" onPress={onVerify} />
              <Button title="重新发送" onPress={onSend} />
            </>
          )}
        </>
      )}
      {error ? <Text style={{ color: "crimson" }}>{error}</Text> : null}
    </View>
  );
}

使用 textContentType="oneTimeCode"(iOS)以及 autoComplete="sms-otp" / "one-time-code",方便系统在可用时从通知建议验证码。仍把短信自动填充库当作可选项——邮箱 OTP 落在邮件 App,不是 SMS Retriever 载荷。EXPO_PUBLIC_API_BASE 只应指向你的 BFF 源站。

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

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

  • Header ≤ 256 字符;Rovela 记住 24 小时,重放返回原始响应(API: emails)。
  • 由 code hash 派生(otp- + 前 32 位十六进制),使一次逻辑铸造对应一封入队邮件。
  • 冷却后的主动重发会铸造新码 → 新哈希 → 新键。不要用旧键发新码。
  • React Native 客户端不会对 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-rn-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."
  }'

为什么不要在 React Native 里直连 Rovela

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

Expo 的公开环境变量与 Metro 打包,会让「把密钥藏在客户端」格外脆弱。通用 Node/fetch 发送见 Express OTP 与 用 Node.js 发邮件。那些偏服务端传输;本篇是带移动端边界的注册/登录 OTP。

认证数字码的投递注意

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

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

React Native 里:验证码 vs 魔法链接

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

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

上线前清单

  1. 服务端用 crypto.randomInt(或系统 CSPRNG)+ HMAC pepper;永不存明文码;不用 Math.random() 发 OTP。
  2. 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、BFF 上按 IP 限流。
  3. upsert / 覆盖会使旧码失效;验码成功后 delete。
  4. 同时有 html 和 text;可选把码放进 subject 方便预览。
  5. ROVELA_API_KEY(sending_access)只放服务端——不放 RN,不放 EXPO_PUBLIC_*。
  6. Idempotency-Key 对同一次逻辑发送保持稳定;新码 → 新键。
  7. 若隐藏账号存在性,公开文案保持一致。
  8. RN 只调你的 API;使用 one-time-code 自动填充提示;会话 token 放 Secure Store / 钥匙串。
  9. 发信域已验证;进垃圾箱则按清单排查。

相关阅读

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

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