用 Hono 发邮箱验证码(2026):HTTP 调事务邮件 API
在边缘或 Node 上用 Hono 做注册/登录邮箱验证码:Web Crypto getRandomValues、HMAC 落 KV 或内存 Map、fetch 调 POST /emails 并带 Idempotency-Key——Rovela 只负责投递。
为什么用 Hono 在边缘发 OTP
Hono 做注册/登录本来就很轻:几条路由就能跑在 Node、Bun、Cloudflare Workers、Deno 或任意兼容 Fetch 的运行时上。你不需要 Rovela 的 Hono 插件,也不需要产品侧专门的 OTP 接口,更不必用「只能在 Worker 里发信」的绑定把邮件 API 伪装成身份提供方。
你需要的是 邮箱验证码示例 那套配方:应用用 Web Crypto(crypto.getRandomValues)生成六位数字,只存 HMAC(永不存明文),在 KV 或内存 Map 里设过期与尝试次数,再用 fetch POST /emails 同时发 html 与 text。Rovela 负责排队、DKIM 签名、重试与事件轨迹。它不会生成验证码、校验验证码,也不会创建 session。
Rovela 不提供 OTP 功能。 密码学、存储条目、重发节流、核销逻辑、session cookie / JWT 都归你的 Hono 应用。Rovela 只负责投递。
本文聚焦 Hono / TypeScript(边缘可用的 Web Crypto、KV 或内存 Map、c.env 密钥)。它不是 Express.js 发送邮箱验证码(Node crypto.randomInt + SQL)的复读,也不是 Cloudflare Workers 发事务邮件(没有 OTP 存储的通用边缘发送)。姊妹 OTP 文:Go、Django、Laravel、Spring Boot。验证码 vs 链接选型:邮箱验证:验证码还是链接。与语言无关的 OTP:用邮件发送 OTP。
你负责什么,Rovela 发什么
你的 Hono 应用负责:
crypto.getRandomValues→ 六位数字并补零(不要用Math.random())- 用 Web Crypto 对
email + ":" + code做 HMAC-SHA256,密钥为服务端 pepper(CODE_PEPPER) - 过期(文档示例约 10 分钟)、尝试计数,以及「同一地址每分钟最多发一次」的重发闸门
- 按规范化邮箱 upsert,使新发送作废旧码(Workers KV、Upstash Redis、Durable Object,或单实例 Node/Bun 的进程内
Map) - 核销时做恒定时间比较;成功后删除 / 作废
- 不论是否暴露账号存在性,对外响应保持一致(产品选择)
- 对
/auth/otp/send与/auth/otp/verify限流(中间件或边缘限流产品)
Rovela 负责:
POST https://api.rovela.dev/emails,Authorization: Bearer+ 具备sending_access的密钥(ROVELA_API_KEY放环境变量 / Worker secrets——不要提交进仓库,也不要放进客户端包)- 排队、签名、重试、退信/投诉处理
发送接口返回 200 只表示已接受并入队,不是「已进收件箱」。
环境变量与精简 Rovela 客户端(fetch)
在 Cloudflare Workers / Pages 上,密钥放进 wrangler secret 或控制台绑定。在 Node 或 Bun 上,与其它服务端密钥相同。
// src/env.ts
export type AppEnv = {
Bindings: {
ROVELA_API_KEY: string; // sending_access
CODE_PEPPER: string; // 足够长的随机密钥
OTP_FROM?: string;
OTP_KV?: KVNamespace; // 可选 Workers KV
};
Variables: {
// 可选的请求级字段
};
};
// src/rovela.ts
export async function sendEmail(
apiKey: string,
from: string,
input: {
to: string;
subject: string;
html: string;
text: string;
idempotencyKey: string;
},
) {
const res = await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": input.idempotencyKey.slice(0, 256),
},
body: JSON.stringify({
from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
}),
});
if (!res.ok) {
// 不要打印 API 密钥。
const errText = await res.text();
throw new Error(`Rovela send failed: ${res.status} ${errText}`);
}
return (await res.json()) as { object: string; id: string };
}
鉴权见 Authentication,报文见 API: emails,首次发送见 Quick Start。生产环境 OTP 发送用 sending_access 就够——也更合适。
Web Crypto:getRandomValues + HMAC(边缘可用)
六位数字只有一百万种可能。泄漏的裸 SHA-256 表不到一秒就能反推。按文档:用永远不离开服务器的 pepper 做 HMAC。优先 Web Crypto,同一模块可跑在 Workers、Deno、Bun 与 Node 18+。
// src/otp/crypto.ts
const textEncoder = new TextEncoder();
export function normalizeEmail(raw: string) {
return raw.trim().toLowerCase();
}
/** CSPRNG 六位数字 — 不要用 Math.random() */
export function mintCode(): string {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const n = buf[0]! % 1_000_000;
return n.toString().padStart(6, "0");
}
async function importPepper(pepper: string) {
return crypto.subtle.importKey(
"raw",
textEncoder.encode(pepper),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
}
function toHex(buf: ArrayBuffer) {
return [...new Uint8Array(buf)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function hashCode(pepper: string, email: string, code: string) {
const key = await importPepper(pepper);
const mac = await crypto.subtle.sign(
"HMAC",
key,
textEncoder.encode(`${email}:${code}`),
);
return toHex(mac);
}
/** 恒定时间十六进制比较(哈希后等长)。 */
export function safeEqualHex(a: string, b: string) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i)! ^ b.charCodeAt(i)!;
}
return diff === 0;
}
仅 Node 部署时也可以改用 node:crypto(randomInt / createHmac)——见姊妹文 Express OTP。每个部署目标只保留一种风格,避免 Workers 构建拉进 Node 内置模块。
存储:Workers KV 或进程内 Map
建议的 JSON 值(每个邮箱一条有效码),对齐 验证码示例:
// src/otp/store.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>;
}
/** 单实例 Node/Bun / 本地冒烟 */
export class MemoryOtpStore implements OtpStore {
private map = new Map<string, CodeRecord>();
async get(email: string) {
return this.map.get(email) ?? null;
}
async put(email: string, record: CodeRecord, _ttl: number) {
this.map.set(email, record);
}
async del(email: string) {
this.map.delete(email);
}
}
/** Cloudflare Workers KV — key = 规范化邮箱 */
export class KvOtpStore implements OtpStore {
constructor(private kv: KVNamespace) {}
async get(email: string) {
return (await this.kv.get(email, "json")) as CodeRecord | null;
}
async put(email: string, record: CodeRecord, ttlSeconds: number) {
await this.kv.put(email, JSON.stringify(record), {
expirationTtl: Math.max(60, ttlSeconds),
});
}
async del(email: string) {
await this.kv.delete(email);
}
}
多 isolate 的 Workers 请用 KV(或 Redis / Durable Objects)。进程内 Map 适合单进程 Node/Bun 与演示——不要假装它能在边缘 isolate 之间共享。
Service:发送 + 校验
// src/otp/service.ts
import { sendEmail } from "../rovela";
import {
hashCode,
mintCode,
normalizeEmail,
safeEqualHex,
} from "./crypto";
import type { OtpStore } from "./store";
const SAFE_SEND_MSG =
"If that address can receive mail, a code is on its way.";
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 = await 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(opts.apiKey, 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 = await 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
return { ok: true as const };
}
把验证码放进 subject 方便锁屏预览——多数产品可以;共享屏幕场景可省略(验证码文档)。务必同时发 html 与 text,并在纯文本里带数字(POST /emails)。
Hono 路由
// src/index.ts
import { Hono } from "hono";
import type { AppEnv } from "./env";
import { sendOtp, verifyOtp } from "./otp/service";
import { KvOtpStore, MemoryOtpStore } from "./otp/store";
const app = new Hono<AppEnv>();
function storeFor(c: { env: AppEnv["Bindings"] }) {
if (c.env.OTP_KV) return new KvOtpStore(c.env.OTP_KV);
// 本地 Node/Bun 回退 — 多实例生产请替换
return new MemoryOtpStore();
}
app.post("/auth/otp/send", async (c) => {
// 同时按 IP 限流(Cloudflare 限流 / 你的中间件)。
const body = await c.req.json<{ email?: string }>().catch(() => ({}));
const email = String(body.email ?? "");
if (!email.includes("@")) {
return c.json({ error: "invalid_email" }, 400);
}
const result = await sendOtp({
store: storeFor(c),
apiKey: c.env.ROVELA_API_KEY,
pepper: c.env.CODE_PEPPER,
from: c.env.OTP_FROM ?? "Acme <security@mail.acme.com>",
rawEmail: email,
});
if (!result.ok && result.error === "too_soon") {
return c.json({ error: "too_soon" }, 429);
}
return c.json({ message: result.message ?? "ok" });
});
app.post("/auth/otp/verify", async (c) => {
const body = await c.req
.json<{ email?: string; code?: string }>()
.catch(() => ({}));
const result = await verifyOtp({
store: storeFor(c),
pepper: c.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 c.json({ error: result.error }, status);
}
return c.json({ ok: true });
});
export default app;
在运行时绑定 ROVELA_API_KEY、CODE_PEPPER,以及可选的 OTP_KV。Rovela 密钥不会离开 Worker / 服务端进程。from 优先用已验证的鉴权子域(如 security@mail.…)。
Idempotency-Key、html + text、投递
Idempotency-Key由验证码哈希派生(otp-+ 前缀),超时重试不会再入队一封内容不同的信。真正「重发」应换新码,因而换新 key。- 务必同时发 html 与 text;纯文本里带六位数字。
- 用与营销信隔离声誉的子域发送(验证码文档)。
- 若验证码进垃圾箱,按 OTP / 魔法链接进垃圾箱 排查。
限流 / 单次 / 过期清单
- 发放限流 — 同一邮箱每分钟一条新码;边缘同时按 IP 封顶。
- 短 TTL — 约 10 分钟(文档示例);二次验证可以更短。
- 落库只存哈希 —
email:code的 HMAC-SHA256 + pepper;永不存数字。 - Upsert — 新发送作废该邮箱上一条未用哈希。
- 尝试上限 — 约 5 次失败后锁定;成功后删除。
- 防枚举发送 — 不论是否暴露账号,状态码与正文一致。
- Idempotency-Key — 每次逻辑发送稳定;新码 → 新 key。
- 边缘存储 — 多 isolate 用 KV / Redis;内存 Map 仅适合单进程。
curl 等价(代理 / 冒烟)
与 Hono 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)。
Hono vs Express vs Workers(避免混淆)
| 本文(Hono OTP) | Express OTP | Workers 事务邮件 | |
|---|---|---|---|
| 运行时 | 边缘 或 经 Hono 的 Node/Bun | Node Express | Cloudflare Worker fetch |
| 密码学 | Web Crypto getRandomValues + HMAC | node:crypto randomInt + HMAC | 不适用(通用发送) |
| 存储 | KV / 内存 Map(适合边缘) | SQL / 你的 ORM | 可选——该文不是 OTP |
| 重点 | Hono 里的注册/登录 OTP | Express 里的注册/登录 OTP | 边缘任意事务发送 |
三者都调同一个 POST /emails。不要把 Hono OTP 路由和 Cloudflare Email Workers 混为一谈,也不要把 Workers 事务邮件文当成带哈希 OTP 存储的替代品。
同一 Hono 应用里:OTP vs 魔法链接
| OTP(六位数字) | 魔法链接 | |
|---|---|---|
| 文档形状 | 验证码 | 同 密码重置 |
| Hono 位置 | /auth/otp/send + /auth/otp/verify | 独立魔法路由 |
| 体验 | 输入数字 | 一点即登(+ Continue) |
| 存储 | email:code 的 HMAC + pepper | 高熵 token 的 SHA-256 |
| 常见 TTL | ~10 分钟 | ~30 分钟 |
| 适合 | 共享屏幕、二次验证、手机 | 桌面/手机浏览器登录 |
两者都调同一个 POST /emails。存储要分开——不要把 OTP 记录当魔法链接 token 用。产品选型:验证码 vs 链接。Remix 风格魔法链接见 Remix 魔法登录。
上线清单
- 域名已验证;鉴权子域 SPF/DKIM/DMARC 正常。
- 仅具备
sending_access的ROVELA_API_KEY放在 Worker secrets / 服务端环境——不要进公开客户端配置。 CODE_PEPPER足够长且随机;永不提交。OTP_FROM用已验证子域;html + text 都有;纯文本(以及可选 subject)含数字。- 每次逻辑发送带
Idempotency-Key;存储 upsert + 尝试上限 + 成功删除。 - 多 isolate:用 KV/Redis——不要用天真的全局
Map。 - 把发送
200当作已入队;若需投递真相,用 webhook 看退信/投诉(Webhooks)。
本文不是什么
- 不是完整生产级鉴权框架(OAuth2、MFA、通行密钥)。
- 不是 Express OTP 或 Cloudflare Workers 事务邮件 的重复。
- 不是声称 Rovela 会存储、校验或过期你的验证码。
- 不是国内 ESP 对比,也不捏造产品功能。
下一步
- 按 验证码示例 实现铸造 + HMAC + 存储。
- 用 POST /emails · Quick Start · Authentication 发送。
- 投递加固:OTP / 魔法链接进垃圾箱。
- UX 选型:验证码 vs 链接。
- 姊妹栈:Express OTP · Go OTP · Workers 事务邮件 · Remix 魔法登录。
- 额度:Pricing。
一句话: 把 OTP 放进 Hono 路由:用 Web Crypto 铸造验证码,HMAC 后写入 KV 或内存 Map,再对 Rovela fetch 一次 POST /emails——密钥留在服务端/边缘,也永远不要把邮件 API 当成身份提供方。