用 AWS Cognito + Rovela 发自定义认证邮件(2026)
用 Cognito Custom Email Sender Lambda,经 Rovela POST /emails 与 Idempotency-Key 投递验证、重置与邀请邮件——Cognito 管认证,Rovela 负责投递;不替代 Cognito/SES;与 Firebase、Clerk、Supabase 指南区分。
Cognito 管认证 —— Rovela 投递 Cognito 触发的邮件
Amazon Cognito 用户池负责注册、登录、忘记密码、MFA/邮箱 OTP、管理员邀请与属性验证。默认情况下,Cognito 通过内置邮件通道投递这些消息(配置后通常走 Amazon SES)。很多团队希望继续用 Cognito 做身份,但要品牌化 HTML、独立的 mail. 子域声誉、双语文案,或统一的 HTTP 发送 API。
本文把 Cognito 的 Custom email sender(自定义邮件发送) Lambda(并说明何时用 Custom message 就够)接到 Rovela 的 POST https://api.rovela.dev/emails,并带上 Idempotency-Key,用于验证码、重置密码码、重发确认码与管理员邀请/临时密码邮件。Cognito 仍负责建用户、校验码与认证生命周期;你的 Lambda 只负责解密 Cognito 提供的密文并投递。
Rovela 不替代 Cognito。 它不创建用户池、不签发令牌、不确认注册、不核销忘记密码码。它也不是「全面替代 SES」——通用对比见 Amazon SES alternative: simple HTTP。这里 Rovela 只承担 Cognito 触发邮件的自定义事务投递路径。
这与 Firebase Auth 自定义邮件、Clerk、Supabase 指南都不同:集成面是 Cognito 用户池 Lambda 触发器 + AWS KMS 解密 + 可信端调用 Rovela。
Custom message 与 Custom email sender
| 触发器 | Cognito 做什么 | 何时用 |
|---|---|---|
| Custom message | 自定义主题/正文占位符;邮件仍由 Cognito 自己发送 | 轻度换皮,继续用 Cognito 默认投递 |
| Custom email sender | Cognito 调用你的 Lambda 而不发送;你必须投递全部邮件 | 要用 Rovela(或任意第三方 HTTP API)做传输 |
接 Rovela 请用 Custom email sender。仅 Custom message 不会调用 POST /emails。
自定义邮件发送常见的 triggerSource(完整表见 AWS 文档):
CustomEmailSender_SignUp— 注册后确认/欢迎CustomEmailSender_ResendCode— 重发确认码CustomEmailSender_ForgotPassword— 重置密码码CustomEmailSender_VerifyUserAttribute/CustomEmailSender_UpdateUserAttribute— 属性验证CustomEmailSender_Authentication— 邮箱 OTP / MFA 类邮件码CustomEmailSender_AdminCreateUser— 邀请 / 临时密码CustomEmailSender_AccountTakeOverNotification— 账户接管通知
你负责什么,Rovela 发送什么
你的 AWS 账户(用户池 + Lambda)负责:
- Cognito 用户池、客户端与认证流程
- 挂载 Custom email sender Lambda 与 客户托管 KMS 密钥(Cognito 会加密触发器中的码/临时密码)
- 用 AWS Encryption SDK +
kms:Decrypt解密event.request.code - HTML/文本模板、语言与围绕 Cognito API 的限流/滥用策略
- 每次逻辑发送稳定的
Idempotency-Key(Lambda 重试很常见) - 密钥:Rovela API key 与
from放在 Lambda 环境变量 / Secrets Manager——绝不进浏览器或移动端
Rovela 负责:
- 带
Authorization: Bearer与sending_access密钥的POST /emails(Authentication) - 排队、DKIM 签名、重试、退信/投诉处理
200 表示已接受并入队,不是「已经进收件箱」(Quick Start)。
前置条件
- 在 Rovela 控制台验证发信域名。未验证的
from会以400拒绝(Quick Start)。认证邮件优先用mail.example.com(OTP/魔术链接进垃圾箱)。 - 形如
re_…的 API 密钥。生产发送优先sending_access。 - Cognito 用户池,以及与自定义发送器配置在同一区域的 Lambda。
- Cognito 用于加密触发器密钥的 KMS 客户托管密钥;Lambda 角色需解密权限(按 AWS 自定义发送器文档配置)。
- 切勿把 Rovela 密钥放进 Amplify 客户端配置、React Native / Flutter 包或公开仓库。
配置 Custom email sender(高层)
精确的 UpdateUserPool / LambdaConfig 字段(CustomEmailSender、KMSKeyID)请遵循 AWS 的 custom email sender 与 custom sender triggers。配置偏 API/CLI/CDK,不要臆造控制台开关。
示意(CDK 意图——按你的 IaC 改写):
// 示意:在用户池 LambdaConfig 上挂 CustomEmailSender + KMSKeyID。
// 使用当前 AWS Cognito API 形态,而非可直接粘贴的完整片段。
userPool.addTrigger(
/* CustomEmailSender */ "CustomEmailSender",
customEmailSenderFn,
);
// 同时按 AWS 文档为 CMK 授予 Cognito 加密与 Lambda 解密权限。
函数密钥示例:
aws lambda update-function-configuration \
--function-name cognito-custom-email-sender \
--environment "Variables={ROVELA_API_KEY=re_your_sending_access_key,ROVELA_FROM=Acme <security@mail.example.com>}"
生产环境优先 Secrets Manager。轮换时先建新 Rovela 密钥、更新密钥、再在控制台吊销旧密钥(Authentication)。
Lambda:解密 Cognito 的码,再 POST Rovela
Cognito 不会把明文验证码直接交给你的函数。自定义发送器事件携带密文;你需用 AWS Encryption SDK 与配置的 KMS 密钥解密,再组邮件。下面把解密放在 helper 后——请严格按 AWS 文档为你的运行时实现该 helper。
// cognito-custom-email-sender.ts — Node 20+ 示意
import type { CustomEmailSenderTriggerHandler } from "aws-lambda";
const ROVELA_URL = "https://api.rovela.dev/emails";
// 按 Cognito custom sender 文档用 AWS Encryption SDK + KMS 实现
declare function decryptCognitoCode(
encryptedCode: string,
kmsKeyArn: string,
): Promise<string>;
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function subjectFor(source: string, code: string): string {
switch (source) {
case "CustomEmailSender_ForgotPassword":
return `Your Acme reset code: ${code}`;
case "CustomEmailSender_AdminCreateUser":
return "Your Acme temporary password";
case "CustomEmailSender_Authentication":
return `Your Acme sign-in code: ${code}`;
default:
return `Your Acme verification code: ${code}`;
}
}
export const handler: CustomEmailSenderTriggerHandler = async (event) => {
const email = event.request.userAttributes?.email;
if (!email) {
throw new Error("missing_email_attribute");
}
const encrypted = event.request.code;
if (!encrypted) {
throw new Error("missing_encrypted_code");
}
const code = await decryptCognitoCode(
encrypted,
event.request.kmsKeyId /* 字段名以当前 AWS 事件为准 */,
);
const apiKey = process.env.ROVELA_API_KEY!;
const from = process.env.ROVELA_FROM!;
const source = event.triggerSource;
const sub = event.userName || email;
const idem =
`cognito-${source}-${sub}-${code.slice(0, 8)}`.slice(0, 256);
const safeCode = escapeHtml(code);
const html =
`<p>Your code is</p>` +
`<p style="font-size:28px;font-weight:600;letter-spacing:4px"><b>${safeCode}</b></p>` +
`<p>If you did not request this, you can ignore this email.</p>`;
const text =
`Your code is ${code}. If you did not request this, you can ignore this email.`;
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": idem,
},
body: JSON.stringify({
from,
to: email,
subject: subjectFor(source, code),
html,
text,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
console.error("rovela_send_failed", res.status, data);
throw new Error(`rovela_send_failed:${res.status}`);
}
return;
};
同一发送的 curl 形态(Send Email API):
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer re_your_sending_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: cognito-CustomEmailSender_ForgotPassword-alice-48291301" \
-d '{
"from": "Acme <security@mail.example.com>",
"to": "alice@example.com",
"subject": "Your Acme reset code: 482913",
"html": "<p>Your code is</p><p style=\"font-size:28px;letter-spacing:6px\"><b>482913</b></p>",
"text": "Your code is 482913. If you did not request this, ignore this email."
}'
响应:
{"object": "email", "id": "9f1c2e40-..."}
按 triggerSource 分支
保持一个 Lambda,按来源换文案——不要为 Cognito 接五套传输:
triggerSource | 邮件意图 | 说明 |
|---|---|---|
…_SignUp / …_ResendCode | 确认账户 | 码的有效性仍由 Cognito 管 |
…_ForgotPassword | 重置 | 同上 |
…_AdminCreateUser | 邀请 / 临时密码 | 临时密码勿写入日志 |
…_Authentication | 邮箱 OTP / MFA 邮件 | 短文案;主题带码利于手机预览 |
…_VerifyUserAttribute / …_UpdateUserAttribute | 验证新邮箱 |
不要记录明文码或临时密码。结构化日志只留 triggerSource、Cognito userName 与 Rovela 消息 id。
Cognito + Lambda 重试与幂等
Cognito 与 Lambda 都可能重试。没有 Idempotency-Key,超时可能投出两封不一致的码。
按 emails API:
- Header ≤ 256 字符
- 24 小时内复用同一 key 返回原响应
- 用 Cognito 事件身份 + 本次码尝试的指纹派生
- Cognito 主动重发产生新码时必须用新 key
你会真正碰到的错误
形状统一(Quick Start):
400点名域名 — 本组织未验证该from400recipient is suppressed: …— 曾退信/投诉401— 密钥无效或已吊销429— 遵守Retry-After;重试保持同一Idempotency-Key
若 Rovela 拒收,从 Lambda 抛错以触发重试策略,或写入 SQS 做可控重试。API 未接受时不要告诉用户「去查收件箱」。
清单
- 在 Rovela 验证
mail.example.com(或你的域名);SPF/DKIM/DMARC 通过 - 创建
sending_access密钥;只放 Lambda 密钥存储——绝不进 Cognito 应用客户端 - 按 AWS 文档配置 Custom email sender + KMS CMK(不要只用 Custom message)
- 用 Encryption SDK 解密 Cognito 密文;不要臆造明文字段
fetchRovela 时同时带html与text+Idempotency-Key- 按
triggerSource分支模板;避免记录密钥材料 - 用 Rovela 控制台或 webhook 观察投递——不要假设
200等于进箱
文档:Send Email · Authentication · Email verification codes · Quick Start。
- support@rovela.dev