- 后端
- 前端
- Web框架
- 开发工具
【免费下载链接】redwood
RedwoodGraphQL
RedwoodJS 应用在业务成熟后几乎都会遇到"发邮件"的需求——无论是欢迎信、密码重置还是内部通知。本文将基于 RedwoodJS 7.x 官方教程(docs/versioned_docs/version-7.x/how-to/sending-emails.md,当前版本内容与 docs/docs/how-to/sending-emails.md 一致)展开:从一个可运行的完整示例出发,逐步搭建"用户列表 + 邮件发送按钮 + 发送审计"三层功能,并深入讲解nodemailer+ SMTP(以 SendInBlue 为例)的接入方式,以及"在一个 service 中调用另一个 service"这一 Redwood 服务的核心特性与安全边界。读完后,你将掌握从零实现、调试到审计邮件发送的完整技能链,并了解 RedwoodJS 官方 Mailer 模块这一更现代的替代方案。
方案选型:为什么是 nodemailer + SMTP 云服务
要实现真实的邮件投递,需要一个可通过 SMTP 协议通信的邮件服务器。nodemailer官方示例中使用的 Ethereal 只能生成测试消息,邮件永远不会真正送达收件人;使用个人 Gmail 账号则需要配置 OAuth2,复杂度较高。因此更稳妥的做法是使用提供免费额度的云 SaaS 邮件服务。本教程选用 SendInBlue(现 Brevo),其免费档位允许每天发送 300 封邮件,足以支撑一个小型生产应用。
注意:RedwoodJS 后续版本已内置更完整的 Mailer 模块(见文末),其 Nodemailer handler 正是封装了本节所用的同一套
nodemailer机制,因此本教程的底层原理依然适用。
环境准备:创建项目并安装依赖
创建 RedwoodJS 项目
yarn create redwood-app --typescript email这里使用--typescript生成 TypeScript 模板。email是项目目录名,可自行替换。项目创建完成后,进入目录并安装邮件发送所需的 npm 包:
cd email yarn workspace api add nodemailer使用yarn workspace api add是因为 RedwoodJS 采用 monorepo 结构,nodemailer只在 api 侧(Node 服务端)使用,不需要污染 web 侧的依赖。
数据模型设计: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和关系字段audits,但保留id与createdAt/updatedAt两个时间戳几乎是 Redwood/Prisma 社区的通用习惯——事后补加字段的迁移成本远高于一开始就加上。name字段让示例更接近真实业务。 - Audit 模型:为了演示刻意保持精简(尤其是单个
log字符串字段),真实的审计追踪通常需要更多信息(操作者、操作类型、请求上下文等)。 - 一对多关系:
User.audits与Audit.user通过userId外键关联,后续可以通过该关系轻松查询某个用户的所有审计日志。
模型就绪后执行数据库迁移:
yarn rw prisma migrate dev --name email用 Scaffold 快速搭建 UI 与 GraphQL 层
Scaffold 是 RedwoodJS 的招牌能力之一,会根据 Prisma 模型自动生成完整的 CRUD 页面、路由、SDL(GraphQL Schema Definition Language)与 service:
yarn rw g scaffold User yarn rw g scaffold Audit两条命令分别生成 User 与 Audit 的完整前后端脚手架。然后启动开发服务器:
yarn rw dev浏览器会自动打开 Redwood 默认首页。点击/users链接进入用户列表页,创建几个用户。由于后续要真实发送邮件,建议使用能实际收件的邮箱地址(例如 fakenamegenerator.com 这类可激活的临时真实邮箱)。
创建几个用户后,列表页与详情页的形态可以参考 scaffold 相关文档 中的说明。接下来我们要在详情页加入"发送邮件"按钮。
前端改造:为 User 详情页添加发送邮件按钮
在 scaffold 生成的User.tsx组件中,需要补充一个 GraphQL mutation、一个发送处理函数,并在按钮组中新增按钮。完整代码如下:
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,是 Redwood 对 Apollo Client 的封装,通过onCompleted/onError处理成功与失败提示(toast)。EMAIL_USER_MUTATION调用服务端的emailUsermutation,入参为id: String!。confirm()弹窗用于防止误触发送。
在 SDL 中暴露 mutation
要让这个 mutation 可被 GraphQL 调用,需要在users.sdl.ts的 Mutation 类型中添加对应字段:
export const schema = gql` // ... type Mutation { // ... emailUser(id: String!): User! @requireAuth } `@requireAuth是 RedwoodJS 的内置指令,要求调用者必须通过认证,这在后面讨论 service 复用的安全边界时至关重要。关于指令的更多细节可参考 directives 文档。
先用占位实现打通链路
先在 users service 中写一个只查询用户、不真正发信的占位方法,验证整条链路通畅:
// ... 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"按钮已经能触发 mutation 并打印日志。接下来配置真正的邮件服务。
配置 SendInBlue(SMTP 云服务)
SendInBlue 注册时需要提供地址和电话号码(用于反垃圾注册)。注册完成后,点击右上角公司名称菜单,选择 "SMTP & API",再进入 "SMTP" 页签,生成一个新的 SMTP key 并复制。
在项目根目录的.env文件中新增环境变量:
SEND_IN_BLUE_KEY=xsmtpsib-7fa6eb37c244429933ea870185063c493ba1c820f826c5f620877dd815392602-rZgB6GUV1CF2NLAK(示例 key,请替换为你的真实密钥。)
重要:如果你的 dev server 仍在运行,必须重启它才能让新环境变量生效。RedwoodJS 遵循 environment-variables 文档 中的约定,开发环境会从.env加载变量,而生产环境的密钥应配置在部署平台的环境中,绝不应提交到代码仓库。
实现邮件发送:封装 email lib
在 api 侧的lib目录下新建email.ts:
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 }需要替换的占位内容:
- 两处
"your@email.com"替换为注册 SendInBlue 时的邮箱(注意:以 SendInBlue 网站上显示的为准,区分大小写); from中的显示名称"Your Name"可自定义。
nodemailer.createTransport的配置说明:
| 配置项 | 值 | 说明 |
|---|---|---|
host | smtp-relay.sendinblue.com | SendInBlue 的 SMTP 中继服务器地址 |
port | 587 | SMTP 提交端口,配合secure: false(即 STARTTLS)使用 |
secure | false | true对应 465 端口(隐式 TLS),false对应其他端口 |
auth.user | 你的发件邮箱 | SendInBlue 账户邮箱,大小写敏感 |
auth.pass | process.env.SEND_IN_BLUE_KEY | SMTP key,从环境变量读取 |
sendMail的to字段做了Array.isArray兼容处理,支持单个与多个收件人。返回的info包含messageId等投递信息。
在 users service 中接入
在users.ts顶部、db 导入之后添加导入:
// ... import { sendEmail } from 'src/lib/email' // ...添加一个测试邮件封装函数:
// ... 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 }) } // ...把之前占位实现中的console.log替换为:
// ... await sendTestEmail(user.email) // ...现在点击详情页的 "Send email" 按钮,终端会打印类似Sending email to: horacebcarrier@teleworm.us的日志,几分钟后收件箱就能收到邮件(使用 fakenamegenerator 生成的地址时投递可能较慢,需要耐心等待)。
审计功能:在 service 中调用另一个 service
最后为邮件发送加上审计。当 users service 发送邮件后,调用 audits service 的createAudit函数写入一条日志。RedwoodJS 让这一步变得异常简单——直接导入 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的参数结构{ input: { userId, log } }看起来不太直观,但 TypeScript 类型(scaffold 生成的Audit相关类型)会给出明确提示。- 审计记录会自动获得
createdAt时间戳和生成的id,无需手动设置。 - 查看审计日志:访问
http://localhost:8910/audits,使用之前 scaffold 生成的页面即可浏览。
安全边界:service 间直接调用会绕过 GraphQL 指令
这是本教程最重要的提醒。当从 web 侧调用 service 时,请求经由 GraphQL 层,会受到@requireAuth/@skipAuth等指令的保护;但当 service A直接导入并调用service B 的函数时,调用发生在 Node 进程内部,完全绕过 GraphQL 层,也就绕过了指令校验。
具体风险场景:如果一个使用@skipAuth(任何人均可访问)的 service 导入了另一个受保护 service 的函数,那么任何人都能通过暴露的入口间接触发受保护函数,无论被调函数自身声明了何种指令。本示例中emailUser使用了@requireAuth,调用链整体是安全的;但设计公共 service 时务必注意这一点,可参考 services 文档 中关于服务组织与安全的最佳实践。
从源码看 Mailer 的现代实现(进阶延伸)
本教程基于nodemailer直连的方式(v7.x 时代的主流做法)。RedwoodJS 后续在 Mailer 模块 中对邮件发送做了体系化封装,其核心架构与本文的实践一一对应,值得延伸理解:
- Handler(发送端):负责把渲染好的内容交给真实投递服务。官方提供
@redwoodjs/mailer-handler-nodemailer、@redwoodjs/mailer-handler-in-memory(测试用)、@redwoodjs/mailer-handler-studio(开发用)与@redwoodjs/mailer-handler-resend。Nodemailer handler 实现 与本文手写的email.ts逻辑一致——createTransport+sendMail,并把to/cc/bcc/from/replyTo/subject/headers/attachments逐一映射为MailSendOptionsComplete,最后返回{ messageID, handlerInformation }。 - Renderer(渲染端):把 React 组件(如 React Email、MJML 模板)渲染为
{ html, text }字符串。 - 模式路由:Mailer 核心类 在构造时根据
NODE_ENV判定test/development/production三种模式(isTest/isDevelopment支持布尔值或函数形式的when),send()方法按当前模式选择 handler:测试模式默认用 in-memory handler 拦截,开发模式默认路由到 Studio 本地收件箱,只有生产模式才真正投递给默认 handler。这一设计让"开发/测试环境绝不误发真实邮件"成为框架级保障,相关行为在 mailer.test.ts 中有完整测试覆盖。 - 快速上手:
yarn rw setup mailer会生成api/src/lib/mailer.ts配置与示例模板(--force覆盖已有文件,--skip-examples跳过示例内容),详见 setup mailer 命令说明。
对"从零手写 nodemailer"的开发者来说,理解 Mailer 的 Handler/Renderer 分离、默认值合并(utils.ts 中constructCompleteSendOptions对from/subject/to缺失会直接抛错)等设计,有助于写出更健壮的邮件模块。
小结
本文完整走通了一条"RedwoodJS 应用发邮件"的路径:从 Prisma 数据模型、scaffold 生成,到 GraphQL mutation、nodemailer + SMTP 云服务接入,再到 service 间复用实现审计闭环。核心收获有三点:
- 链路清晰:web 端 mutation → SDL 暴露 → service 处理 → lib 封装
nodemailer→ SMTP 投递,每一层职责单一。 - 安全边界:service 间直接函数调用会绕过
@requireAuth等 GraphQL 指令,设计公共 service 时须警惕被@skipAuth入口间接调用。 - 现代替代:官方 Mailer 模块把 handler/renderer 分离、环境模式路由等最佳实践沉淀为框架能力,可作为生产项目的首选方案。
- 后端
- 前端
- Web框架
- 开发工具
【免费下载链接】redwood
RedwoodGraphQL
相关推荐
RedwoodJS 实战:用 Nodemailer + SMTP 发送邮件,并在 Service 之间实现审计日志
RedwoodJS 实战:用 Nodemailer + SMTP 发送邮件,并在 Service 之间实现审计日志 本教程基于 RedwoodJS 官方 how
后端前端Web框架开发工具RedwoodJS 实战:基于 Nodemailer 与 SendInBlue 实现邮件发送与审计日志
RedwoodJS 实战:基于 Nodemailer 与 SendInBlue 实现邮件发送与审计日志 本篇指南以 RedwoodJS v4.x 为背景,完整演
后端前端Web框架开发工具RedwoodJS 邮件发送实战:基于 Nodemailer 与 SendInBlue 构建用户邮件与审计日志功能
RedwoodJS 邮件发送实战:基于 Nodemailer 与 SendInBlue 构建用户邮件与审计日志功能 本文以 RedwoodJS 官方 How T
后端前端Web框架开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考