PAYATHON 2026

如何用 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 接受、报告投递成功、进入收件箱和真人阅读。

备注:内容仅供参考。