用 Bun 发事务邮件(2026):原生 fetch 调 HTTP API
在 Bun 里用原生 fetch 调 Rovela POST /emails 发 OTP、魔法链接与收据:环境变量 ROVELA_API_KEY、Idempotency-Key、无需 Bun 专用 SDK——与 Express、Cloudflare Workers 指南区分。
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、快速开始)。
前置条件
- 在 Rovela 控制台验证发信域名。未验证域名上的
from会直接400拒绝——不会入队后静默丢掉(快速开始)。 - API 密钥形如
re_…。生产优先sending_access:只能发信,不能管别的(Authentication)。 - 发信机器安装 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 OTP | Workers → Rovela | |
|---|---|---|---|
| 运行时 | Bun 进程 / Bun.serve | Node + Express | Cloudflare Worker 隔离环境 |
| HTTP 客户端 | 原生 fetch | fetch 或 undici | Worker fetch |
| 密钥 | .env / 进程环境变量 | dotenv / 平台密钥 | wrangler secret |
| Rovela SDK? | 无 | 无 | 无 |
| 同一 API | POST /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 前缀即可。
已验证域名与最小权限
- 只从已验证域名上的地址发信(快速开始)。
ROVELA_API_KEY用sending_access;读事件等管理操作用full_access工具密钥(Authentication)。- 多租户发信时可把密钥限制在单个域名。
- OTP / 魔法链接优先用安全子域,例如
security@mail.example.com。
套餐与配额见 Pricing,以该页为准,勿在此臆造数字。
上线前清单
- 域名已验证;
from匹配该域名。 ROVELA_API_KEY仅为sending_access,来自环境变量——不是浏览器。- Bun 用原生
fetch,认证邮件同时带 JSONhtml与text。 - 每次逻辑发送用稳定
Idempotency-Key;新验证码 / token → 新 key。 - OTP / token 密码学在你方存储——不在 Rovela。
- 处理非 2xx 与
429,且日志不含密钥。 - 记住:没有 Bun 专用 Rovela SDK——HTTP 就是集成方式。
相关阅读
- 文档:快速开始 · Authentication · POST /emails · 邮箱验证码。
- 同一 HTTP、其他运行时:Cloudflare Workers 事务邮件 · Express OTP · Hono OTP。
- 认证体验:验证码 vs 链接 · OTP / 魔法链接进垃圾箱。
- Agent 向(同一 HTTP、不同读者):AI agent 用 HTTP 发邮件。
**一句话:**把 sending_access 密钥放进 ROVELA_API_KEY,用 Bun 原生 fetch 调 POST /emails 并带上 Idempotency-Key,OTP / 魔法链接 / 收据状态留在你方存储——Bun 是运行时,Rovela 是邮路。无需 Bun 专用 SDK。