用 Firebase Auth + Rovela 发自定义认证邮件(2026)
在 Cloud Functions 里用 Rovela POST /emails 发自定义 OTP、验证与欢迎信——Firebase 管 Auth,Rovela 负责投递;不替代 Firebase Auth;与 Clerk、Supabase、Auth.js 指南区分。
Firebase Auth 负责身份 —— Rovela 负责投递
Firebase Authentication 提供邮箱密码、社交登录、手机号、邮件操作链接,以及用 Admin SDK 生成验证、重置密码、邮箱链接登录 URL 的能力。很多团队先用 Firebase 内置模板邮件(由 Google 发送),随后需要自定义事务邮件——品牌化验证 HTML、双语欢迎信、自己签发的 OTP,或独立的 mail. 子域与信誉。
本指南保持 Firebase Auth 做身份,用 Cloud Functions 或可信后端 调用 Rovela HTTP API,发送应用自有的事务邮件:Admin SDK 生成的验证链接、用户创建时的欢迎信、产品侧 OTP / 二次确认码。Firebase 管用户与会话;你的函数管模板与限流;Rovela 负责投递。
Rovela 不能替代 Firebase Auth。 它不创建用户、不签发 ID Token、不核销 oobCode、不校验 OTP。Cloud Function(或 API)自管令牌、哈希与核销逻辑;Rovela 只负责排队、DKIM 签名与投递:POST https://api.rovela.dev/emails(发送邮件 API、快速开始)。
本文不是 Clerk + Rovela,不是 Supabase + Rovela,也不是 Auth.js / NextAuth + Rovela。集成面是 Firebase Admin SDK + Cloud Functions secrets(或已使用 firebase-admin 的 Node 服务)。
何时继续用 Firebase 邮件,何时改走 Rovela
| 需求 | 常见路径 |
|---|---|
| Firebase 自带验证 / 重置 / 邮箱链接模板 | 先用 Auth 内置邮件即可 |
| 自定义 HTML、品牌版式、主题行带验证码、双语文案 | 应用自管发送 → Rovela |
在 mail.yourdomain.com 上隔离信誉 | 在 Rovela 验证域名 + 你的 from |
| 欢迎 / 回执 / 邀请等非 Auth 模板 | 一律应用自管 → Rovela |
| 仍要 Firebase 会话与客户端 SDK | 身份继续用 Auth;只把自定义流程的投递通道换成 Rovela |
常见做法:用 Admin SDK 的邮件操作链接 API(generateEmailVerificationLink、generatePasswordResetLink、generateSignInWithEmailLink)让 Firebase 继续管理 oobCode 生命周期,再把链接嵌进你用 Rovela 发出的邮件。若是产品侧 OTP(非 Auth 模板),则自行签发、存哈希,并在后端核销。
你负责什么,Rovela 负责什么
你的 Firebase 项目(及 Cloud Functions / 后端)负责:
- 用户、ID Token 与 Auth 提供方(Firebase Auth)
- 用 Admin SDK 生成操作链接(或用 CSPRNG 自行签发 OTP)
- 为应用自有验证码存储 HMAC 哈希 / 过期 / 尝试次数(Firestore 或你的库——永不存明文)
- 限流(例如「每地址每分钟一封」)
- 核销逻辑(自定义操作页上的
applyActionCode,或自有 OTP 校验) - 每次逻辑发送稳定的
Idempotency-Key - 密钥:绝不把 Rovela key 放进客户端 SDK
Rovela 负责:
- 带
Authorization: Bearer与sending_access密钥的POST /emails - 排队、签名、重试、退信 / 投诉处理
200 表示已接受并入队,不是「已进收件箱」(快速开始)。
前置条件
- 在 Rovela 控制台验证发信域名。未验证的
from域名会以400拒绝(快速开始)。认证类邮件建议用mail.example.com这类子域(OTP / 魔法链接进垃圾箱)。 - 形如
re_…的 API 密钥。生产发信优先sending_access(鉴权)。 - 已启用 Authentication 的 Firebase 项目,并在 Cloud Functions(或其他可信 Node 服务)中可用 Admin SDK。
- 切勿把 Rovela 密钥放进 Firebase 客户端 SDK、React Native / Flutter 或公开仓库。只有 Cloud Functions(或可信服务端)可调用 Rovela。
在 Cloud Functions 中存放密钥
使用 Cloud Functions 参数化配置 / Secret Manager(或宿主的密钥库)——不要硬编码,也不要放进会打进浏览器的环境变量:
# Firebase CLI + Secret Manager 示例(名称可按约定)
firebase functions:secrets:set ROVELA_API_KEY
# 提示时粘贴 re_your_sending_access_key
firebase functions:secrets:set ROVELA_FROM
# 粘贴:Acme <security@mail.example.com>
firebase functions:secrets:set CODE_PEPPER
# 粘贴用于应用 OTP 哈希的长随机 pepper
在代码里把 secret 绑定到函数(具体语法随 Functions 版本而定)。轮换:更新 secret 并重新部署;完成后在控制台吊销旧 Rovela 密钥(鉴权)。
模式 A:自定义验证邮件(Admin 链接 + Rovela)
客户端 sendEmailVerification() 走 Google 模板。若要品牌化 HTML,在服务端用 Admin SDK 生成链接,再 fetch Rovela。用户必须已存在(Admin 链接 API 要求已有 Auth 用户)。
Callable / HTTPS 示意(functions/src/sendVerification.ts,Node 18+ 风格示意):
import { getAuth } from "firebase-admin/auth";
import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
const ROVELA_URL = "https://api.rovela.dev/emails";
const rovelaKey = defineSecret("ROVELA_API_KEY");
const rovelaFrom = defineSecret("ROVELA_FROM");
export const sendCustomVerificationEmail = onCall(
{ secrets: [rovelaKey, rovelaFrom] },
async (request) => {
if (!request.auth?.uid) {
throw new HttpsError("unauthenticated", "sign_in_required");
}
const auth = getAuth();
const user = await auth.getUser(request.auth.uid);
if (!user.email) {
throw new HttpsError("failed-precondition", "no_email");
}
if (user.emailVerified) {
return { ok: true, alreadyVerified: true };
}
// Firebase 管 oobCode;你管 HTML 外壳
const link = await auth.generateEmailVerificationLink(user.email, {
url: "https://acme.example/verified",
handleCodeInApp: false,
});
const sendId = (request.data?.sendId as string) || crypto.randomUUID();
const apiKey = rovelaKey.value();
const from = rovelaFrom.value();
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": `firebase-verify-${user.uid}-${sendId}`.slice(0, 256),
},
body: JSON.stringify({
from,
to: user.email,
subject: "验证你的 Acme 邮箱",
html:
`<p>你好${user.displayName ? ` ${escapeHtml(user.displayName)}` : ""},</p>` +
`<p>请确认用于 Acme 的邮箱:</p>` +
`<p><a href="${link}">验证邮箱</a></p>` +
`<p>若非本人操作,请忽略本邮件。</p>`,
text:
`请确认用于 Acme 的邮箱:${link}\n` +
`若非本人操作,请忽略本邮件。`,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// 不要记录 API 密钥,只打状态与提供方 message
console.error("rovela_send_failed", res.status, (data as { message?: string }).message);
throw new HttpsError("internal", "send_failed");
}
return { ok: true, emailId: (data as { id?: string }).id };
},
);
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
若还要品牌化验证落地页,可配置自定义邮件操作处理页——oobCode 仍由 Firebase 管理。相关文档:邮箱验证码示例 · 发送邮件。
模式 B:auth.user().onCreate 发欢迎信
用户创建后(邮箱密码、Admin 创建,或部分提供方流程)发送非 Auth 模板的欢迎信。若发送可能耗时,优先用异步 Auth 触发器,而不是阻塞型 beforeUserCreated——阻塞触发器必须很快返回,否则客户端操作会失败。
import { onUserCreated } from "firebase-functions/v2/identity"; // 或 v1 的 auth.user().onCreate
import { defineSecret } from "firebase-functions/params";
const ROVELA_URL = "https://api.rovela.dev/emails";
const rovelaKey = defineSecret("ROVELA_API_KEY");
const rovelaFrom = defineSecret("ROVELA_FROM");
export const welcomeOnCreate = onUserCreated(
{ secrets: [rovelaKey, rovelaFrom] },
async (event) => {
const email = event.data.email;
const uid = event.data.uid;
const name = event.data.displayName || "there";
if (!email) return;
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${rovelaKey.value()}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": `firebase-welcome-${uid}`.slice(0, 256),
},
body: JSON.stringify({
from: rovelaFrom.value(),
to: email,
subject: "欢迎加入 Acme",
html:
`<p>你好 ${escapeHtml(name)},你的 Acme 账户已就绪。</p>` +
`<p><a href="https://acme.example/dashboard">打开控制台</a></p>`,
text: `你好 ${name},你的 Acme 账户已就绪。打开 https://acme.example/dashboard`,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.error("rovela_send_failed", res.status, (data as { message?: string }).message);
throw new Error("rovela_send_failed");
}
},
);
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
触发器导入名在 Functions v1(functions.auth.user().onCreate)与 v2 identity 事件之间不同——以你部署的版本文档为准。模板化欢迎信见用模板发欢迎邮件。
模式 C:应用自有 OTP(Firebase 会话,你的验证码)
需要产品侧 OTP(二次确认、改邮箱、敏感操作)且要自有 HTML,而用户已有 Firebase ID Token 时:在服务端签发验证码,只存哈希,用 Rovela 投递。会话仍由 Firebase 管;挑战由你的函数管。
import { createHash, randomInt } from "node:crypto";
import { getAuth } from "firebase-admin/auth";
import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
const ROVELA_URL = "https://api.rovela.dev/emails";
const rovelaKey = defineSecret("ROVELA_API_KEY");
const rovelaFrom = defineSecret("ROVELA_FROM");
const codePepper = defineSecret("CODE_PEPPER");
export const sendStepUpOtp = onCall(
{ secrets: [rovelaKey, rovelaFrom, codePepper] },
async (request) => {
if (!request.auth?.uid) {
throw new HttpsError("unauthenticated", "sign_in_required");
}
const user = await getAuth().getUser(request.auth.uid);
const email = user.email;
if (!email) throw new HttpsError("failed-precondition", "no_email");
const sendId = (request.data?.sendId as string) || crypto.randomUUID();
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
const pepper = codePepper.value();
const codeHash = createHash("sha256")
.update(`${pepper}:${email}:${code}`)
.digest("hex");
// 在 Firestore / 自有库写入哈希与过期(略)。永不存明文验证码。
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${rovelaKey.value()}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": `firebase-otp-${user.uid}-${sendId}`.slice(0, 256),
},
body: JSON.stringify({
from: rovelaFrom.value(),
to: email,
subject: `你的 Acme 验证码:${code}`,
html:
`<p>你的验证码是</p>` +
`<p style="font-size:28px;font-weight:600;letter-spacing:4px"><b>${code}</b></p>` +
`<p>10 分钟内有效。若非本人操作,请忽略。</p>`,
text: `你的验证码是 ${code}。10 分钟内有效。若非本人操作,请忽略。`,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new HttpsError("internal", "send_failed");
}
return { ok: true, emailId: (data as { id?: string }).id };
},
);
验证码 vs 链接:邮箱验证:验证码还是链接。链接安全:魔法链接安全实践。
模式 D:重置密码 / 邮箱链接登录(同一思路)
与验证相同:
- Admin SDK:
generatePasswordResetLink或generateSignInWithEmailLink - 把返回 URL 嵌进你控制的 HTML/text
POST /emails,并用如firebase-reset-{uidOrEmailHash}-{sendId}的Idempotency-Key
不要在客户端用 Rovela 密钥发重置信。收件人邮箱应来自 Admin getUser / 鉴权后的可信输入,并对未鉴权入口做好限流。
Function 重试与幂等
Cloud Functions、队列与客户端重试都可能重复发送。没有 Idempotency-Key 时,超时可能导致两封欢迎信或两枚互相矛盾的 OTP。
按发送邮件 API:
- Header ≤ 256 字符
- 24 小时内重放返回原始响应
- 从稳定值派生(
firebase-welcome-{uid}、firebase-otp-{uid}-{sendId}) - 故意重发且签发新码时必须使用新 key
你会真正遇到的错误
形状统一(快速开始):
400点名域名 — 本 Rovela 组织未验证该from400recipient is suppressed: …— 曾退信/投诉;请换地址401— 密钥无效或已吊销429— 遵守Retry-After;重试时保持同一Idempotency-Key
API 已拒绝发送时,不要对用户说「请查收件箱」。在 Auth 触发器上要明确:Rovela 失败是抛错重试,还是写入本地队列后记日志。
清单
- 在 Rovela 验证
mail.example.com(或所选域名);SPF/DKIM/DMARC 通过 - 创建
sending_access密钥;只放进 Cloud Functions secrets(或服务端环境)——永不进客户端 SDK - Auth 操作邮件:Admin
generate*Link+ 经 Rovela 的自定义 HTML;可选自定义操作页 - 欢迎信:
onCreate/ identity 触发器 +Idempotency-Keyfirebase-welcome-{uid} - 产品 OTP:哈希 + 过期 + 尝试次数表;用 Admin SDK 解析邮箱
fetch同时带html与text+Idempotency-Key- 用控制台消息页或 Rovela webhook 观察投递——不要把
200当成已进箱
相关文档:发送邮件 · 鉴权 · 邮箱验证码 · 快速开始。
- support@rovela.dev