← 返回博客

用 Stytch + Rovela 发事务邮件(2026)

用 Rovela POST /emails 投递 Stytch Embeddable Magic Link、应用自铸 OTP 与产品邀请——Stytch 管无密码/B2B 认证,Rovela 投递;不替代 Stytch;与 WorkOS、Clerk、Cognito、Auth.js 指南区分。

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

Stytch 管无密码认证 —— Rovela 只投递你发出的邮件

Stytch 是面向无密码与 B2B 的认证平台:Consumer / B2B API、邮箱 OTP、Email Magic Links、会话、组织等。默认情况下 Stytch 可以代发登录邮件——来自 login@stytch.com(或自定义发信域名),并可选用自定义 HTML 模板(otp_code、magic_link_url 等变量)。许多团队仍需要应用自有投递:与账单/产品通知同一 mail. 子域与声誉、端到端可控的双语 OTP HTML,或与整套事务邮件栈放在一起的魔法链接文案。

本文保留 Stytch 负责认证(用户、会话、Consumer/B2B 无密码流程),由可信后端通过 Rovela HTTP API 发送选定的事务邮件——你在 Stytch 创建的 Embeddable Magic Link、应用自铸的产品 OTP、欢迎信与邀请说明。

Rovela 不替代 Stytch。 它不运行 Stytch 的 Email OTP / Email Magic Links 产品、不签发 Stytch 会话、不管理 B2B 组织,也不托管 Stytch 内置消息通道。身份仍以 Stytch 为准;Rovela 只负责入队、DKIM 签名与投递:POST https://api.rovela.dev/emails(发送邮件 API、快速开始)。

这不是 WorkOS AuthKit + Rovela,也不是 Clerk + Rovela、AWS Cognito 自定义邮件 或 Auth.js / NextAuth + Rovela。本文的集成面是 Stytch Embeddable Magic Links 创建 API(Consumer)、应用自铸 OTP,以及认证之后的产品邮件——加上你的服务端密钥。

何时继续用 Stytch 邮件,何时改走 Rovela

需求常见路径
默认的 Email OTP / Email Magic Links,由 Stytch 投递Stytch 内置消息(可选自定义域名 + HTML 模板)
仍由 Stytch 发送,但要完整 HTML 与发件人品牌Stytch 自定义发信域名 + 自定义模板加购项
与账单/产品邮件同一 ESP / mail. 声誉应用自有发送 → Rovela
把魔法链接嵌进你自己的邮件(或其他渠道)Stytch Embeddable Magic Links 创建 → Rovela
你自铸的产品 OTP / 敏感操作确认(不是 Stytch otps/email/send)应用铸造 + 哈希 + Rovela;登录后会话仍归 Stytch
不属于 Stytch 模板的欢迎 / 产品邀请一律应用自有 → Rovela

务必诚实: Stytch 的 otps/email/send 与 Email Magic Links 发送接口会通过 Stytch 消息通道投递。公开文档里没有「把 OTP 交给我,我自己发信」的钩子。不要臆造会返回 Stytch OTP 的 webhook。若要自己投递魔法链接,用 Embeddable Magic Links(Consumer);若要自己投递数字 OTP,在应用内铸造(或继续用 Stytch Email OTP 并由 Stytch 发信)。

你负责什么,Rovela 发什么

应用(加上 Stytch)负责:

  • 用户、会话、组织与无密码 verify/authenticate(Stytch)
  • 通过 Stytch 创建 API 生成 Embeddable Magic Link token(工作区需已启用)
  • 用 CSPRNG 铸造应用自有 OTP;只存 HMAC 哈希、过期与尝试次数(你的数据库——绝不存明文验证码)
  • 速率限制(「每地址每分钟至多一封」)
  • 自投 Embeddable 链接时,在落地页做机器人 / 扫描器防护(Stytch 托管的 Email Magic Links 有中间页保护;Embeddable 没有)
  • 每次逻辑发送使用稳定的 Idempotency-Key

Rovela 负责:

  • 带 Authorization: Bearer 与 sending_access 密钥的 POST /emails
  • 入队、签名、重试、退信/投诉处理

200 表示已接受并入队,不等于「已经进收件箱」(快速开始)。

前置条件

  1. 在 Rovela 控制台验证发信域名。未验证的 from 域名会以 400 拒绝(快速开始)。认证类邮件优先用 mail.example.com 这类子域(为何 OTP / 魔法链接进垃圾箱)。
  2. 形如 re_… 的 API 密钥。生产发送优先 sending_access(鉴权)。
  3. 带服务端凭证的 Stytch 项目(STYTCH_PROJECT_ID、STYTCH_SECRET)。切勿把 secret 暴露给浏览器。
  4. Embeddable Magic Links:许多工作区默认关闭——需按 Stytch 文档/支持流程开启。Embeddable 仅限 Consumer API(不是 B2B),且认证后不会像 Email Magic Links 那样把邮箱因子标为已验证。选用前请读完 Stytch 的注意事项。
  5. 切勿把 Rovela 密钥或 Stytch secret 放进客户端或公开仓库。只有可信服务端可调用 Rovela 或 Stytch 机密 API。

把密钥放进环境变量

# Node / Next.js 服务端示例——命名约定
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 STYTCH_PROJECT_ID=project-live-...
export STYTCH_SECRET=secret-live-...
export MAGIC_LINK_REDIRECT_URL=https://acme.example/authenticate

通过更新环境变量并重新部署完成轮换;旧 Rovela 密钥在控制台吊销(鉴权)。Stytch 与 Rovela 凭证放进宿主已有的密钥库即可。

共用的 Rovela 发送辅助函数

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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

尽量同时提供 html 与 text。短验证码可放进主题行,方便手机通知预览(邮箱验证码示例)。传入 Idempotency-Key(≤ 256 字符,保留 24 小时),避免超时重试发出两封互相矛盾的邮件(发送邮件 API)。

模式 A:Embeddable Magic Link → Rovela(Consumer)

当你希望 Stytch 创建 token、由你的栈投递邮件时:

  1. 查找或创建 Stytch 用户(Search / Create User),拿到 user_id。
  2. 对应该 user_id 调用 POST /v1/magic_links(或 SDK magicLinks.create,可选 expiration_minutes)。
  3. 拼出 https://yourapp.com/authenticate?token=…&stytch_token_type=magic_links(与重定向及 Stytch 文档一致)。
  4. 用 Rovela 发送该 URL。
  5. 用户点击后,调用 Stytch Magic Link authenticate 并建立会话。
import * as stytch from "stytch";

const client = new stytch.Client({
  project_id: process.env.STYTCH_PROJECT_ID!,
  secret: process.env.STYTCH_SECRET!,
});

async function sendEmbeddableMagicLinkEmail(userId: string, email: string) {
  const created = await client.magicLinks.create({
    user_id: userId,
    // expiration_minutes: 60, // 可选;上下限以 Stytch 为准
  });

  const redirect = process.env.MAGIC_LINK_REDIRECT_URL!;
  const url =
    `${redirect}?token=${encodeURIComponent(created.token)}` +
    `&stytch_token_type=magic_links`;

  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: `stytch-eml-${userId}-${created.request_id || created.token.slice(0, 24)}`,
  });
}

把 token 当机密:不要记日志,不要塞进客户端分析。在 authenticate 页加 CAPTCHA 等防护——邮件安全扫描器可能预点链接并消耗一次性 token。若你需要 Stytch 中间页机器人防护与自动邮箱验证语义,优先用内置 Email Magic Links。

模式 B:应用自铸 OTP + Rovela(会话仍归 Stytch)

适用于数字验证码由你铸造的场景(产品敏感操作确认、你设计的登录路径)——不是调用了 Stytch otps/email/send 却指望自己发信。用户验证通过后,继续使用 Stytch 会话(或你已持有的经 Stytch 认证的会话)。

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: `stytch-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);
}

限制发送与校验次数。成功后按产品需要继续走 Stytch 认证会话或相应 API——核销与 Rovela 无关。

模式 C:Stytch 认证之后的欢迎 / 产品邀请

Stytch webhook 以及登录/注册后的自有 API,适合触发产品邮件(欢迎、「完成组织设置」),它们不是 Stytch 登录模板。校验 webhook 签名,按至少一次投递处理,并用稳定的 Rovela Idempotency-Key(如 stytch-welcome-{user_id})去重。

对于你自己撰写的 B2B 产品邀请:按 Stytch B2B 文档创建或更新组织/成员,再用 Rovela 发送你的深链。这与依赖 Stytch 自有邀请/登录消息不同。Embeddable Magic Links 仅限 Consumer——不要假定 B2B 有同一创建接口。

你会真正碰到的错误

Rovela 错误形态统一(快速开始):

  • 400 点名你的域名 —— 该 Rovela 组织未验证此 from
  • 400 recipient is suppressed: … —— 先前退信/投诉;请换地址
  • 401 —— 密钥无效或已吊销
  • 429 —— 遵守 Retry-After;重试时保持同一 Idempotency-Key

API 拒绝发送时,不要告诉用户「去查收件箱」。Stytch 侧的 create/authenticate 错误应与 Rovela 传输失败分开处理。

清单

  1. 在 Rovela 验证 mail.example.com(或你选择的域名);SPF/DKIM/DMARC 通过
  2. 创建 sending_access 密钥;只放在服务端环境变量,与 Stytch 密钥并列
  3. 按流程选择:Stytch 内置 Email OTP / Email Magic Links 或 Embeddable 创建 + Rovela 或 应用自铸 OTP + Rovela
  4. 若用 Embeddable:向 Stytch 开启、处理机器人预点、接受「邮箱未验证」语义
  5. fetch 同时带 html、text 与 Idempotency-Key
  6. 会话 / 组织 / 无密码 authenticate 留在 Stytch;Rovela 只做你自发送流程的邮件传输
  7. 用控制台消息页或 Rovela webhook 观察投递——不要把 200 当成已进箱

相关文档:发送邮件 · 鉴权 · 邮箱验证码 · 快速开始 · Stytch Embeddable Magic Links · 自定义邮件模板。