用 Better Auth + Rovela 发事务认证邮件(2026)
自定义 Better Auth 的 sendVerificationEmail、sendResetPassword、magicLink 与 emailOTP 钩子,经 Rovela POST /emails 与 Idempotency-Key 发送——Better Auth 管会话/令牌,Rovela 负责投递;与 Auth.js、Clerk、Supabase、Firebase 指南区分。
Better Auth 管会话 —— Rovela 只负责投递
Better Auth 是一套 TypeScript 认证框架:由它签发会话、校验凭据,并生成验证链接、魔法链接与邮箱 OTP 背后的令牌。它不自带邮件传输层。你需要通过诸如 emailVerification.sendVerificationEmail、emailAndPassword.sendResetPassword、magicLink 插件的 sendMagicLink,以及 emailOTP 插件的 sendVerificationOTP 等钩子自带发信实现(见 Better Auth Email)。
本文将这些钩子接到 Rovela 扁平的 POST https://api.rovela.dev/emails,并带上 Idempotency-Key:Better Auth 继续拥有用户、会话与令牌生命周期,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。
Rovela 不会替代 Better Auth。 它不会创建用户、存储会话、哈希验证令牌,也不会判定点击链接或输入 OTP 是否有效。你的发送回调只是把 Better Auth 已经生成的 url 或 otp 送到收件箱。
与下列文章区分:
- Auth.js / NextAuth + Rovela — Auth.js Email 提供方 +
sendVerificationRequest - Clerk + Rovela / Supabase + Rovela / Firebase Auth + Rovela — 托管认证平台
- 魔法链接安全实践 — 威胁模型,不是 Better Auth 接线指南
Better Auth 做什么,Rovela 发什么
| 职责 | 归属 |
|---|---|
| 认证配置、插件、数据库适配器 | Better Auth + 你的数据库 |
| 生成验证 / 魔法链接 / OTP 挑战 | Better Auth |
传给钩子的 url 或 otp | Better Auth |
| 认证邮件的 HTML/文本 | 你的发送回调 |
POST /emails、DKIM、队列、退信 | Rovela |
| 兑换链接/OTP 并建立会话 | Better Auth |
Rovela 返回 200 表示已接受并入队,并不等于「已经进收件箱」(Quick Start)。
前置条件
- 在 Rovela 控制台完成发信域名验证。未验证域名的
from会被400拒绝(Quick Start)。认证邮件建议用独立子域,例如mail.example.com(OTP/魔法链接进垃圾箱原因)。 - 形如
re_…的 API 密钥。生产发信优先sending_access(Authentication)。 - 已按官方文档配置好 Better Auth 服务端与数据库适配器。邮箱验证、重置密码、魔法链接、邮箱 OTP 都在
betterAuth({ ... })中声明——本文不虚构框架能力。 ROVELA_API_KEY、Better Auth 密钥与数据库凭据只放在服务端,绝不打进客户端包。
环境变量
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <auth@mail.example.com>'
# Better Auth secret 与适配器所需的数据库 URL
轮换 Rovela 密钥时:先创建新密钥、完成部署,再吊销旧密钥(Authentication)。
共用 Rovela 发信助手
把 fetch 集中起来,让每个 Better Auth 钩子共用同一套请求头、错误处理与幂等规则。不要让 Rovela 发明令牌——透传 Better Auth 已经给你的值。
// 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 密钥 —— 只记状态与提供商 message
console.error("rovela_send_failed", res.status, data.message);
throw new Error(`Rovela send failed: ${res.status}`);
}
}
与 Rovela 文档对齐的要点:
- 尽量同时发送
html与text(Send Email API)。 Idempotency-Key(≤ 256 字符):24 小时内相同请求重试会返回原先的受理结果,而不会再发一封(Send Email API — Idempotency)。优先用 Better Auth 的token/otp(或其他按挑战稳定的 id)派生,不要每次生成随机 UUID。from必须落在已验证域名上,否则 API 返回400。
Better Auth 文档对部分流程建议不要为了发信阻塞认证响应(防时序攻击)。在 serverless 上,可将 void 发信与 waitUntil(或等价机制)搭配,避免平台在 fetch 完成前冻结 isolate——见 Better Auth Email。当 Rovela 返回非 2xx 时,在回调里抛错仍有助于让硬性传输失败出现在日志中。
邮箱验证 → Rovela
配置 emailVerification.sendVerificationEmail。Better Auth 会传入 { user, url, token }(Better Auth Email):
import { betterAuth } from "better-auth";
import { rovelaSendAuthEmail } from "./lib/rovelaSendAuthEmail";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
// 按产品需要配置 requireEmailVerification / sendResetPassword
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url, token }, _request) => {
const html =
`<p>验证你的 Acme 邮箱</p>` +
`<p><a href="${url}">验证邮箱</a></p>` +
`<p>若非你本人操作,可忽略本邮件。</p>`;
const text = `验证你的 Acme 邮箱\n${url}\n`;
void rovelaSendAuthEmail({
to: user.email,
subject: "验证你的邮箱地址",
html,
text,
idempotencyKey: `better-auth-verify-${token}`,
});
},
},
});
Better Auth 还文档化了 sendOnSignIn、autoSignInAfterVerification、expiresIn 等选项——在 Better Auth 侧配置即可;Rovela 不会解读它们。
重置密码 → Rovela
重置密码使用 emailAndPassword.sendResetPassword,参数同样是 { user, url, token }(Better Auth Email):
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url, token }, _request) => {
void rovelaSendAuthEmail({
to: user.email,
subject: "重置你的密码",
html:
`<p>重置 Acme 密码</p>` +
`<p><a href="${url}">重置密码</a></p>` +
`<p>若非你本人请求,可忽略本邮件。</p>`,
text: `重置 Acme 密码\n${url}\n`,
idempotencyKey: `better-auth-reset-${token}`,
});
},
},
魔法链接插件 → Rovela
加入服务端 magicLink 插件,并实现 sendMagicLink({ email, token, url, metadata })(Magic link 插件):
import { magicLink } from "better-auth/plugins";
plugins: [
magicLink({
sendMagicLink: async ({ email, token, url }, _ctx) => {
void rovelaSendAuthEmail({
to: email,
subject: "你的登录链接",
html:
`<p>登录 Acme</p>` +
`<p><a href="${url}">登录</a></p>` +
`<p>若非你本人请求,可忽略本邮件。</p>`,
text: `登录 Acme\n${url}\n`,
idempotencyKey: `better-auth-magic-${token}`,
});
},
}),
],
客户端在注册 magicLinkClient() 后调用 authClient.signIn.magicLink({ email, callbackURL })。Better Auth 生成 url 与 token;Rovela 只投递。用户点击后由 Better Auth 校验挑战并建立会话——该路径不经过 Rovela。
邮箱 OTP 插件 → Rovela
emailOTP 插件会调用 sendVerificationOTP({ email, otp, type }),其中 type 为 "sign-in" | "email-verification" | "forget-password"(Email OTP 插件):
import { emailOTP } from "better-auth/plugins";
plugins: [
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
const labels: Record<string, string> = {
"sign-in": "登录",
"email-verification": "邮箱验证",
"forget-password": "重置密码",
};
const label = labels[type] ?? "验证";
void rovelaSendAuthEmail({
to: email,
subject: `你的 Acme ${label} 验证码:${otp}`,
html:
`<p>你的 ${label} 验证码是</p>` +
`<p style="font-size:28px;letter-spacing:6px"><b>${otp}</b></p>` +
`<p>若非你本人请求,可忽略本邮件。</p>`,
text: `你的 ${label} 验证码是 ${otp}。`,
idempotencyKey: `better-auth-otp-${type}-${email}-${otp}`,
});
},
}),
],
尽量不要把明文 OTP 写进 Rovela tags 或可读面很广的日志;邮件正文已经携带一次性值。在应用层限制同一地址请求新 OTP 的频率——Rovela 的幂等窗口防止的是相同请求重复投递,而不是用新验证码刷接口。
投递与运维清单
- 为
ROVELA_FROM所用子域配置 SPF/DKIM/DMARC(DKIM 设置、SaaS 的 DMARC)。 - 认证邮件使用独立子域,避免产品营销信拖累同一声誉(垃圾箱问题)。
- 生产发信优先
sending_access;需要读取消息事件时再用full_access(Authentication)。 - 让 Better Auth 的令牌/
expiresIn(以及插件 OTP 有效期)与邮件文案承诺一致——Rovela 不会替你让链接过期。 - 超时重试时复用由挑战派生的稳定
Idempotency-Key,避免用户收到两封互相矛盾的验证码或链接。
排障
| 现象 | 常见原因 |
|---|---|
发送时 Rovela 400 | from 域名未验证,或缺少 html/text/template |
Rovela 401 | 缺少或无效的 Bearer 密钥(Authentication) |
| 发送返回 200 但收不到信 | 已入队但延迟/过滤——检查域名认证与垃圾箱;必要时用 full_access 查看事件 |
| 重试出现重复邮件 | 缺少或不稳定的 Idempotency-Key |
| 钩子从未触发 | 未在 betterAuth({ ... }) 中注册插件或 emailVerification / sendResetPassword |
小结
把 Better Auth 的验证、重置、魔法链接与邮箱 OTP 发送钩子接到共用助手,由其 fetch Rovela POST /emails,设置稳定的 Idempotency-Key,并把密钥留在服务端。Better Auth 仍是令牌与会话的真实来源;Rovela 只是事务邮件投递层——不多也不少。