← 返回博客

用 AWS Cognito + Rovela 发自定义认证邮件(2026)

用 Cognito Custom Email Sender Lambda,经 Rovela POST /emails 与 Idempotency-Key 投递验证、重置与邀请邮件——Cognito 管认证,Rovela 负责投递;不替代 Cognito/SES;与 Firebase、Clerk、Supabase 指南区分。

更新于 2026年11月4日
用 AWS Cognito + Rovela 发自定义认证邮件(2026)

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 senderCognito 调用你的 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)。

前置条件

  1. 在 Rovela 控制台验证发信域名。未验证的 from 会以 400 拒绝(Quick Start)。认证邮件优先用 mail.example.com(OTP/魔术链接进垃圾箱)。
  2. 形如 re_… 的 API 密钥。生产发送优先 sending_access。
  3. Cognito 用户池,以及与自定义发送器配置在同一区域的 Lambda。
  4. Cognito 用于加密触发器密钥的 KMS 客户托管密钥;Lambda 角色需解密权限(按 AWS 自定义发送器文档配置)。
  5. 切勿把 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

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 点名域名 — 本组织未验证该 from
  • 400 recipient is suppressed: … — 曾退信/投诉
  • 401 — 密钥无效或已吊销
  • 429 — 遵守 Retry-After;重试保持同一 Idempotency-Key

若 Rovela 拒收,从 Lambda 抛错以触发重试策略,或写入 SQS 做可控重试。API 未接受时不要告诉用户「去查收件箱」。

清单

  1. 在 Rovela 验证 mail.example.com(或你的域名);SPF/DKIM/DMARC 通过
  2. 创建 sending_access 密钥;只放 Lambda 密钥存储——绝不进 Cognito 应用客户端
  3. 按 AWS 文档配置 Custom email sender + KMS CMK(不要只用 Custom message)
  4. 用 Encryption SDK 解密 Cognito 密文;不要臆造明文字段
  5. fetch Rovela 时同时带 html 与 text + Idempotency-Key
  6. 按 triggerSource 分支模板;避免记录密钥材料
  7. 用 Rovela 控制台或 webhook 观察投递——不要假设 200 等于进箱

文档:Send Email · Authentication · Email verification codes · Quick Start。

  • support@rovela.dev