ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Nodemailer邮件发送实战:从SMTP配置到生产级稳定性方案

Nodemailer邮件发送实战:从SMTP配置到生产级稳定性方案 凌晨一点线上用户注册量突然上来一波紧接着工单群就弹出提示验证码邮件延迟超过五分钟。我打开队列后台一看几十封邮件卡在 SMTP 连接上过了几分钟才陆续投递成功。那是我第一次切身感受到邮件发送这种看起来不起眼的功能在 Node.js 项目里真能把人绊一跤。后来我在团队里搭了一套基于 Nodemailer 的发送模块把认证信息、模板渲染、重试机制和日志全部收拢到一起才把这类问题彻底管住。Nodemailer 是 Node.js 生态里最常用的邮件发送库它不做云端业务而是充当一个直接对接 SMTP 服务器的客户端工具。注册验证码、密码重置、订单通知、定时报表这些功能邮件都可以用它发送只要你有邮箱服务商的 SMTP 地址、账号以及对应的授权密码。这篇内容我会按自己从零搭模块的顺序来写先说选型思路再讲环境准备、最小发送示例、模板与附件处理然后聊生产环境的稳定性最后是一堆实测中踩过的坑。适合刚接触 Node.js、需要在 Express/Koa/NestJS 里加邮件功能的开发者也适合已经能跑通代码、但被错误配置和高频发送折磨过的人。1. 为什么项目里需要自建邮件发送模块1.1 从一封“发给全体用户”的公告说起有一回运营同事要做站内公告邮件第一批名单五千人。当时公司的做法是让运营去邮件服务商的后台手动导入名单、套用模板、点击发送。听起来没什么问题实际跑完才发现模板里需要拼接每个用户的用户名和专属链接服务商后台的变量语法跟运营理解的完全不是一回事有人导入表格时把邮箱列和姓名列搞混了发完以后要统计送达率又得人工导出一份报告。整个流程折腾了三天最后还被用户投诉说邮件里名字写错。从那以后我就开始坚持在业务系统里自建邮件发送模块。不是说服务商后台不好用而是业务邮件一旦跟用户系统、工单系统、订单系统产生关联就应该通过接口自动触发而不是靠人工搬运。Nodemailer 在这个链条里承担的是最后一步把程序里准备好的发件人、收件人、主题、正文和附件按照 SMTP 协议交给邮件服务器。它不带界面、不存储数据也不负责统计但正因为足够底层反而容易嵌入到各种架构里。还有一层原因是成本可控。很多第三方邮件 API 虽然开箱即用但报价通常按封数计算业务进入快速增长期后验证码、通知、营销这三类邮件的量会迅速上升账单很快就超出预期。如果公司自己已经有企业邮箱或面向开发者的邮箱服务通过 Nodemailer 走自家 SMTP边际成本几乎为零。当然这不代表 Nodemailer 适合所有场景下面我把边界说清楚。1.2 什么时候适合 Nodemailer什么时候应该换方案我经常会收到类似“我们想用 Nodemailer 做营销邮件自动化”的咨询。这类需求我一般会泼点冷水。Nodemailer 解决的是发送协议问题不是自动化营销问题。它没有内置的收件人分组、A/B 测试、退订管理、送达率看板、点击追踪。你当然可以用代码把这些全部拼出来但那是把简单问题复杂化。为了帮大家快速判断我把常见方案分成了三类。第一种是 Nodemailer 加任意 SMTP 服务商适合系统功能邮件比如验证码、通知、告警特点是发送量相对可控、内容偏事务性、需要跟业务代码深度集成。第二种是第三方邮件 API比如 SendGrid、Mailgun、阿里云邮件推送这类适合不想自己维护 SMTP 服务、对送达率有更高要求、同时预算充足的团队它们的 SDK 底层有的也用 Nodemailer但额外的数据分析、退订管理确实省心。第三种是邮件营销平台比如 Mailchimp 这类适合市场部做批量营销活动核心价值在受众管理和模板设计跟写代码的关系不大。我现在的选型原则很简单凡是业务代码里需要同步等待发送结果的邮件或者说发送逻辑需要跟用户状态、订单状态绑定的邮件一律走 Nodemailer凡是运营自己去后台折腾的批量营销邮件一律走营销平台。两者通过业务标识互相隔离避免一封“验证码”和“本周优惠推荐”混在同一个发送通道里互相拉低到达率。2. 环境准备Node.js 版本选择与项目初始化2.1 安装 Node.js 并确认版本Nodemailer 是一个纯 JavaScript 库对 Node.js 版本的要求不算苛刻但为了让开发体验顺畅我建议从一开始就装 LTS 版本。去 Node.js 官网下载页能找到标着 LTS 字样的安装包以目前常见的版本来看18.20.4、20.x、22.x 这几个长期维护版本都可以用。新项目我会优先选偶数版本也就是 20 或 22原因很简单偶数版本是 LTS 主力社区更新和依赖兼容性更稳奇数版本虽然也有爱好者在使用但不建议放在生产环境里追新。如果你是 CentOS 7.9 这类服务器环境用 nvm 安装会更省心。nvm 的好处是可以在同一台机器上维护多个 Node.js 版本项目需要 18 就切 18需要 22 就切 22不会因为系统包管理器里的版本太老而卡住。安装完成后在终端里执行node -v和npm -v能正常输出版本号说明环境就绪。这一步本身不复杂但我在团队里见过很多次“明明安装了却提示找不到 node”的情况基本都是在安装之后没有重开终端PATH 环境变量没有刷新重开一个终端窗口问题就消失了。接下来初始化项目。我习惯先建一个干净的目录然后执行npm init -y生成默认的 package.json。这样做不是为了仪式感而是为了后面安装依赖时有版本记录、有 lock 文件团队协作时大家能拉到完全一致的环境。如果你用的是 pnpm 或 yarn 也完全没问题Nodemailer 对这些包管理器一视同仁只是命令从 npm install 换成对应的包管理器命令而已。2.2 安装 Nodemailer 并锁定版本在项目根目录下执行下面的命令npm install nodemailer安装完成之后建议顺手看一眼 package.json 里的版本号。当前 Nodemailer 的稳定大版本是 6.xAPI 已经非常成熟。我在生产项目里一直用 6.9.x 系列升级小版本时也只需要跑一遍现有测试用例确认兼容性即可。前几年有人在 npm 上看到过 beta 版本号一时好奇装上去结果发现部分配置参数跟稳定版不兼容这种没有必要去尝鲜邮件模块最怕不确定。Nodemailer 同时支持 CommonJS 和 ES Module 两种引入方式。老项目里最常用的是const nodemailer require(nodemailer)如果你的 package.json 里设置了type: module那就写成import nodemailer from nodemailer。两种写法在功能上没有差异唯一要注意的是别在同一个文件里混用。我自己的习惯是保持项目的统一风格已经切了 ESM 的团队就全部用import老项目继续用require不要因为同事喜好而反复横跳。3. 第一封能跑通的邮件SMTP 配置与最小发送示例3.1 先理解 transport 和 mail 这两个核心对象Nodemailer 有两个核心概念transport 和 mail。简单类比一下transport 相当于你选定的邮局柜台它知道邮局地址是哪里、你用哪个账号登录、走哪个门进去也就是 SMTP 服务器的连接配置mail 则是你要寄出的那封信上面写着发件人、收件人、主题、正文内容和附件。这两个概念分开设计的好处是transport 可以在进程启动时创建一次长期复用mail 则根据业务不同频繁变化每一封邮件都是独立的描述对象。很多第一次接触的人容易把两者混为一谈总想每次发送都新建一个 transport其实完全没有必要。建立一个 SMTP 连接需要经过 TCP 握手、TLS 协商、认证多个步骤高并发场景下如果每封邮件都重新建连开销会非常吓人。正确做法是把创建好的 transporter 实例放到模块顶层或者依赖注入容器里整个进程生命周期只建一次。我在写生产代码时通常会写一个专门的邮件服务模块模块内部维护 transporter外部只暴露sendMail(options)这样的方法。这样即便后面要换邮箱服务商也只需要改动模块内部的 transport 配置业务方完全无感。3.2 准备 SMTP 授权码不是邮箱登录密码开始写代码之前先去你使用的邮箱服务商后台开启 SMTP 服务并生成一个授权码。这里必须强调一件事绝大多数邮箱服务商不允许直接用邮箱登录密码作为 SMTP 密码否则会有撞库风险。以 QQ 邮箱为例登录网页邮箱后进入设置找到账户下的“开启服务”把 SMTP 服务打开然后按提示生成一个十六位左右的授权码。这个授权码本质上是一个只用于第三方客户端的独立凭证就算泄露了也可以在服务商后台随时撤销并重新生成。我见过很多人在配置里直接填 QQ 密码然后报Invalid login错误折腾半天查不出问题。其实只要把账号密码换成邮箱账号加授权码问题立刻消失。另外还要注意授权码绑定的邮箱服务商是独立的不同服务商之间的授权码不能混用。163 邮箱、126 邮箱、Outlook、Gmail 各自有不同的生成方式后面我会专门讲各个服务商的差异。暂时先用最通用的配置跑通流程。3.3 最小发送示例async/await 版本下面这段代码是完整的发送流程可以直接保存为一个.js文件运行。我用的是 QQ 邮箱的 SMTP 配置你在测试时可以换成自己的邮箱和授权码收件人也建议填一个你能实时打开的其他邮箱地址。const nodemailer require(nodemailer); const transporter nodemailer.createTransport({ host: smtp.qq.com, port: 465, secure: true, auth: { user: 你的邮箱地址qq.com, pass: 你的十六位授权码 } }); async function main() { const info await transporter.sendMail({ from: 网站通知 你的邮箱地址qq.com, to: adamexample.com, subject: Nodemailer 测试邮件, text: 这是一封来自 Nodemailer 的测试邮件。 }); console.log(邮件发送结果:, info); } main().catch(console.error);逐行解释一下。createTransport创建了一个发送通道host是 SMTP 服务器地址port是端口secure: true表示使用 SSL 加密连接。QQ 邮箱的 465 端口要求 SSL所以这里设置为 true。如果使用 587 端口一般secure要设为 falseNodemailer 会通过 STARTTLS 方式在连接建立后升级为加密连接。sendMail的参数里from是发件人显示名和邮箱地址的组合to是收件人subject是主题text是纯文本正文。运行这段代码之后如果控制台打印出一个包含messageId的对象说明邮件已经交给 SMTP 服务器了。messageId非常关键后续查日志、向邮箱服务商反馈问题时都需要用到它我一般会把它存进数据库和日志系统。3.4 三种异步处理方式回调、Promise 与 async/awaitsendMail在不同时期有不同用法。老版本支持回调函数你可以写成transporter.sendMail(mailOptions, (err, info) { if (err) { console.error(发送失败, err); return; } console.log(发送成功, info.messageId); });Nodemailer 长期支持 Promise 风格所以上面代码等价于transporter.sendMail(mailOptions).then(info {}).catch(err {})。我个人强烈推荐 async/await 写法因为邮件发送在业务里通常不是孤立动作它可能伴随着用户状态更新、数据库写入、审计日志记录。用同步写法把这些串起来代码的阅读顺序就是执行顺序调试的时候心智负担小很多。还有一个容易忽略的点sendMail返回的 Promise 只有在 SMTP 服务器正确响应了250 OK之后才会 resolve。也就是说如果返回成功基本意味着邮件已经进入邮件服务商的发送队列后面的投递过程就不是我们这段代码能控制的了。这一点对理解“到达率”问题非常重要后面第六节会展开讲。4. 从裸文本到精致邮件模板、附件与内联图片的处理4.1 HTML 正文与内联图片理解 cid 机制业务邮件很少只用纯文本至少也要有个排版。Nodemailer 的html字段可以直接传一段 HTML 字符串比如await transporter.sendMail({ from: 商城系统 youexample.com, to: user.email, subject: 您的订单已发货, html: div stylefont-family: sans-serif; h2您好${user.name}/h2 p您的订单 ${orderNo} 已由顺丰发出。/p img srccid:logo alt商城Logo width120 / /div , attachments: [ { filename: logo.png, path: ./assets/logo.png, cid: logo } ] });这里srccid:logo是关键。cid 是 Content-ID 的缩写它把 HTML 页面里的图片引用和邮件附件绑定起来。邮件客户端读到cid:logo时会去查找邮件资源中 cid 为 logo 的那个附件然后把它嵌入到对应位置。这样做的最大好处是图片随邮件一起投递收件人不联网也能看到同时也避免被邮箱服务商拦截“外部图片加载”的请求。很多人会踩一个坑直接在 HTML 里写src/assets/logo.png或者srcC:\assets\logo.png。这在网页开发里没问题但邮件不是网页。收件人的电脑上没有你的本地文件路径即使他把邮件转给别人文件依然不存在。正确做法就是通过 attachments 里的 cid 引用。4.2 附件处理路径、Buffer、Stream 三种数据来源附件是邮件模块里相当常用的能力。Nodemailer 的attachments字段是数组每一项至少包含filename和内容来源。内容来源可以是磁盘路径path、内存 Buffer也可以是 Stream。如果你要发送的文件已经存在磁盘上那直接写path最简单。比如导出用户列表时后端先用 Excel 库生成临时文件再作为附件发出attachments: [ { filename: 用户列表.xlsx, path: /tmp/users.xlsx } ]但临时文件有个问题发完邮件之后释放不及时磁盘上容易堆一堆垃圾。更好的做法是不落盘直接把文件内容生成 Buffer然后通过content字段发送。下面是一个生成 CSV 并作为附件的例子const csvContent 姓名,邮箱\n张三,zhangsanexample.com\n李四,lisiexample.com; const csvBuffer Buffer.from(csvContent, utf-8); await transporter.sendMail({ from: 系统 youexample.com, to: adminexample.com, subject: 用户导出数据, attachments: [ { filename: users.csv, content: csvBuffer, contentType: text/csv; charsetutf-8 } ] });使用 Buffer 的好处是不用清理临时文件也避免了并发环境下多个请求同时读写同一个临时文件名造成的错乱。遇到超大文件或者内容来自数据库查询结果并且量比较大时可以改用 Stream。Nodemailer 支持直接把readable stream传给content这样文件内容不会一次性全加载进内存对内存占用更友好。4.3 模板渲染别把页面字符串堆在代码里如果邮件正文很短HTML 字符串直接写在代码里可以接受。但一旦页面结构复杂起来比如有页头、页脚、商品列表、推荐位把所有字符串拼在一个.js文件里会让代码迅速失控而且运营同事想改个 logo 或者二维码位置还得找开发来改代码。我通常会把 HTML 模板抽离成独立文件放在项目里的email-templates/目录下然后在代码里读取并渲染。如果项目用 Express可以搭配 EJS 或 Handlebars 这类模板引擎。渲染思路是把模板文件内容读出来再用模板引擎注入变量const fs require(fs/promises); const ejs require(ejs); async function sendTemplateEmail(transporter, options) { const template await fs.readFile(./email-templates/${options.templateName}.ejs, utf-8); const html ejs.render(template, options.data); return transporter.sendMail({ from: options.from, to: options.to, subject: options.subject, html }); }这里有个安全细节要提醒模板里插入用户可控内容时一定要做 HTML 转义否则用户把自己的昵称写成script标签邮件客户端处理时虽然不太会执行脚本但这种做法总归不干净。EJS 默认转义方式是用% %如果你确定内容安全才用%- %输出原始内容。上次我看到有人为了图省事全部用%- %这个习惯很危险。4.4 发件人、收件人、抄送与自定义邮件头from字段不只是邮箱地址还能带上显示名。写成网站通知 noreplyexample.com之后收件人在邮件列表里看到的是“网站通知”这个名字。注意显示名如果包含中文Nodemailer 会自动做 RFC 2047 编码不需要手动处理。to、cc、bcc都支持字符串或者数组。CC 是抄送收件人能看到其他抄送人的地址BCC 是密送收件人看不到其他密送人。批量给不同客户发同一封邮件时BCC 是保护隐私的关键手段千万不要把所有客户邮箱都放到to里。自定义邮件头用headers字段比如设置回复地址和优先级await transporter.sendMail({ from: 客服系统 supportexample.com, to: user.email, subject: 工单回复, html: p您的工单已处理完成。/p, headers: { Reply-To: noreplyexample.com, X-Priority: 1 } });Reply-To指定用户点“回复”时送达的地址很多发件地址不接收人回信这个头非常有用。X-Priority是给部分邮件客户端看的优先级提示不过别滥用真正紧急的系统告警才需要高优先级普通通知用默认级别就好。5. 生产环境下的稳定性异步发送、重试机制与日志埋点5.1 把邮件发送从请求链路里拆出去很多新手会把邮件发送直接写在 HTTP 请求处理函数里比如用户注册时等待sendMail返回成功再响应 200。这样做处理小流量勉强能跑但生产环境迟早出问题。SMTP 发送涉及到外部网络顺畅时几十毫秒慢的时候可能几秒钟如果收件人邮箱服务器暂时不可用SMTP 客户端还会不停重试用户请求就被一直吊在那里。我在项目里的通用做法是引入任务队列。注册接口只负责创建用户、生成验证码、把“发送验证码邮件”这个任务塞进队列然后立刻返回。后台 Worker 进程从队列里取任务调用邮件模块发送。这样做的好处是接口响应速度不受邮件影响邮件发送高峰时可以靠队列缓冲发送失败还能重新入队重试不用把失败逻辑散落在业务代码里。轻量项目不用一上来就上 Redis、BullMQ 这类重型组件可以先在进程内维护一个简单的任务数组用 async 循环挨个处理。如果公司已有 RabbitMQ、Kafka 这类消息中间件直接把任务作为一个消息发到队列即可。架构上记住一条原则邮件是典型的异步、可重试、对延迟不敏感的任务它不应该阻塞用户的主流程。5.2 失败分类与指数退避重试邮件发送失败不可能完全避免关键是怎么失败重试。重试之前先分类。第一类错误是连接层错误比如ENOTFOUND、ETIMEDOUT、ECONNECTION说明网络不通或者 SMTP 服务器没响应。这类问题往往是临时的可以重试。第二类错误是认证错误比如Invalid login说明账号、授权码配置有问题这种问题重试一万次也没用应该直接告警让开发去检查配置。第三类是 SMTP 服务器返回的错误码4xx 开头表示临时性失败比如421 too many connections可以退避重试5xx 开头表示永久失败比如550 mailbox unavailable通常重试也无意义。写重试逻辑时可以借鉴下面的结构async function sendWithRetry(transporter, mailOptions, maxAttempts 5) { let attempt 0; const baseDelay 1000; while (attempt maxAttempts) { try { const info await transporter.sendMail(mailOptions); logger.info(邮件发送成功messageId${info.messageId}); return info; } catch (err) { attempt 1; const permanent err.responseCode 500 || err.code EAUTH; if (permanent || attempt maxAttempts) { logger.error(邮件最终发送失败attempt${attempt}error${err.message}); throw err; } const delay baseDelay * Math.pow(2, attempt - 1); logger.warn(邮件发送失败尝试第 ${attempt} 次重试延迟 ${delay}mserror${err.message}); await sleep(delay); } } }这里用的指数退避策略是每次重试间隔翻倍1 秒、2 秒、4 秒、8 秒。为什么要这样做因为如果 SMTP 服务器已经过载你固定每隔 1 秒重试一次等于持续给服务器施加压力退避可以让服务端有喘息时间也让我们的重试行为更像一个“正常人”而不是一台失控的重试机器。5.3 日志与可观测性邮件模块上线之后如果什么都不记录等运营跑来问“为什么客户没收到邮件”时你就只能两眼一抹黑。所以日志要从入口就开始埋点。至少记录这些字段发送时间、from、to、subject、模板名称、messageId、SMTP 响应码、耗时、错误信息。messageId 尤为重要它是你向邮件服务商咨询的唯一凭证。Nodemailer 本身也提供了调试辅助选项。在createTransport传参时加上logger: true、debug: true、transactionLog: true会把与 SMTP 服务器的交互过程打印到控制台。本地开发时这些日志很有用能让你直观看到认证过程、邮件头、服务器返回的每一行状态码。但生产环境慎开debug因为邮件头里会有收件人信息打印到日志会有隐私问题。我一般只保留业务自己记录的发送结果日志debug开关做成环境变量控制排查问题时临时打开。如果公司已经有日志收集系统建议把邮件日志结构化输出成 JSON 放进 ES 或 Loki。这样出了问题可以快速按to字段搜索某位用户的所有发送记录也能统计某段时间的发信成功率。邮件不可观测的痛我体会过太多次了。5.4 连接池与并发控制Nodemailer 默认每个 transporter 内部会维护独立的连接状态。如果发送量大可以开启连接池const transporter nodemailer.createTransport({ host: smtp.qq.com, port: 465, secure: true, pool: true, maxConnections: 5, maxMessages: 100, auth: { user: youqq.com, pass: 授权码 } });pool: true表示开启连接池maxConnections是同时保持的最大连接数maxMessages是每条连接发送多少封邮件之后自动重连。连接复用能显著降低握手成本尤其是在短时间发送大量邮件时。需要提醒的是连接池对邮箱服务商来说意味着同一账号短时间内从多个连接并发发信个别服务商对并发连接数有限制开得太大反而会触发风控。先从 5 开始压测看情况再调。还有一个小技巧createTransport只调用一次。很多员工代码在每次发送邮件时都重新创建 transporter这在并发量上来后会创建大量重复连接造成Too many connections错误。把这个只配置一次的对象放进模块作用域就能避免这个问题。6. 换邮箱、真机调试与邮件到达率实测中踩过的坑6.1 不同邮箱服务商的 SMTP 配置差异我踩过最深的坑是服务商配置差异。每个邮箱服务商的 SMTP 地址、端口、安全方式都不完全一样加上账号安全策略差异几乎每个新接手的项目我都会重新翻一遍文档。这里把我常用的配置整理成表服务商SMTP 地址端口secure 设置认证方式QQ 邮箱smtp.qq.com465true授权码163 邮箱smtp.163.com465true客户端授权密码126 邮箱smtp.126.com465true客户端授权密码Outlooksmtp-mail.outlook.com587false常规密码或应用密码Gmailsmtp.gmail.com465true应用专用密码这份表格只做参考因为服务商可能会调整端口和安全策略。我发现最稳妥的办法是用一个最小脚本挨个测试createTransport加sendMail而不是凭记忆写配置。尤其是端口 465 和 587 的选择secure: true通常配 465secure: false配 587。如果配错了最常见的表现就是连接被对方服务器重置日志里出现socket hang up。另外要提醒一句部分境外邮箱服务的连通性和投递稳定性受当前网络环境影响比较大。如果项目主要面向国内用户优先使用国内邮箱服务商的 SMTP如果主要面向海外用户则优先使用对应地区的邮件服务商。邮件服务器的物理距离对发送时延和到达率有实实在在的影响这个和代码本身没关系。6.2 授权码之外Invalid login的排查链路Invalid login可能是邮件模块里最常见也最让人抓狂的错误。我建议遇到这个错误时按下面的链路排查。第一步确认auth.user填的是完整邮箱地址而不是只填了前缀第二步确认pass填的是授权码而不是邮箱密码很多服务商在登录网页邮箱后可生成别把两者混淆第三步去邮箱后台确认 SMTP 服务开关是否已经打开有些服务商默认关闭第三方客户端访问第四步确认账号是否因为异地登录或异常行为被临时风控这种情况通常需要登录网页邮箱做一次安全验证第五步确认授权码是否曾被撤销过如果你的配置是从朋友代码或旧项目拷贝来的很可能授权码早就失效了。我把这套排查链路写进了团队的知识库无论谁接到邮件告警先照着走一遍大部分Invalid login都能在十分钟内解决。那些一开始就怀疑“Nodemailer 库有 bug”的人往往排查到最后发现是配置或者服务商安全问题。6.3 邮件到达率这其实是投递侧的问题代码里sendMail返回成功只代表邮件已经成功交给了 SMTP 服务器不代表它一定进了收件人的收件箱。后面还有一大堆过滤规则在等着。最典型的一个坑是发件人域名和 SMTP 服务器域名的 SPF 记录不匹配。比如你的发件地址是noreplymycompany.com但 SMTP 服务器是 QQ 邮箱的而 QQ 邮箱并不等同于mycompany.com的邮件托管方对方邮件系统校验 SPF 时会认为这封邮件来源不合法轻则进垃圾箱重则直接拒收。正规做法是让发件域名和 SMTP 服务商的域名保持一致或者配置好 SPF、DKIM、DMARC 记录。SPF 声明“哪些服务器允许以这个域名发信”DKIM 对邮件内容进行签名DMARC 则告诉接收方验证失败后怎么处理。这三条 DNS 记录通常要由运维或域名管理员添加Nodemailer 本身不负责这件事。我见过很多团队把全部精力放在代码调优上却忽略了 DNS 配置最后到达率惨不忍睹。内容层面也要尽量避免踩雷。纯文字的验证码邮件里夹带大量图片、链接和“免费”“促销”这类词很容易被判定为营销邮件。功能邮件就是功能邮件语气平实、链接明确别为了宣传顺手加一堆导购元素。6.4 本地开发建议使用假 SMTP 服务开发环境下反复用真实邮箱发测试邮件不仅会把测试数据污染到用户日志里还容易触发服务商的发送频率限制。我现在的做法是在本地或测试环境跑一个假 SMTP 服务最常用的是 MailHog。它提供一个 SMTP 端口收到邮件之后不实际投递而是存在内存里并提供一个 Web 界面方便查看。使用方式很简单用 Docker 启动docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog然后 Nodemailer 的配置改成const transporter nodemailer.createTransport({ host: localhost, port: 1025, secure: false });注意这里不用配置auth。发送之后打开localhost:8025就能像看真实邮件一样查看主题、正文和附件。用这种方式调试模板和附件编码效率比发真实邮箱高很多。等开发环境验证通过再把配置切到真实 SMTP 邮箱。6.5 高频发送与限流量大了必须分通道普通邮箱账号的 SMTP 服务不是为了海量营销设计的发送量超过阈值后会触发限流甚至临时封禁。常见的错误码包括421 too many connections、451 temporary failure、550 rejected等。遇到这种情况第一反应不是调大并发而是检查是不是把功能邮件和营销邮件混在了同一个账号、同一个通道里。实践证明功能邮件的发送频率虽然峰值高但有规律营销邮件的量则可能突然爆发。如果两者共用同一个邮箱账号一次营销活动就可能把功能邮件的配额吃掉导致验证码大面积延迟。我现在的做法是注册验证码、密码重置这类强时效邮件走一套独立的 SMTP 账号和 transporter营销通知走另一套两边互不干扰。每一套账号的日发送量都被记录并可视化接近阈值时自动告警。如果发送量已经大到普通邮箱账号完全扛不住那就该考虑升级到邮件服务商的企业方案了。Nodemailer 不变只需要把 SMTP 地址、端口、账号换成服务商提供的发信专用配置即可。6.6 中文字符集与主题乱码邮件乱码问题现在少了但偶尔还是会出现。Nodemailer 处理文本时默认按 UTF-8 编码所以只要你的源码文件本身是 UTF-8正文里的中文一般不会乱码。如果使用 Buffer 或二进制流作为内容一定要在contentType里带上charsetutf-8。HTML 邮件的head里也可以加一行meta charsetutf-8让邮件客户端解析时更明确。邮件主题的编码有点特殊它不能直接在头字段里放原始中文必须经过 RFC 2047 编码。比如“测试邮件”最终可能变成?UTF-8?B?5rWL6KV6YKu5Lu2?这样的字符串。Nodemailer 会自动完成这个转换所以你在代码里直接写中文主题即可。有个别服务商或老客户端对过长主题的处理有 bug主题尽量控制在几十个字符内超过太长一段反而容易被截断。最后说一个我在项目里养成的习惯凡是邮件模块一定保留一个只能从内部管理后台触发的测试接口传入收件人地址即可现场发送一封包含所有模板类型和附件的测试邮件。新接手的同学、换邮箱服务商、调 SPF 记录之后都可以通过这个接口快速验证配置和送达情况。这比翻着代码改参数、写临时脚本再删掉要高效得多。邮件模块作为系统里最“拖后腿”但又不可缺的一环值得你提前把这些细节都填平。
返回列表