用 SuperTokens + Rovela 发事务认证邮件(2026)
自定义 SuperTokens emailDelivery sendEmail(Passwordless、密码重置、邮箱验证),经 Rovela POST /emails 与 Idempotency-Key 投递——SuperTokens 管会话/认证,Rovela 负责投递;与 Stytch、WorkOS、Clerk、Auth.js、Better Auth 指南区分。
SuperTokens 管认证 —— Rovela 只投递邮件
SuperTokens 是开源认证栈:EmailPassword、Passwordless、EmailVerification、WebAuthn、Session 等 recipe,Core 可自托管或托管。邮件按 recipe 通过 emailDelivery 配置——内置投递、SMTP,或自定义 sendEmail override,详见 Configure Email Delivery。
本文保留 SuperTokens 负责身份(用户、会话、无密码码/链接、重置与验证令牌),投递走 Rovela 的扁平 POST https://api.rovela.dev/emails 并带上 Idempotency-Key,让认证邮件与账单/产品通知共用同一已验证域名与声誉(发送邮件 API、快速开始、鉴权)。
Rovela 不替代 SuperTokens。 它不运行 SuperTokens Core、不签发会话、不铸造无密码码、不校验重置/验证链接,也不托管 SuperTokens 内置投递端点。身份仍以 SuperTokens 为准;你的 override 只是把已生成的 passwordResetLink、emailVerifyLink、userInputCode 或 urlWithLinkCode 送到收件箱。
与以下文章区分:
- Stytch + Rovela — 托管无密码 API 周边的 Embeddable / 应用 OTP
- WorkOS AuthKit + Rovela — AuthKit 自定义邮件 + Get API
- Clerk + Rovela — Clerk 自定义模板 / webhook
- Auth.js / NextAuth + Rovela / Better Auth + Rovela — 框架 Email provider 与发送钩子
本文集成面是 SuperTokens emailDelivery.override → sendEmail(EmailPassword、EmailVerification、Passwordless;以 Node 为例,Go/Python 同为 override 思路)。
何时继续用内置 / SMTP,何时走 Rovela
| 需求 | 常见路径 |
|---|---|
| 最少配置的库存认证邮件 | SuperTokens 内置投递(不可自定义模板;失败行为因 recipe/SDK 而异,见官方文档) |
| 用自有域名 + SuperTokens 自带 SMTP 模板 | recipe 的 SMTPService + 可选 getContent override |
与产品邮件同一 ESP / mail. 声誉;自有完整 HTML | 自定义 sendEmail → Rovela |
| 端到端可控的无密码 OTP + 魔法链接 HTML | Passwordless emailDelivery.override → Rovela |
| 非 SuperTokens recipe 的欢迎信 / 产品邀请 | 认证完成后应用自有发送 → Rovela |
投递对安全关键时更宜自定义 override:官方提醒不要把内置服务当成可靠队列;部分 recipe 会 await 投递,另一些在非 serverless 环境下可能吞掉内置失败。
你负责什么,Rovela 发什么
应用(加 SuperTokens)负责:
- Core 连接、
appInfo、recipe 列表、Session cookie / 令牌 - 生成重置链接、邮箱验证链接、无密码
userInputCode/urlWithLinkCode - SuperTokens 已有的限流与租户上下文
- 每个逻辑挑战稳定的
Idempotency-Key(由preAuthSessionId、链接片段等派生——不要每次重试换新 UUID) - 投递失败时在
sendEmail中抛错,便于 SuperTokens 按文档暴露错误
Rovela 负责:
- 带
Authorization: Bearer与sending_access密钥的POST /emails - 入队、DKIM 签名、重试、退信/投诉处理
200 表示已接受并入队,不是「已进收件箱」(快速开始)。
前置条件
- 在 Rovela 控制台验证发信域名。未验证的
from域会400(快速开始)。认证邮件建议用mail.example.com等子域(OTP/魔法链接进垃圾箱)。 - 形如
re_…的 API 密钥。生产发信优先sending_access(鉴权)。 - 可用的 SuperTokens 后端(
supertokens-node等)与 Core URI,并启用你实际用到的 recipe。在每个会发信的 recipe 上配置emailDelivery——AccountLinking不拥有投递配置。 - 切勿把 Rovela 密钥或 SuperTokens Core API 密钥放进客户端或公开仓库。只有可信服务端可调用 Rovela 或 SuperTokens 后端 API。
环境变量
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <auth@mail.example.com>'
# SuperTokens Core connectionURI / API keys 按你现有部署配置
轮换 Rovela 密钥:新建 → 部署 → 在控制台吊销旧钥(鉴权)。与 SuperTokens 凭证放在同一密钥库。
共用 Rovela 发送助手
集中 fetch,让各 recipe override 共用请求头、错误处理与幂等规则。不要让 Rovela 发明码或链接——透传 SuperTokens 已给出的值。
// lib/rovelaSendAuthEmail.ts
const ROVELA_URL = "https://api.rovela.dev/emails";
export async function rovelaSendAuthEmail(opts: {
to: string;
subject: string;
html: string;
text: string;
/** 每个挑战稳定 — 24 小时内重试复用同一接受结果 */
idempotencyKey: string;
}) {
const apiKey = process.env.ROVELA_API_KEY!;
const from = process.env.ROVELA_FROM!;
const key = opts.idempotencyKey.slice(0, 256);
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({
from,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
// 不要记录 API 密钥 — 只记状态与提供商消息
console.error("rovela_send_failed", res.status, data.message);
throw new Error(`Rovela send failed: ${res.status}`);
}
}
export function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
与 Rovela 文档对齐的要点:
- 尽量同时发
html与text(发送邮件 API)。 - 短码可放进主题,便于手机通知预览(邮箱验证码示例)。
Idempotency-Key(≤ 256 字符):24 小时内重试返回原接受结果,避免连发两封(Idempotency)。
模式 A:Passwordless → Rovela
无密码邮件投递会收到 type: "PASSWORDLESS_LOGIN",以及 email、codeLifetime、preAuthSessionId、tenantId,并按 flowType(USER_INPUT_CODE / MAGIC_LINK / USER_INPUT_CODE_AND_MAGIC_LINK)可选带上 userInputCode 与/或 urlWithLinkCode。Override sendEmail,改走 Rovela:
import SuperTokens from "supertokens-node";
import Passwordless from "supertokens-node/recipe/passwordless";
import Session from "supertokens-node/recipe/session";
import { escapeHtml, rovelaSendAuthEmail } from "./lib/rovelaSendAuthEmail";
SuperTokens.init({
// supertokens: { connectionURI: "..." },
appInfo: {
appName: "Acme",
apiDomain: "https://api.acme.example",
websiteDomain: "https://acme.example",
},
recipeList: [
Passwordless.init({
contactMethod: "EMAIL",
flowType: "USER_INPUT_CODE_AND_MAGIC_LINK",
emailDelivery: {
override: (originalImplementation) => ({
...originalImplementation,
sendEmail: async function (input) {
// 除非仍要 ST/SMTP 投递,否则不要调用 originalImplementation.sendEmail
const code = input.userInputCode;
const link = input.urlWithLinkCode;
const parts: string[] = [];
if (code) {
parts.push(
`<p>你的登录验证码是</p>` +
`<p style="font-size:28px;font-weight:600;letter-spacing:4px"><b>${escapeHtml(code)}</b></p>`,
);
}
if (link) {
parts.push(
`<p><a href="${escapeHtml(link)}">用魔法链接登录</a></p>` +
`<p>或粘贴:${escapeHtml(link)}</p>`,
);
}
parts.push(
`<p>该挑战约 ${Math.round(input.codeLifetime / 60_000)} 分钟后失效。</p>` +
`<p>如非本人操作,可忽略此邮件。</p>`,
);
const subject = code
? `你的 Acme 验证码:${code}`
: "登录 Acme";
await rovelaSendAuthEmail({
to: input.email,
subject,
html: parts.join(""),
text: [
code ? `你的登录验证码是 ${code}。` : null,
link ? `登录链接:${link}` : null,
`约 ${Math.round(input.codeLifetime / 60_000)} 分钟后失效。`,
"如非本人操作,请忽略此邮件。",
]
.filter(Boolean)
.join("\n"),
idempotencyKey: `st-pless-${input.tenantId}-${input.preAuthSessionId}`,
});
},
}),
},
}),
Session.init(),
],
});
把 userInputCode 与 urlWithLinkCode 当秘密:不要记日志,不要进客户端分析。魔法链接落地页要防扫描器预点。创建/消费码仍归 SuperTokens;Rovela 只投递。
模式 B:密码重置(EmailPassword)→ Rovela
EmailPassword 重置投递会提供 type: "PASSWORD_RESET" 与 passwordResetLink(以及用户字段)。Override 后经 Rovela 发送:
import EmailPassword from "supertokens-node/recipe/emailpassword";
import { escapeHtml, rovelaSendAuthEmail } from "./lib/rovelaSendAuthEmail";
EmailPassword.init({
emailDelivery: {
override: (originalImplementation) => ({
...originalImplementation,
sendEmail: async function (input) {
if (input.type !== "PASSWORD_RESET") {
return originalImplementation.sendEmail(input);
}
const link = input.passwordResetLink;
await rovelaSendAuthEmail({
to: input.user.email,
subject: "重置你的 Acme 密码",
html:
`<p>重置 Acme 密码:</p>` +
`<p><a href="${escapeHtml(link)}">选择新密码</a></p>` +
`<p>或粘贴:${escapeHtml(link)}</p>` +
`<p>如非本人请求,可忽略此邮件。</p>`,
text:
`重置 Acme 密码:\n${link}\n\n` +
`如非本人请求,请忽略此邮件。`,
idempotencyKey: `st-reset-${input.user.id}-${link.slice(-32)}`,
});
},
}),
},
});
令牌寿命与链接路径由 SuperTokens Core / appInfo 决定——在 SuperTokens 配置里改,不要在 Rovela 里“发明”。Rovela 从不校验链接。
模式 C:邮箱验证 → Rovela
EmailVerification 的 sendEmail 收到 emailVerifyLink(及用户)。同一模式:
import EmailVerification from "supertokens-node/recipe/emailverification";
import { escapeHtml, rovelaSendAuthEmail } from "./lib/rovelaSendAuthEmail";
EmailVerification.init({
mode: "REQUIRED", // 或 OPTIONAL — 按产品选择
emailDelivery: {
override: (originalImplementation) => ({
...originalImplementation,
sendEmail: async function (input) {
const link = input.emailVerifyLink;
await rovelaSendAuthEmail({
to: input.user.email,
subject: "验证你的 Acme 邮箱",
html:
`<p>确认 Acme 邮箱:</p>` +
`<p><a href="${escapeHtml(link)}">验证邮箱</a></p>` +
`<p>或粘贴:${escapeHtml(link)}</p>`,
text: `验证 Acme 邮箱:\n${link}`,
idempotencyKey: `st-verify-${input.user.id}-${link.slice(-32)}`,
});
},
}),
},
});
若使用 WebAuthn 账户恢复邮件,在该 recipe 上按同一 emailDelivery override 思路接线——不要编造 SDK 未文档化的字段。
SMTP 与自定义 override(如实选择)
SuperTokens 的 SMTP service 适合沿用其默认 HTML + 你自己的 SMTP 凭证。Rovela 是 HTTP API,不是可填进 smtpSettings.host 的 SMTP 中继。对接 Rovela 请用本文的 自定义 sendEmail override,不要虚构「Rovela SMTP 主机名」。你仍可为某个 recipe 用 SMTP、为另一个用 Rovela——各自独立配置 emailDelivery。
SuperTokens 认证之后的产品邮件
欢迎信、组织邀请、账单回执不是 SuperTokens recipe 邮件。在注册/建会话后(API、webhook 或任务)调用同一 rovelaSendAuthEmail,并用 st-welcome-{userId} 这类稳定幂等键。会话与用户 CRUD 仍留在 SuperTokens。
你会真正碰到的错误
Rovela 错误形态一致(快速开始):
400点名域名 — 本组织未验证该from400recipient is suppressed: …— 曾退信/投诉;请换地址401— 密钥错误或已吊销429— 遵守Retry-After;重试保持同一Idempotency-Key
Rovela 非 2xx 时在 override 中抛错,便于 SuperTokens 在 SDK 支持处向上传递。API 拒发时不要对用户说「请查收件箱」。SuperTokens 创建/消费错误与 Rovela 传输失败要分开呈现。
清单
- 在 Rovela 验证
mail.example.com(或所选域名);SPF/DKIM/DMARC 通过 - 创建
sending_access密钥;仅放服务端环境,与 SuperTokens 密钥并列 - 按需 override Passwordless / EmailPassword / EmailVerification 的
emailDelivery.sendEmail——安全关键流不要只依赖未定制的内置投递 - 透传 SuperTokens 的链接/验证码;不要在 Rovela 侧另铸「认证令牌」
fetch同时带html与text,并用preAuthSessionId或链接身份派生Idempotency-Key- Core、会话与 recipe API 留在 SuperTokens;Rovela 只做邮件传输
- 用控制台消息页或 Rovela webhook 看投递——不要假设
200等于进箱
相关文档:发送邮件 · 鉴权 · 邮箱验证码 · 快速开始 · SuperTokens Configure Email Delivery。