← 返回博客

SolidStart / SolidJS 发送邮箱验证码(2026):用纯 HTTP 事务邮件 API

用 SolidStart API 路由或 "use server" action 做注册/登录邮箱验证码:crypto.randomInt + HMAC、带 Idempotency-Key 调 POST /emails——发送密钥永不进浏览器;Rovela 只负责投递。

更新于 2026年11月4日
SolidStart / SolidJS 发送邮箱验证码(2026):用纯 HTTP 事务邮件 API

为什么用 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" actionExpress / Hono / Nest / 云函数
密钥服务端 env(ROVELA_API_KEY、CODE_PEPPER)同上——绝不用公开的 VITE_*
客户端Solid 组件调同源 /api/... 或 actionSolid 应用经 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、可达性

  1. 用码哈希派生 Idempotency-Key(otp- + 前缀),超时重试不会入队第二条不同正文的消息。主动重发会 mint 新码,因此是新 key。
  2. 始终同时发 html 与 text;六位数字放进 text。
  3. 用与营销信隔离声誉的子域发信(验证码文档)。
  4. 若进垃圾箱,参见 OTP / 魔法链接进垃圾箱。

限流 / 一次性 / 过期清单

  1. 发放节流 — 每邮箱每分钟最多一个新码;边缘再按 IP 封顶。
  2. 短 TTL — 约 10 分钟(文档示例);步进验证可更短。
  3. 落库哈希 — email:code + pepper 的 HMAC-SHA256;永不存明文数字。
  4. Upsert — 新发送使该邮箱先前未用哈希失效。
  5. 尝试上限 — 约 5 次失败后锁定;成功后删除。
  6. 防枚举发送 — 是否暴露账号存在时,对外状态/正文保持一致。
  7. Idempotency-Key — 同一逻辑发送稳定;新码 → 新 key。
  8. 共享存储 — 多实例用 Redis/SQL;内存仅适合单进程演示。
  9. 无公开 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 OTPSvelteKit 魔法链接
栈SolidStart API 路由 / "use server"Nuxt 3 + Nitro server/apiSvelteKit form actions
UX 原语六位 OTP六位 OTP魔法链接 token URL
密码学node:crypto randomInt + HMACWeb Crypto / node:crypto + HMAC高熵 token + SHA-256
配置服务端 process.envruntimeConfig(仅服务端)$env/dynamic/private
侧重Solid 细粒度 UI + 服务端 BFFVue/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。

上线清单

  1. 域名已验证;认证子域 SPF/DKIM/DMARC 正常。
  2. 仅含 sending_access 的 ROVELA_API_KEY 只在服务端 env——绝不要 VITE_*。
  3. CODE_PEPPER 足够长且随机;永不提交仓库。
  4. OTP_FROM 使用已验证子域;html + text 齐全;数字在 text(可选主题)。
  5. 每次逻辑发送带 Idempotency-Key;存储 upsert + 尝试上限 + 成功删除。
  6. 多实例:Redis/SQL——不要只靠朴素内存 Map。
  7. 把发送 200 当作已入队;需要投递真相时用 webhooks 处理退信/投诉。

本文不是什么

  • 不是完整生产级认证框架(OAuth2、MFA、通行密钥、solid-auth)。
  • 不是 Nuxt / Express / Hono OTP 或 SvelteKit / Remix 魔法链接文章的重复。
  • 不是声称 Rovela 会存储、校验或过期你的验证码。
  • 不是中国国内 ESP 对比,也没有编造产品功能。

下一步

  1. 按 验证码示例 实现 mint + HMAC + 存储。
  2. 用 POST /emails · Quick Start · Authentication 发送。
  3. 加固可达性:OTP / 魔法链接进垃圾箱。
  4. 对比 UX 原语:验证码 vs 链接。
  5. 同系栈:Nuxt OTP · Hono OTP · Express OTP · SvelteKit 魔法链接。
  6. 额度:Pricing。

一句话: 把 OTP 放进 SolidStart API 路由或 "use server" action——用 crypto.randomInt 生成码、HMAC 写入 Redis 或持久存储、再 fetch 一次 POST /emails 给 Rovela——sending_access 留在服务端,永远不要把邮件 API 当成身份提供方。