Expo 托管应用发送邮箱验证码(2026):后端 HTTP 调事务邮件 API
用 Expo Router + Express 或 API route 为 Expo 托管工作流做注册邮箱验证码:crypto.randomInt + HMAC、EAS secrets、带 Idempotency-Key 调 POST /emails——发送密钥永不进客户端;Rovela 只负责投递。
为什么 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 / Update | Metro + 原生工程 |
| 密钥易泄露处 | EXPO_PUBLIC_*、extra、OTA 更新 | 同样的 JS 提取风险 |
| 常见 BFF | Express 或 Expo API route + EAS secrets | Express / 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 + pepper | URL token 熵高 → 通常对 token 做 SHA-256 即可 |
| 文档形态 | 验证码 | 密码重置 / 链接模式 |
| Expo 表面 | Expo Router 邮箱 + PIN → 你的 BFF | Linking / Expo Router → 你的 BFF |
| 点击追踪 | 可选关注 | 安全发信域上建议关闭 |
两者都从服务端调同一个 POST /emails。存储分开——不要把 OTP 记录当成魔法链接 token 复用。
上线前清单
- 服务端用
crypto.randomInt(或系统 CSPRNG)+ HMAC pepper;永不存明文码;不用Math.random()发 OTP。 - 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、BFF 上按 IP 限流。
- upsert / 覆盖会使旧码失效;验码成功后 delete。
- 同时有
html和text;可选把码放进 subject 方便预览。 ROVELA_API_KEY(sending_access)只作 EAS/服务端密钥——不放 Expo 客户端,不放EXPO_PUBLIC_*。Idempotency-Key对同一次逻辑发送保持稳定;新码 → 新键。- 若隐藏账号存在性,公开文案保持一致。
- Expo 只调你的 API;使用 one-time-code 自动填充提示;会话 token 放 Secure Store。
- 发信域已验证;进垃圾箱则按清单排查。
- OTP 存持久化存储(Redis/SQL)——不要在多实例 EAS 后用进程内
Map。
相关阅读
- 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
- 产品无关 OTP:用邮件发 OTP · 邮箱 OTP 安全。
- UX 选择:邮箱验证:验证码 vs 链接。
- 投递:为什么 OTP / 魔法链接进垃圾箱。
- 框架姊妹篇:React Native OTP · Flutter OTP · Express OTP · Hono OTP。
- 仅 Node 发送(非移动 OTP):用 Node.js 发邮件。
一句话: 让 Expo 托管应用只做 OTP UI;把铸码/HMAC/验码与 Rovela sending_access 密钥放在服务端(Express 或带 EAS secrets 的 Expo API route),带 Idempotency-Key 调 POST /emails,永远不要把发送密钥打进客户端——Rovela 负责投递,认证仍归你的应用。