如何用 Node.js、Nodemailer 和原生 SMTP 追踪邮件投递、打开与退信状态?
可以自行搭建 SMTP 邮件反馈闭环,生产环境不强制使用第三方 Webhook 服务。但仅凭 SMTP 凭据和 Nodemailer,无法可靠获知邮件是否进入收件箱,也无法确认收件人是否真正阅读。
一种做法是在发送时记录 SMTP 接受结果,为每次投递设置可关联的退信地址,接收并解析 DSN 或其他退信,再将反馈写入自己的事件系统。
先明确四种状态能证明什么
| 状态 | 可用依据 | 判断边界 |
|---|---|---|
| Sent | 提交邮件的 SMTP server 接受了邮件 | 只确认当前这一跳接受了邮件 |
| Delivered | 成功 DSN,或可访问的投递系统事件 | 需要根据报告来源解释,不能直接证明进入收件箱 |
| Opened | 追踪图片被请求 | 只能记录资源加载,不能确认真人阅读 |
| Bounced | 明确的收件人拒绝,或异步失败通知 | 需要区分提交阶段拒绝、延迟和最终投递失败 |
sendMail() 的 Promise 成功完成后,还需要结合收件人结果判断。多收件人发送时,只要至少一个地址被接受,就可能返回成功,因此应分别检查 info.accepted 和 info.rejected。Nodemailer 返回值说明
标准 DSN 中的 Action: delivered 表示报告方声明邮件已投递到指定地址,不表示已读,也不能证明邮件位于收件箱而非垃圾邮件文件夹。relayed 和 expanded 有各自的含义,不应统一当作最终送达。RFC 3464
根据你对 SMTP server 的控制范围设计架构
只有 SMTP 登录凭据,不代表能够访问服务器队列、日志或下游投递结果。应先确认服务商是否允许自定义 envelope sender、是否支持 DSN,以及退信会转到哪里。
建议为每个收件人建立一条投递记录,保存业务投递 ID、收件地址、Message-ID 和提交结果。反馈路径可以设计为:
Node.js 创建投递记录
→ Nodemailer 提交 SMTP
→ SMTP server 排队和转投
→ 投递日志、DSN 或退信
→ 接收与解析程序
→ 关联投递记录
→ 保存事件并更新状态
如果控制实际出站 MTA,可以将队列日志和投递结果接入应用。日志中的远端接受结果仍需按实际 SMTP 跳点解释。如果仅使用服务商提供的 SMTP 凭据,则主要依靠服务商开放的反馈能力,以及能够收到的 DSN 和退信。
使用 envelope sender 和 DSN 参数发送
DSN 通过 SMTP 扩展参数请求,给邮件添加普通自定义头部不能替代这一机制。Nodemailer 提供 dsn 配置;连接的 SMTP server 必须在 EHLO 响应中声明支持 DSN,配置才有作用。第一跳支持 DSN,也不能保证获得最终成功回执。Nodemailer DSN 文档、RFC 3461
下面的示例每次只发送给一个收件人,便于关联反馈:
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: 'smtp.myprovider.com',
port: 465,
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
async function sendTrackedMail(deliveryId, recipient) {
// deliveryId 应由应用生成,并在发送前保存到数据库。
const info = await transporter.sendMail({
from: 'my_email@example.com',
to: recipient,
envelope: {
from: `bounce+${deliveryId}@example.com`,
to: [recipient]
},
messageId: `<${deliveryId}@example.com>`,
subject: 'Testing Tracking',
text: 'Hello World',
html: '<p>Hello World</p>',
dsn: {
id: deliveryId,
return: 'headers',
notify: ['success', 'failure', 'delay'],
recipient
}
});
return {
deliveryId,
messageId: info.messageId,
accepted: info.accepted,
rejected: info.rejected,
response: info.response
};
}
这里的 example.com 必须替换为你实际控制并配置好的域名。deliveryId 应使用唯一、不含个人信息且适合放入邮箱地址的随机标识,例如 UUID。
退信返回路径由 SMTP MAIL FROM 决定,对应 Nodemailer 的 envelope.from。可见的 From: 和 Reply-To: 不负责指定退信地址。为不同投递设置不同返回地址,是 VERP 的常见用法。Nodemailer SMTP envelope 文档
实际使用时还需要满足几个条件:
- 接收端能够接收
bounce+标识@域名这样的地址。加号地址不会自动生效,需要邮箱或路由规则支持。 - SMTP 服务商允许该 envelope sender,且不会将它改写成无法关联的地址。
dsn.recipient表示原始收件地址,用于ORCPT,不是接收回执的邮箱。
调用方需要捕获发送异常并保存诊断信息。认证失败、连接失败属于提交错误,不能一律标记为收件人退信;明确的收件人拒绝则应记录到对应地址。
接收退信不一定要轮询个人邮箱
可以使用专门的退信邮箱,也可以让接收 MTA 直接将邮件交给处理程序。
如果采用邮箱方式,可用 ImapFlow 通过 IMAP 获取原始邮件,利用 IDLE 接收变化通知,并在重连后补拉遗漏邮件。消费进度应按邮箱、UIDVALIDITY 和 UID 保存,不能仅依赖已读标记。
如果控制退信域名的 MX 和接收服务,可以通过 MTA 的本地投递接口、管道或消息队列,把原始邮件直接传给 Node.js,不需要再经过 IMAP。
无论采用哪种方式,都需要先确保退信能到达接收端。只有一个出站 SMTP 账号,不会自动提供接收能力。
使用 MailParser 提取 DSN,再处理状态字段
Node.js 中可以用 mailparser 处理 MIME 邮件。设置 keepDeliveryStatus: true 后,message/delivery-status 部分会作为附件保留,便于后续解析。MailParser 文档
const { simpleParser } = require('mailparser');
async function extractDsnParts(rawMessage) {
const mail = await simpleParser(rawMessage, {
keepDeliveryStatus: true
});
return mail.attachments
.filter(part =>
part.contentType.toLowerCase() === 'message/delivery-status'
)
.map(part => part.content.toString('utf8'));
}
这段代码只提取 DSN 内容,还没有实现完整的退信分类器。标准报告中的收件人字段可能类似:
Final-Recipient: rfc822; recipient@example.com
Action: failed
Status: 5.1.1
Diagnostic-Code: smtp; 550 5.1.1 User unknown
后续需要分别解析 DSN 的消息级字段和收件人级字段,并支持一份报告包含多个收件人。重点保存 Original-Envelope-Id、Original-Recipient、Final-Recipient、Action、Status 和 Diagnostic-Code,再结合返回地址中的标识关联原始投递。RFC 3464 字段定义
处理时应区分以下状态:
Action: failed:报告方已放弃该收件人的投递,可记录最终失败事件。Action: delayed:仍在尝试投递,不能直接标记最终退信。Action: delivered:记录报告方声明的投递成功。Action: relayed或expanded:保留原始事件,不能据此断言最终邮箱已收到。
不要只根据 4.x.x、5.x.x 或邮件主题判断结果,应结合 Action。非标准退信可能需要针对实际收到的格式增加规则;无法可靠解析时,应保存原始邮件并标为待处理。
mailparser 负责邮件解析,ImapFlow 负责收取邮件,两者都不能单独保证识别所有服务商的退信格式。生产使用前,应拿实际邮件链路中的退信样本验证解析和关联规则。
将打开事件独立保存,并允许状态未知
追踪像素可以继续使用,但建议将事件命名为 pixel_loaded,同时记录首次请求时间和来源信息。图片请求不等于阅读证明。例如,Apple 的邮件隐私保护可以在用户没有打开邮件时,提前在后台下载远程内容。Apple 邮件隐私保护说明
投递状态和打开信号应分开存储。收到图片请求后,不应覆盖原有的投递事件;没有图片请求,也不能据此判定未读。
整个反馈闭环还需要做到:
- 保留
submitted、deferred、delivered_reported、failed、unknown等可区分状态,长期没有回执时保持未知。 - 保存原始事件并幂等处理,允许通知重复或乱序到达。
- 使用不可预测的关联标识,并校验报告与原始投递的对应关系,不能仅凭退信主题修改状态。
- 明确重试由应用还是出站 MTA 负责;MTA 已接受并排队后,应用不要因为暂时没有回执就重复发送。
第三方 Webhook 服务可以减少事件接收和格式适配的工作。自行运营 MTA、接收服务和解析程序,也可以形成生产反馈闭环。无论采用哪种方案,都需要区分 SMTP 接受、报告投递成功、进入收件箱和真人阅读。
备注:内容仅供参考。
