SolidStart / SolidJS 发送邮箱验证码(2026):用纯 HTTP 事务邮件 API
用 SolidStart API 路由或 "use server" action 做注册/登录邮箱验证码:crypto.randomInt + HMAC、带 Idempotency-Key 调 POST /emails——发送密钥永不进浏览器;Rovela 只负责投递。
为什么用 SolidStart 做邮箱 OTP
SolidStart 已经给注册/登录准备好了服务端优先形态:src/routes/api 下的文件式 API 路由、来自 @solidjs/router 的 "use server" action,以及不会把发信密钥打进浏览器包的环境变量。你不需要 Rovela 的 Solid 插件、所谓「OTP 产品」端点,也不需要在客户端 SDK 里把邮件 API 伪装成身份提供方。
你需要的是 邮箱验证码示例 里的配方:由 SolidStart(或 SolidJS SPA + BFF)在服务端用 crypto.randomInt 生成六位码,只存 HMAC(永不存明文数字),配合过期与尝试次数(Redis / SQL / KV),再 fetch POST /emails,同时带上 html 与 text。Rovela 负责入队、DKIM 签名、重试与事件记录。它不会生成验证码、校验验证码,也不会创建会话。
Rovela 不提供 OTP 功能。 密码学、存储条目、重发节流、兑换逻辑以及 session cookie / JWT,全部由你的 SolidStart server function / API 路由负责。Rovela 只负责投递邮件。
本文聚焦 SolidStart + SolidJS(APIEvent 路由、"use server" action、Node crypto)。它不是 Nuxt 发送 OTP、SvelteKit 魔法链接登录、Remix 魔法链接 或 Express OTP 的复述。同系列 OTP:Hono、Go、Django。数字码与链接二选一:验证码 vs 链接。语言无关总览:用邮件 API 发送 OTP。
SolidStart 与纯 SolidJS SPA
| SolidStart(本文) | SolidJS SPA + 独立 BFF | |
|---|---|---|
| OTP 运行位置 | src/routes/api/auth/otp/* 或 "use server" action | Express / Hono / Nest / 云函数 |
| 密钥 | 服务端 env(ROVELA_API_KEY、CODE_PEPPER) | 同上——绝不用公开的 VITE_* |
| 客户端 | Solid 组件调同源 /api/... 或 action | Solid 应用经 HTTPS 调你的 BFF |
| Rovela 角色 | 仅 POST /emails 投递 | 相同 |
纯客户端 SolidJS SPA 绝不能从浏览器直接调 api.rovela.dev。像 Expo / React Native 文章一样,把 mint + send 放在 BFF 后面。SolidStart 只是把这个 BFF 与 UI 放在同一项目里。
你负责什么,Rovela 发送什么
你的 SolidStart / BFF 应用负责:
crypto.randomInt(0, 1_000_000)→ 六位、左侧补零(绝不用Math.random())- 对
email + ":" + code做 HMAC-SHA256,密钥为服务端 pepper(CODE_PEPPER) - 过期(文档示例约 10 分钟)、尝试计数、「每邮箱每分钟一次」重发门闩
- 按规范化邮箱 upsert,新发送使旧码失效(Redis、Postgres 或持久 KV——不要用跨副本的进程内
Map) - 兑换时常数时间比较;成功后删除 / 作废
- 对外响应是否暴露账号存在由产品决定,但建议登录场景回答一致
- 对 send / verify 做限流(中间件、反向代理或边缘)
Rovela 负责:
POST https://api.rovela.dev/emails,Authorization: Bearer+ 带sending_access的密钥(ROVELA_API_KEY只放服务端 env——勿提交仓库,勿放进VITE_*/ 客户端包)- 入队、签名、重试、退信/投诉处理
发送接口返回 200 表示已接受并入队,不等于「已进收件箱」。
环境变量(仅服务端)
# .env(仅服务端 —— 绝不要 VITE_ROVELA_* / import.meta.env.VITE_*)
ROVELA_API_KEY=re_your_sending_key
CODE_PEPPER=long-random-server-secret
OTP_FROM="Acme <security@mail.acme.com>"
鉴权:Authentication · 报文格式:API: emails · 首次发送:Quick Start。生产 OTP 发送用 sending_access 密钥即可,也更合适。
精简 Rovela 客户端(服务端模块)
放在仅服务端路径(src/lib/server/,或只被 API 路由 / "use server" 模块引用):
// src/lib/server/rovela.ts
export async function sendEmail(input: {
apiKey: string;
from: string;
to: string;
subject: string;
html: string;
text: string;
idempotencyKey: string;
}) {
const res = await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${input.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": input.idempotencyKey.slice(0, 256),
},
body: JSON.stringify({
from: input.from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(
`Rovela ${res.status}: ${(body as { message?: string }).message ?? "send failed"}`,
);
}
return body as { object: string; id: string };
}
不要把 API key 打进日志。from 优先用已验证的认证子域(security@mail.…)。
生成码 + HMAC(node:crypto)
六位数字只有一百万种可能。泄露的裸 SHA-256 表会在远不到一秒内被撞出。按文档:用永不离开服务器的 pepper 做 HMAC。
// src/lib/server/otpCrypto.ts
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
export function normalizeEmail(raw: string) {
return raw.trim().toLowerCase();
}
/** CSPRNG 六位码 —— 绝不用 Math.random() */
export function mintCode(): string {
return randomInt(0, 1_000_000).toString().padStart(6, "0");
}
export function hashCode(pepper: string, email: string, code: string) {
return createHmac("sha256", pepper)
.update(`${email}:${code}`)
.digest("hex");
}
export function safeEqualHex(a: string, b: string) {
const ba = Buffer.from(a, "hex");
const bb = Buffer.from(b, "hex");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
若在没有 node:crypto 的边缘运行时,可像 Nuxt OTP、Hono OTP 那样改用 Web Crypto(crypto.getRandomValues + crypto.subtle HMAC)——无论哪种,pepper 都必须只在服务端。
存储草图(Redis / SQL)
建议形态(每邮箱一个有效码),与验证码示例一致:
// src/lib/server/otpStore.ts
export type CodeRecord = {
codeHash: string;
expiresAt: number; // epoch ms
attempts: number;
sentAt: number;
};
export interface OtpStore {
get(email: string): Promise<CodeRecord | null>;
put(email: string, record: CodeRecord, ttlSeconds: number): Promise<void>;
del(email: string): Promise<void>;
}
多实例 SolidStart(多 Node 进程、serverless isolate)请用 Redis / Upstash / Postgres,不要用进程内 Map。
服务:发送 + 校验
// src/lib/server/otpService.ts
import { sendEmail } from "./rovela";
import {
hashCode,
mintCode,
normalizeEmail,
safeEqualHex,
} from "./otpCrypto";
import type { OtpStore } from "./otpStore";
const SAFE_SEND_MSG =
"若该地址可收信,验证码已在路上。";
const TTL_MINUTES = 10;
const RESEND_COOLDOWN_MS = 60_000;
const MAX_ATTEMPTS = 5;
export async function sendOtp(opts: {
store: OtpStore;
apiKey: string;
pepper: string;
from: string;
rawEmail: string;
}) {
const email = normalizeEmail(opts.rawEmail);
const existing = await opts.store.get(email);
if (existing && Date.now() - existing.sentAt < RESEND_COOLDOWN_MS) {
return { ok: false as const, error: "too_soon" };
}
const code = mintCode();
const codeHash = hashCode(opts.pepper, email, code);
const now = Date.now();
const expiresAt = now + TTL_MINUTES * 60_000;
await opts.store.put(
email,
{ codeHash, expiresAt, attempts: 0, sentAt: now },
TTL_MINUTES * 60 + 60,
);
// 同一逻辑发送稳定:哈希前缀。新码 ⇒ 新 key。
const idempotencyKey = `otp-${codeHash.slice(0, 32)}`;
await sendEmail({
apiKey: opts.apiKey,
from: opts.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 ${TTL_MINUTES} minutes. If you did not ask for it, ignore this email.</p>`,
text: `Your verification code is ${code}. It expires in ${TTL_MINUTES} minutes. If you did not ask for it, ignore this email.`,
idempotencyKey,
});
return { ok: true as const, message: SAFE_SEND_MSG };
}
export async function verifyOtp(opts: {
store: OtpStore;
pepper: string;
rawEmail: string;
rawCode: string;
}) {
const email = normalizeEmail(opts.rawEmail);
const code = opts.rawCode.trim();
if (!/^\d{6}$/.test(code)) {
return { ok: false as const, error: "invalid" };
}
const row = await opts.store.get(email);
if (!row) return { ok: false as const, error: "invalid" };
if (row.expiresAt < Date.now()) {
await opts.store.del(email);
return { ok: false as const, error: "expired" };
}
if (row.attempts >= MAX_ATTEMPTS) {
return { ok: false as const, error: "locked" };
}
const candidate = hashCode(opts.pepper, email, code);
const match = safeEqualHex(candidate, row.codeHash);
if (!match) {
await opts.store.put(
email,
{ ...row, attempts: row.attempts + 1 },
Math.max(60, Math.ceil((row.expiresAt - Date.now()) / 1000)),
);
return { ok: false as const, error: "invalid" };
}
await opts.store.del(email);
// 在此签发 session / JWT / set-cookie
return { ok: true as const };
}
把码放进主题便于锁屏预览——多数产品可用;频繁共享屏幕时可不放(见验证码文档)。务必同时发送 html 与 text,并在 text 中包含数字(POST /emails)。
方案 A:SolidStart API 路由
// src/routes/api/auth/otp/send.ts
import type { APIEvent } from "@solidjs/start/server";
import { sendOtp } from "~/lib/server/otpService";
import { getStore } from "~/lib/server/otpStoreRedis"; // 你的 Redis/SQL 适配器
export async function POST(event: APIEvent) {
// 同时按 IP 限流(反向代理 / Cloudflare / 中间件)。
const body = (await event.request.json().catch(() => ({}))) as {
email?: string;
};
const email = String(body?.email ?? "");
if (!email.includes("@")) {
return Response.json({ error: "invalid_email" }, { status: 400 });
}
const result = await sendOtp({
store: getStore(),
apiKey: process.env.ROVELA_API_KEY!,
pepper: process.env.CODE_PEPPER!,
from: process.env.OTP_FROM!,
rawEmail: email,
});
if (!result.ok && result.error === "too_soon") {
return Response.json({ error: "too_soon" }, { status: 429 });
}
return Response.json({ message: result.message ?? "ok" });
}
// src/routes/api/auth/otp/verify.ts
import type { APIEvent } from "@solidjs/start/server";
import { verifyOtp } from "~/lib/server/otpService";
import { getStore } from "~/lib/server/otpStoreRedis";
export async function POST(event: APIEvent) {
const body = (await event.request.json().catch(() => ({}))) as {
email?: string;
code?: string;
};
const result = await verifyOtp({
store: getStore(),
pepper: process.env.CODE_PEPPER!,
rawEmail: String(body?.email ?? ""),
rawCode: String(body?.code ?? ""),
});
if (!result.ok) {
const status =
result.error === "locked" || result.error === "too_soon" ? 429 : 400;
return Response.json({ error: result.error }, { status });
}
return Response.json({ ok: true });
}
在 Solid 组件里只调这些同源 API——绝不要从浏览器 fetch Rovela:
await fetch("/api/auth/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
await fetch("/api/auth/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code }),
});
方案 B:"use server" action
当 UI 是 SolidStart 内的表单时,带 "use server" 的 action 可不另开 REST 路径,也能把密钥留在服务端:
// src/lib/server/otpActions.ts
import { action } from "@solidjs/router";
import { sendOtp, verifyOtp } from "./otpService";
import { getStore } from "./otpStoreRedis";
export const sendOtpAction = action(async (formData: FormData) => {
"use server";
const email = String(formData.get("email") ?? "");
if (!email.includes("@")) return { ok: false as const, error: "invalid_email" };
return sendOtp({
store: getStore(),
apiKey: process.env.ROVELA_API_KEY!,
pepper: process.env.CODE_PEPPER!,
from: process.env.OTP_FROM!,
rawEmail: email,
});
}, "send-otp");
export const verifyOtpAction = action(async (formData: FormData) => {
"use server";
return verifyOtp({
store: getStore(),
pepper: process.env.CODE_PEPPER!,
rawEmail: String(formData.get("email") ?? ""),
rawCode: String(formData.get("code") ?? ""),
});
}, "verify-otp");
移动端、Agent 或其他服务要共用同一套 send/verify 契约时,优先 API 路由;仅 SolidStart UI 提交 FormData 时可用 action。无论哪种:sending_access 永不出现在客户端 JS。
Idempotency-Key、html + text、可达性
- 用码哈希派生
Idempotency-Key(otp-+ 前缀),超时重试不会入队第二条不同正文的消息。主动重发会 mint 新码,因此是新 key。 - 始终同时发
html与text;六位数字放进 text。 - 用与营销信隔离声誉的子域发信(验证码文档)。
- 若进垃圾箱,参见 OTP / 魔法链接进垃圾箱。
限流 / 一次性 / 过期清单
- 发放节流 — 每邮箱每分钟最多一个新码;边缘再按 IP 封顶。
- 短 TTL — 约 10 分钟(文档示例);步进验证可更短。
- 落库哈希 —
email:code+ pepper 的 HMAC-SHA256;永不存明文数字。 - Upsert — 新发送使该邮箱先前未用哈希失效。
- 尝试上限 — 约 5 次失败后锁定;成功后删除。
- 防枚举发送 — 是否暴露账号存在时,对外状态/正文保持一致。
- Idempotency-Key — 同一逻辑发送稳定;新码 → 新 key。
- 共享存储 — 多实例用 Redis/SQL;内存仅适合单进程演示。
- 无公开 env — 绝不要
VITE_ROVELA_*/ 客户端可访问密钥。
curl 等价(Agent / 冒烟)
与 SolidStart handler 相同的报文——适合本地冒烟,以及直接调 HTTP 的 AI Agent(AI Agent 发信):
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer re_your_sending_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-smoke-2026-11-05" \
-d '{
"from": "Acme <security@mail.acme.com>",
"to": "alice@example.com",
"subject": "Your Acme code: 482913",
"html": "<p>Your verification code is</p><p style=\"font-size:28px;font-weight:600;letter-spacing:4px\">482913</p><p>It expires in 10 minutes.</p>",
"text": "Your verification code is 482913. It expires in 10 minutes."
}'
生产密钥需要 sending_access;from 必须落在已验证域名(Quick Start)。
SolidStart vs Nuxt OTP vs SvelteKit 魔法链接
| 本文(SolidStart OTP) | Nuxt OTP | SvelteKit 魔法链接 | |
|---|---|---|---|
| 栈 | SolidStart API 路由 / "use server" | Nuxt 3 + Nitro server/api | SvelteKit form actions |
| UX 原语 | 六位 OTP | 六位 OTP | 魔法链接 token URL |
| 密码学 | node:crypto randomInt + HMAC | Web Crypto / node:crypto + HMAC | 高熵 token + SHA-256 |
| 配置 | 服务端 process.env | runtimeConfig(仅服务端) | $env/dynamic/private |
| 侧重 | Solid 细粒度 UI + 服务端 BFF | Vue/Nuxt 注册登录码 | 无密码链接登录 |
三者都调同一个 POST /emails。不要把 Next/SvelteKit 魔法链接 handler 贴进 SolidStart 并称为「OTP」,也不要把 Rovela 当成 Auth.js / solid-auth。
同一 SolidStart 应用里的 OTP vs 魔法链接
| OTP(六位) | 魔法链接 | |
|---|---|---|
| 文档形态 | 验证码 | 类似密码重置 |
| SolidStart 落点 | /api/auth/otp/send + /api/auth/otp/verify | 独立魔法链接路由/页面 |
| UX | 输入数字 | 一点击(+ Continue) |
| 存储 | email:code + pepper 的 HMAC | 高熵 token 的 SHA-256 |
| 典型 TTL | ~10 分钟 | ~30 分钟 |
| 适合 | 共享屏幕、步进验证、移动端 | 桌面/移动浏览器登录 |
两者都调同一个 POST /emails。分开存储——不要把 OTP 记录当魔法链接 token 复用。产品选择:验证码 vs 链接。Next 风格魔法链接见 Next.js 魔法链接登录;Remix / SvelteKit 同系见 Remix 与 SvelteKit。
上线清单
- 域名已验证;认证子域 SPF/DKIM/DMARC 正常。
- 仅含
sending_access的ROVELA_API_KEY只在服务端 env——绝不要VITE_*。 CODE_PEPPER足够长且随机;永不提交仓库。OTP_FROM使用已验证子域;html + text 齐全;数字在 text(可选主题)。- 每次逻辑发送带
Idempotency-Key;存储 upsert + 尝试上限 + 成功删除。 - 多实例:Redis/SQL——不要只靠朴素内存 Map。
- 把发送
200当作已入队;需要投递真相时用 webhooks 处理退信/投诉。
本文不是什么
- 不是完整生产级认证框架(OAuth2、MFA、通行密钥、solid-auth)。
- 不是 Nuxt / Express / Hono OTP 或 SvelteKit / Remix 魔法链接文章的重复。
- 不是声称 Rovela 会存储、校验或过期你的验证码。
- 不是中国国内 ESP 对比,也没有编造产品功能。
下一步
- 按 验证码示例 实现 mint + HMAC + 存储。
- 用 POST /emails · Quick Start · Authentication 发送。
- 加固可达性:OTP / 魔法链接进垃圾箱。
- 对比 UX 原语:验证码 vs 链接。
- 同系栈:Nuxt OTP · Hono OTP · Express OTP · SvelteKit 魔法链接。
- 额度:Pricing。
一句话: 把 OTP 放进 SolidStart API 路由或 "use server" action——用 crypto.randomInt 生成码、HMAC 写入 Redis 或持久存储、再 fetch 一次 POST /emails 给 Rovela——sending_access 留在服务端,永远不要把邮件 API 当成身份提供方。