
做了这么多年后端开发我发现“发邮件”这功能看着不起眼真上手却能把人折磨得够呛。早几年接公司一个内部报表系统的需求要求每天早上定时把统计数据发给各业务线负责人我当时天真地想这不就是调个接口的事结果在服务器上折腾邮件客户端、处理认证、应对退信两天都没完全跑顺。后来把整个发送逻辑收拢到 Node.js 服务里用 Nodemailer 统一处理才真正省心。如果你正在 Node.js 项目里做邮件发送或者准备给某个系统接入邮件通知能力这篇教程能让你少走我踩过的大半坑。我会从环境准备、最简示例讲起一直讲到 HTML 模板、附件、批量发送和生产环境的配置管理最后附上一套高频问题排查实录无论你是刚会 Node.js 的入门选手还是已经在维护线上服务的开发者都能直接照着用。1. 环境准备版本选型和项目初始化1.1 Node.js 版本怎么选LTS 比你想象的重要先聊版本。很多人搜“node.js 18.20.4 LTS 版本下载”或者“node.js 22.12”说明大家普遍对版本选择有些拿不准。Nodemailer 对 Node.js 本身要求不算苛刻官方文档说它支持很老的版本但我强烈建议直接装官方 LTS 版本。原因不复杂LTS 版本意味着社区踩坑资料最全、依赖兼容性最稳而且生产环境里你大概率不止跑 Nodemailer 一个包其他依赖往往会对 Node 版本有要求。我自己惯用的组合是 Node 18 LTS 或 20 LTS比如 18.20.4 这种小版本已经修补了之前的安全问题跑起来稳定得有点无聊——这恰恰是好事。如果你还在用 Node 10 或 12 这类早已过时的版本建议抽时间升级。倒不是说 Nodemailer 一定跑不起来而是老版本内置的 TLS 实现和现代 SMTP 服务器的握手兼容性会越来越没谱很可能出现“本地正常、线上连不上”的灵异问题。安装方面我习惯用 nvm 管理版本因为它支持多版本切换还不需要 sudo 权限。Linux 服务器上直接用系统包管理器装的 Node 往往版本偏旧用 nvm 拉官方 LTS 就不会有这个问题。安装完记得在终端执行node -v和npm -v确认一下输出如果版本号能正常打出来就说明环境没问题了。1.2 初始化项目并安装 Nodemailer打开终端进入工作目录执行npm init -y会快速生成一个 package.json里面就是项目的基本元信息和依赖记录。然后安装 Nodemailernpm install nodemailer装完之后可以顺手确认版本号node -e console.log(require(nodemailer/package.json).version)我印象里 Nodemailer 的依赖树非常浅只有一个与 SMTP 连接池相关的底层库安装过程没有编译步骤基本不会出现安装失败的情况。如果你用的是 Yarn 或 pnpm分别执行yarn add nodemailer或相关的安装命令即可效果一样。安装完成后把node_modules加进.gitignore是常规操作但很多刚接触 Node 的同学会漏掉导致仓库体积爆炸。1.3 稍微搞清楚 SMTP 是怎么回事后面排查才有方向很多人第一次用 Nodemailer配置一填能发出去就算完事。但收发出现异常时不懂 SMTP 的基本流程会非常被动。可以把 SMTP 理解成邮政系统你的代码是寄件人SMTP 服务器是发件邮局收件人的邮箱服务器是收件邮局。实际投递路径是你的程序 - 发信服务器 - 互联网上的 MX 解析 - 收信服务器 - 收件人邮箱。Nodemailer 的createTransport本质是帮你建立并维护一条到“发件邮局”的 TCP 连接sendMail则是把封装好的信封投递进去。理解这一点以后你就会明白为什么很多问题不能只看自己代码比如 550 错误可能是收件方服务器拒绝而不是你的 SMTP 配置错投递延迟高可能是 MX 解析路径上的问题跟你本地一点关系都没有。这套基础在后面排查章节还会反复用到。接下来开始写第一封邮件。2. 第一封邮件最小可用示例与配置拆解2.1 创建 transporter 时那些参数到底是什么意思先看一段最常见的初始化代码const nodemailer require(nodemailer); const transporter nodemailer.createTransport({ host: smtp.example.com, port: 465, secure: true, auth: { user: senderexample.com, pass: your_auth_code, }, });四个关键参数里host是你的发信服务器地址企业邮箱也好、第三方邮箱服务也好通常都能在服务商的设置页找到。port和secure最好绑定记忆465 端口对应隐式 SSLsecure必须为true587 端口对应 STARTTLS 加密升级secure一般设false。简单记就是“465 开 true587 开 false”。我实际项目里更常用 465因为很多企业邮箱服务商只开放 465而且代码里不需要额外处理 STARTTLS 握手逻辑少一个环节就少一类问题。auth是新手最容易卡住的地方。这里的pass不是邮箱的登录密码而是服务商生成的一串“授权码”或专用密码。以常见的 163、QQ 邮箱为例你需要先去网页版邮箱设置里开启 SMTP 服务拿到专属授权码再把它填到这里。如果你直接填了登录密码大概率会得到535或EAUTH之类的认证失败错误。这个细节当年坑过我半个多小时后来才反应过来密码和授权码完全是两码事。| 服务商 | SMTP 地址 | 端口 | 授权码说明 | | --- | --- | --- | --- | | QQ 邮箱 | smtp.qq.com | 465/587 | 需要在设置里开启 SMTP 并生成授权码 | | 163 邮箱 | smtp.163.com | 465/587 | 需要在设置里开启 SMTP 并生成客户端授权码 | | Gmail | smtp.gmail.com | 465/587 | 需要开启两步验证并创建应用专用密码 | | Outlook | smtp-mail.outlook.com | 587 | 支持基础认证也可走 OAuth2 | | Ethereal测试用 | 动态生成 | 动态生成 | 官网注册后会直接给出测试账号 |2.2 一封最小可用的发送代码配置好 transporter 后发送一封纯文本邮件只需要几行async function sendMail() { const info await transporter.sendMail({ from: 报表系统 senderexample.com, to: bossexample.com, subject: 今日统计报表, text: 这是今天的统计数据请查收附件。, }); console.log(发送结果, info); } sendMail().catch(console.error);from字段建议写成昵称 邮箱地址的格式这样收件人看到的发件人信息更友好。to可以是单个邮箱字符串也可以传数组表示多个收件人。sendMail返回的是一个 Promise所以用async/await处理是很自然的写法如果项目里还在用回调风格sendMail的第二个参数也可以接收回调但我个人不太推荐异步函数可读性好得多。跑完这段代码检查控制台输出的messageId只要它非空基本说明邮件已经进入发信服务器的队列。如果不想拿真实邮箱折腾可以先用 Ethereal 这个专门做测试的 SMTP 服务。在官网生成一个一次性账号把 host 和端口填进 transporter发信后去网页上就能看到邮件渲染效果非常适合开发阶段验证逻辑。2.3 发送返回的消息里藏着哪些细节第一次成功发送后很多人看一眼就过了其实sendMail的返回对象里信息量很大。info.messageId是邮件的全局唯一标识形如...smtp.example.com将来追查邮件状态全靠它。info.accepted和info.rejected分别表示被发信服务器接受和拒绝的收件人列表尤其是给多个收件人发送时一定要检查rejected是否为空否则你以为发出去了实际可能被入口就拦了。info.response是人类可读的服务器应答信息不同服务商格式不同但通常会包含250 OK之类的状态码。我自己习惯把messageId和accepted打一条结构化日志后续配合邮箱服务商的控制台能快速定位问题。如果返回对象显示accepted里没有某个邮箱别犹豫先检查那个地址是不是拼错了。还有个冷门字段info.envelope它记录的是 SMTP 信封上的实际发件人和收件人地址在排查“我明明写的 from 是 A对方收到显示 B”这类问题时很有用。3. 进阶实战HTML 模板、附件与批量通知3.1 从纯文本到 HTML 邮件样式与兼容性业务开发中纯文本邮件只能应付简单通知真正常用的还是 HTML 邮件。Nodemailer 里把text换成html字段就行await transporter.sendMail({ from: 报表系统 senderexample.com, to: user.email, subject: 你的周报已生成, html: div stylefont-family: Arial, sans-serif; max-width: 600px; h2 stylecolor: #333;你好 ${user.name}/h2 p这是 ${weekRange} 的报告摘要。/p table styleborder-collapse: collapse; width: 100%; tr td styleborder: 1px solid #ddd; padding: 8px;指标A/td td styleborder: 1px solid #ddd; padding: 8px;${metricA}/td /tr /table /div , });这里有两个容易踩的坑。第一绝大多数邮件客户端根本不加载style标签里的外部 CSS也不会处理link引用的样式表能保证显示效果的只有内联样式。第二HTML 结构不要用现代前端的 flex 或 grid 布局很多老邮箱客户端对复合布局支持得很差用 table 布局加上内联样式才是邮件渲染的“最稳底座”。这听起来确实老派但邮件行业就是这样想兼容更多用户就必须守规矩。如果是复杂邮件模板建议单独维护模板文件不要在主代码里拼那一大坨 HTML。可以用简单模板引擎做变量插值也可以自己写一个renderTemplate(templateName, data)函数把模板读取和渲染逻辑收拢在一处。我见过很多人前期图省事直接在业务代码里拼字符串后来模板多了整理起来非常痛苦。3.2 附件本地文件、流式数据与文件名编码附件的实现比很多人想象中简单sendMail里加一个attachments数组就行await transporter.sendMail({ from: 报表系统 senderexample.com, to: bossexample.com, subject: 本月销售明细, text: 明细请见附件, attachments: [ { filename: sales-report.xlsx, path: /tmp/sales-report.xlsx, }, ], });除了用path指向本地文件Nodemailer 还支持用content直接传 Buffer 或字符串也可以传 Readable 流。比如程序里临时生成的 PDF 不想落盘直接传 Buffer 就能省掉一次磁盘 IO。文件名里有中文时Nodemailer 会按邮件标准处理编码但极老的客户端可能显示乱码必要时可以手动处理附件头。还有一个容易忽视的限制公共 SMTP 服务对附件大小普遍有上限常见在 25MB 到 50MB超过会被退信大文件还是走对象存储链接更现实。内嵌图片是另一个高频需求。传统的做法是给html里写绝对 URL但这样依赖公网可达性更可靠的做法是用attachments里的cidawait transporter.sendMail({ to: bossexample.com, subject: 带图报告, html: p请看图/pimg srccid:chart1 /, attachments: [ { filename: chart.png, path: /tmp/chart.png, cid: chart1, }, ], });这个cid会在 HTML 里被解析成对应的内嵌资源很多邮件客户端会主动屏蔽外部图片但内嵌图片通常不在屏蔽范围内。3.3 批量发送循环别踩这些坑批量通知是最常见的需求比如每天向几百个用户发送日报。新手容易犯的错误是每次都调用createTransport创建新连接又慢又容易被服务商限流。正确做法是复用同一个 transporter 对象因为它的内部实现会维护连接池。比如这样写async function sendBatch(users) { const transporter nodemailer.createTransport(config); for (const user of users) { try { await transporter.sendMail({ from: SENDER, to: user.email, subject: 每日更新, text: Hi ${user.name}, 这是今天的资讯。, }); } catch (err) { console.error(发送失败: ${user.email}, err.message); } } }这里有一个很实际的权衡for 循环里一个个await虽然慢但对普通 SMTP 服务恰恰是安全的节奏。如果想提升吞吐可以用Promise.all并发但并发数必须控制住建议用 p-limit 这类工具限制在 5 个以内。我见过有人为了追求速度一次性并发 200 个结果被服务商判定为垃圾邮件行为整个出口 IP 都临时受限教训很深刻。真要每天发几千封就别走普通 SMTP 了应该换云邮件推送服务或专业邮件发送平台这类服务更适合大规模投递。4. 生产环境的最佳实践认证安全、配置管理与重试策略4.1 不要把密码写在代码里授权码与加密传输把 SMTP 授权码硬编码在源码里是我见过的最常见安全失误。代码迟早要提交到仓库一旦仓库泄露授权码就跟着泄露了别人就能用你的邮箱发垃圾邮件。正确做法是放环境变量代码里用process.env读取。如果你不想一个个手动 export可以用 dotenv 加载 .env 文件npm install dotenvrequire(dotenv).config(); const transporter nodemailer.createTransport({ host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT), secure: process.env.SMTP_SECURE true, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, });同时把.env加进.gitignore这在任何 Node 项目里都是基本纪律。如果邮箱服务商支持 OAuth2也可以配置auth.type为 OAuth2用 accessToken 完成认证这适合企业级自动化场景不过配置复杂度会高一些按需取舍。传输层方面只要端口是 465 或 587Nodemailer 默认就会启用 TLS 或 STARTTLS不需要额外设置。不要为了图省事选择 25 端口明文传输用户名密码在网络上裸奔真的是自找麻烦。4.2 开发、测试、生产环境的配置隔离邮件功能在本地开发时有一个痛点直接用生产邮箱发测试信会给真实用户造成困扰还可能触发服务商风控。我的做法是把环境拆开本地开发用 Ethereal 这类临时测试 SMTP 服务每次官网生成一个一次性账号发信后到网页上检查渲染效果async function createTestTransporter() { const account await nodemailer.createTestAccount(); return nodemailer.createTransport({ host: account.smtp.host, port: account.smtp.port, secure: account.smtp.secure, auth: { user: account.user, pass: account.pass, }, }); }生产环境则用真实的企业邮箱或云邮件推送服务。通过环境变量区分当前环境代码本身不做硬编码这是我给自己定的纪律NODE_ENV为development时走测试 provider为production时走正式 provider。这样一来本地调试、测试联调、线上运行互不干扰新同事接手项目后也能通过.env.example文件快速了解需要配置哪些变量。4.3 失败重试与队列指数退避比硬冲更靠谱邮件发送必然有偶发失败网络抖动、服务商限流、连接被重置都是家常便饭。我的策略是区分错误类型认证类错误EAUTH、535不需要重试说明配置有问题再试多少遍都一样临时性错误连接超时、连接被拒才值得重试。重试不能固定间隔硬冲而是用指数退避第一次失败后等 1 秒第二次等 2 秒第三次等 4 秒最多三次超过上限放到“待人工处理”队列并告警。代码示意const MAX_RETRIES 3; async function sendWithRetry(mailOptions, attempt 0) { try { return await transporter.sendMail(mailOptions); } catch (err) { if (attempt MAX_RETRIES) { throw new Error(超过最大重试次数: ${err.message}); } const delay Math.pow(2, attempt) * 1000; // 1s, 2s, 4s await sleep(delay); return sendWithRetry(mailOptions, attempt 1); } } function sleep(ms) { return new Promise((resolve) setTimeout(resolve, ms)); }如果系统里邮件任务很多我还会加一层队列来削峰。最简单的可以用内存队列进程重启会丢任务更稳妥的是 Redis 队列或专门的任务队列中间件。队列不仅能限速还能把失败的邮件重新投入队尾给运维留出处理时间。生产环境把这套重试策略配置好邮件功能基本不会再给你半夜打电话。5. 高频问题排查实录5.1 常见错误码快速定位表先给出一张我长期整理的错误速查表遇到问题先对号入座错误特征典型含义处理方向EAUTH 或 535SMTP 认证失败检查授权码是否正确、user 是否写错、是否已开启服务商 SMTP 功能ECONNECTION无法连接服务器检查 host、port、网络连通性、服务器安全组和防火墙ETIMEDOUT连接或交互超时排查网络链路、防火墙以及 SMTP 服务商当前状态550收件方拒绝该信件检查收件地址是否存在、发件域名信誉、邮件内容是否疑似垃圾421服务商限流降低发送频率暂停一段时间再继续554发信被判定为垃圾邮件检查 SPF/DKIM 记录、邮件内容、收件人列表质量ECONNREFUSED端口被拒绝确认服务商开放的端口本地测试时检查测试服务是否在运行遇到错误时第一件事是看错误信息里是否包含 SMTP 回复码比如带 535 就直接往认证方向排查不要盯着代码反复看。这个确认动作能把排查时间缩短一大半。5.2 本机能发上生产环境就发不出去服务器部署时本地 Windows/Mac 上测试正常代码部署到云服务器后报ECONNECTION或ETIMEDOUT这个我遇到过不止一次。最常见原因有三个云服务器安全组或防火墙没有放行 465/587 端口部分云厂商对境外邮件服务商的端口有独立限制规则服务器 IP 的域名反查记录缺失导致收信服务器直接拒绝。排查顺序建议是这样先用 telnet 或 nc 测试端口连通性telnet smtp.example.com 465端口通了再测认证最后查发件域名的 SPF、DKIM、DMARC 记录。SPF 记录的作用是声明哪些服务器有权以你的域名发信没有这条记录收件方大概率会提高拦截概率。这些 DNS 记录通常需要在域名管理后台配置配置完成后可以用在线工具检查是否生效。5.3 中文乱码与编码问题邮件中文乱码多见于两个位置主题和附件文件名。Nodemailer 会自动对非 ASCII 的 subject 做 RFC2047 编码正常情况下不需要手动处理。但如果你在 subject 里拼接了特殊字符或者上层框架已经做了一层转码就可能出现双重编码显示成乱码。我的经验是 subject 里保持原始字符串不要在业务层手动 encodeURIComponent 或 Base64。另一个是正文编码建议在 HTML 内容里显式声明meta charsetUTF-8虽然现代邮件客户端大多能自动识别但显式声明更稳尤其遇到老客户端时差距很明显。5.4 状态看起来成功收件人却没收到这可能是最折磨人的场景sendMail 返回成功info.response明确显示 250但收件人邮箱就是没有新邮件。这种情况十有八九不是代码问题而是投递到了垃圾箱、发件域名信誉差导致延迟或者企业邮件网关对内外域做了拦截。我自己的排查顺序是先查收件方垃圾箱再看有没有退信然后检查发件域名 SPF/DKIM 配置最后用另一个服务商的邮箱同时测试排除单一服务商的拦截策略。如果问题出在域名信誉上短期能做的就是控制发送量、保持内容质量、把退订机制做好。长期来看攒出一个健康的发信口碑比什么都重要。最后说一个我自己坚持的习惯每次上线邮件功能或者更换服务商我会先往自己的邮箱发一封测试邮件把返回的messageId记进文档顺手再配置好退信回调。等哪天用户反馈说没收到邮件你翻出这个messageId既能查发送日志又能找对方服务商举证能省下好几个小时的扯皮时间。邮件系统本身不复杂但细节是真的多把这些细节按流程管起来后面维护的幸福感才会持续在线。