news 2026/9/23 10:50:01

RedwoodJS 邮件发送实战:基于 Nodemailer 与 SMTP 服务构建带审计记录的用户邮件系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RedwoodJS 邮件发送实战:基于 Nodemailer 与 SMTP 服务构建带审计记录的用户邮件系统

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 的典型开发节奏是"数据模型先行、代码生成跟上、业务逻辑收尾"。本文要构建的示例应用包含两部分核心能力:

  1. 用户列表管理:保存用户的姓名与邮箱,提供一个可视化界面用来查看用户,并触发"给某用户发送测试邮件"的动作;
  2. 邮件审计:每当管理员向某用户发送一封邮件,就在数据库中追加一条审计日志,记录"谁在什么时候给哪个用户发了邮件"。

审计功能将通过从一个 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和关联字段,但示例始终保留idcreatedAtupdatedAt。这是良好的数据建模习惯——后期补充字段时不必回头做破坏性迁移。
  • 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/toasttoast.success/toast.error给出用户反馈;
  • EMAIL_USER_MUTATION:声明式定义前端要调用的 Mutation,参数为$id: String!,返回User对象;
  • 确认弹窗:删除与发送邮件前都使用confirm二次确认,避免误操作。

定义 GraphQL SDL 与 Service 占位实现

前端 Mutation 需要后端 SDL 中对应字段才能生效。在api/src/graphql/users.sdl.tsMutation类型中加入:

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 侧的操作步骤:

  1. 注册账号(需要提供地址与电话号码,用于防止垃圾邮件滥用);
  2. 点击右上角公司名称菜单,选择SMTP & API
  3. 进入SMTP选项卡;
  4. 生成一个新的 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类支持三种运行模式——testdevelopmentproduction,可在配置中分别指定不同模式下的 handler(例如测试模式用内存 handler、开发模式用 Studio handler、生产模式才真正走 SMTP),并根据NODE_ENV自动判定当前模式。这一设计与本文手写的email.ts方案一脉相承:都是围绕 SMTP 投递链路做封装,区别在于 Mailer 框架把 handler 可插拔化、把渲染与投递解耦、并把环境差异纳入配置管理。完整的 Mailer 使用说明可参考仓库文档 docs/docs/mailer.md。

对于小型应用或快速原型,本文基于 nodemailer 的手写方案足够直接有效;随着项目规模增长,可以平滑迁移到官方 Mailer 框架以获得更规范的开发/测试/生产隔离能力。

小结

本文完整走通了 RedwoodJS 邮件发送的实战链路:

  1. 建模与迁移:Prisma 中定义 User 与 Audit 一对多模型,yarn rw prisma migrate dev落地数据库;
  2. Scaffold 生成yarn rw g scaffold User/Audit一键获得 CRUD 页面与服务;
  3. 前端触发:User 组件中通过useMutation调用emailUserMutation;
  4. SDL 保护:Mutation 声明@requireAuth,由指令系统实施鉴权;
  5. SMTP 集成api/src/lib/email.ts用 nodemailer 对接 SendInBlue,凭据存放于.env
  6. 服务编排emailUser内部调用sendTestEmailcreateAudit,实现"发信即留痕"的审计能力,同时理解了服务间调用绕开 GraphQL 指令的权限边界。

这套模式可以推广到任何需要邮件能力的 Redwood 应用——欢迎信、重置密码、通知提醒等,只需替换邮件内容生成逻辑与触发时机即可复用。

【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 10:47:27

Qt Creator从零安装配置与CMake项目实战避坑指南

1. 为什么Qt Creator值得你花时间折腾如果你正在看这篇文章&#xff0c;大概率是遇到了下面几种情况之一&#xff1a;想学C但不知道用什么写代码&#xff0c;听说Qt能做出带界面的桌面软件但装了半天没跑起来&#xff0c;或者被Visual Studio那几十个G的安装包劝退了。我当初也…

作者头像 李华
网站建设 2026/9/23 10:45:21

三角函数与解三角形:公式记忆、辅助角公式与实战解题套路全梳理

1. 开篇&#xff1a;为什么这一期专门讲这块内容如果你正在追这个系列&#xff0c;应该已经发现&#xff0c;前几期我们把高中数学里那些“绕不开的硬骨头”做了梳理。这一期我想集中聊聊三角函数与解三角形&#xff0c;原因很简单&#xff1a;这块内容在高考卷里的存在感极强&…

作者头像 李华
网站建设 2026/9/23 10:43:47

储能运维工程师证有必要报班吗?从报名学习到考试拿证,报考全攻略

储能是新能源产业的”新增长极”&#xff0c;储能运维工程师是新兴的热门技术岗位。想考证入行&#xff0c;报不报班&#xff1f;本文围绕储能运维工程师证&#xff0c;把自学与报班的差距、费用、选班要点和报考流程讲透。 先说结论&#xff1a;储能技术更新快、专业性强&…

作者头像 李华
网站建设 2026/9/23 10:43:05

2026年值得折腾的Docker项目:自托管、开发工具与监控运维实战

1. 为什么2026年还值得折腾Docker项目1.1 从“能跑就行”到“跑得优雅”的转变如果你在2026年还在用docker run裸奔一个MySQL容器&#xff0c;然后把数据卷随手扔在/var/lib/docker里&#xff0c;那这篇文章就是写给你的。我接触Docker差不多有七八年了&#xff0c;从最早的doc…

作者头像 李华