Payload 邮件集成实战:从 examples/email 示例解析适配器配置、认证邮件定制与 HTML 模板引擎
Payload 通过「邮件适配器」模式将发信能力解耦到独立的适配器包中,再经由 payload.sendEmail() 这一统一 API 对外暴露。本文以仓库中的 examples/email 示例 为主体,完整讲清如何在 Payload 配置中接入 @payloadcms/email-nodemailer 适配器、如何用 afterChange 钩子触发业务邮件、如何定制认证(密码重置/邮箱验证)邮件主题与正文,以及如何用 EJS + juice 渲染生产级邮件 HTML 模板;读完后可直接复制该示例的完整链路,让任何 Payload 项目具备发信能力。
1. 快速启动 email 示例
examples/email/README.md 给出的本地运行步骤如下,可原样照做:
- 从示例脚手架创建项目:
npx create-payload-app --example email; cp .env.example .env复制示例环境变量文件;- 确保 MongoDB 正在运行,且
DATABASE_URL指向它,例如mongodb://127.0.0.1/payload-example-email; pnpm install && pnpm dev安装依赖并启动开发服务器;- 打开
http://localhost:3000/admin进入后台; - 创建第一个用户(此时若启用了
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)为:
- 未传任何配置 → 调用
createMockAccount()走 ethereal.email 模拟通道,发件人默认为Payload <info@payloadcms.com>(L64-L68),并在控制台打印模拟收件箱 URL 与账号密码(L120-L126); - 传了
transport→ 直接复用你创建的 transport; - 传了
transportOptions→ 用该选项调用nodemailer.createTransport()创建; - 都不传但传了其他字段 → 同样回退到
createMockAccount(); - 除
skipVerify: true外,启动时都会执行transport.verify()做连通性检查,失败仅打印错误不中断启动(L92-L98)。
sendEmail() 的执行路径也很直白(L38-L49):先拼上 from: 名称 <地址>,再展开业务侧传入的 message(to/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,消息体包含to、subject及html或text; - 只响应
operation === 'create':afterChange在create与update都会触发,这里显式过滤,避免编辑文档时重复发信; - 异步不阻塞主流程:
sendEmail未await,而是挂.catch()记录日志——邮件发送失败不影响文档创建本身的响应; - 用户输入先净化:用户自填的
doc.name会被拼接进 HTML 正文,示例统一用payload/shared导出的sanitizeUserDataForEmail()转义,防止注入破坏邮件结构或夹带恶意标记。
sendEmail 还支持 attachments 附件,且能力边界取决于所用适配器:Nodemailer 适配器可透传 Nodemailer 的全部附件能力(文件路径 path、Buffer、contentType 等);Resend 适配器的 content 需要 Base64 字符串。完整示例见 docs/email/overview.mdx。
4. 认证邮件定制:generateEmailHTML 与 generateEmailSubject
第三条链路是认证邮件。开启 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.ts 与 generateVerificationEmail.ts):
| 函数 | 接收参数 | 返回 |
|---|---|---|
generateEmailSubject |
上下文(如 token、user、req 等) |
邮件主题字符串 |
generateEmailHTML |
token、user(含 email/name)、req 等 |
完整 HTML 字符串(通常为 Promise<string>) |
重置密码邮件的实现展示了 CTA 按钮的构造方式——把一次性 token 拼进回调 URL:
export const generateForgotPasswordEmail = async (args): Promise<string> => {
return generateEmailHTML({
content: '<p>Let'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 净化。两个细节值得注意:
- 回调地址的前缀来自环境变量
PAYLOAD_PUBLIC_SERVER_URL,意味着部署时必须正确设置它,否则邮件中的按钮会指向错误域名; - 这两个生成函数最终由 Payload 认证操作链路调用,源码上分别可见于 packages/payload/src/auth/operations/forgotPassword.ts(重置密码流程)与 packages/payload/src/auth/sendVerificationEmail.ts(验证邮箱流程),两处都消费了
auth.forgotPassword/auth.verify上配置的generateEmailSubject。
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)
}
两步管线各解决一个邮件工程的经典问题:
- EJS 模板:template.ejs 是一份「表格布局」邮件模板(邮件客户端普遍不信任
div + 类名,故用<table>嵌套)。模板中只有三个动态占位:<%= headline %>(转义插值,邮件主标题)、<%- content %>(非转义插值,注入调用方提供的正文 HTML)、以及<% if (cta) { %> ... <% } %>条件块渲染 CTA 按钮(cta.url与cta.buttonLabel)。这正是为什么 Newsletter 欢迎信只传content+headline而没有cta,而认证邮件必须传cta。 - 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-sendgridtransport; overrideRecipientAddress:测试环境把所有邮件重定向到自己的地址,是排查「为什么收不到验证邮件」的高频利器;skipVerify:容器冷启动环境若不希望启动时执行transport.verify()网络探测,可置为true;- Resend 适配器:
@payloadcms/email-resend只需apiKey,体积更轻,更适合 Vercel 等 serverless 平台。
7. 端到端验证与生产构建
按 README 的收尾建议做冒烟测试,链路即被完整覆盖:
- 在后台新建一条
newsletter-signups记录 → 触发第 3 节的afterChange欢迎信; - 创建/修改
users记录触发验证或重置流程 → 触发第 4 节定制的认证邮件; - 开发环境下打开启动日志打印的 ethereal 模拟收件箱,确认 HTML 正文、CTA 按钮与内联样式均正常渲染。
生产环境按 examples/email/README.md 的步骤:
- 项目根目录执行
pnpm build(或npm run build)运行next build,生成.next中的生产级 Admin 打包产物; - 执行
pnpm start(或npm run start),以生产模式运行 Node 并对外提供 Payload 服务。
部署方面可使用 Payload Cloud 一键导入部署,或参考 docs/production/deployment.mdx 手动部署;部署后记得同时设置 DATABASE_URL、PAYLOAD_SECRET 与 PAYLOAD_PUBLIC_SERVER_URL(后者直接决定邮件中验证/重置链接的正确性)。
小结
这个示例用极小的代码量串起了 Payload 邮件系统的四条能力线:适配器注入(email: nodemailerAdapter(),零配置回退 ethereal 模拟通道)、业务发信(afterChange + req.payload.sendEmail())、认证邮件定制(auth.forgotPassword / auth.verify 的 generateEmailHTML 与 generateEmailSubject)、模板工程(EJS 渲染 + juice 内联 CSS)。各文件职责清晰,可直接作为真实项目的邮件模块蓝本拷贝后按品牌替换模板即可投产。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00