RedwoodJS 邮件发送实战:基于 Nodemailer 与 SMTP 服务构建带审计记录的用户邮件系统
【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood
本文以 RedwoodJS 官方文档《Sending Emails》为骨架,完整演示如何在 Redwood 全栈应用中集成 SMTP 邮件能力:从数据模型设计、Scaffold 脚手架、GraphQL Mutation,到使用 nodemailer 对接 SendInBlue(现 Brevo)云邮件服务发送真实邮件,最后利用"服务调用服务"这一特性实现邮件审计日志。读完本文你将掌握一套可复制、可运行的邮件发送方案,并理解 Redwood 服务层之间的调用关系与权限边界,还能了解到 Redwood 仓库中内置 Mailer 框架(位于 packages/mailer/core)的演进脉络。
整体思路:先建模,再生成,后接线
RedwoodJS 的典型开发节奏是"数据模型先行、代码生成跟上、业务逻辑收尾"。本文要构建的示例应用包含两部分核心能力:
- 用户列表管理:保存用户的姓名与邮箱,提供一个可视化界面用来查看用户,并触发"给某用户发送测试邮件"的动作;
- 邮件审计:每当管理员向某用户发送一封邮件,就在数据库中追加一条审计日志,记录"谁在什么时候给哪个用户发了邮件"。
审计功能将通过从一个 Service 内部调用另一个 Service 的导出函数来实现——这是 Redwood 服务层的一项强大特性,能让业务逻辑保持高度复用。
邮件本身由 npm 包nodemailer负责发送,通过 SMTP 协议与云邮件服务商(文中以 SendInBlue 为例)通信。后续小节会看到,Redwood 后续版本在仓库中内置了更完整的 Mailer 框架(见 packages/mailer/core/src/mailer.ts),但底层依然围绕 SMTP 与邮件处理器(handler)展开,理解本节的手写方案有助于吃透整个邮件链路。
环境准备:创建项目并安装 nodemailer
首先创建一个新的 RedwoodJS 项目(TypeScript 版本):
yarn create redwood-app --typescript email项目创建完成后,进入email目录,将nodemailer安装到 api 工作区。Redwood 使用 Yarn workspaces 管理 api 与 web 两个子包,因此需要显式指定安装目标:
yarn workspace api add nodemailer之所以装在api侧,是因为邮件的 SMTP 通信属于服务端职责,绝不能把 SMTP 凭据暴露给浏览器端。
数据模型设计:User 与 Audit
打开api/db/schema.prisma,删除示例模型,替换为以下两个模型:
model User { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt email String @unique name String? audits Audit[] } model Audit { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt userId String user User @relation(fields: [userId], references: [id]) log String }设计要点:
- 主键与时间戳:技术上 User 模型只需要
email和关联字段,但示例始终保留id、createdAt、updatedAt。这是良好的数据建模习惯——后期补充字段时不必回头做破坏性迁移。 email唯一约束:@unique确保一个邮箱只对应一个用户,这也是发送邮件的目标地址。- 一对多关系:
User通过audits Audit[]与Audit建立一对多关系,Audit侧用userId+@relation指向User.id。这样可以通过关系字段轻松查出某个用户的所有审计记录。 - 字段取舍:示例中的审计模型刻意保持简单(仅一个
log字符串),真实的生产审计追踪往往需要更多信息(操作者、IP、请求上下文等),此处仅作演示。
模型就绪后执行迁移,生成数据库表并同步 Prisma Client:
yarn rw prisma migrate dev --name email用 Scaffold 快速生成 CRUD 界面
Scaffold 是 Redwood 的招牌功能之一,一条命令即可生成针对某个模型的 SDL、Service、路由与页面,免去手工编写增删改查样板代码:
yarn rw g scaffold User yarn rw g scaffold Audit两条命令分别生成 User 与 Audit 的完整 CRUD。随后启动开发服务器:
yarn rw dev浏览器会自动打开默认首页。点击/users链接进入用户列表页,创建几个用户用于测试。注意:务必使用真实可接收邮件的邮箱地址,这样后续才能验证邮件是否真正送达。文中示例使用 fakenamegenerator.com 生成的随机用户邮箱,需要先在对应页面激活邮箱地址才能收到邮件。
前端接线:在用户详情页添加"发送邮件"按钮
要让管理员一键给用户发邮件,需要给 User 组件添加一个触发 GraphQL Mutation 的按钮。下面给出web/src/components/User/User.tsx的完整代码(包含原有的删除逻辑与新增的邮件逻辑):
import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { Link, routes, navigate } from '@redwoodjs/router' const DELETE_USER_MUTATION = gql` mutation DeleteUserMutation($id: String!) { deleteUser(id: $id) { id } } ` const EMAIL_USER_MUTATION = gql` mutation EmailUserMutation($id: String!) { emailUser(id: $id) { id } } ` const timeTag = (datetime) => { return ( <time dateTime={datetime} title={datetime}> {new Date(datetime).toUTCString()} </time> ) } const User = ({ user }) => { const [deleteUser] = useMutation(DELETE_USER_MUTATION, { onCompleted: () => { toast.success('User deleted') navigate(routes.users()) }, onError: (error) => { toast.error(error.message) }, }) const [emailUser] = useMutation(EMAIL_USER_MUTATION, { onCompleted: () => { toast.success('Email sent') }, onError: (error) => { toast.error(error.message) }, }) const onDeleteClick = (id) => { if (confirm('Are you sure you want to delete user ' + id + '?')) { deleteUser({ variables: { id } }) } } const onEmailClick = (user) => { if (confirm(`Are you sure you want to send an email to ${user.name}?`)) { emailUser({ variables: { id: user.id } }) } } return ( <> <div className="rw-segment"> <header className="rw-segment-header"> <h2 className="rw-heading rw-heading-secondary"> User {user.id} Detail </h2> </header> <table className="rw-table"> <tbody> <tr> <th>Id</th> <td>{user.id}</td> </tr> <tr> <th>Created at</th> <td>{timeTag(user.createdAt)}</td> </tr> <tr> <th>Updated at</th> <td>{timeTag(user.updatedAt)}</td> </tr> <tr> <th>Email</th> <td>{user.email}</td> </tr> <tr> <th>Name</th> <td>{user.name}</td> </tr> </tbody> </table> </div> <nav className="rw-button-group"> <Link to={routes.editUser({ id: user.id })} className="rw-button rw-button-blue" > Edit </Link> <button type="button" className="rw-button rw-button-red" onClick={() => onDeleteClick(user.id)} > Delete </button> <button type="button" className="rw-button rw-button-blue" onClick={() => onEmailClick(user)} > Send email </button> </nav> </> ) } export default User代码要点:
useMutation:来自@redwoodjs/web,封装了 GraphQL 请求的发送与状态管理,onCompleted/onError分别处理成功与失败回调,配合@redwoodjs/web/toast的toast.success/toast.error给出用户反馈;EMAIL_USER_MUTATION:声明式定义前端要调用的 Mutation,参数为$id: String!,返回User对象;- 确认弹窗:删除与发送邮件前都使用
confirm二次确认,避免误操作。
定义 GraphQL SDL 与 Service 占位实现
前端 Mutation 需要后端 SDL 中对应字段才能生效。在api/src/graphql/users.sdl.ts的Mutation类型中加入:
export const schema = gql` // ... type Mutation { // ... emailUser(id: String!): User! @requireAuth } `@requireAuth指令表示该 Mutation 要求用户已登录,属于受保护操作。从仓库源码看,Redwood 的指令系统集中在 packages/graphql-server/src/directives,负责把 SDL 中声明的@requireAuth、@skipAuth等指令转换为 GraphQL 执行时的鉴权校验逻辑,这是服务端安全的最后一道闸门。
随后在api/src/services/users/users.ts中先写一个占位实现,验证链路是否打通:
// ... import type { Prisma } from '@prisma/client' // ... export const emailUser = async ({ id }: Prisma.UserWhereUniqueInput) => { const user = await db.user.findUnique({ where: { id }, }) console.log('Sending email to', user) return user } // ...此时点击"Send email"按钮,服务端终端应打印Sending email to ...,证明从前端 Mutation 到 Service 的完整链路已经打通。
配置 SMTP 邮件服务商:以 SendInBlue 为例
要真正发出邮件,需要一个可通过 SMTP 协议通信的邮件服务器。可选方案对比:
- Ethereal:nodemailer 官方示例使用的测试服务,邮件不会真正投递,仅用于本地调试;
- 个人 Gmail:可行但需要配置 OAuth2,流程繁琐且不够可靠;
- 云邮件服务商(推荐):多数提供免费额度,足以支撑小型生产应用。本文使用 SendInBlue(现更名为 Brevo),其免费套餐每天可发送 300 封邮件。
SendInBlue 侧的操作步骤:
- 注册账号(需要提供地址与电话号码,用于防止垃圾邮件滥用);
- 点击右上角公司名称菜单,选择SMTP & API;
- 进入SMTP选项卡;
- 生成一个新的 SMTP key(名称随意),复制生成的密钥。
回到代码,编辑项目根目录的.env文件,在末尾新增一行环境变量:
SEND_IN_BLUE_KEY=xsmtpsib-7fa6eb37c244429933ea870185063c493ba1c820f826c5f620877dd815392602-rZgB6GUV1CF2NLAK上例仅为占位格式,请替换为你自己生成的密钥。若开发服务器仍在运行,需要重启才能加载新的环境变量。
封装发送函数:email.ts
在 api 侧的lib目录(即api/src/lib/)新建email.ts,封装基于 nodemailer 的邮件发送函数:
import * as nodemailer from 'nodemailer' interface Options { to: string | string[] subject: string text: string html: string } export async function sendEmail({ to, subject, text, html }: Options) { console.log('Sending email to:', to) // create reusable transporter object using SendInBlue for SMTP const transporter = nodemailer.createTransport({ host: 'smtp-relay.sendinblue.com', port: 587, secure: false, // true for 465, false for other ports auth: { user: 'your@email.com', pass: process.env.SEND_IN_BLUE_KEY, }, }) // send mail with defined transport object const info = await transporter.sendMail({ from: '"Your Name" <your@email.com>', to: Array.isArray(to) ? to : [to], // list of receivers subject, // Subject line text, // plain text body html, // html body }) return info }配置细节说明:
- SMTP 端点:
host使用smtp-relay.sendinblue.com,端口 587、secure: false(对应 STARTTLS 加密);若改用 465 端口则需secure: true; - 认证:
auth.user填写你在 SendInBlue 注册时使用的邮箱(注意区分大小写),auth.pass使用环境变量SEND_IN_BLUE_KEY,避免把密钥硬编码进源码; - 收件人兼容性:
to参数设计为string | string[],发送前通过Array.isArray归一化为数组,兼容单个与批量收件人; - 双正文:同时提供
text(纯文本)与html(富文本)两种正文,适配不同邮件客户端; from地址:代码中"Your Name" <your@email.com>的your@email.com需要替换为注册 SendInBlue 时使用的邮箱,邮箱地址以 SendInBlue 网站显示为准,大小写敏感。
打通 Service:将邮件发送接入业务逻辑
回到api/src/services/users/users.ts,完成三处接线:
1. 导入发送函数(放在 db import 之后):
// ... import { sendEmail } from 'src/lib/email' // ...2. 定义测试邮件辅助函数:
// ... function sendTestEmail(emailAddress: string) { const subject = 'Test Email' const text = 'This is a manually triggered test email.\n\n' + 'It was sent from a RedwoodJS application.' const html = 'This is a manually triggered test email.<br><br>' + 'It was sent from a RedwoodJS application.' return sendEmail({ to: emailAddress, subject, text, html }) } // ...3. 替换占位实现中的console.log:
// ... await sendTestEmail(user.email) // ...现在回到浏览器,点击用户详情页的 "Send email" 按钮。终端会打印Sending email to: <邮箱地址>,稍等片刻邮件即出现在收件箱中(若使用 fakenamegenerator 生成的邮箱,投递可能有延迟,需要耐心等待)。
服务调用服务:实现邮件审计
最后一个环节是审计。需求是:每次通过emailUser发送邮件后,自动调用审计服务的createAudit写入一条日志。Redwood 让这件事异常简单——直接在 Service 中 import 另一个 Service,即可调用其所有导出函数:
// ... import { createAudit } from '../audits/audits' // ... export const emailUser = async ({ id }: Prisma.UserWhereUniqueInput) => { // ... await sendTestEmail(user.email) await createAudit({ input: { userId: id, log: 'Admin sent test email to user' }, }) // ... } // ...createAudit是 Scaffold 为 Audit 模型自动生成的 Service 函数,其入参结构{ input: { userId, log } }可能初看不直观,但 TypeScript 类型会给出完整提示。这里做的事情是把新审计记录与现有用户关联(userId),并写入日志消息Admin sent test email to user。审计记录会自动带上时间戳与生成的 id。
查看审计日志同样简单:Scaffold 已生成/audits页面,访问http://localhost:8910/audits即可看到所有记录。
安全边界:服务间调用会绕过 GraphQL 指令
文档在这里有一个重要的安全提示(PSA):服务间调用可能绕过你在 GraphQL 层设置的安全措施。原因在于:
- 从web 端调用 Service 时走的是 GraphQL,请求会被
@requireAuth等指令拦截校验; - 但Service 之间的直接函数调用不经过 GraphQL 层。如果一个对所有人开放的服务(即 SDL 中使用
@skipAuth)导入了另一个受保护的服务并调用其函数,那么这些函数内部的逻辑会无条件执行,无论目标函数在 GraphQL 侧声明了什么指令。
在本文示例中,emailUserMutation 声明了@requireAuth,因此不存在这个风险。但在设计自己的服务时,务必把"GraphQL 指令是传输层保护,函数调用是业务层逻辑"这一区别牢记在心,必要时在 Service 函数内部自行做权限判断。
进阶:仓库内置 Mailer 框架的演进脉络
当前仓库中,Redwood 已演进出一套更完整的官方邮件方案。从源码结构看,packages/mailer/core 提供了核心的Mailer类(见 mailer.ts),它把发送邮件抽象为两层组件:
- Handler(处理器):负责实际投递,抽象基类定义在 handler.ts,其
send方法接收渲染后的邮件内容、发送选项与处理器选项,返回发送结果; - Renderer(渲染器):负责把模板渲染为邮件内容。
Mailer类支持三种运行模式——test、development、production,可在配置中分别指定不同模式下的 handler(例如测试模式用内存 handler、开发模式用 Studio handler、生产模式才真正走 SMTP),并根据NODE_ENV自动判定当前模式。这一设计与本文手写的email.ts方案一脉相承:都是围绕 SMTP 投递链路做封装,区别在于 Mailer 框架把 handler 可插拔化、把渲染与投递解耦、并把环境差异纳入配置管理。完整的 Mailer 使用说明可参考仓库文档 docs/docs/mailer.md。
对于小型应用或快速原型,本文基于 nodemailer 的手写方案足够直接有效;随着项目规模增长,可以平滑迁移到官方 Mailer 框架以获得更规范的开发/测试/生产隔离能力。
小结
本文完整走通了 RedwoodJS 邮件发送的实战链路:
- 建模与迁移:Prisma 中定义 User 与 Audit 一对多模型,
yarn rw prisma migrate dev落地数据库; - Scaffold 生成:
yarn rw g scaffold User/Audit一键获得 CRUD 页面与服务; - 前端触发:User 组件中通过
useMutation调用emailUserMutation; - SDL 保护:Mutation 声明
@requireAuth,由指令系统实施鉴权; - SMTP 集成:
api/src/lib/email.ts用 nodemailer 对接 SendInBlue,凭据存放于.env; - 服务编排:
emailUser内部调用sendTestEmail与createAudit,实现"发信即留痕"的审计能力,同时理解了服务间调用绕开 GraphQL 指令的权限边界。
这套模式可以推广到任何需要邮件能力的 Redwood 应用——欢迎信、重置密码、通知提醒等,只需替换邮件内容生成逻辑与触发时机即可复用。
【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考