用 Clerk + Rovela 发事务邮件(2026)
在 Clerk 后端或 webhook 里用 Rovela POST /emails 发自定义 OTP、欢迎信与邀请——应用自管令牌,Rovela 只负责投递;不替代 Clerk;与 Supabase、框架 OTP 指南区分。
Clerk 管身份,Rovela 负责投递
Clerk 是认证与用户管理平台:托管登录 UI、会话、组织,以及用户/邀请相关的 Backend API。很多团队先用 Clerk 自带邮件发验证与重置,随后需要自定义事务邮件——品牌化 OTP HTML、双语欢迎信、带产品深链的邀请信,或独立 mail. 子域与声誉。
本文说明如何继续用 Clerk 管身份,同时从可信后端或 Clerk webhook 处理器调用 Rovela HTTP API,发送应用自管的事务邮件:自造 OTP、在 user.created 上发欢迎信、工作区邀请等。
Rovela 不替代 Clerk。 它不创建用户、不签发会话、不校验 OTP,也不管理邀请。令牌、哈希、限流与核销逻辑归你的服务端;Rovela 只通过 POST https://api.rovela.dev/emails 入队、DKIM 签名并投递(发送邮件 API、快速开始)。
这不是 Supabase + Rovela 事务邮件(Edge Function + Postgres),也不是框架 OTP 教程(Express、Next.js 魔法链接)。这里的集成面是 Clerk webhook / Backend API + 服务端密钥。
何时继续用 Clerk 邮件,何时改走 Rovela
| 需求 | 常见路径 |
|---|---|
| Clerk 默认验证 / 重置模板 | 起步阶段用 Clerk 自带邮件即可 |
| 自定义 HTML、品牌版式、主题含验证码、双语文案 | 应用自管发送 → Rovela |
在 mail.yourdomain.com 上隔离声誉 | 已验证的 Rovela 域名 + 你的 from |
| 非 Clerk 模板的欢迎 / 产品邀请 | 一律应用自管 → Rovela |
| 仍要 Clerk 会话与组件 | 认证继续用 Clerk;自定义流程只换邮件传输 |
常见做法:以 Clerk 为用户与会话的权威来源;在 user.created 等事件(或你在邀请动作后的 API)里自行签发验证码/令牌,用 Rovela 发送,并在你的后端核销——不要在浏览器里持有 Rovela 密钥。
你负责什么,Rovela 发送什么
你的应用(加上 Clerk)负责:
- 用户、会话、组织(Clerk)
- 用 CSPRNG 生成 OTP 或邀请 / 魔法链接令牌
- 只存 HMAC 哈希、过期时间、尝试次数(你自己的库——绝不存明文验证码)
- 限流(例如每地址每分钟一次)
- 核销逻辑(以及核销后如需调用的 Clerk Backend API)
- 校验 Clerk webhook 签名并去重
- 每次逻辑发送使用稳定的
Idempotency-Key
Rovela 负责:
- 带
Authorization: Bearer与sending_access密钥的POST /emails - 入队、签名、重试、退信/投诉处理
200 只表示已接受并入队,不等于「已进收件箱」(快速开始)。
前置条件
- 在 Rovela 控制台验证发信域名。未验证的
from域会直接400(快速开始)。认证类邮件优先用mail.example.com这类子域(为何 OTP / 魔法链接进垃圾箱)。 - 形如
re_…的 API 密钥。生产发信优先sending_access(鉴权)。 - Clerk 应用具备 Backend API 密钥(
CLERK_SECRET_KEY);若做事件驱动邮件,还需 webhook 端点与签名密钥(CLERK_WEBHOOK_SIGNING_SECRET)。 - 切勿把 Rovela 密钥或 Clerk 密钥放进浏览器、React Native 客户端或公开仓库。只有可信服务端(或无服务器函数)可调用 Rovela 与 Clerk Backend API。
把密钥放进环境变量
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <security@mail.example.com>'
export CODE_PEPPER="$(openssl rand -hex 32)"
export CLERK_SECRET_KEY=sk_live_...
export CLERK_WEBHOOK_SIGNING_SECRET=whsec_...
轮换时更新环境变量并重新部署;旧 Rovela 密钥在控制台吊销(鉴权)。Clerk 与 Rovela 凭证放进主机已有的密钥库(Vercel / Fly / Railway 等),不要写死在代码里。
模式 A:user.created 上发欢迎信(webhook)
用户创建时(仪表盘、托管 UI 或 Backend API),Clerk 可向你的端点 POST 已签名的 webhook。在验证签名之前,载荷一律不可信(Clerk 使用 Svix 签名头,如 svix-id、svix-timestamp、svix-signature)。投递是至少一次:接受工作后返回 2xx,并用 svix-id(或稳定的 welcome-{userId} 幂等键)去重,避免重试刷爆收件箱。
Next.js App Router 示意(app/api/webhooks/clerk/route.ts)——按 Clerk webhook 文档 用官方 helper 或 Svix 库验签,再发送:
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import type { NextRequest } from "next/server";
const ROVELA_URL = "https://api.rovela.dev/emails";
export async function POST(req: NextRequest) {
let evt;
try {
evt = await verifyWebhook(req); // 读取 CLERK_WEBHOOK_SIGNING_SECRET
} catch {
return new Response("bad_signature", { status: 400 });
}
if (evt.type === "user.created") {
const primaryId = evt.data.primary_email_address_id;
const email = evt.data.email_addresses.find(
(a) => a.id === primaryId,
)?.email_address;
const name = evt.data.first_name || "there";
const userId = evt.data.id;
if (email) {
const apiKey = process.env.ROVELA_API_KEY!;
const from = process.env.ROVELA_FROM!;
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": `clerk-welcome-${userId}`.slice(0, 256),
},
body: JSON.stringify({
from,
to: email,
subject: "Welcome to Acme",
html:
`<p>Hi ${escapeHtml(name)}, your Acme account is ready.</p>` +
`<p><a href="https://acme.example/dashboard">Open your dashboard</a></p>`,
text:
`Hi ${name}, your Acme account is ready. ` +
`Open https://acme.example/dashboard`,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.error("rovela_send_failed", res.status, data.message);
return new Response("send_failed", { status: 502 });
}
}
}
return new Response("ok", { status: 200 });
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
在 Clerk Dashboard → Webhooks 登记 URL,订阅 user.created,把签名密钥写入环境变量。发送被接受(或已安全写入你自己的任务队列)后再返回 200,Clerk 的重试行为会更可预期。模板化欢迎信见 用模板发欢迎邮件。
模式 B:后端自管 OTP(Clerk 会话 + 应用验证码)
当你需要产品侧 OTP(升权确认、改邮箱、敏感操作)并自定义 HTML——用户已是 Clerk 用户——在服务端生成验证码、只存哈希,用 Rovela 投递。会话仍归 Clerk;挑战归你的路由。
import { auth, clerkClient } from "@clerk/nextjs/server";
import { createHash, randomInt } from "node:crypto";
const ROVELA_URL = "https://api.rovela.dev/emails";
export async function POST(req: Request) {
const { userId } = await auth();
if (!userId) return Response.json({ error: "unauthorized" }, { status: 401 });
const apiKey = process.env.ROVELA_API_KEY!;
const from = process.env.ROVELA_FROM!;
const pepper = process.env.CODE_PEPPER!;
// 通过 Clerk Backend API 解析主邮箱——不要信任客户端提交的地址
const user = await (await clerkClient()).users.getUser(userId);
const primaryId = user.primaryEmailAddressId;
const email = user.emailAddresses.find((e) => e.id === primaryId)?.emailAddress;
if (!email) return Response.json({ error: "no_email" }, { status: 400 });
const body = (await req.json().catch(() => ({}))) as { sendId?: string };
const sendId = (body.sendId || crypto.randomUUID()).trim();
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
const codeHash = createHash("sha256")
.update(`${pepper}:${email}:${code}`)
.digest("hex");
// 在你自己的数据库 upsert 哈希与过期时间(此处省略)。绝不存明文验证码。
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": `clerk-otp-${userId}-${sendId}`.slice(0, 256),
},
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 });
}
在另一路由用恒定时间比较校验哈希,成功后删除记录。表结构可参考 验证码示例。验证码与链接的取舍见 邮箱验证:验证码还是链接。
模式 C:你自己撰写的邀请信
Clerk 可通过 Backend API 创建组织邀请,并可能按你的 Clerk 配置与套餐发送自带邀请邮件。若需要完全自定义邀请 HTML(进应用的深链、双语正文、无附件的事务版式),在服务端创建邀请(或自管邀请令牌),再用 Rovela 发送,Idempotency-Key 例如 clerk-invite-{invitationId}。不要在客户端带着 Rovela 密钥发邀请。
await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `clerk-invite-${invitationId}`,
},
body: JSON.stringify({
from: process.env.ROVELA_FROM,
to: inviteeEmail,
subject: "You're invited to Acme",
html: `<p>Join the workspace: <a href="${inviteUrl}">Accept invite</a></p>`,
text: `Join the workspace: ${inviteUrl}`,
}),
});
webhook 与 API 重试下的幂等
Clerk webhook、你的队列与平台重试都可能重复 POST。没有 Idempotency-Key 时,超时可能导致两封欢迎信或两套不一致的 OTP。
按 emails API:
- Header ≤ 256 字符
- 记住 24 小时;重放返回原始响应
- 从稳定标识派生(
clerk-welcome-{userId}、clerk-otp-{userId}-{sendId}) - 故意重发并生成新验证码时必须用新键
- 同时保存已处理的
svix-id,让 Rovela 以外的副作用也只执行一次
你会真正碰到的错误
错误形状统一(快速开始):
400点名域名 —from未在本 Rovela 组织验证400recipient is suppressed: …— 曾退信/投诉;请换地址401— 密钥无效或已吊销429— 遵守Retry-After;重试时保持同一Idempotency-Key
API 拒绝发送时,不要对用户说「去查收件箱」。在 webhook 里要刻意决定:Rovela 失败是返回非 2xx(让 Clerk 重试),还是先入本地队列再返回 200。
清单
- 在 Rovela 验证
mail.example.com(或你选的域名);SPF/DKIM/DMARC 通过 - 创建
sending_access密钥;只放进服务端环境,与CLERK_SECRET_KEY并列 - 事件邮件:webhook 端点 +
CLERK_WEBHOOK_SIGNING_SECRET;验签;按svix-id去重 - OTP:哈希 + 过期 + 尝试次数表;邮箱经 Clerk Backend API 解析,勿单信客户端
fetch同时带html与text,并加Idempotency-Key- 用控制台消息页或 Rovela webhook 观察投递——不要假设
200等于进箱