用 Descope + Rovela 发事务邮件(2026)
用 Rovela SMTP 连接器投递 Descope OTP/Magic Link,或经 POST /emails 发送 Embedded Link、应用自铸 OTP 与欢迎信——Descope 管 Flow/会话,Rovela 投递;不替代 Descope;与 Stytch、SuperTokens、WorkOS、Clerk 指南区分。
Descope 管 Flow 与会话 —— Rovela 只投递你发出的邮件
Descope 是围绕 Flow、SDK 以及邮箱 OTP、Magic Link、Enchanted Link、Embedded Link 等方法构建的认证与用户管理平台。默认情况下 Descope 可通过内置消息服务发登录邮件,也可在控制台配置消息连接器(通用 SMTP、SendGrid 等)。许多产品团队仍需要应用自有投递:与账单/产品通知同一已验证 mail. 子域与声誉、端到端可控的双语 HTML,或与整套事务邮件栈放在一起的魔法链接文案。
本文保留 Descope 负责身份(Flow、OTP / Magic Link 校验、会话、租户),将选定的事务邮件交给 Rovela——既可以把 Descope 的 SMTP 连接器指向 Rovela 中继,也可以由可信后端调用 Rovela HTTP API,用于 Embedded Link、应用自铸的产品 OTP,以及欢迎 / 邀请说明。
Rovela 不替代 Descope。 它不托管 Descope Flow、不签发 Descope session JWT、不把 Descope 的 Email OTP / Magic Link 发送 API 当成 Descope 产品来跑,也不在 Descope 控制台里管理连接器。身份仍以 Descope 为准;Rovela 只负责通过 POST https://api.rovela.dev/emails 入队、DKIM 签名与投递(Send Email API、Quick Start)——或在你把 Descope 连接器配到 Rovela 中继时走 SMTP。
本文不是 Stytch + Rovela(Embeddable Magic Links / Consumer API),也不是 SuperTokens、WorkOS AuthKit 或 Clerk。这里的集成面是 Descope 消息连接器、Embedded Link 生成与校验,以及 Descope 认证之后的应用自有产品邮件。
何时继续用 Descope 发信,何时走 Rovela
| 需求 | 常见路径 |
|---|---|
| 使用 Descope 默认发件人的标准 Email OTP / Magic Link | Descope 内置消息 + Authentication Methods 中的模板 |
| 仍由 Descope 触发 OTP / Magic Link,但从你的 ESP / 域名发出 | 配置 Descope SMTP 连接器 → Rovela SMTP(mx.rovela.dev),并在 OTP / Magic Link 设置中选用 |
需要与账单相同的 HTTP 声誉、Webhook 与 Idempotency-Key | 应用自有发送 → Rovela POST /emails |
| 要嵌进你自己邮件里的登录 / 魔法链接 | Descope Embedded Link 生成 → Rovela |
应用自铸的产品 OTP / 二次验证码(不是 Descope otp.*.email 发送) | 应用铸造 + HMAC + Rovela;登录后的会话仍由 Descope 管 |
| 不属于 Descope 认证模板的欢迎 / 产品邀请 | 一律应用自有 → Rovela |
重要诚实说明: Descope 的 Email OTP 与 Magic Link 发送路径(otp.signUpOrIn.email、magicLink.signUpOrIn.email、Flow 中的 “Send OTP / Magic Link” 动作)会通过你在控制台选中的连接器(或默认消息服务)投递。生产环境没有公开钩子会把 Descope 当前 OTP 数字交还给你再自行发信——不要虚构这种钩子。管理端的 generateOTPForTestUser / generateMagicLinkForTestUser 仅用于测试用户,不是生产登录。生产里若要 BYO 魔法链接投递,用 Embedded Link 生成 + 自有邮件;若要 BYO OTP 数字,在应用里自铸(或继续用 Descope Email OTP + 连接器 / 默认发件)。
你负责什么,Rovela 发什么
你的应用(加上 Descope)负责:
- Flow、用户、租户、session / refresh JWT,以及 OTP / Magic Link / Embedded Link 的 verify(Descope)
- 当仍由 Descope 发认证信时,配置指向 Rovela 中继的 SMTP 连接器
- 通过管理 SDK / API 为已存在用户创建 Embedded Link token
- 用 CSPRNG 铸造应用自有 OTP;只存 HMAC 摘要、过期时间与尝试次数(你的数据库——永不存明文验证码)
- 限流(例如「每地址每分钟一封」)
- 对你自行发出的一次性链接落地页做机器人 / 邮件扫描器防护
- 每次逻辑 HTTP 发送使用稳定的
Idempotency-Key
Rovela 负责:
- 在
mx.rovela.dev接受 SMTP(作为 Descope 连接器目标时),和 / 或 - 带
Authorization: Bearer与sending_access密钥的POST /emails - 入队、签名、重试、退信 / 投诉处理
HTTP API 返回 200 表示已接受并入队,不表示「已进收件箱」(Quick Start)。SMTP 交接同样只表示中继已接受,不是收件箱确认。
前置条件
- 在 Rovela 控制台验证发信域名。未验证的
from域名会被400拒绝(Quick Start)。认证类邮件优先用mail.example.com这类子域(OTP / 魔法链接为何进垃圾箱)。 - HTTP 发送:形如
re_…的 API 密钥。生产发信优先sending_access(Authentication)。 - Descope SMTP 连接器路径:Rovela 邮箱凭证(完整地址 + Mailboxes → Credentials 里的应用密码)——不是控制台登录密码;SMTP 主机
mx.rovela.dev,端口 465(SSL/TLS)或 587(STARTTLS),与 Rovela 商务邮箱文档一致。 - Descope 项目:生成 Embedded Link 时需要仅服务端使用的 management key,以及 SDK 所需的 project ID / 客户端凭据。切勿把 management key 或 Rovela 密钥打进浏览器包。
- 不要编造 Descope 或 Rovela 文档未写明的能力。
模式 A:Descope SMTP 连接器 → Rovela 中继
适合:仍希望由 Descope 在 Flow / SDK 内拥有 Email OTP / Magic Link 的发送,但每封认证信都要从你在 Rovela 验证过的域名发出,并与其它事务邮件共享声誉。
- 在 Rovela 验证
mail.example.com(或你选择的域名),并为该域上的地址创建邮箱 / 发信凭证(例如security@mail.example.com)。 - 在 Descope:Dashboard → Connectors → SMTP。主机名
mx.rovela.dev,端口465(或按 Descope SMTP 字段使用587+ STARTTLS),用户名为完整邮箱地址,密码为 Rovela 邮箱凭证,发件人地址(及可选发件人名)使用已验证身份。用 Descope 的 Test 按钮测通后保存(SMTP 连接器指南)。 - 在 Authentication Methods → OTP 与 Magic Link 中,将该 SMTP 连接器选为邮箱连接器(若使用自定义连接器可改模板)。可按 Descope OTP / Magic Link 设置文档配置备用连接器。
- 继续像往常一样从 Flow 或 SDK 调用 Descope OTP / Magic Link 发送——验证码与链接仍由 Descope 生成;Rovela 只传输连接器构建的 MIME。
取舍: 连接器 SMTP 没有 Rovela HTTP 的 Idempotency-Key,Webhook 形态也与 POST /emails 不同。若需要幂等 HTTP 重试与统一投递 Webhook,优先模式 B/C/D。
HTTP 模式的密钥存放
# Node 服务端示例 —— 变量名可按约定调整
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 DESCOPE_PROJECT_ID=P...
export DESCOPE_MANAGEMENT_KEY=K...
export MAGIC_LINK_REDIRECT_URL=https://acme.example/auth/embedded
轮换时更新环境变量并重新部署;旧 Rovela 密钥在控制台吊销(Authentication)。Descope management key 与 Rovela 密钥放进主机已有的同一密钥库。
共用的 Rovela HTTP 辅助函数
const ROVELA_URL = "https://api.rovela.dev/emails";
async function sendRovelaEmail(opts: {
to: string;
subject: string;
html: string;
text: string;
idempotencyKey: string;
}) {
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": opts.idempotencyKey.slice(0, 256),
},
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 密钥打进日志,只暴露状态与提供方 message。
console.error("rovela_send_failed", res.status, data.message);
throw new Error(`rovela_${res.status}`);
}
return res.json(); // { object: "email", id: "..." }
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
同时发送 html 与 text。短验证码可放进主题,方便手机通知预览阅读(Email verification codes)。传入 Idempotency-Key(≤ 256 字符,记忆 24 小时),避免超时重试发出两封互相矛盾的邮件(emails API)。
模式 B:Embedded Link → Rovela(生产 BYO 魔法链接投递)
当你希望由 Descope 为已存在用户创建一次性登录 token,但由你的栈负责发信时(Embedded Link 后端指南):
- 在 Descope Authentication Methods 中启用 Embedded Link 的 API/SDK,并确认用户已存在。
- 使用仅服务端的 management key 调用
management.user.generateEmbeddedLink(loginId, customClaims?)。 - 拼出你的应用将核销的 URL,例如
https://acme.example/auth/embedded?t=…(与重定向及 Descope 文档一致)。 - 用 Rovela 发出该 URL。
- 用户打开后调用 Descope
magicLink.verify(token)(Embedded Link token 用 Magic Link 的 verify 校验)并建立 Descope 会话。
import DescopeClient from "@descope/node-sdk";
const descope = DescopeClient({
projectId: process.env.DESCOPE_PROJECT_ID!,
managementKey: process.env.DESCOPE_MANAGEMENT_KEY!,
});
async function sendEmbeddedLinkEmail(loginId: string, email: string) {
const resp = await descope.management.user.generateEmbeddedLink(loginId);
if (!resp.ok) {
console.error("descope_embedded_failed", resp.code, resp.error?.errorMessage);
throw new Error(`descope_${resp.code}`);
}
const token = resp.data.token;
const redirect = process.env.MAGIC_LINK_REDIRECT_URL!;
const url = `${redirect}?t=${encodeURIComponent(token)}`;
await sendRovelaEmail({
to: email,
subject: "登录 Acme",
html:
`<p>点击登录 Acme:</p>` +
`<p><a href="${escapeHtml(url)}">登录</a></p>` +
`<p>若按钮无效,请将下列地址粘贴到浏览器:</p>` +
`<p>${escapeHtml(url)}</p>` +
`<p>若非本人操作,可忽略本邮件。</p>`,
text:
`登录 Acme:\n${url}\n\n` +
`若非本人操作,可忽略本邮件。`,
idempotencyKey: `descope-embedded-${loginId}-${token.slice(0, 24)}`,
});
}
把 token 当秘密:不要记日志,不要塞进客户端分析。在核销页加 CAPTCHA 等防护——邮件安全扫描器可能预点链接并消耗一次性 token。若希望保留 Descope Flow UX 与连接器模板、而不自己拼 URL,优先用内置 Magic Link + 连接器。Embedded Link 面向已存在用户,不要把它当成每一种 Magic Link 注册 Flow 的即插即用替代。
模式 C:应用自铸 OTP + Rovela(会话仍由 Descope 管)
适合验证码数字归你的场景(产品二次验证、敏感操作确认,或你自建的登录路径)——不是你调用了 Descope Email OTP 发送却指望自己再发那串码。用户证明验证码后,继续使用你已持有的 Descope 认证会话(或以其它 Descope 方式完成登录)。
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
function mintSixDigitCode() {
return String(randomInt(0, 1_000_000)).padStart(6, "0");
}
function hashCode(code: string, email: string) {
const pepper = process.env.CODE_PEPPER!;
return createHmac("sha256", pepper).update(`${email}:${code}`).digest("hex");
}
async function sendAppOtp(email: string, purpose: string) {
const code = mintSixDigitCode();
const digest = hashCode(code, email.toLowerCase());
// 在你自己的数据库中保存 digest + 过期时间 + 尝试次数 —— 永不存明文验证码。
// await db.otp.upsert({ email, purpose, digest, expiresAt: Date.now() + 10 * 60_000 })
await sendRovelaEmail({
to: email,
subject: `你的 Acme 验证码:${code}`,
html:
`<p>你的验证码是</p>` +
`<p style="font-size:28px;font-weight:600;letter-spacing:4px"><b>${escapeHtml(code)}</b></p>` +
`<p>10 分钟内有效。若非本人操作,可忽略本邮件。</p>`,
text:
`你的验证码是 ${code}。10 分钟内有效。` +
`若非本人操作,可忽略本邮件。`,
idempotencyKey: `descope-app-otp-${purpose}-${email.toLowerCase()}-${digest.slice(0, 16)}`,
});
}
function verifyAppOtp(email: string, code: string, storedDigest: string) {
const a = Buffer.from(hashCode(code, email.toLowerCase()));
const b = Buffer.from(storedDigest);
return a.length === b.length && timingSafeEqual(a, b);
}
限制发送与尝试验证次数。成功后走你的 Descope 会话——核销与 Rovela 无关。生产用户不要使用 generateOTPForTestUser。
模式 D:Descope 认证后的欢迎 / 产品邀请
Descope Flow 中的连接器步骤、Webhook,或会话建立后你自己的 API,都适合触发产品邮件(欢迎、「完成工作区设置」),而不是认证 OTP 模板。校验 Webhook / Flow 密钥,把投递当作至少一次,并用稳定的 Rovela Idempotency-Key(例如 descope-welcome-{userId})去重。
也可以在 Flow 里加消息连接器步骤写自定义文案,同时把 SMTP 指到 Rovela(模式 A);若需要幂等 HTTP 与 Rovela Webhook,则在后端拼 HTML 后调用 POST /emails(本模式)。
你会真实遇到的错误
Rovela HTTP 错误形态统一(Quick Start):
400点名域名 —— 该 Rovela 组织里from未验证400recipient is suppressed: …—— 曾退信 / 投诉;请换地址401—— 密钥错误或已吊销429—— 遵守Retry-After;重试时保持同一Idempotency-Key
API 拒绝发送时,不要告诉用户「去查收件箱」。Descope 一侧要把连接器 Test 失败、Embedded Link 生成错误与 verify 错误,与 Rovela 传输失败分开呈现。SMTP 连接器出问题时,确认主机是 mx.rovela.dev、端口 / TLS 正确,且密码是邮箱凭证而不是 Rovela 控制台登录密码。
清单
- 在 Rovela 验证
mail.example.com(或所选域名);SPF/DKIM/DMARC 通过 - 按流程选择:Descope 默认消息 vs Descope SMTP 连接器 → Rovela vs Embedded Link + Rovela HTTP vs 应用自铸 OTP + Rovela
- 若用连接器:创建邮箱凭证,在 Descope 中 Test,并在 OTP / Magic Link 设置中选用
- 若用 HTTP:创建
sending_access密钥;仅存服务端环境变量,与 Descope management key 并列 - 若用 Embedded Link:启用方法,仅为已存在用户生成,处理机器人预点,用
magicLink.verify校验 - Flow / 会话 / OTP-Magic 校验留在 Descope;Rovela 只作你自有路径的邮件传输
- 用控制台消息页或 Rovela Webhook 观察投递——不要假设 HTTP
200或 SMTP 接受等于进箱
相关文档:Send Email · Authentication · Email verification codes · Quick Start · Descope SMTP 连接器 · Embedded Link(后端) · 消息连接器。