← 返回博客

在 Cloudflare Workers 里发事务邮件(2026):用 HTTP 调 API

在 Cloudflare Worker 里用 fetch 调 Rovela POST /emails:wrangler secrets 存放 sending_access 的 ROVELA_API_KEY、Idempotency-Key、已验证域名——OTP、魔法链接、收据。不是 Cloudflare Email Workers。

更新于 2026年11月4日
在 Cloudflare Workers 里发事务邮件(2026):用 HTTP 调 API

Cloudflare Workers + Rovela(不是 Cloudflare Email Workers)

本文讲的是:在 Cloudflare Worker 里用 fetch 调用 Rovela 的 HTTP API,发送事务邮件——OTP 验证码、魔法登录链接、收据、密码重置等。

它不是 Cloudflare Email Workers、Email Routing,也不是 Cloudflare 自带的「发信」产品。那些是 Cloudflare 侧用于在你域名上接收或路由邮件的能力。这里 Worker 只是一个 HTTPS 客户端:把 sending_access 密钥放在 Worker secrets 里,向 https://api.rovela.dev/emails POST JSON,由 Rovela 入队、DKIM 签名并投递。

若你要的是面向 AI agent 的脚手架(Cursor / Claude 写同一条 curl),见 AI agent 用 HTTP 发邮件。各框架的 OTP / 魔法链接教程在别处(Next.js、Express OTP、Go OTP 等)。本文只盯 Worker 边缘:密钥、fetch、幂等与已验证域名。

你负责什么,Rovela 发什么

Worker(或其背后的源站)负责:

  • 生成 OTP / 魔法链接 token / 收据内容
  • 存储哈希、过期与核销逻辑(Workers KV、D1、Durable Objects 或源站数据库)
  • 边缘限流与防滥用
  • 为每次逻辑发送选择稳定的 Idempotency-Key

Rovela 负责:

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

返回 200 表示已接受并入队,不等于「已进收件箱」(Send Email API、Quick Start)。

前置条件

  1. 在 Rovela 控制台验证发信域名。未验证域名的 from 会直接 400 拒绝——不会入队后静默丢掉(Quick Start)。
  2. 形如 re_… 的 API 密钥。生产 Worker 优先用 sending_access:只能发信,不能做别的(Authentication)。
  3. 用 wrangler 管理的 Worker 项目:密钥永不进源码或公开绑定。

不要把密钥放进前端、公开仓库,或会打进脚本的普通 vars。用加密 secrets。

用 Wrangler secrets 存密钥

# 在 Worker 项目目录
npx wrangler secret put ROVELA_API_KEY
# 按提示粘贴 re_… 的 sending_access 密钥

# 可选:发件人地址用普通 var(若不敏感)
npx wrangler vars put ROVELA_FROM
# 例如 Acme <security@mail.example.com>

在 wrangler.toml / wrangler.jsonc 里只声明绑定名,不写密钥值:

name = "acme-mail"
main = "src/index.ts"
compatibility_date = "2026-11-01"

[vars]
ROVELA_FROM = "Acme <security@mail.example.com>"
# ROVELA_API_KEY 来自 `wrangler secret put` —— 不要提交

TypeScript 里声明环境:

export interface Env {
  ROVELA_API_KEY: string;
  ROVELA_FROM: string;
}

最小 Worker:fetch → POST /emails

// src/index.ts
export interface Env {
  ROVELA_API_KEY: string;
  ROVELA_FROM: string;
}

type SendBody = {
  to: string;
  subject: string;
  html: string;
  text: string;
  idempotencyKey: string;
};

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

  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    // 不要打印 API 密钥。记 status 与邮件 id 即可。
    throw new Error(`rovela_send_failed:${res.status}`);
  }
  return data as { object: string; id: string };
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") {
      return new Response("method_not_allowed", { status: 405 });
    }

    // 示例:应用在 mint OTP 之后调用的边缘接口
    const body = (await request.json()) as {
      to?: string;
      code?: string;
      sendId?: string;
    };

    const to = (body.to || "").trim().toLowerCase();
    const code = (body.code || "").trim();
    const sendId = (body.sendId || "").trim();
    if (!to || !/^\d{6}$/.test(code) || !sendId) {
      return Response.json({ error: "invalid_request" }, { status: 400 });
    }

    // 同一逻辑发送保持稳定 —— Worker/平台重试时复用
    const idempotencyKey = `otp-${sendId}`;

    try {
      const queued = await sendTransactional(env, {
        to,
        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,
      });
      return Response.json({ ok: true, emailId: queued.id });
    } catch {
      return Response.json({ error: "send_failed" }, { status: 502 });
    }
  },
};

纯 JavaScript 形态相同——fetch、Bearer 头、JSON 体。不需要安装 Rovela 的 Cloudflare SDK;Workers 本身就能发 HTTPS。

密钥配好后再用 curl 打你的 Worker URL 做冒烟。DNS/密钥排查可先直连 Rovela 一次:

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

Idempotency-Key:边缘重试不发双信

Cloudflare、你的客户端,以及自己的 waitUntil / 队列消费者都会重试。没有 Idempotency-Key,一次超时可能投递两封不一致的 OTP,或两封魔法链接。

按 emails API:

  • Header ≤ 256 字符
  • Rovela 记住 24 小时,重放返回原响应
  • 从该次逻辑发送的稳定标识派生(otp-{hashPrefix}、magic-{tokenId}、receipt-{orderId})
  • 有意重发并 mint 新码/token 时必须用新 key
headers: {
  Authorization: `Bearer ${env.ROVELA_API_KEY}`,
  "Content-Type": "application/json",
  "Idempotency-Key": `otp-${codeHash.slice(0, 32)}`,
}

边缘上的 OTP 与魔法链接注意点

Rovela 不会生成 OTP 或登录 token。按 邮箱验证码:用 CSPRNG 生成,只存 HMAC(永不存明文数字),设过期与尝试次数,再同时发 html 与 text。

流程边缘关注点正文
OTP短秘密 → KV/D1/源站里 HMAC + pepper主题 + html/text 里放数字
魔法链接不透明 token;安全域上建议关点击追踪html 和 text 里放绝对 HTTPS URL
收据对 orderId / 发票号做幂等订单摘要;附件可后加

数字码 vs 链接的产品选择:邮箱验证:验证码 vs 链接。认证邮件进垃圾箱清单:为什么 OTP / 魔法链接进垃圾箱。

尽量让核销逻辑不暴露在公网:Worker 校验来自源站的签名内部请求,或仅在源站写好哈希之后再由 Worker 发送。

限流与 429

发送过快时 Rovela 可能返回 429(API errors)。在 Worker 侧:

  • 若不确定第一次是否已被接受,退避后用同一 Idempotency-Key 重试
  • 调 Rovela 前先做按 IP / 按邮箱闸门,避免滥用烧光配额而不只是边缘 CPU
  • 每个收件人计入配额;cc / bcc 同样计数——认证事务邮件通常只要一个 to

不要在 Cron Trigger 或队列消费者里对 429 忙等死循环。

已验证域名、最小权限、日志

  1. 只从已验证域名上的地址发送(Quick Start)。
  2. ROVELA_API_KEY 用 sending_access;读事件等管理操作用 full_access(Authentication)。
  3. 多租户发信时可把密钥限制到单一域名。
  4. 永远不要 console.log Bearer token。记邮件 id、状态码、幂等 key 前缀即可。
  5. OTP / 魔法链接优先用安全子域,如 security@mail.example.com。

套餐与配额见 Pricing——以该页为准,本文不编造数字。

Workers vs Email Routing(避免混淆)

本文(Worker → Rovela HTTP)Cloudflare Email Routing / Email Workers
方向出站事务发送主要是入站路由 / 处理
传输fetch 到 api.rovela.dev你域名上的 Cloudflare 邮件管道
鉴权Rovela sending_access BearerCloudflare zone / Email Workers API
产品邮件 DKIMRovela 在你已验证域名上签名不能替代 Rovela 发信路径

同一产品可以入站用 Email Routing、出站用 Rovela。查文档或 Stack Overflow 时不要把两者当成一回事。

上线前清单

  1. 域名已验证;from 匹配该域。
  2. ROVELA_API_KEY 用 wrangler secret put(仅 sending_access)。
  3. Worker 用 fetch + 认证邮件同时带 html 和 text。
  4. 每次逻辑发送用稳定 Idempotency-Key;新码/token → 新 key。
  5. OTP/token 密码学在你自己的存储里——不在 Rovela。
  6. 处理非 2xx 与 429,且不把密钥打进日志。
  7. 记住:这是对 Rovela 的 HTTP,不是 Cloudflare Email Workers「发信」。

相关阅读

  1. 文档:Quick Start · Authentication · POST /emails · 邮箱验证码。
  2. Agent(同一 HTTP、不同受众):AI agent 用 HTTP 发邮件。
  3. 认证 UX:邮箱验证:验证码 vs 链接 · OTP / 魔法链接进垃圾箱。
  4. 框架姊妹篇:Next.js 魔法链接 · Express OTP · Spring Boot OTP · Go OTP。

**一句话:**把 sending_access 密钥放进 Worker secrets,用带 Idempotency-Key 的 fetch 调 POST /emails,OTP/魔法链接状态放在你自己的存储里——Cloudflare Workers 是运行时,Rovela 是邮件管道。这不是 Cloudflare Email Workers。