← 返回博客

用 Supabase + Rovela 发事务邮件(2026)

在 Supabase Edge Function 里用 Rovela POST /emails 发自定义 OTP、魔法链接与欢迎信——应用自管令牌,Rovela 只负责投递;不替代 Auth;与 Express/Next.js OTP、Workers 指南区分。

更新于 2026年11月4日
用 Supabase + Rovela 发事务邮件(2026)

Supabase 管应用数据,Rovela 负责投递

Supabase 把 Postgres、Auth、Storage、Realtime 和 Edge Functions(Deno)放在同一项目里。很多团队先用 Auth 自带的邮件发魔法链接和验证码,随后会碰到:要自定义 HTML、独立的 mail. 子域、更可控的投递,或「欢迎信 / 收据 / 邀请」必须从已验证域名发出,而默认 Auth 模板不够用。

本文说明如何继续用 Supabase 管库(以及可选的 Auth),同时从 Supabase Edge Function(或已连接该项目的后端)调用 Rovela 的 HTTP API,发送 事务邮件——OTP、自建魔法链接、欢迎信、你自己签发的重置类链接。

Rovela 不替代 Supabase Auth。 它不创建用户、不签发 JWT、不校验验证码。Edge Function(或 API)拥有令牌与哈希;Rovela 只通过 POST https://api.rovela.dev/emails 入队、DKIM 签名并投递(发送邮件 API、快速开始)。

这不是各框架 OTP 通关文(Express、Next.js 魔法链接、Deno OTP),也不是 Cloudflare Workers + Rovela 或 AI 代理用 HTTP 发信。这里的运行时是 Supabase 的 Deno Edge Function + 项目 Secrets。

何时继续用 Auth 邮件,何时走 Rovela

需求常见做法
默认魔法链接 / OTP、模板够用先用 Supabase Auth 邮件
自定义 HTML、品牌版式、主题里带码、双语文案应用自管发送 → Rovela
在 mail.yourdomain.com 上隔离信誉已验证的 Rovela 域名 + 你的 from
欢迎 / 邀请 / 收据(非 Auth 模板)一律应用自管 → Rovela
仍要 Supabase 会话Auth 管会话;自定义流程只换 邮件传输

常见模式:对你自定义的流程关闭或绕过 Auth 内置邮件;自己签发码/令牌(或 Auth Hook / 自建表);用 Rovela 发送;在 Edge Function 里对照 Postgres 校验。

你负责什么,Rovela 负责什么

你的 Supabase 项目负责:

  • 用户表 / Auth 用户(若使用 Auth)
  • 用密码学安全随机源生成 OTP 或魔法链接令牌
  • 存 HMAC 哈希、过期时间、尝试次数(Postgres——从不存明文码)
  • 限流(例如每地址每分钟一封)
  • 核销逻辑与会话创建
  • 每次逻辑发送稳定的 Idempotency-Key

Rovela 负责:

  • 带 Authorization: Bearer 与 sending_access 密钥的 POST /emails
  • 入队、签名、重试、退信/投诉处理

200 表示 已接受并入队,不是「已经进收件箱」(快速开始)。

前置条件

  1. 在 Rovela 控制台 验证发信域名。未验证的 from 会直接 400(快速开始)。认证类邮件优先用 mail.example.com 一类子域(为什么验证码和魔法链接进垃圾箱)。
  2. re_… 形态的 API 密钥;生产 Edge Function 优先 sending_access(鉴权)。
  3. 已启用 Edge Functions 的 Supabase 项目,并用 CLI 或控制台配置 secrets。
  4. 切勿把 Rovela 密钥放进浏览器、移动端或公开仓库;只有 Edge Function(或受信服务端)可调用 Rovela。

把密钥放进 Supabase 项目 Secrets

supabase secrets set ROVELA_API_KEY=re_your_sending_access_key
supabase secrets set ROVELA_FROM="Acme <security@mail.example.com>"
supabase secrets set CODE_PEPPER="$(openssl rand -hex 32)"

Edge Function 通过 Deno.env.get(...) 读取。轮换时写入新 secret 并重新部署,再在 Rovela 控制台吊销旧密钥(鉴权)。

最小 Edge Function:fetch → POST /emails

创建 supabase/functions/send-otp/index.ts(Deno,标准 fetch,无需 Rovela SDK):

import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const ROVELA_URL = "https://api.rovela.dev/emails";

function requireEnv(name: string): string {
  const v = Deno.env.get(name);
  if (!v) throw new Error(`missing_env:${name}`);
  return v;
}

Deno.serve(async (req) => {
  if (req.method !== "POST") {
    return new Response("method_not_allowed", { status: 405 });
  }

  const apiKey = requireEnv("ROVELA_API_KEY");
  const from = requireEnv("ROVELA_FROM");
  const pepper = requireEnv("CODE_PEPPER");

  const body = await req.json().catch(() => ({})) as {
    email?: string;
    sendId?: string;
  };
  const email = (body.email || "").trim().toLowerCase();
  const sendId = (body.sendId || crypto.randomUUID()).trim();
  if (!email || !email.includes("@")) {
    return Response.json({ error: "invalid_request" }, { status: 400 });
  }

  const buf = new Uint32Array(1);
  crypto.getRandomValues(buf);
  const code = String(buf[0] % 1_000_000).padStart(6, "0");

  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(pepper),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const mac = await crypto.subtle.sign(
    "HMAC",
    keyMaterial,
    new TextEncoder().encode(`${email}:${code}`),
  );
  const codeHash = [...new Uint8Array(mac)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
  );
  const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
  const { error: dbErr } = await supabase.from("verification_codes").upsert({
    email,
    code_hash: codeHash,
    expires_at: expiresAt,
    attempts: 0,
    sent_at: new Date().toISOString(),
  });
  if (dbErr) {
    return Response.json({ error: "store_failed" }, { status: 500 });
  }

  const idempotencyKey = `supabase-otp-${sendId}`.slice(0, 256);

  const res = await fetch(ROVELA_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      Accept: "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({
      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"><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.`,
    }),
  });

  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    return Response.json(
      { error: "send_failed", status: res.status, message: data.message },
      { status: 502 },
    );
  }

  return Response.json({ ok: true, emailId: data.id });
});

表结构与验证码示例一致:

CREATE TABLE verification_codes (
  email      TEXT PRIMARY KEY,
  code_hash  TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  attempts   INT NOT NULL DEFAULT 0,
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

部署:

supabase functions deploy send-otp

由应用带用户令牌或受信网关调用——切勿把 ROVELA_API_KEY 暴露给客户端。

魔法链接与欢迎信变体

同一 Edge Function 模式,只改载荷:

魔法链接 — 存不透明令牌的哈希,在 html 与 text 中放入绝对 HTTPS URL;安全子域上尽量关闭点击追踪。产品取舍见邮箱验证:验证码还是链接。

欢迎信 — 注册确认后发送模板或内联 HTML(用模板发欢迎邮件)。幂等键用 welcome-{userId}。

await fetch("https://api.rovela.dev/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `welcome-${userId}`,
  },
  body: JSON.stringify({
    from,
    to: email,
    subject: "Welcome to Acme",
    html: `<p>Hi ${name}, your workspace is ready.</p>`,
    text: `Hi ${name}, your workspace is ready.`,
  }),
});

Edge Function 重试与幂等

Supabase、客户端与平台重试都可能重放 POST。没有 Idempotency-Key 时,超时可能导致用户收到 两封不一致的验证码。

按发送邮件 API:

  • 头长度 ≤ 256
  • 记住 24 小时;重放返回原响应
  • 从稳定标识派生(supabase-otp-{sendId}、magic-{tokenId})
  • 故意 重发 并生成新码时必须换新 key

非 Edge 后端同样可调 Rovela

若 API 已在 Node / Go / Next.js 上跑、只用 Supabase 客户端读写数据,也可在该服务端用同样方式 fetch / httpx / net/http 调 POST /emails。Edge Function 便于把发信与 Supabase secrets 放在一起,但不是硬性要求。姊妹文:Deno OTP、Express OTP、通用用邮件发 OTP。

你会真正碰到的错误

形态统一(快速开始):

  • 400 点名域名 — from 未在本 Rovela 组织验证
  • 400 recipient is suppressed: … — 曾退信/投诉;请换地址
  • 401 — 密钥无效或已吊销
  • 429 — 遵守 Retry-After;重试时保持同一 Idempotency-Key

API 已拒绝发送时,不要对用户说「请查收邮箱」。

清单

  1. 在 Rovela 验证 mail.example.com(或你的域名);SPF/DKIM/DMARC 通过
  2. 创建 sending_access 密钥,只放进 supabase secrets
  3. 用表存 HMAC + 过期 + 尝试次数;从不存明文 OTP
  4. Edge Function fetch 同时带 html/text 与 Idempotency-Key
  5. 在第二个 Function 或 RPC 中核销;自行创建会话
  6. 用控制台消息页或 webhook 看投递——不要把 200 当成进箱

文档入口:发送邮件 · 鉴权 · 邮箱验证码示例 · 快速开始。