← 返回博客

用 SuperTokens + Rovela 发事务认证邮件(2026)

自定义 SuperTokens emailDelivery sendEmail(Passwordless、密码重置、邮箱验证),经 Rovela POST /emails 与 Idempotency-Key 投递——SuperTokens 管会话/认证,Rovela 负责投递;与 Stytch、WorkOS、Clerk、Auth.js、Better Auth 指南区分。

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

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 送到收件箱。

与以下文章区分:

本文集成面是 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 + 魔法链接 HTMLPasswordless 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 表示已接受并入队,不是「已进收件箱」(快速开始)。

前置条件

  1. 在 Rovela 控制台验证发信域名。未验证的 from 域会 400(快速开始)。认证邮件建议用 mail.example.com 等子域(OTP/魔法链接进垃圾箱)。
  2. 形如 re_… 的 API 密钥。生产发信优先 sending_access(鉴权)。
  3. 可用的 SuperTokens 后端(supertokens-node 等)与 Core URI,并启用你实际用到的 recipe。在每个会发信的 recipe 上配置 emailDelivery——AccountLinking 不拥有投递配置。
  4. 切勿把 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

与 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 点名域名 — 本组织未验证该 from
  • 400 recipient is suppressed: … — 曾退信/投诉;请换地址
  • 401 — 密钥错误或已吊销
  • 429 — 遵守 Retry-After;重试保持同一 Idempotency-Key

Rovela 非 2xx 时在 override 中抛错,便于 SuperTokens 在 SDK 支持处向上传递。API 拒发时不要对用户说「请查收件箱」。SuperTokens 创建/消费错误与 Rovela 传输失败要分开呈现。

清单

  1. 在 Rovela 验证 mail.example.com(或所选域名);SPF/DKIM/DMARC 通过
  2. 创建 sending_access 密钥;仅放服务端环境,与 SuperTokens 密钥并列
  3. 按需 override Passwordless / EmailPassword / EmailVerification 的 emailDelivery.sendEmail——安全关键流不要只依赖未定制的内置投递
  4. 透传 SuperTokens 的链接/验证码;不要在 Rovela 侧另铸「认证令牌」
  5. fetch 同时带 html 与 text,并用 preAuthSessionId 或链接身份派生 Idempotency-Key
  6. Core、会话与 recipe API 留在 SuperTokens;Rovela 只做邮件传输
  7. 用控制台消息页或 Rovela webhook 看投递——不要假设 200 等于进箱

相关文档:发送邮件 · 鉴权 · 邮箱验证码 · 快速开始 · SuperTokens Configure Email Delivery。