← 返回博客

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

在 Deno 里用 Deno.env、Web Crypto 与 fetch 调 POST /emails(含 Idempotency-Key)发注册/登录邮箱验证码——Rovela 只负责投递;与 Bun 事务邮件、Hono/Express OTP 指南区分。

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

为什么用 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 表示已接受并入队,不等于「已进收件箱」。

前置条件

  1. 在 Rovela 控制台验证发信域名。未验证域名的 from 会直接 400 拒绝(快速开始)。
  2. 一把形如 re_… 的 API Key。生产 OTP 发送端优先 sending_access(鉴权)。
  3. 服务器上跑 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、投递

  1. 用码的哈希前缀派生 Idempotency-Key(otp- + 前缀),超时重试就不会入队第二封、内容还不一致的邮件。刻意重发会铸新码,因此要用新 key。Rovela 记住 key 24 小时(emails API)。
  2. 始终同时发 html 与 text;六位数字写进 text。
  3. 用与营销信隔离声誉的子域发认证信(验证码文档)。
  4. 若进垃圾箱,对照 OTP / 魔法链接进垃圾箱。

限流 / 一次性 / 过期清单

  1. 发放节流 — 每邮箱每分钟最多一枚新码;代理侧再按 IP 封顶。
  2. 短 TTL — 约 10 分钟(文档示例);step-up 可以更短。
  3. 落库只存哈希 — email:code + pepper 的 HMAC-SHA256;永不存明文。
  4. Upsert — 新发使该邮箱未用旧哈希失效。
  5. 尝试上限 — 约 5 次失败后锁定;成功即删。
  6. 防枚举发送 — 不管是否暴露账号,对外状态/正文尽量一致。
  7. Idempotency-Key — 同一逻辑发送稳定;新码 → 新 key。
  8. 共享存储 — 多实例用 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 OTPExpress OTP
运行时Deno / Deno.serveBun / Bun.serve边缘或 Node/Bun 上的 HonoNode + Express
密钥Deno.env + --allow-envprocess.env / .envc.env / wrangler secretsdotenv / 平台密钥
密码学Web Crypto常见 node:cryptoWeb Cryptonode:crypto
存储示例Deno KV / 内存 Map你选的 DBWorkers KV / MapSQL / ORM
焦点Deno 上的注册/登录 OTPBun 上任意事务邮件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。

上线清单

  1. 域名已验证;认证子域 SPF/DKIM/DMARC 绿灯。
  2. ROVELA_API_KEY 仅 sending_access,经 Deno.env 注入——勿进公开客户端配置。
  3. CODE_PEPPER 足够长且随机;永不提交。
  4. OTP_FROM 用已验证子域;html + text 齐全;数字在 text(主题可选)。
  5. 每次逻辑发送带 Idempotency-Key;存储 upsert + 尝试上限 + 成功即删。
  6. 多实例:Deno KV / Redis / SQL——别用天真的全局 Map。
  7. 最小 --allow-net / --allow-env(用 KV 再开对应权限)。
  8. 发送 200 只当入队;要投递真相就接 webhook(Webhooks)。

本文不是什么

  • 不是完整生产鉴权框架(OAuth2、MFA、通行密钥)。
  • 不是 Bun 事务邮件、Hono OTP、Express OTP 的复述。
  • 不声称 Rovela 会存储、校验或过期你的验证码。
  • 不做中国大陆 ESP 对比,也不编造产品能力。

下一步

  1. 按 验证码示例 实现铸造 + HMAC + 存储。
  2. 用 POST /emails · Quick Start · Authentication 发送。
  3. 投递加固:OTP / 魔法链接进垃圾箱。
  4. UX 对照:code vs link。
  5. 兄弟栈:Bun 事务邮件 · Hono OTP · Express OTP · Cloudflare Workers 事务邮件。
  6. 额度:Pricing。

一句话: 在 Deno 里用 Deno.env、Web Crypto 与 Deno.serve 做 OTP,把 HMAC 后的码放进 Deno KV 或你的数据库,再 fetch 一次带 Idempotency-Key 的 POST /emails 给 Rovela——密钥留在服务端,别把邮件 API 当成身份提供方。