用 Lucia Auth + Rovela 发事务认证邮件(2026)
应用自铸 Lucia 验证链接、OTP 与魔术链接,经 Rovela POST /emails 与 Idempotency-Key 投递——Lucia 管会话/令牌,Rovela 负责投递;与 Better Auth、Auth.js、Clerk、Supabase、Firebase 指南区分。
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 能力。
与下列文章区分:
- Better Auth + Rovela — 框架钩子(
sendVerificationEmail、插件) - Auth.js / NextAuth + Rovela — Email provider +
sendVerificationRequest - Clerk / Supabase / Firebase Auth — 托管认证平台
- 魔术链接安全实践 — 威胁模型,不是 Lucia 接线指南
Lucia 做什么,Rovela 发什么
| 关注点 | 归属 |
|---|---|
| 会话创建 / 校验 / 失效 + Cookie | Lucia + 你的 DB 适配器 |
用户行、密码哈希、email_verified | 你的数据库 |
| 铸造验证 / 魔术链接 / OTP 挑战 | 你的应用(Lucia 辅助函数 + 表) |
| 认证邮件的 HTML/文本 | 你的发送 helper |
POST /emails、DKIM、队列、退信 | Rovela |
| 核销链接/OTP 并签发新会话 | 你的路由 + Lucia |
Rovela 返回 200 表示已接受并入队,并不等于「已经进收件箱」(快速开始)。
前置条件
- 在 Rovela 控制台完成发信域名验证。未验证的
from域名会以400拒绝(快速开始)。认证邮件建议使用独立子域,如mail.example.com(为何 OTP/魔术链接进垃圾箱)。 - API Key 形如
re_…。生产发送优先sending_access(鉴权)。 - 已按 Lucia v3 配置数据库适配器,以及产品已有的验证令牌/验证码表(形状遵循 Lucia 邮件指南)——除文档化部分外不做发明。
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 寿命——两套时钟彼此独立。
投递与运维清单
- 为
ROVELA_FROM所用子域配置 SPF/DKIM/DMARC(DKIM 设置、SaaS 的 DMARC)。 - 认证邮件使用独立子域,避免产品营销邮件拖累同一信誉(垃圾箱陷阱)。
- 生产发送优先
sending_access;仅在需要读取消息事件时用full_access(鉴权)。 - 表中的令牌/OTP TTL 与邮件文案承诺一致——Rovela 不会替你过期链接。
- 超时重试时复用由挑战派生的稳定
Idempotency-Key,避免用户收到两封互相矛盾的码或链接。 - 敏感邮件动作后,按 Lucia 验证指南在签发新会话前失效既有会话。
排障
| 现象 | 可能原因 |
|---|---|
发送时 Rovela 400 | from 域名未验证,或缺 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