← 返回博客

用 Hono 发邮箱验证码(2026):HTTP 调事务邮件 API

在边缘或 Node 上用 Hono 做注册/登录邮箱验证码:Web Crypto getRandomValues、HMAC 落 KV 或内存 Map、fetch 调 POST /emails 并带 Idempotency-Key——Rovela 只负责投递。

更新于 2026年11月4日
用 Hono 发邮箱验证码(2026):HTTP 调事务邮件 API

为什么用 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、投递

  1. Idempotency-Key 由验证码哈希派生(otp- + 前缀),超时重试不会再入队一封内容不同的信。真正「重发」应换新码,因而换新 key。
  2. 务必同时发 html 与 text;纯文本里带六位数字。
  3. 用与营销信隔离声誉的子域发送(验证码文档)。
  4. 若验证码进垃圾箱,按 OTP / 魔法链接进垃圾箱 排查。

限流 / 单次 / 过期清单

  1. 发放限流 — 同一邮箱每分钟一条新码;边缘同时按 IP 封顶。
  2. 短 TTL — 约 10 分钟(文档示例);二次验证可以更短。
  3. 落库只存哈希 — email:code 的 HMAC-SHA256 + pepper;永不存数字。
  4. Upsert — 新发送作废该邮箱上一条未用哈希。
  5. 尝试上限 — 约 5 次失败后锁定;成功后删除。
  6. 防枚举发送 — 不论是否暴露账号,状态码与正文一致。
  7. Idempotency-Key — 每次逻辑发送稳定;新码 → 新 key。
  8. 边缘存储 — 多 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 OTPWorkers 事务邮件
运行时边缘 或 经 Hono 的 Node/BunNode ExpressCloudflare Worker fetch
密码学Web Crypto getRandomValues + HMACnode:crypto randomInt + HMAC不适用(通用发送)
存储KV / 内存 Map(适合边缘)SQL / 你的 ORM可选——该文不是 OTP
重点Hono 里的注册/登录 OTPExpress 里的注册/登录 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 魔法登录。

上线清单

  1. 域名已验证;鉴权子域 SPF/DKIM/DMARC 正常。
  2. 仅具备 sending_access 的 ROVELA_API_KEY 放在 Worker secrets / 服务端环境——不要进公开客户端配置。
  3. CODE_PEPPER 足够长且随机;永不提交。
  4. OTP_FROM 用已验证子域;html + text 都有;纯文本(以及可选 subject)含数字。
  5. 每次逻辑发送带 Idempotency-Key;存储 upsert + 尝试上限 + 成功删除。
  6. 多 isolate:用 KV/Redis——不要用天真的全局 Map。
  7. 把发送 200 当作已入队;若需投递真相,用 webhook 看退信/投诉(Webhooks)。

本文不是什么

  • 不是完整生产级鉴权框架(OAuth2、MFA、通行密钥)。
  • 不是 Express OTP 或 Cloudflare Workers 事务邮件 的重复。
  • 不是声称 Rovela 会存储、校验或过期你的验证码。
  • 不是国内 ESP 对比,也不捏造产品功能。

下一步

  1. 按 验证码示例 实现铸造 + HMAC + 存储。
  2. 用 POST /emails · Quick Start · Authentication 发送。
  3. 投递加固:OTP / 魔法链接进垃圾箱。
  4. UX 选型:验证码 vs 链接。
  5. 姊妹栈:Express OTP · Go OTP · Workers 事务邮件 · Remix 魔法登录。
  6. 额度:Pricing。

一句话: 把 OTP 放进 Hono 路由:用 Web Crypto 铸造验证码,HMAC 后写入 KV 或内存 Map,再对 Rovela fetch 一次 POST /emails——密钥留在服务端/边缘,也永远不要把邮件 API 当成身份提供方。