前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
返回 AI 情报前线
All News · 全部资讯10183
  • 审查 AI 生成 PR,先核对任务边界
  • 用有效吞吐压测确定模型接口并发上限
  • Agent 改一个字段,如何证明操作正确
  • 用多任务决策模型自动分流 CVE 漏洞
  • 逐步核算 Token,定位 Agent 的结构性浪费
  • Agent 生成 SQL,如何控制重跑漂移
  • 审核超时后的任务释放与计费隔离
  • DeepSeek 开源大规模推理框架 DeepInfer
  • DeepInfer 开源,公开大规模推理部署能力
  • Cloudflare预览低价结构化抽取模型
  • Headroom 在本地压缩智能体上下文
  • 智能体改记忆,如何验证没有误伤
  • llama-server 工具控制与停止条件现兼容问题
  • 模型快十倍,智能体为何只快一点
  • 用决策模型做评测,先调分类阈值
  • 编码 Agent 实测:成本之外看交付细节
  • Mercury 3 发布:主打高速生成与百万上下文
  • 用 Node 拆解向量池化与检索质量
  • 补齐 Oracle 方言约束,轻量模型首答率跃升
  • 拆解 Claude Code 的完整智能体循环
  • 邮件 Agent 重试前先校验退信
  • 用信号队列约束 Agent 等待邮件
  • 用 Cloudflare 搭建 Agent 收信中枢
  • 一次字符串替换如何让线上文章翻倍
  • AI 搭项目失败,先确认代码是否拉取成功
  • 检索匹配再高,也救不了过期知识库
  • 本地模型帮你给常用命令起快捷名
  • MCP 无状态改版涉及哪些迁移工作
  • 检查 Supabase 服务端鉴权的信任来源
  • 流式 AI 接口延迟该如何拆分测量
  • 模型故障切换如何追踪实际调用路径
  • 百灵开放五款语音模型供开发者组合
  • Drex Max 为电脑操作智能体输出决策概率
  • 无人值守的 Claude 循环如何耗尽额度
  • Bedrock为Claude提供严格结构化输出
  • 错误码搜不准?给向量检索补上 BM25
  • 让 Agent 崩溃后续跑,并处理重复副作用
  • 用 TypeScript 搭建带人工审批的客服智能体
  • Copilot 模型策略支持 REST API 管理
  • Copilot多种使用场景启动模型弃用
  • 把框架说明随包交付,让编程智能体可用
  • Nx 恶意包如何借编程智能体窃取秘密
  • 用分层防护限制 Agent 越权与误操作
  • 在表格中用自然语言分析完整BigQuery数据
  • 亚马逊推 TorchNeuron,拓宽 PyTorch 芯片选择
  • OpenCode平台遭入侵,密钥被盗刷
  • Cloudflare如何将AI审查成本降68%
  • 将Copilot任务无缝接续到VS Code
  • Gemini 4 Argon 主打多 Agent 三维制作
  • 用关联 ID 串起 Agent 日志与异常
  • MiniMax新模型以半价提升编程表现
  • 已加载 51 / 10183
8.0
热点
AI SCORE
技术实践2026-10-20 10:24

邮件 Agent 重试前先校验退信

dev.to · AI#Agent#邮件自动化#可靠性
Editor brief · 编辑速览

教程将退信通知视为不可信输入,要求先匹配已发送邮件的 Original-Message-ID 和收件人,再决定重试或停用地址。它强调区分失败类型,并让重试次数有界、状态持久化,示例使用作者团队的邮件服务。

文章思维导图
Knowledge map
拖拽缩放
Full translation

完整中文译文

官方声明:我们是 EngageLab Email 团队。本教程使用 EngageLab Agent Email 处理收件箱工作流,但退信处理模式不依赖具体厂商:将每一份投递状态通知都视为不可信输入,将它与系统实际发送过的邮件关联起来,并确保重试有次数限制、状态可持久化。

你的售后 Agent 给客户发送了一封换货确认邮件,但邮箱地址里有个拼写错误。几分钟后,Agent 收件箱收到了一封新邮件:

From: Mailer-Daemon <MAILER-DAEMON@mx.example.net>
Subject: Undelivered Mail Returned to Sender
Content-Type: multipart/report; report-type=delivery-status; boundary="..."

一个处理方式简单粗暴的 Agent 会把它当成客户回复。它可能起草一封给邮件守护进程的道歉信,也可能在每次轮询时都向同一个错误地址重发邮件。这两种做法都帮不到客户,后者还可能损害你的发信信誉。

这里缺少的是退信分诊:读取报告,判断失败类型,然后再决定允许重试,还是禁止向该地址发信。

Agent 邮件退信的三条规则

DSN(投递状态通知)是一份报告,不是回复。不要把它当成人写的邮件来回复。

DSN 在完成关联验证之前都不可信。只有当它的 Original-Message-ID 匹配系统实际发送过的邮件,而且收件人与该次发送记录一致时,才能自动采取行动。

硬退信、软退信和未知失败需要分别处理。硬退信可以触发地址禁发;软退信应进行有限次数的重试;其他情况都应进入人工处理队列。

1. 从 Agent 收件箱获取原始 DSN

安装 CLI,并将它配置为使用测试账号:

npm install -g @engagelabemail/cli
engagelab-email config set --secret-key sk_sg_xxx

sk_sg_xxx 是占位符。请使用测试用的 Secret Key,绝不要将它提交到代码仓库。

以 JSON 格式列出可疑的入站邮件。CLI 的 --keyword 会搜索发件人、主题、Message-ID 和线程 ID,因此可以用 DSN 的主题作为筛选条件:

engagelab-email emails receiving list \
  --keyword "Undelivered" \
  --page-size 20 \
  --json

然后获取你要检查的那封邮件的详情:

engagelab-email emails receiving get <messageUid> --json

详情响应包含 headers、text、html 和 emlUrl。渲染后的正文不一定包含 delivery-status 字段,因此需要下载原始 EML 并解析:

curl -sS "<emlUrl>" -o bounce.eml

这里有两个需要明确的边界:

DSN 是否会进入 Agent 邮箱,取决于发信域名的 Return-Path/信封发件人以及退信路由配置。在依赖这条链路之前,先用测试邮箱验证。

emlUrl、诊断信息和邮件头可能包含个人数据。不要将它们写入日志或放进模型 prompt,或者先做脱敏。

2. 解析 message/delivery-status 部分

DSN 是一种 multipart/report,其 report-type=delivery-status。真正有用的部分如下:

Content-Type: message/delivery-status

Reporting-MTA: dns; mx.example.net
Arrival-Date: Tue, 20 Oct 2026 09:14:58 +0800

Final-Recipient: rfc822; customer@example.com
Action: failed
Status: 5.1.1
Diagnostic-Code: smtp; 550 5.1.1 user unknown
Original-Message-ID: <order-8841-replacement@agent.example>

一个小型解析器只需要完成四件事:拆分 MIME 各部分,找到 message/delivery-status 部分,读取其中的数据块,并将收件人地址标准化:

function splitMessage(raw) {
  const text = raw.replace(/\r\n/g, '\n');
  const cut = text.indexOf('\n\n');
  if (cut === -1) return [text, ''];
  return [text.slice(0, cut), text.slice(cut + 2)];
}

function parseHeaders(text) {
  const map = new Map();
  for (const line of text.split('\n')) {
    if (/^\s/.test(line) && map.size) {
      const last = [...map.keys()].at(-1);
      map.set(last, `${map.get(last)} ${line.trim()}`);
    } else {
      const colon = line.indexOf(':');
      if (colon > 0) map.set(line.slice(0, colon).toLowerCase(), line.slice(colon + 1).trim());
    }
  }
  return map;
}

function parseContentType(value = '') {
  const [type, ...parts] = value.split(';');
  const params = Object.fromEntries(parts.map((part) => {
    const [key, ...rest] = part.split('=');
    return [key.trim().toLowerCase(), rest.join('=').trim().replace(/^"|"$/g, '')];
  }));
  return { type: type.trim().toLowerCase(), params };
}

function parseDsn(raw) {
  const [headerText, body] = splitMessage(raw);
  const headers = parseHeaders(headerText);
  const dsnMessageId = headers.get('message-id') || null;
  const type = parseContentType(headers.get('content-type'));
  if (type.type !== 'multipart/report' || type.params['report-type'] !== 'delivery-status') {
    return { isDsn: false, dsnMessageId, reason: 'not a delivery-status report', recipients: [] };
  }
  if (!type.params.boundary) {
    return { isDsn: false, dsnMessageId, reason: 'missing multipart boundary', recipients: [] };
  }

  const part = body.split(`--${type.params.boundary}`)
    .map((chunk) => chunk.replace(/^\n/, '').replace(/\n$/, ''))
    .find((chunk) => parseContentType(parseHeaders(splitMessage(chunk)[0]).get('content-type')).type === 'message/delivery-status');
  if (!part) return { isDsn: false, dsnMessageId, reason: 'delivery-status part not found', recipients: [] };

  const blocks = splitMessage(part)[1]
    .split(/\n{2,}/)
    .map((block) => parseHeaders(block))
    .filter((block) => block.has('final-recipient') || block.has('action'));

  const recipients = blocks.map((block) => ({
    address: (block.get('final-recipient') || '').replace(/^\s*rfc822\s*;\s*/i, '').toLowerCase(),
    action: (block.get('action') || '').toLowerCase(),
    status: block.get('status') || '',
    diagnostic: block.get('diagnostic-code') || '',
    originalMessageId: block.get('original-message-id') || null,
  }));

  return { isDsn: recipients.length > 0, dsnMessageId, reason: null, recipients };
}

对于一份硬退信测试样本,解析结果如下:

{
  "isDsn": true,
  "recipients": [
    {
      "address": "customer@example.com",
      "action": "failed",
      "status": "5.1.1",
      "originalMessageId": "<order-8841-replacement@agent.example>"
    }
  ]
}

这个解析器有意保持精简。它只处理本教程使用的 DSN 结构,无法处理任意 MIME 格式。在生产环境中,请使用有人持续维护的 MIME 解析器;如果解析失败或缺少必填字段,应默认拒绝自动操作。

3. 先关联验证,再禁止发信

DSN 可以伪造。不要让一封入站邮件就能把任意地址永久拉黑。记录每一次发送,包括你生成的 Message-ID、收件人和关联的业务对象:

CREATE TABLE email_sends (
  message_id TEXT PRIMARY KEY,
  recipient TEXT NOT NULL,
  order_id TEXT NOT NULL,
  state TEXT NOT NULL
);

收到 DSN 后,查找 originalMessageId。只有发送记录存在且收件人匹配时,才能根据这份 DSN 采取行动:

function decideOnce(recipient, send) {
  if (recipient.action === 'delivered') {
    return send && send.recipient === recipient.address
      ? { type: 'delivered' }
      : { type: 'quarantine', reason: 'unmatched_delivery' };
  }

  if (recipient.action === 'failed' && recipient.status.startsWith('5')) {
    if (!send || send.recipient !== recipient.address) {
      return { type: 'quarantine', reason: 'unmatched_hard_bounce' };
    }
    return { type: 'suppress', reason: 'hard_bounce' };
  }

  if (recipient.action === 'delayed' || (recipient.action === 'failed' && recipient.status.startsWith('4'))) {
    if (!send || send.recipient !== recipient.address) {
      return { type: 'quarantine', reason: 'unmatched_soft_bounce' };
    }
    return { type: 'retry', reason: 'soft_bounce' };
  }

  return { type: 'quarantine', reason: 'unclassified' };
}

关键分支是 unmatched_hard_bounce:一份无法关联到真实发送记录的 5.x.x 报告,不会导致任何地址被禁发,而是交给人工处理。这样做慢一些,但能防止攻击者用伪造的 DSN 污染你的禁发名单。

硬退信还有另一个后果:换货确认邮件无法投递到该地址。不要回复这份 DSN,也不要向同一地址重试。将订单状态更新为 needs_alternate_contact,让客服工作流向客户索取新地址。

4. 让状态持久化,并保证幂等性

Agent 可能不止一次看到同一份 DSN:重启、重试或重复投递都可能造成这种情况。使用一个确定性的报告键,并在执行有副作用的操作之前将其存储:

report_key = sha256(
  DSN Message-ID | final recipient | action | status | original Message-ID
)
import { createHash } from 'node:crypto';

function reportKey(dsn, recipient) {
  return createHash('sha256').update([
    dsn.dsnMessageId,
    recipient.address,
    recipient.action,
    recipient.status,
    recipient.originalMessageId,
  ].join('|')).digest('hex');
}

这个演示只需要三张小表:

CREATE TABLE dsn_events (
  report_key TEXT PRIMARY KEY,
  recipient TEXT NOT NULL,
  status_code TEXT NOT NULL,
  decision TEXT NOT NULL,
  received_at INTEGER NOT NULL
);

CREATE TABLE suppressions (
  recipient TEXT PRIMARY KEY,
  status_code TEXT NOT NULL,
  created_at INTEGER NOT NULL
);

CREATE TABLE retry_state (
  original_message_id TEXT NOT NULL,
  recipient TEXT NOT NULL,
  attempts INTEGER NOT NULL,
  next_retry_at INTEGER,
  state TEXT NOT NULL,
  PRIMARY KEY (original_message_id, recipient)
);

将事件插入、禁发或重试状态更新,以及发送状态更新,放进同一个事务:

function ingest(db, dsn, now) {
  db.exec('BEGIN IMMEDIATE');
  try {
    for (const recipient of dsn.recipients) {
      const key = reportKey(dsn, recipient);
      if (db.prepare('SELECT 1 FROM dsn_events WHERE report_key = ?').get(key)) continue;

      const send = db.prepare('SELECT * FROM email_sends WHERE message_id = ?')
        .get(recipient.originalMessageId);
      const outcome = decideOnce(recipient, send);

      // Insert dsn_events, then apply suppression or retry_state, then update email_sends.
      applyOutcome(db, key, dsn, recipient, outcome, now);
    }
    db.exec('COMMIT');
  } catch (error) {
    db.exec('ROLLBACK');
    throw error;
  }
}

reportKey 计算上面的哈希值;applyOutcome 执行对 dsn_events、suppressions/retry_state 和 email_sends 的写入。在完整示例中,两者都是简短的 SQL 辅助函数。

软退信应设置重试额度,而不是永久禁发。示例使用 30、90 和 270 分钟的退避间隔。重试三次后,这次发送进入 retry_exhausted 状态,由人工决定后续处理。这个地址仍然可以接收未来其他业务的邮件,因为邮箱满了不代表地址无效。

5. 为下一次发送设置检查关卡

在调用 emails receiving reply 或 emails send 之前,检查禁发名单和重试时间窗口:

function canSend(db, recipient) {
  const blocked = db.prepare('SELECT status_code FROM suppressions WHERE recipient = ?')
    .get(recipient.toLowerCase());
  return blocked
    ? { allowed: false, reason: 'recipient_suppressed', statusCode: blocked.status_code }
    : { allowed: true };
}

CLI 封装让 Agent 工作流中的这道检查清晰可见:

node dsn-triage.mjs check --db state.db --recipient customer@example.com
# { "allowed": false, "reason": "recipient_suppressed", "statusCode": "5.1.1" }

node dsn-triage.mjs retry-check \
  --db state.db \
  --message-id '<order-8842-reminder@agent.example>' \
  --recipient customer2@example.com
# { "allowed": false, "reason": "retry_not_due", "attempts": 1, "nextRetryAt": ... }

当 allowed 为 false 时,Agent 必须停止发送。如果是硬退信,就转交订单工作流处理。如果是尚未到重试时间的软退信,就安排重试任务,不要在工具调用里休眠等待。

EngageLab Agent Email 的 reply API 会根据入站邮件的 messageUid 生成 from、to、In-Reply-To 和 References,因此 Agent 应使用 reply 端点,而不是手动拼装邮件线程头。退信检查决定是否允许回复;API 负责让回复正确留在原线程中。

配套示例是一个零依赖的 Node.js 项目,包含合成测试样本,并使用 SQLite 存储状态。它可以离线运行完整流程:

node dsn-triage.mjs demo
node --test dsn-triage.test.mjs

演示会处理七份报告,并为每个决策输出一行:

hard bounce (hard-bounce.eml)
customer@example.com: suppress (hard_bounce)

soft bounce (soft-bounce.eml)
customer2@example.com: retry_later (soft_bounce) attempts=1 next=...

unmatched hard bounce (unmatched-hard-bounce.eml)
stranger@example.com: quarantine (unmatched_hard_bounce)

duplicate hard bounce (hard-bounce.eml)
customer@example.com: duplicate_ignored (duplicate_dsn)

测试套件覆盖十种情况:硬退信、软退信、投递确认、自动回复、格式错误的 DSN、重复 DSN、无法匹配的硬退信、重试额度耗尽、退避时间,以及一份报告中包含多个收件人。它在 Node.js 26 上通过测试,不需要网络访问,也不使用真实邮箱地址。

值得明确写下的边界

DSN 不代表投递的最终事实。没有 DSN 不等于已送达;DSN 可能延迟、被过滤、被改写,甚至根本不会发出。应将服务商的投递事件或带签名的 webhook 作为主要信号,DSN 解析只作为后备方案。

DSN 路由取决于配置。确认发信域名的 Return-Path/信封发件人指向 Agent 能读取的邮箱。不要假设每封退信都会进入 Agent 收件箱。

不要信任 From 邮件头。应通过你自己生成的 Message-ID 进行关联验证。无法匹配的报告应隔离处理,而不是直接禁止向该地址发信。

软退信是暂时的。邮箱已满或临时 DNS 故障,不应导致客户被永久禁发。应使用有限次数的重试,并提供转交人工处理的路径。

生产环境要使用真正的 MIME 解析器。这里的解析器只实现了用于教学的一部分功能。缺少 Final-Recipient、Action 或 Status 时,应默认拒绝自动操作。

幂等键也有局限。如果服务商重发同一份 DSN 时使用了新的 Message-ID,软退信的尝试次数可能被多算。如果有服务商事件 ID 或标准化的报告指纹,应优先使用。

SQLite 适用于单主机。演示使用本地数据库展示事务边界;多实例部署需要共享存储和并发控制。

不要将个人数据写入日志或放进 prompt。Diagnostic-Code 可能包含邮箱地址和服务器细节。发送给 LLM 之前应先脱敏,而且绝不能让邮件正文成为授权发送的依据。

自动回复不是退信。Auto-Submitted: auto-replied 不应导致任何地址被禁发。不过,你可能仍然需要一条独立规则,避免回复休假自动应答邮件。

EngageLab Email CLI 源码:github.com/Metaverse-Cloud/engagelab-email-cli

EngageLab Email CLI 软件包:npmjs.com/package/@engagelabemail/cli

EngageLab Agent Email MCP 源码:github.com/Metaverse-Cloud/engagelab-email-mcp

EngageLab Agent Email MCP 软件包:npmjs.com/package/@engagelabemail/mcp

Smithery 目录条目:smithery.ai/servers/engagelab-email/agent-email

官方账号入口(登录或创建账号):engagelab.com/accounts/signin?scene=cli

npx -y @engagelabemail/mcp
npm install -g @engagelabemail/cli

EngageLab Agent Email 是面向 AI Agent 的邮件基础设施,支持发送、接收,以及保留线程上下文的回复。本文的退信分诊层刻意采用保守策略:宁可把一份报告交给人工处理,也不默默地向错误地址重试,或禁止向真实客户发信。

如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。

Original source

本文由 AI 翻译整理自 dev.to · AI,原文版权归原作者所有。

阅读英文原文
上一篇
拆解 Claude Code 的完整智能体循环
下一篇
用信号队列约束 Agent 等待邮件