教程将退信通知视为不可信输入,要求先匹配已发送邮件的 Original-Message-ID 和收件人,再决定重试或停用地址。它强调区分失败类型,并让重试次数有界、状态持久化,示例使用作者团队的邮件服务。
官方声明:我们是 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 会把它当成客户回复。它可能起草一封给邮件守护进程的道歉信,也可能在每次轮询时都向同一个错误地址重发邮件。这两种做法都帮不到客户,后者还可能损害你的发信信誉。
这里缺少的是退信分诊:读取报告,判断失败类型,然后再决定允许重试,还是禁止向该地址发信。
DSN(投递状态通知)是一份报告,不是回复。不要把它当成人写的邮件来回复。
DSN 在完成关联验证之前都不可信。只有当它的 Original-Message-ID 匹配系统实际发送过的邮件,而且收件人与该次发送记录一致时,才能自动采取行动。
硬退信、软退信和未知失败需要分别处理。硬退信可以触发地址禁发;软退信应进行有限次数的重试;其他情况都应进入人工处理队列。
安装 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,或者先做脱敏。
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 解析器;如果解析失败或缺少必填字段,应默认拒绝自动操作。
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,让客服工作流向客户索取新地址。
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 状态,由人工决定后续处理。这个地址仍然可以接收未来其他业务的邮件,因为邮箱满了不代表地址无效。
在调用 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 的邮件基础设施,支持发送、接收,以及保留线程上下文的回复。本文的退信分诊层刻意采用保守策略:宁可把一份报告交给人工处理,也不默默地向错误地址重试,或禁止向真实客户发信。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。