用 Deno 发邮箱验证码(2026):HTTP 调事务邮件 API
在 Deno 里用 Deno.env、Web Crypto 与 fetch 调 POST /emails(含 Idempotency-Key)发注册/登录邮箱验证码——Rovela 只负责投递;与 Bun 事务邮件、Hono/Express OTP 指南区分。
为什么用 Deno 发邮箱验证码
Deno 自带 TypeScript、默认安全的权限模型、Web Crypto 与标准 fetch。你不需要 Rovela 的 Deno SDK,也不需要所谓「OTP 产品」端点,更不要把邮件 API 当成身份系统。
你要做的是文档 邮箱验证码示例 里的那套:用 Web Crypto(crypto.getRandomValues)生成六位码,只存 HMAC(永不落明文),设过期与尝试次数,再 fetch 调 POST /emails,同时带上 html、text 和稳定的 Idempotency-Key。Rovela 负责入队、DKIM 签名、重试与事件记录。它不会生成验证码、校验验证码或创建会话。
Rovela 不提供 OTP 功能。 密码学、存储、重发节流、核销与会话 Cookie / JWT 都在你的 Deno 进程里;Rovela 只负责投递。
本文是 Deno 原生写法(Deno.env、Deno.serve、Web Crypto,不用 Node 的 process.env 或 Bun 专属 API)。它不是 用 Bun 发事务邮件(Bun 上的通用事务邮件),不是 用 Hono 发邮箱验证码(Hono + 边缘绑定),也不是 Express OTP(node:crypto + Express)。同系列 OTP:Go、Django、Laravel、Spring Boot。数字码 vs 链接:验证码 vs 魔法链接。语言无关总览:用邮件发 OTP。
你负责什么,Rovela 发什么
你的 Deno 应用负责:
crypto.getRandomValues→ 六位补零(禁止Math.random())- 用 Web Crypto
subtle对email + ":" + code做 HMAC-SHA256(pepper 为CODE_PEPPER) - 过期(文档示例约 10 分钟)、尝试次数、「同一地址一分钟一发」的重发闸门
- 按规范化邮箱 upsert,新发使旧码失效(SQLite /
Deno.openKv/ Postgres / Redis,或单进程内存Map) - 核销时恒定时间比较;成功后删除 / 失效
- 对外响应是否暴露「账号是否存在」由产品决定,但登录场景建议两边一致
- 对
/auth/otp/send与/auth/otp/verify做限流(中间件或反向代理)
Rovela 负责:
POST https://api.rovela.dev/emails,Authorization: Bearer+sending_access密钥(ROVELA_API_KEY走Deno.env——勿提交、勿放进前端包)- 入队、签名、重试、退信 / 投诉处理
发送接口返回 200 表示已接受并入队,不等于「已进收件箱」。
前置条件
- 在 Rovela 控制台验证发信域名。未验证域名的
from会直接400拒绝(快速开始)。 - 一把形如
re_…的 API Key。生产 OTP 发送端优先sending_access(鉴权)。 - 服务器上跑 Deno 2.x(或较新的 1.x)——绝不要在浏览器里发。
用 Deno.env 读密钥(权限很重要)
Deno 不会默默读取所有环境变量。需要 --allow-env(或点名允许的键),密钥不要进 git:
# .env(勿提交)——用 --env-file 或编排系统注入
ROVELA_API_KEY=re_your_sending_access_key
CODE_PEPPER=long_random_server_secret
OTP_FROM="Acme <security@mail.example.com>"
deno run --allow-net=api.rovela.dev --allow-env=ROVELA_API_KEY,CODE_PEPPER,OTP_FROM \
--env-file=.env main.ts
TypeScript 里读取:
// env.ts
export function requireEnv(name: string): string {
const v = Deno.env.get(name);
if (!v) throw new Error(`missing_env:${name}`);
return v;
}
export const ROVELA_API_KEY = () => requireEnv("ROVELA_API_KEY");
export const CODE_PEPPER = () => requireEnv("CODE_PEPPER");
export const OTP_FROM = () =>
Deno.env.get("OTP_FROM") ?? "Acme <security@mail.example.com>";
相对 Bun 自动加载 .env、或 Node 的 process.env,Deno 强制白名单——对 OTP 服务是好事:不必继承整个 shell 里的秘密。
精简 Rovela 客户端(fetch + Idempotency-Key)
// 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,
}),
});
if (!res.ok) {
// 不要日志打出 API Key
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。Deno 与浏览器 / Workers 同一套 Web Crypto——不必 import "node:crypto"。
// 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;
}
存储:Deno KV 或进程内 Map
建议的 JSON 值(每个邮箱一条现行码),对齐 验证码示例:
// 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>;
}
/** 单 Deno 进程 / 本地冒烟 */
export class MemoryOtpStore implements OtpStore {
#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);
}
}
/** Deno.openKv —— key = ["otp", normalizedEmail] */
export class DenoKvOtpStore implements OtpStore {
constructor(private kv: Deno.Kv) {}
async get(email: string) {
const res = await this.kv.get<CodeRecord>(["otp", email]);
return res.value;
}
async put(email: string, record: CodeRecord, ttlSeconds: number) {
await this.kv.set(["otp", email], record, {
expireIn: Math.max(60, ttlSeconds) * 1000,
});
}
async del(email: string) {
await this.kv.delete(["otp", email]);
}
}
多实例 Deploy / 容器请用 Deno KV、Postgres 或 Redis。进程内 Map 只适合单个 deno run 与演示——别假装它在副本间共享。
服务:发送 + 核销
// otp/service.ts
import { sendEmail } from "../rovela.ts";
import {
hashCode,
mintCode,
normalizeEmail,
safeEqualHex,
} from "./crypto.ts";
import type { OtpStore } from "./store.ts";
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({
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 = 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 };
}
把码放进主题方便锁屏预览——多数产品可用;共享屏幕场景可省略(验证码文档)。务必同时发 html 与 text,并在 text 里放数字(POST /emails)。
Deno.serve 路由
// main.ts
import { CODE_PEPPER, OTP_FROM, ROVELA_API_KEY } from "./env.ts";
import { sendOtp, verifyOtp } from "./otp/service.ts";
import { DenoKvOtpStore, MemoryOtpStore } from "./otp/store.ts";
const useKv = Deno.env.get("OTP_USE_KV") === "1";
const store = useKv
? new DenoKvOtpStore(await Deno.openKv())
: new MemoryOtpStore();
Deno.serve({ port: Number(Deno.env.get("PORT") ?? 8000) }, async (req) => {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/auth/otp/send") {
const body = await req.json().catch(() => ({})) as { email?: string };
const email = String(body.email ?? "");
if (!email.includes("@")) {
return Response.json({ error: "invalid_email" }, { status: 400 });
}
try {
const result = await sendOtp({
store,
apiKey: ROVELA_API_KEY(),
pepper: CODE_PEPPER(),
from: 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" });
} catch {
return Response.json({ error: "send_failed" }, { status: 502 });
}
}
if (req.method === "POST" && url.pathname === "/auth/otp/verify") {
const body = await req.json().catch(() => ({})) as {
email?: string;
code?: string;
};
const result = await verifyOtp({
store,
pepper: 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 });
}
return new Response("not_found", { status: 404 });
});
网络 + 环境变量(用 Deno.openKv 时还要相应 KV 权限 / Deploy KV):
deno run --allow-net=api.rovela.dev,0.0.0.0:8000 \
--allow-env=ROVELA_API_KEY,CODE_PEPPER,OTP_FROM,OTP_USE_KV,PORT \
--env-file=.env main.ts
Rovela 密钥绝不能离开 Deno 进程。from 优先用已验证的认证子域(security@mail.…)。
Idempotency-Key、html + text、投递
- 用码的哈希前缀派生
Idempotency-Key(otp-+ 前缀),超时重试就不会入队第二封、内容还不一致的邮件。刻意重发会铸新码,因此要用新 key。Rovela 记住 key 24 小时(emails API)。 - 始终同时发 html 与 text;六位数字写进 text。
- 用与营销信隔离声誉的子域发认证信(验证码文档)。
- 若进垃圾箱,对照 OTP / 魔法链接进垃圾箱。
限流 / 一次性 / 过期清单
- 发放节流 — 每邮箱每分钟最多一枚新码;代理侧再按 IP 封顶。
- 短 TTL — 约 10 分钟(文档示例);step-up 可以更短。
- 落库只存哈希 —
email:code+ pepper 的 HMAC-SHA256;永不存明文。 - Upsert — 新发使该邮箱未用旧哈希失效。
- 尝试上限 — 约 5 次失败后锁定;成功即删。
- 防枚举发送 — 不管是否暴露账号,对外状态/正文尽量一致。
- Idempotency-Key — 同一逻辑发送稳定;新码 → 新 key。
- 共享存储 — 多实例用 Deno KV / Redis / SQL;内存 Map 仅限单进程。
curl 等价(冒烟 / Agent)
与 Deno 处理器发出的报文相同——适合本地冒烟,以及直接打 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-deno-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)。
Deno vs Bun vs Hono vs Express(对照)
| 本文(Deno OTP) | Bun 事务邮件 | Hono OTP | Express OTP | |
|---|---|---|---|---|
| 运行时 | Deno / Deno.serve | Bun / Bun.serve | 边缘或 Node/Bun 上的 Hono | Node + Express |
| 密钥 | Deno.env + --allow-env | process.env / .env | c.env / wrangler secrets | dotenv / 平台密钥 |
| 密码学 | Web Crypto | 常见 node:crypto | Web Crypto | node:crypto |
| 存储示例 | Deno KV / 内存 Map | 你选的 DB | Workers KV / Map | SQL / ORM |
| 焦点 | Deno 上的注册/登录 OTP | Bun 上任意事务邮件 | Hono 里的 OTP 路由 | Express 里的 OTP 路由 |
四者都调同一 POST /emails。别把 Bun 事务邮件文当成「已含哈希 OTP 存储」;也别为 Rovela 硬包一层「Deno SDK」——fetch 就够。
同一 Deno 应用里:OTP vs 魔法链接
| OTP(六位) | 魔法链接 | |
|---|---|---|
| 文档形态 | 验证码 | 同 密码重置 |
| Deno 路由 | /auth/otp/send + /auth/otp/verify | 独立 magic 路由 |
| 体验 | 输数字 | 一点即达(+ Continue) |
| 存储 | email:code + pepper 的 HMAC | 高熵 token 的 SHA-256 |
| 典型 TTL | ~10 分钟 | ~30 分钟 |
| 适合 | 共享屏幕、step-up、手机 | 桌面/移动浏览器登录 |
两者都调同一 POST /emails。分开放——不要把 OTP 记录当魔法链接 token。产品取舍:code vs link。框架向魔法链接见 Next.js 或 Remix——Deno 这边仍是 fetch + Deno.env。
上线清单
- 域名已验证;认证子域 SPF/DKIM/DMARC 绿灯。
ROVELA_API_KEY仅sending_access,经Deno.env注入——勿进公开客户端配置。CODE_PEPPER足够长且随机;永不提交。OTP_FROM用已验证子域;html + text 齐全;数字在 text(主题可选)。- 每次逻辑发送带
Idempotency-Key;存储 upsert + 尝试上限 + 成功即删。 - 多实例:Deno KV / Redis / SQL——别用天真的全局
Map。 - 最小
--allow-net/--allow-env(用 KV 再开对应权限)。 - 发送
200只当入队;要投递真相就接 webhook(Webhooks)。
本文不是什么
- 不是完整生产鉴权框架(OAuth2、MFA、通行密钥)。
- 不是 Bun 事务邮件、Hono OTP、Express OTP 的复述。
- 不声称 Rovela 会存储、校验或过期你的验证码。
- 不做中国大陆 ESP 对比,也不编造产品能力。
下一步
- 按 验证码示例 实现铸造 + HMAC + 存储。
- 用 POST /emails · Quick Start · Authentication 发送。
- 投递加固:OTP / 魔法链接进垃圾箱。
- UX 对照:code vs link。
- 兄弟栈:Bun 事务邮件 · Hono OTP · Express OTP · Cloudflare Workers 事务邮件。
- 额度:Pricing。
一句话: 在 Deno 里用 Deno.env、Web Crypto 与 Deno.serve 做 OTP,把 HMAC 后的码放进 Deno KV 或你的数据库,再 fetch 一次带 Idempotency-Key 的 POST /emails 给 Rovela——密钥留在服务端,别把邮件 API 当成身份提供方。