news 2026/9/24 23:50:06

基于 RedwoodJS 发送电子邮件:从 nodemailer + SMTP 实战到 Service 间复用与审计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于 RedwoodJS 发送电子邮件:从 nodemailer + SMTP 实战到 Service 间复用与审计
  • 后端
  • 前端
  • Web框架
  • 开发工具

【免费下载链接】redwood

RedwoodGraphQL

项目地址:https://gitcode.com/gh_mirrors/re/redwood
点击查看免费下载

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的配置说明:

配置项值说明
hostsmtp-relay.sendinblue.comSendInBlue 的 SMTP 中继服务器地址
port587SMTP 提交端口,配合secure: false(即 STARTTLS)使用
securefalsetrue对应 465 端口(隐式 TLS),false对应其他端口
auth.user你的发件邮箱SendInBlue 账户邮箱,大小写敏感
auth.passprocess.env.SEND_IN_BLUE_KEYSMTP 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 间复用实现审计闭环。核心收获有三点:

  1. 链路清晰:web 端 mutation → SDL 暴露 → service 处理 → lib 封装nodemailer→ SMTP 投递,每一层职责单一。
  2. 安全边界:service 间直接函数调用会绕过@requireAuth等 GraphQL 指令,设计公共 service 时须警惕被@skipAuth入口间接调用。
  3. 现代替代:官方 Mailer 模块把 handler/renderer 分离、环境模式路由等最佳实践沉淀为框架能力,可作为生产项目的首选方案。
  • 后端
  • 前端
  • Web框架
  • 开发工具

【免费下载链接】redwood

RedwoodGraphQL

项目地址:https://gitcode.com/gh_mirrors/re/redwood
点击查看免费下载
上一篇:如何在3秒内预览Office文档:QuickLook原生插件完整指南
下一篇:Topit:macOS窗口置顶工具终极指南 - 如何让任意窗口永远置顶

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

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

分布式优化与非合作博弈下的产消者能量共享MATLAB仿真实现

这是一个让很多人都头疼过的题目。一听到“分布式优化”“非合作博弈”这两个词组合在一起&#xff0c;第一反应往往是在想&#xff1a;这又是哪篇论文里的理论模型&#xff1f;真放在MATLAB里能跑通吗&#xff1f;既得处理博弈论里的均衡概念&#xff0c;又得写分布式迭代算法…

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

SpringBoot生产级日志配置:Logback滚动、异步与MDC实战

先说明一个事实&#xff1a;绝大多数SpringBoot项目的日志&#xff0c;其实都处于“能跑、但不能用”的状态。默认配置打出来的日志&#xff0c;开发阶段看看还好&#xff0c;一到生产环境就露馅&#xff1a;问题排查靠猜、日志文件几天就占满磁盘、想按业务切分却无从下手。这…

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

ChatGPT无限token实战指南:从模型选型到分段投喂的完整方案

1. 先搞懂“无限 token”到底在说什么最近总有人问我&#xff0c;网上传的“ChatGPT 开启无限 token”到底是不是真的能搞出无限上下文&#xff1f;这个问题一出来&#xff0c;我就知道多半是标题党看多了。先说结论&#xff1a;“无限 token”从来不是一个开关&#xff0c;也不…

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

STM32调试避坑指南:BOOT0、SWD、Flash算法与时钟树配置实战

1. 从一块"点不亮"的板子说起&#xff1a;STM32调试的共性痛点搞STM32开发的人&#xff0c;几乎都有过这样的经历&#xff1a;板子焊好了&#xff0c;代码编译通过了&#xff0c;下载器也插上了&#xff0c;结果Keil弹出一个红框——Error: Flash Download failed - …

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

ESP32开发板换板跑不起小智?一文搞懂固件板级适配

我在小智相关的交流群里见过最多的求助&#xff0c;不是“大模型API怎么配”&#xff0c;而是这句话——“我换了一块ESP32开发板&#xff0c;为什么同样的小智源码刷进去就是跑不起来&#xff1f;”每次都往下聊&#xff0c;最后都会落到同一个话题上&#xff1a;适配。很多人…

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

iframe 实战指南:从移动端 PDF 预览到动态数据抓取

不知道你有没有遇到过这种局面&#xff1a;一个看起来再简单不过的iframe嵌套页面&#xff0c;本地联调一切正常&#xff0c;一到线上手机端&#xff0c;用户点开合同却不是预览而是直接下载&#xff1b;又或者用 Scrapy 去抓一个网页&#xff0c;关键数据全在动态生成的 ifram…

作者头像 李华