用 Supabase + Rovela 发事务邮件(2026)
在 Supabase Edge Function 里用 Rovela POST /emails 发自定义 OTP、魔法链接与欢迎信——应用自管令牌,Rovela 只负责投递;不替代 Auth;与 Express/Next.js OTP、Workers 指南区分。
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 表示 已接受并入队,不是「已经进收件箱」(快速开始)。
前置条件
- 在 Rovela 控制台 验证发信域名。未验证的
from会直接400(快速开始)。认证类邮件优先用mail.example.com一类子域(为什么验证码和魔法链接进垃圾箱)。 re_…形态的 API 密钥;生产 Edge Function 优先sending_access(鉴权)。- 已启用 Edge Functions 的 Supabase 项目,并用 CLI 或控制台配置 secrets。
- 切勿把 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 组织验证400recipient is suppressed: …— 曾退信/投诉;请换地址401— 密钥无效或已吊销429— 遵守Retry-After;重试时保持同一Idempotency-Key
API 已拒绝发送时,不要对用户说「请查收邮箱」。
清单
- 在 Rovela 验证
mail.example.com(或你的域名);SPF/DKIM/DMARC 通过 - 创建
sending_access密钥,只放进supabase secrets - 用表存 HMAC + 过期 + 尝试次数;从不存明文 OTP
- Edge Function
fetch同时带html/text与Idempotency-Key - 在第二个 Function 或 RPC 中核销;自行创建会话
- 用控制台消息页或 webhook 看投递——不要把
200当成进箱