← 返回博客

用 Bun 发事务邮件(2026):原生 fetch 调 HTTP API

在 Bun 里用原生 fetch 调 Rovela POST /emails 发 OTP、魔法链接与收据:环境变量 ROVELA_API_KEY、Idempotency-Key、无需 Bun 专用 SDK——与 Express、Cloudflare Workers 指南区分。

更新于 2026年11月4日
用 Bun 发事务邮件(2026):原生 fetch 调 HTTP API

Bun + Rovela:原生 fetch,无需 SDK

Bun 本身就能发 HTTPS。本文说明如何在 Bun 进程里用内置 fetch 调用 Rovela 的 HTTP API,发送事务邮件——验证码、魔法登录链接、收据、重置密码。验证码与 token 仍由你方落库;Rovela 负责入队、DKIM 签名与投递。

不要发明或安装「Bun 专用 Rovela SDK」。生产环境优先用 sending_access 密钥写入 ROVELA_API_KEY,请求 POST https://api.rovela.dev/emails,并为每次逻辑发送带上稳定的 Idempotency-Key。

这不是 Express OTP 那篇(Node + Express 路由),也不是 Cloudflare Workers 边缘指南。这里的运行时是服务器 / CLI / 容器里的 Bun——同一套 HTTP 约定,进程模型与环境变量加载不同。

你负责什么,Rovela 负责什么

你的 Bun 应用负责:

  • 生成 OTP / 魔法链接 token / 收据内容
  • 哈希、过期与核销逻辑(SQLite、Postgres、Redis 等)
  • 限流与防滥用
  • 为每次逻辑发送选定稳定的 Idempotency-Key

Rovela 负责:

  • POST https://api.rovela.dev/emails,Authorization: Bearer + sending_access 密钥
  • 入队、签名、重试、退信 / 投诉处理

返回 200 表示已接受并入队,不是「已经进收件箱」(发送邮件 API、快速开始)。

前置条件

  1. 在 Rovela 控制台验证发信域名。未验证域名上的 from 会直接 400 拒绝——不会入队后静默丢掉(快速开始)。
  2. API 密钥形如 re_…。生产优先 sending_access:只能发信,不能管别的(Authentication)。
  3. 发信机器安装 Bun 1.x(绝不要放浏览器)。

不要把密钥写进前端、公开仓库,或明文打进 Docker 镜像层。

环境变量:ROVELA_API_KEY(以及发件人)

许多 Bun 场景会自动加载 .env。密钥不要进 git:

# .env(勿提交)
ROVELA_API_KEY=re_your_sending_access_key
ROVELA_FROM="Acme <security@mail.example.com>"

或在进程管理器 / 容器密钥里导出:

export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <security@mail.example.com>'

TypeScript 读取:

const apiKey = process.env.ROVELA_API_KEY;
const from = process.env.ROVELA_FROM;
if (!apiKey || !from) {
  throw new Error("missing_rovela_env");
}

最小发送封装:Bun 原生 fetch

// src/rovela.ts
export type SendTransactionalInput = {
  to: string;
  subject: string;
  html: string;
  text: string;
  idempotencyKey: string;
};

export async function sendTransactional(msg: SendTransactionalInput) {
  const apiKey = process.env.ROVELA_API_KEY;
  const from = process.env.ROVELA_FROM;
  if (!apiKey || !from) throw new Error("missing_rovela_env");

  const res = await fetch("https://api.rovela.dev/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      Accept: "application/json",
      "Idempotency-Key": msg.idempotencyKey.slice(0, 256),
    },
    body: JSON.stringify({
      from,
      to: msg.to,
      subject: msg.subject,
      html: msg.html,
      text: msg.text,
    }),
  });

  const data = (await res.json().catch(() => ({}))) as {
    object?: string;
    id?: string;
    message?: string;
  };

  if (!res.ok) {
    // 不要打印 API key。记录 status 与可选的 message id 即可。
    throw new Error(`rovela_send_failed:${res.status}`);
  }

  return data as { object: string; id: string };
}

DNS 与密钥就绪后,先对 Rovela 做一次冒烟:

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: bun-smoke-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.example.com>",
    "to": "you@example.com",
    "subject": "Bun smoke test",
    "html": "<p>It worked.</p>",
    "text": "It worked."
  }'

成功响应形如 {"object":"email","id":"…"}。

示例 A — 六位 OTP

Rovela 不会帮你生成验证码。按 邮箱验证码示例:用密码学安全随机数生成数字、只存 HMAC(不存明文)、设过期与尝试次数,再同时发 html 与 text。

// src/otp.ts
import { createHmac, randomInt, timingSafeEqual } from "crypto";
import { sendTransactional } from "./rovela";

const PEPPER = process.env.OTP_PEPPER!;
const TTL_MS = 10 * 60 * 1000;

function hashCode(email: string, code: string) {
  return createHmac("sha256", PEPPER).update(`${email}:${code}`).digest("hex");
}

export async function issueOtp(email: string, sendId: string) {
  const normalized = email.trim().toLowerCase();
  const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
  const codeHash = hashCode(normalized, code);
  const expiresAt = Date.now() + TTL_MS;

  // 落库 { email, codeHash, expiresAt, attempts: 0 } — 永不存明文 code
  await db.upsertVerification({ email: normalized, codeHash, expiresAt });

  await sendTransactional({
    to: normalized,
    subject: `Your Acme code: ${code}`,
    html: `<p>Your verification code is</p><p style="font-size:28px;font-weight:600;letter-spacing:4px"><b>${code}</b></p><p>It expires in 10 minutes. If you did not ask for it, ignore this email.</p>`,
    text: `Your verification code is ${code}. It expires in 10 minutes. If you did not ask for it, ignore this email.`,
    // 同一次逻辑发送保持稳定 — 重试复用
    idempotencyKey: `otp-${sendId}`,
  });
}

验证码还是链接,见 邮箱验证:验证码 vs 链接。认证邮件进垃圾箱清单:OTP / 魔法链接进垃圾箱。

示例 B — 魔法登录链接

同一套 HTTP 封装,换正文即可。html 与 text 都放绝对 HTTPS URL,token 不透明且落库只存哈希,TTL 要短。

export async function sendMagicLink(email: string, tokenId: string, absoluteUrl: string) {
  const to = email.trim().toLowerCase();
  await sendTransactional({
    to,
    subject: "Sign in to Acme",
    html: `<p><a href="${absoluteUrl}">Sign in to Acme</a></p><p>This link expires in 15 minutes. If you did not ask for it, ignore this email.</p>`,
    text: `Sign in to Acme: ${absoluteUrl}\n\nThis link expires in 15 minutes. If you did not ask for it, ignore this email.`,
    idempotencyKey: `magic-${tokenId}`,
  });
}

带 cookie / SSR 的核销流程可参考 Next.js 魔法登录 或 Remix 魔法登录——Bun 这边仍然只是 fetch + 环境变量。

示例 C — 收据 / 订单确认

用订单号(或发票号)做幂等种子,避免抖动的支付 webhook 连发重复收据:

export async function sendReceipt(opts: {
  to: string;
  orderId: string;
  totalLabel: string;
}) {
  await sendTransactional({
    to: opts.to,
    subject: `Your Acme receipt ${opts.orderId}`,
    html: `<p>Thanks for your order <b>${opts.orderId}</b>.</p><p>Total: ${opts.totalLabel}</p>`,
    text: `Thanks for your order ${opts.orderId}. Total: ${opts.totalLabel}`,
    idempotencyKey: `receipt-${opts.orderId}`,
  });
}

Idempotency-Key:重试不重复投递

Bun 服务、反向代理、自建任务队列都会重试。没有 Idempotency-Key,超时可能导致两封不一致的验证码,或两张收据。

按 emails API:

  • Header ≤ 256 字符
  • Rovela 记忆 24 小时,重放返回原响应
  • 从该次逻辑发送的稳定标识派生(otp-{sendId}、magic-{tokenId}、receipt-{orderId})
  • 若主动重发并生成了新验证码 / token,必须用新 key

可选:很小的 Bun.serve 内部接口

若要暴露内部发信路由(自带鉴权),Bun 的 HTTP 服务器就够:

// src/server.ts
import { issueOtp } from "./otp";

Bun.serve({
  port: Number(process.env.PORT || 3000),
  async fetch(req) {
    const url = new URL(req.url);
    if (req.method === "POST" && url.pathname === "/internal/otp") {
      // 先校验共享密钥 / mTLS / 专网,再信任 body
      const body = (await req.json()) as { to?: string; sendId?: string };
      const to = (body.to || "").trim().toLowerCase();
      const sendId = (body.sendId || "").trim();
      if (!to || !sendId) {
        return Response.json({ error: "invalid_request" }, { status: 400 });
      }
      try {
        await issueOtp(to, sendId);
        return Response.json({ ok: true });
      } catch {
        return Response.json({ error: "send_failed" }, { status: 502 });
      }
    }
    return new Response("not_found", { status: 404 });
  },
});

能不上公网就不上公网。浏览器绝不能持有 ROVELA_API_KEY。

Bun vs Node(Express)vs Cloudflare Workers

本文(Bun)Express OTPWorkers → Rovela
运行时Bun 进程 / Bun.serveNode + ExpressCloudflare Worker 隔离环境
HTTP 客户端原生 fetchfetch 或 undiciWorker fetch
密钥.env / 进程环境变量dotenv / 平台密钥wrangler secret
Rovela SDK?无无无
同一 APIPOST /emails + Idempotency-Key相同相同

用你应用已经在跑的运行时即可。fetch 够用时,不要再包一层假的「Bun SDK」。

限流、429 与日志

发送触达限流时 Rovela 可能返回 429(API errors)。在 Bun 侧:

  • 不确定首次是否已被接受时,退避并用同一 Idempotency-Key 重试
  • 调用 Rovela 之前先做每 IP / 每邮箱门禁,避免滥用先烧配额
  • 认证邮件通常只要单个 to(cc / bcc 也计入收件人上限)

永远不要 console.log bearer token。记录邮件 id、状态码、幂等 key 前缀即可。

已验证域名与最小权限

  1. 只从已验证域名上的地址发信(快速开始)。
  2. ROVELA_API_KEY 用 sending_access;读事件等管理操作用 full_access 工具密钥(Authentication)。
  3. 多租户发信时可把密钥限制在单个域名。
  4. OTP / 魔法链接优先用安全子域,例如 security@mail.example.com。

套餐与配额见 Pricing,以该页为准,勿在此臆造数字。

上线前清单

  1. 域名已验证;from 匹配该域名。
  2. ROVELA_API_KEY 仅为 sending_access,来自环境变量——不是浏览器。
  3. Bun 用原生 fetch,认证邮件同时带 JSON html 与 text。
  4. 每次逻辑发送用稳定 Idempotency-Key;新验证码 / token → 新 key。
  5. OTP / token 密码学在你方存储——不在 Rovela。
  6. 处理非 2xx 与 429,且日志不含密钥。
  7. 记住:没有 Bun 专用 Rovela SDK——HTTP 就是集成方式。

相关阅读

  1. 文档:快速开始 · Authentication · POST /emails · 邮箱验证码。
  2. 同一 HTTP、其他运行时:Cloudflare Workers 事务邮件 · Express OTP · Hono OTP。
  3. 认证体验:验证码 vs 链接 · OTP / 魔法链接进垃圾箱。
  4. Agent 向(同一 HTTP、不同读者):AI agent 用 HTTP 发邮件。

**一句话:**把 sending_access 密钥放进 ROVELA_API_KEY,用 Bun 原生 fetch 调 POST /emails 并带上 Idempotency-Key,OTP / 魔法链接 / 收据状态留在你方存储——Bun 是运行时,Rovela 是邮路。无需 Bun 专用 SDK。