← 返回博客

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

自定义 Better Auth 的 sendVerificationEmail、sendResetPassword、magicLink 与 emailOTP 钩子,经 Rovela POST /emails 与 Idempotency-Key 发送——Better Auth 管会话/令牌,Rovela 负责投递;与 Auth.js、Clerk、Supabase、Firebase 指南区分。

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

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

与下列文章区分:

Better Auth 做什么,Rovela 发什么

职责归属
认证配置、插件、数据库适配器Better Auth + 你的数据库
生成验证 / 魔法链接 / OTP 挑战Better Auth
传给钩子的 url 或 otpBetter Auth
认证邮件的 HTML/文本你的发送回调
POST /emails、DKIM、队列、退信Rovela
兑换链接/OTP 并建立会话Better Auth

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

前置条件

  1. 在 Rovela 控制台完成发信域名验证。未验证域名的 from 会被 400 拒绝(Quick Start)。认证邮件建议用独立子域,例如 mail.example.com(OTP/魔法链接进垃圾箱原因)。
  2. 形如 re_… 的 API 密钥。生产发信优先 sending_access(Authentication)。
  3. 已按官方文档配置好 Better Auth 服务端与数据库适配器。邮箱验证、重置密码、魔法链接、邮箱 OTP 都在 betterAuth({ ... }) 中声明——本文不虚构框架能力。
  4. 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 的幂等窗口防止的是相同请求重复投递,而不是用新验证码刷接口。

投递与运维清单

  1. 为 ROVELA_FROM 所用子域配置 SPF/DKIM/DMARC(DKIM 设置、SaaS 的 DMARC)。
  2. 认证邮件使用独立子域,避免产品营销信拖累同一声誉(垃圾箱问题)。
  3. 生产发信优先 sending_access;需要读取消息事件时再用 full_access(Authentication)。
  4. 让 Better Auth 的令牌/expiresIn(以及插件 OTP 有效期)与邮件文案承诺一致——Rovela 不会替你让链接过期。
  5. 超时重试时复用由挑战派生的稳定 Idempotency-Key,避免用户收到两封互相矛盾的验证码或链接。

排障

现象常见原因
发送时 Rovela 400from 域名未验证,或缺少 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 只是事务邮件投递层——不多也不少。

下一步:Quick Start · Send Email API · Better Auth Email