首页
/ Payload 邮件集成实战:从 examples/email 示例解析适配器配置、认证邮件定制与 HTML 模板引擎

Payload 邮件集成实战:从 examples/email 示例解析适配器配置、认证邮件定制与 HTML 模板引擎

2026-09-05 21:54:56作者:彭桢灵Jeremy

Payload 通过「邮件适配器」模式将发信能力解耦到独立的适配器包中,再经由 payload.sendEmail() 这一统一 API 对外暴露。本文以仓库中的 examples/email 示例 为主体,完整讲清如何在 Payload 配置中接入 @payloadcms/email-nodemailer 适配器、如何用 afterChange 钩子触发业务邮件、如何定制认证(密码重置/邮箱验证)邮件主题与正文,以及如何用 EJS + juice 渲染生产级邮件 HTML 模板;读完后可直接复制该示例的完整链路,让任何 Payload 项目具备发信能力。

1. 快速启动 email 示例

examples/email/README.md 给出的本地运行步骤如下,可原样照做:

  1. 从示例脚手架创建项目:npx create-payload-app --example email
  2. cp .env.example .env 复制示例环境变量文件;
  3. 确保 MongoDB 正在运行,且 DATABASE_URL 指向它,例如 mongodb://127.0.0.1/payload-example-email
  4. pnpm install && pnpm dev 安装依赖并启动开发服务器;
  5. 打开 http://localhost:3000/admin 进入后台;
  6. 创建第一个用户(此时若启用了 verify,就会收到一封验证邮件)。

示例的完整目录结构很小,核心文件只有 6 个,这也是全文的讲解范围:

examples/email/src/
├── collections/
│   ├── Newsletter.ts          # 业务邮件:afterChange 钩子触发欢迎邮件
│   └── Users.ts               # 认证邮件:定制 reset / verify 邮件
├── email/
│   ├── generateEmailHTML.ts       # 通用 HTML 渲染入口(EJS + juice)
│   ├── generateForgotPasswordEmail.ts
│   ├── generateVerificationEmail.ts
│   └── template.ejs               # 邮件 HTML 模板
└── payload.config.ts      # email 属性接入 nodemailerAdapter

2. 核心机制:适配器模式与 email 配置项

Payload 的邮件功能采用适配器模式:Payload 核心只定义「能发信」的接口契约,具体走 SMTP、SendGrid 还是 Resend,由你传入的适配器决定。官方推荐大多数场景使用 @payloadcms/email-nodemailer 包(基于 Nodemailer,支持任意 Nodemailer transport)。启用方式只有一行:把适配器配置传给 Payload Config 的 email 属性,此后 Payload 即可发送密码重置、新用户验证等所有认证邮件,以及任意业务邮件。

示例的配置入口在 payload.config.ts

import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
// ...
export default buildConfig({
  admin: {
    importMap: { baseDir: path.resolve(dirname) },
    user: Users.slug,
  },
  collections: [Newsletter, Users],
  db: mongooseAdapter({
    url: process.env.DATABASE_URL || '',
  }),
  editor: lexicalEditor({}),
  // 示例中不传任何参数,开发环境将回退到 ethereal.email 模拟服务
  email: nodemailerAdapter(),
  secret: process.env.PAYLOAD_SECRET || '',
  typescript: {
    outputFile: path.resolve(dirname, 'payload-types.ts'),
  },
})

注意这里的 nodemailerAdapter() 没有传任何参数——这是该示例故意为之:开发期间不配置 SMTP 时,Payload 会自动创建 ethereal.email 模拟账号。此时启动日志会打印模拟邮箱的登录入口、用户名和密码,发出去的「认证邮件」可在该模拟收件箱里查看,本地联调无需任何真实邮箱服务商。

此外,若项目完全未配置 email 属性,Payload 启动时会记录一条「email 未配置」的警告日志,任何发信尝试也会再次警告——即未配置时框架可正常运行,但发信会失败,详见 docs/email/overview.mdx

2.1 从源码看 nodemailerAdapter 的完整参数

阅读适配器实现 packages/email-nodemailer/src/index.ts 可以看到它接受的全部参数(NodemailerAdapterArgs):

参数 说明
defaultFromAddress(必填,类型层面) From 字段的地址部分
defaultFromName(必填,类型层面) From 字段显示的名称
transport 自行创建好的 Nodemailer Transporter 对象;提供后无需 transportOptions
transportOptions SMTP 连接选项对象(SMTPConnection.Options),由适配器内部调用 nodemailer.createTransport() 创建
overrideRecipientAddress 所有邮件强制改投到指定地址,非常适合联调测试
skipVerify 跳过启动时对 transport 的连通性校验

其内部 buildEmail() 的决策链(见 packages/email-nodemailer/src/index.ts#L53-L90)为:

  1. 未传任何配置 → 调用 createMockAccount() 走 ethereal.email 模拟通道,发件人默认为 Payload <info@payloadcms.com>L64-L68),并在控制台打印模拟收件箱 URL 与账号密码(L120-L126);
  2. 传了 transport → 直接复用你创建的 transport;
  3. 传了 transportOptions → 用该选项调用 nodemailer.createTransport() 创建;
  4. 都不传但传了其他字段 → 同样回退到 createMockAccount()
  5. skipVerify: true 外,启动时都会执行 transport.verify() 做连通性检查,失败仅打印错误不中断启动(L92-L98)。

sendEmail() 的执行路径也很直白(L38-L49):先拼上 from: 名称 <地址>,再展开业务侧传入的 messageto/subject/html/text 等),最后若有 overrideRecipientAddress 则覆盖 to 字段——也就是说发件人始终由适配器统一控制,收件人可被测试钩子劫持。

3. 业务邮件:用 afterChange 钩子触发注册欢迎信

示例的第二条链路是业务邮件:用户在 Newsletter 集合注册后立即收到欢迎邮件。完整实现见 src/collections/Newsletter.ts

import type { CollectionConfig } from 'payload'
import { sanitizeUserDataForEmail } from 'payload/shared'
import { generateEmailHTML } from '../email/generateEmailHTML'

export const Newsletter: CollectionConfig = {
  slug: 'newsletter-signups',
  admin: {
    defaultColumns: ['name', 'email'],
  },
  fields: [
    { name: 'name', type: 'text' },
    { name: 'email', type: 'text', required: true },
  ],
  hooks: {
    afterChange: [
      async ({ doc, operation, req }) => {
      if (operation === 'create') {
          req.payload
            .sendEmail({
              from: 'sender@example.com',
              html: await generateEmailHTML({
                content: `<p>${doc.name ? `Hi ${sanitizeUserDataForEmail(doc.name)}!` : 'Hi!'} We'll be in touch soon...</p>`,
                headline: 'Welcome to the newsletter!',
              }),
              subject: 'Thanks for signing up!',
              to: doc.email,
            })
            .catch((error) => {
              console.error('Error sending email:', error)
            })
        }
      },
    ],
  },
}

这段代码体现了 Payload 发业务邮件的三个惯例,值得逐点掌握:

  • 通过 req.payload.sendEmail() 发信:任何能拿到 req.payload 的地方(钩子、自定义 API 路由、GraphQL resolver 等)都可以调用统一发信 API,消息体包含 tosubjecthtmltext
  • 只响应 operation === 'create'afterChangecreateupdate 都会触发,这里显式过滤,避免编辑文档时重复发信;
  • 异步不阻塞主流程sendEmailawait,而是挂 .catch() 记录日志——邮件发送失败不影响文档创建本身的响应;
  • 用户输入先净化:用户自填的 doc.name 会被拼接进 HTML 正文,示例统一用 payload/shared 导出的 sanitizeUserDataForEmail() 转义,防止注入破坏邮件结构或夹带恶意标记。

sendEmail 还支持 attachments 附件,且能力边界取决于所用适配器:Nodemailer 适配器可透传 Nodemailer 的全部附件能力(文件路径 pathBuffercontentType 等);Resend 适配器的 content 需要 Base64 字符串。完整示例见 docs/email/overview.mdx

4. 认证邮件定制:generateEmailHTMLgenerateEmailSubject

第三条链路是认证邮件。开启 auth 的集合(示例中的 Users 集合)自带「验证邮箱」与「重置密码」两个开箱流程,而这两个流程发出的邮件主题和正文都可以在集合配置里被替换:

export const Users: CollectionConfig = {
  slug: 'users',
  admin: { useAsTitle: 'email' },
  auth: {
    forgotPassword: {
      generateEmailHTML: generateForgotPasswordEmail,
      generateEmailSubject: () => 'Reset your password',
    },
    verify: {
      generateEmailHTML: generateVerificationEmail,
      generateEmailSubject: () => 'Verify your email',
    },
  },
  fields: [{ name: 'name', type: 'text' }],
}

两个钩子函数的签名与职责(见 generateForgotPasswordEmail.tsgenerateVerificationEmail.ts):

函数 接收参数 返回
generateEmailSubject 上下文(如 tokenuserreq 等) 邮件主题字符串
generateEmailHTML tokenuser(含 email/name)、req 完整 HTML 字符串(通常为 Promise<string>

重置密码邮件的实现展示了 CTA 按钮的构造方式——把一次性 token 拼进回调 URL:

export const generateForgotPasswordEmail = async (args): Promise<string> => {
  return generateEmailHTML({
    content: '<p>Let&apos;s get you back in.</p>',
    cta: {
      buttonLabel: 'Reset your password',
      url: `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/reset-password?token=${args?.token}`,
    },
    headline: 'Locked out?',
  })
}

验证邮件同理,URL 形如 ${PAYLOAD_PUBLIC_SERVER_URL}/verify?token=...&email=...,并对用户名使用 sanitizeUserDataForEmail 净化。两个细节值得注意:

5. HTML 模板引擎:EJS 渲染 + juice 内联 CSS

第四层是「正文从哪来」。示例把邮件 HTML 收敛到一个通用渲染函数 src/email/generateEmailHTML.ts

import ejs from 'ejs'
import fs from 'fs'
import juice from 'juice'
import path from 'path'

export const generateEmailHTML = async (data: any): Promise<string> => {
  const templatePath = path.join(process.cwd(), 'src/email/template.ejs')
  const templateContent = fs.readFileSync(templatePath, 'utf8')

  // 1. 用 EJS 编译渲染模板
  const preInlinedCSS = ejs.render(templateContent, { ...data, cta: data.cta || {} })

  // 2. 用 juice 把 <style> 中的 CSS 内联到元素 style 上
  const html = juice(preInlinedCSS)

  return Promise.resolve(html)
}

两步管线各解决一个邮件工程的经典问题:

  1. EJS 模板template.ejs 是一份「表格布局」邮件模板(邮件客户端普遍不信任 div + 类名,故用 <table> 嵌套)。模板中只有三个动态占位:<%= headline %>(转义插值,邮件主标题)、<%- content %>(非转义插值,注入调用方提供的正文 HTML)、以及 <% if (cta) { %> ... <% } %> 条件块渲染 CTA 按钮(cta.urlcta.buttonLabel)。这正是为什么 Newsletter 欢迎信只传 content + headline 而没有 cta,而认证邮件必须传 cta
  2. juice 内联 CSS:模板 <head> 里有大量 <style> 规则(含响应式 @media 断点,如 800px 以下缩小标题字号与内边距)。多数邮件客户端会丢弃 <style> 块,juice() 负责把这些声明内联为每个元素的 style 属性,保证样式在各客户端下一致生效。

模板中还有两处可直接按品牌定制的静态内容:顶部 Logo(当前指向 Payload 官网图片)与整体配色(灰底 #f3f3f3 + 白色主面板 + 深色 #222222 按钮)。README 对此的提示是:src/email/generateEmailHTML 可以替换成任意 HTML 模板,只需保持「输入 headline/content/cta,输出完整 HTML」的契约即可——认证邮件的业务逻辑(token 生成、链接拼装)全部留在 Payload 侧,模板层只关心视觉。

6. 接入生产 SMTP

示例默认走 ethereal 模拟通道,上线时只需把第 2 节中无参的 nodemailerAdapter() 换成带 transportOptions 的形态。以下为 docs/email/overview.mdx 中给出的标准 SMTP 配置:

import { buildConfig } from 'payload'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'

export default buildConfig({
  email: nodemailerAdapter({
    defaultFromAddress: 'info@yourdomain.com',
    defaultFromName: 'Your Product',
    transportOptions: {
      host: process.env.SMTP_HOST,
      port: 587,
      auth: {
        user: process.env.SMTP_USER,
        pass: process.env.SMTP_PASS,
      },
    },
  }),
})

其他可选形态(均见 docs/email/overview.mdx):

  • 自定义 transport:对 Nodemailer 熟悉者可直接 transport: nodemailer.createTransport({...}) 传入成品对象,例如使用 SendGrid 的 nodemailer-sendgrid transport;
  • overrideRecipientAddress:测试环境把所有邮件重定向到自己的地址,是排查「为什么收不到验证邮件」的高频利器;
  • skipVerify:容器冷启动环境若不希望启动时执行 transport.verify() 网络探测,可置为 true
  • Resend 适配器@payloadcms/email-resend 只需 apiKey,体积更轻,更适合 Vercel 等 serverless 平台。

7. 端到端验证与生产构建

按 README 的收尾建议做冒烟测试,链路即被完整覆盖:

  1. 在后台新建一条 newsletter-signups 记录 → 触发第 3 节的 afterChange 欢迎信;
  2. 创建/修改 users 记录触发验证或重置流程 → 触发第 4 节定制的认证邮件;
  3. 开发环境下打开启动日志打印的 ethereal 模拟收件箱,确认 HTML 正文、CTA 按钮与内联样式均正常渲染。

生产环境按 examples/email/README.md 的步骤:

  1. 项目根目录执行 pnpm build(或 npm run build)运行 next build,生成 .next 中的生产级 Admin 打包产物;
  2. 执行 pnpm start(或 npm run start),以生产模式运行 Node 并对外提供 Payload 服务。

部署方面可使用 Payload Cloud 一键导入部署,或参考 docs/production/deployment.mdx 手动部署;部署后记得同时设置 DATABASE_URLPAYLOAD_SECRETPAYLOAD_PUBLIC_SERVER_URL(后者直接决定邮件中验证/重置链接的正确性)。

小结

这个示例用极小的代码量串起了 Payload 邮件系统的四条能力线:适配器注入email: nodemailerAdapter(),零配置回退 ethereal 模拟通道)、业务发信afterChange + req.payload.sendEmail())、认证邮件定制auth.forgotPassword / auth.verifygenerateEmailHTMLgenerateEmailSubject)、模板工程(EJS 渲染 + juice 内联 CSS)。各文件职责清晰,可直接作为真实项目的邮件模块蓝本拷贝后按品牌替换模板即可投产。

登录后查看全文
热门项目推荐
相关项目推荐