← 返回博客

Expo 托管应用发送邮箱验证码(2026):后端 HTTP 调事务邮件 API

用 Expo Router + Express 或 API route 为 Expo 托管工作流做注册邮箱验证码:crypto.randomInt + HMAC、EAS secrets、带 Idempotency-Key 调 POST /emails——发送密钥永不进客户端;Rovela 只负责投递。

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

为什么 Expo 托管工作流发邮箱验证码仍需要服务端

Expo 的**托管工作流(managed workflow)**很适合做注册/登录 UI:Expo Router 页面、TextInput、数字键盘、expo-secure-store,以及无需 eject 的 OTA 更新。但它不适合存放 Rovela 的 sending_access 密钥。凡是进入 JS 包的内容——EXPO_PUBLIC_*、app.config.js 的 extra、EAS Update 载荷,或「远程配置里的秘密」——都可能从 IPA/APK 或缓存的更新包中被提取。

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

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

本文面向 Expo 托管工作流:Expo Router UI + 可用独立 Express 服务或带 EAS secrets 的 Expo Router API route / server function 作为 BFF。这不是通用的 React Native OTP(RN/Expo UI + 通用 Express BFF),不是 Flutter OTP,也不是纯 Web 的 Express OTP。数字码 vs 链接的产品选择见 邮箱验证:验证码 vs 链接。语言无关指南见 用邮件发 OTP。

Managed vs Bare:发送密钥规则相同

Expo managed(本文)Bare React Native
客户端打包Metro + EAS Build / UpdateMetro + 原生工程
密钥易泄露处EXPO_PUBLIC_*、extra、OTA 更新同样的 JS 提取风险
常见 BFFExpress 或 Expo API route + EAS secretsExpress / Nest / 云函数
OTP 归属仍是你的服务端仍是你的服务端
Rovela 角色仅 POST /emails 投递相同

Managed 不等于「在手机上直连 Rovela」。它只是让原生工具链更简单,同时让认证密钥留在永不下发到设备的主机上。

你负责什么,Rovela 发什么

BFF(Express、Fastify、Nest 或 Expo API route)负责:

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

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 密钥(仅服务端 / EAS secret——勿打进客户端包,勿放进 EXPO_PUBLIC_*)
  • 入队、签名、重试、退信/投诉处理

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

架构:Expo UI → 你的 API → Rovela

Expo 托管应用(Expo Router UI)
   │  HTTPS JSON(没有 Rovela 密钥)
   ▼
BFF:Express 或 Expo Router API route
     (EAS secret:ROVELA_API_KEY + CODE_PEPPER)
   │  POST /emails + Idempotency-Key
   ▼
api.rovela.dev  →  收件箱

若其余栈已是 TypeScript,优先用薄 Node BFF。Expo API route 也可以——前提是它跑在带 EAS/环境密钥的服务端运行时,且这些密钥永远不会变成 EXPO_PUBLIC_*。生产 OTP 不要在客户端组件或会被 Metro 打进设备包的共享模块里用 fetch 直连 Rovela。

环境变量:EAS secrets vs EXPO_PUBLIC_

# 仅服务端 / EAS secrets — 绝不要 EXPO_PUBLIC_
# eas secret:create --name ROVELA_API_KEY --value re_... --type string
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
# 客户端可公开(会打进包)
EXPO_PUBLIC_API_BASE=https://api.yourapp.com
# 错误示范:EXPO_PUBLIC_ROVELA_API_KEY=...

按普通服务端凭证接入(Authentication、Quick Start)。生产发 OTP 用 sending_access 即可。文档明确:切勿把 API 密钥放进移动应用。

BFF 方案 A:Express(与 RN 姊妹篇同款密码学)

// server/src/otp.ts
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>(); // 仅演示 — 多实例 / EAS 请用 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);
  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()
);

BFF 方案 B:Expo Router API route(仅服务端)

若把认证与 Expo 项目放在一起,请把 Rovela 调用放在服务端路由模块,并从主机注入密钥——而不是 EXPO_PUBLIC_*:

// app/api/auth/otp/send+api.ts  (草图 — 仅服务端运行时)
import crypto from "node:crypto";

const PEPPER = process.env.CODE_PEPPER!;
const API_KEY = process.env.ROVELA_API_KEY!; // EAS secret / 服务端环境变量
const FROM = process.env.OTP_FROM ?? "Acme <security@mail.acme.com>";

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");
}

export async function POST(request: Request) {
  const body = await request.json();
  const email = String(body?.email ?? "").trim().toLowerCase();
  if (!email.includes("@")) {
    return Response.json({ error: "invalid_email" }, { status: 400 });
  }

  const code = mintCode();
  const codeHash = hashCode(email, code);
  // await upsertVerificationRow({ email, codeHash, ttlMinutes: 10 })

  const idem = `otp-${codeHash.slice(0, 32)}`;
  const res = await fetch("https://api.rovela.dev/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idem,
    },
    body: JSON.stringify({
      from: FROM,
      to: email,
      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 10 minutes. If you did not ask for it, ignore this email.</p>`,
      text: `Your verification code is ${code}. It expires in 10 minutes.`,
    }),
  });
  if (!res.ok) {
    const errText = await res.text();
    return Response.json({ error: "send_failed", detail: errText }, { status: 502 });
  }

  return Response.json({
    message: "若该地址可收信,验证码已在路上。",
  });
}

文件命名与导出形状因框架而异;不变量是:Metro 绝不能看到 ROVELA_API_KEY。若对 Expo API 托管的密钥隔离没有把握,优先用独立的 Express/Fly/Railway 服务。

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

Expo 客户端:Expo Router 页面只调你的 BFF

// src/api/otp.ts — 这里没有 Rovela 密钥
const BASE = process.env.EXPO_PUBLIC_API_BASE!; // 你的 BFF 源站

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;
}
// app/(auth)/otp.tsx — Expo Router 页面(草图)
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 "../../src/api/otp";

export default function OtpScreen() {
  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);
      // router.replace("/(app)")
    } 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。EXPO_PUBLIC_API_BASE 只应指向你的 BFF 源站。

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

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

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

为什么不要在 Expo 应用里直连 Rovela

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

EXPO_PUBLIC_* 与 EAS Update 会让「把密钥藏在客户端」在托管应用里格外脆弱。姊妹篇 React Native OTP 用通用 RN + Express 讲同一边界;本篇强调 managed workflow、Expo Router 与 EAS secrets。

认证数字码的投递注意

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

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

Expo 里:验证码 vs 魔法链接

六位 OTP(本文)魔法链接
用户动作输入数字(适合手机键盘)打开 URL(Deep Link / Universal Link)
秘密熵低 → 必须 HMAC + pepperURL token 熵高 → 通常对 token 做 SHA-256 即可
文档形态验证码密码重置 / 链接模式
Expo 表面Expo Router 邮箱 + 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)只作 EAS/服务端密钥——不放 Expo 客户端,不放 EXPO_PUBLIC_*。
  6. Idempotency-Key 对同一次逻辑发送保持稳定;新码 → 新键。
  7. 若隐藏账号存在性,公开文案保持一致。
  8. Expo 只调你的 API;使用 one-time-code 自动填充提示;会话 token 放 Secure Store。
  9. 发信域已验证;进垃圾箱则按清单排查。
  10. OTP 存持久化存储(Redis/SQL)——不要在多实例 EAS 后用进程内 Map。

相关阅读

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

一句话: 让 Expo 托管应用只做 OTP UI;把铸码/HMAC/验码与 Rovela sending_access 密钥放在服务端(Express 或带 EAS secrets 的 Expo API route),带 Idempotency-Key 调 POST /emails,永远不要把发送密钥打进客户端——Rovela 负责投递,认证仍归你的应用。