← 返回博客

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

应用自铸 Lucia 验证链接、OTP 与魔术链接,经 Rovela POST /emails 与 Idempotency-Key 投递——Lucia 管会话/令牌,Rovela 负责投递;与 Better Auth、Auth.js、Clerk、Supabase、Firebase 指南区分。

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

Lucia 管会话 —— Rovela 只负责投递

Lucia 是面向服务端的会话库:它通过数据库适配器创建与校验会话、签发会话 Cookie,并在敏感操作后失效会话。它不自带邮件传输、Email Provider,也没有托管身份控制台。验证令牌、魔术链接令牌和 OTP 由你的应用铸造(自有表 + 诸如 generateIdFromEntropySize 的辅助函数),再调用你自己的发送逻辑。

本文将该发送逻辑接到 Rovela 的扁平接口 POST https://api.rovela.dev/emails,并带上 Idempotency-Key,从而让 Lucia 继续拥有用户(在你的 schema 中)、会话与核销逻辑,而 Rovela 负责入队、DKIM 签名与投递(发送邮件 API、快速开始、鉴权)。

Rovela 不能替代 Lucia。 它不会创建用户、存储会话、哈希验证令牌、判定链接或 OTP 是否有效,也不会替你调用 createSession / validateSession。路由处理器拥有挑战;Rovela 只传输你已经写好的 HTML/文本。

范围说明(Lucia v3): Lucia v3 官方文档描述的是以会话为中心的模型(createSession、createSessionCookie、validateSession、invalidateUserSessions),并建议用应用自建表做链接/验证码邮件(Sessions、Email verification links、Email verification codes)。项目在 2025 年宣布弃用;本文面向仍在运行 Lucia 风格会话认证、需要生产级邮件通道的团队——并不主张 Lucia 适合每个新项目。除上述指南已文档化的高层 API 外,我们不编造 Lucia 能力。

与下列文章区分:

Lucia 做什么,Rovela 发什么

关注点归属
会话创建 / 校验 / 失效 + CookieLucia + 你的 DB 适配器
用户行、密码哈希、email_verified你的数据库
铸造验证 / 魔术链接 / OTP 挑战你的应用(Lucia 辅助函数 + 表)
认证邮件的 HTML/文本你的发送 helper
POST /emails、DKIM、队列、退信Rovela
核销链接/OTP 并签发新会话你的路由 + Lucia

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

前置条件

  1. 在 Rovela 控制台完成发信域名验证。未验证的 from 域名会以 400 拒绝(快速开始)。认证邮件建议使用独立子域,如 mail.example.com(为何 OTP/魔术链接进垃圾箱)。
  2. API Key 形如 re_…。生产发送优先 sending_access(鉴权)。
  3. 已按 Lucia v3 配置数据库适配器,以及产品已有的验证令牌/验证码表(形状遵循 Lucia 邮件指南)——除文档化部分外不做发明。
  4. ROVELA_API_KEY、会话密钥与数据库凭据只放在服务端,切勿打进客户端包。

环境变量

export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <auth@mail.example.com>'
# Lucia 适配器所需的数据库 URL / 密钥按现有应用配置

轮换 Rovela 密钥:先创建新密钥并部署,再吊销旧密钥(鉴权)。

共用 Rovela 发送 helper

集中 fetch,让注册验证、魔术链接、OTP 等路由共用同一套请求头与幂等规则。不要让 Rovela 发明令牌——只传递应用已铸造的挑战。

// 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 Key —— 只记状态码与提供商 message
    console.error("rovela_send_failed", res.status, data.message);
    throw new Error(`Rovela send failed: ${res.status}`);
  }
}

与 Rovela 文档对齐的要点:

  • 尽量同时发送 html 与 text(发送邮件 API)。
  • Idempotency-Key(≤ 256 字符):24 小时内重试返回原先的接受结果,而不会再发一封(Idempotency)。优先用令牌 id 或 OTP 挑战 id 派生,不要每次随机 UUID。
  • from 必须属于已验证域名,否则 400。

邮箱验证链接 → Rovela

Lucia 指南把短时令牌存在自有表中,拼出 /email-verification/<TOKEN> 一类 URL,并把 sendVerificationEmail 留给你实现(Email verification links)。用户注册后通常先用 Lucia 建会话,再发链接:

import { generateIdFromEntropySize } from "lucia";
import { TimeSpan, createDate } from "oslo";
import { lucia } from "./auth"; // 你的 Lucia 实例
import { rovelaSendAuthEmail } from "./lib/rovelaSendAuthEmail";

async function createEmailVerificationToken(
  userId: string,
  email: string,
): Promise<string> {
  // 可选:在 DB 中作废该用户已有令牌
  const tokenId = generateIdFromEntropySize(25);
  await db.table("email_verification_token").insert({
    id: tokenId,
    user_id: userId,
    email,
    expires_at: createDate(new TimeSpan(2, "h")),
  });
  return tokenId;
}

// 在插入用户行之后的注册处理中:
const verificationToken = await createEmailVerificationToken(userId, email);
const verificationLink =
  `${process.env.APP_ORIGIN}/email-verification/${verificationToken}`;

await rovelaSendAuthEmail({
  to: email,
  subject: "Verify your email address",
  html:
    `<p>Verify your email for Acme</p>` +
    `<p><a href="${verificationLink}">Verify email</a></p>` +
    `<p>If you did not create an account, you can ignore this message.</p>`,
  text: `Verify your email for Acme\n${verificationLink}\n`,
  idempotencyKey: `lucia-verify-${verificationToken}`,
});

const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
// 按你的框架设置 Set-Cookie 并重定向

核销时遵循 Lucia 文档流程:校验过期与邮箱匹配、标记 email_verified、调用 lucia.invalidateUserSessions(userId),再 lucia.createSession(Email verification links)。Rovela 不参与该路径。

按 Lucia 指南按用户 id 与 IP 限制重发——幂等只防止相同内容重复投递,防不了不断铸造新令牌的洪水。

邮箱验证 OTP → Rovela

需要更适合手机输入的体验时用验证码:把短码与 user_id + email 绑定并设短 TTL,再把数字发到邮箱(Email verification codes):

import { alphabet, generateRandomString } from "oslo/crypto";
import { TimeSpan, createDate } from "oslo";
import { rovelaSendAuthEmail } from "./lib/rovelaSendAuthEmail";

async function generateEmailVerificationCode(
  userId: string,
  email: string,
): Promise<string> {
  await db.table("email_verification_code").where("user_id", "=", userId).deleteAll();
  const code = generateRandomString(8, alphabet("0-9"));
  await db.table("email_verification_code").insert({
    code,
    user_id: userId,
    email,
    expires_at: createDate(new TimeSpan(15, "m")),
  });
  return code;
}

const code = await generateEmailVerificationCode(userId, email);

await rovelaSendAuthEmail({
  to: email,
  subject: `Your Acme verification code: ${code}`,
  html:
    `<p>Your verification code is</p>` +
    `<p style="font-size:28px;letter-spacing:6px"><b>${code}</b></p>` +
    `<p>It expires in 15 minutes. If you did not request this, ignore this message.</p>`,
  text: `Your verification code is ${code}. It expires in 15 minutes.`,
  idempotencyKey: `lucia-otp-verify-${userId}-${code}`,
});

用户提交后对照表校验(尽量常量时间比较)、限制暴力尝试、失效会话、设置 email_verified 并创建新的 Lucia 会话——与 Lucia 指南一致。尽量不要把明文 OTP 写进 Rovela tags 或大范围可读日志。

此处 generateRandomString / oslo 用法对齐 Lucia 已发布的代码风格指南;字母表与 TTL 按产品策略调整。Rovela 不会过期或校验该码。

无密码魔术链接(应用铸造)→ Rovela

Lucia 不需要单独的「魔术链接插件」。同一套令牌表模式可用于无密码登录:铸造一次性登录令牌,邮件发送绝对 URL,点击后通过校验再创建 Lucia 会话。认证链接请关闭点击追踪(魔术链接安全)。

import { generateIdFromEntropySize } from "lucia";
import { TimeSpan, createDate } from "oslo";
import { rovelaSendAuthEmail } from "./lib/rovelaSendAuthEmail";

async function createMagicLinkToken(email: string): Promise<string> {
  const tokenId = generateIdFromEntropySize(25);
  await db.table("magic_link_token").insert({
    id: tokenId,
    email,
    expires_at: createDate(new TimeSpan(10, "m")),
  });
  return tokenId;
}

const token = await createMagicLinkToken(email);
const url = `${process.env.APP_ORIGIN}/magic-link/${token}`;

await rovelaSendAuthEmail({
  to: email,
  subject: "Your sign-in link",
  html:
    `<p>Sign in to Acme</p>` +
    `<p><a href="${url}">Sign in</a></p>` +
    `<p>If you did not request this, you can ignore this message.</p>`,
  text: `Sign in to Acme\n${url}\n`,
  idempotencyKey: `lucia-magic-${token}`,
});

核销处理(高层):加载令牌 → 检查过期 → 解析或创建应用已有的用户行 → 若登录时轮换会话则 lucia.invalidateUserSessions → lucia.createSession → lucia.createSessionCookie。Rovela 永远看不到会话 id。

Lucia 会话模型(高层)

给不熟悉 Lucia 形状的读者(Sessions):

  • createSession(userId, attributes) — 插入会话行;返回含 id 的会话。
  • createSessionCookie(sessionId) — 构造你要写到响应里的 Cookie。
  • validateSession(sessionId) — 返回 { session, user },无效时为 null;临近过期延长时可能标记 fresh。
  • invalidateUserSessions(userId) — 在验证或改密后清理会话。

Lucia 文档默认的会话寿命是滑动延长(用到半程附近会续期)。邮件文案里的「10 分钟过期」应对齐你的令牌/OTP TTL,而不是 Lucia 会话 Cookie 寿命——两套时钟彼此独立。

投递与运维清单

  1. 为 ROVELA_FROM 所用子域配置 SPF/DKIM/DMARC(DKIM 设置、SaaS 的 DMARC)。
  2. 认证邮件使用独立子域,避免产品营销邮件拖累同一信誉(垃圾箱陷阱)。
  3. 生产发送优先 sending_access;仅在需要读取消息事件时用 full_access(鉴权)。
  4. 表中的令牌/OTP TTL 与邮件文案承诺一致——Rovela 不会替你过期链接。
  5. 超时重试时复用由挑战派生的稳定 Idempotency-Key,避免用户收到两封互相矛盾的码或链接。
  6. 敏感邮件动作后,按 Lucia 验证指南在签发新会话前失效既有会话。

排障

现象可能原因
发送时 Rovela 400from 域名未验证,或缺 html/text/template
Rovela 401缺少或无效的 Bearer Key(鉴权)
发送返回 200 但收不到已入队但延迟/被过滤——检查域名认证与垃圾箱;必要时用 full_access 查事件
重试出现重复邮件缺少或不稳定的 Idempotency-Key
链接有效但用户仍「未验证」核销处理未更新 email_verified / 未失效并重建会话
核销后缺少会话 Cookie忘记 createSessionCookie / 在验证响应上 Set-Cookie

小结

在 Lucia 应用中铸造验证链接、OTP 与魔术链接令牌,再对 Rovela POST /emails 发起 fetch,并设置稳定的 Idempotency-Key。Lucia 仍是会话与 Cookie 的事实来源;挑战归你的表;Rovela 只是事务投递层——仅此而已。

下一步:快速开始 · 发送邮件 API · Lucia sessions · Lucia email verification codes