Official disclosure: we are the EngageLab Email team. This tutorial uses EngageLab Agent Email for the inbox workflow, but the bounce-handling pattern is vendor-neutral: treat every delivery status notification as untrusted input, correlate it to a message you actually sent, and make retries bounded and durable.
Your after-sales agent sends a replacement confirmation to a customer. The address has a typo. Minutes later, a new message arrives in the agent inbox:
From: Mailer-Daemon <MAILER-DAEMON@mx.example.net>
Subject: Undelivered Mail Returned to Sender
Content-Type: multipart/report; report-type=delivery-status; boundary="..."
A naive agent treats this as a customer reply. It drafts an apology to the daemon, or it retries the same bad address on every poll. Neither helps the customer, and the second behavior can burn your sending reputation.
The missing step is bounce triage: read the report, decide what kind of failure it is, and only then allow a retry or suppress the address.
Three rules for agent email bounces
- A DSN is a report, not a reply. Do not answer it as if a human wrote it.
-
A DSN is untrusted until correlated. Only act automatically when its
Original-Message-IDmatches a message this system actually sent, and the recipient matches that send. - Hard, soft, and unknown failures need different actions. A hard bounce can suppress an address. A soft bounce should get a bounded retry. Everything else should go to a human queue.
| Signal | Example | Agent action |
|---|---|---|
Action: delivered |
Status: 2.0.0 |
Mark the send delivered |
| Hard failure |
Action: failed, Status: 5.1.1
|
Suppress the recipient and escalate |
| Soft failure |
Action: delayed, Status: 4.2.2
|
Retry with backoff and a budget |
| Unknown or malformed | Missing message/delivery-status
|
Quarantine for review |
| Auto-reply | Auto-Submitted: auto-replied |
Not a bounce; do not suppress |
1. Pull the raw DSN from the agent inbox
Install the CLI and point it at a test account:
npm install -g @engagelabemail/cli
engagelab-email config set --secret-key sk_sg_xxx
sk_sg_xxx is a placeholder. Use a test Secret Key and never commit it.
List the suspicious inbound messages as JSON. The CLI's --keyword searches sender, subject, Message-ID, and thread ID, so the DSN subject is a good filter:
engagelab-email emails receiving list \
--keyword "Undelivered" \
--page-size 20 \
--json
Then fetch the detail for the message you want to inspect:
engagelab-email emails receiving get <messageUid> --json
The detail response includes headers, text, html, and emlUrl. The delivery-status fields are not reliably present in the rendered body, so download the raw EML and parse it:
curl -sS "<emlUrl>" -o bounce.eml
Two boundaries matter here:
- Whether a DSN lands in your agent mailbox depends on the sending domain's Return-Path/envelope sender and bounce routing. Verify this with a test mailbox before relying on it.
-
emlUrl, diagnostics, and headers can contain personal data. Keep them out of logs and model prompts, or redact them first.
2. Parse the message/delivery-status part
A DSN is a multipart/report with a report-type=delivery-status. The useful part looks like this:
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>
A small parser only needs four jobs: split the MIME parts, find the message/delivery-status part, read its blocks, and normalize the recipient:
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 };
}
For a hard-bounce fixture, the parsed result is:
{
"isDsn": true,
"recipients": [
{
"address": "customer@example.com",
"action": "failed",
"status": "5.1.1",
"originalMessageId": "<order-8841-replacement@agent.example>"
}
]
}
This parser is intentionally small. It handles the DSN shape used in this tutorial, not arbitrary MIME. In production, use a maintained MIME parser and fail closed when parsing or required fields are missing.
3. Correlate before you suppress
A DSN can be forged. Do not let an inbound email permanently blacklist an arbitrary address. Record every send with the Message-ID you generated, the recipient, and the business object:
CREATE TABLE email_sends (
message_id TEXT PRIMARY KEY,
recipient TEXT NOT NULL,
order_id TEXT NOT NULL,
state TEXT NOT NULL
);
When a DSN arrives, look up originalMessageId. The DSN is actionable only when the send exists and its recipient matches:
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' };
}
The key branch is unmatched_hard_bounce: a 5.x.x report that cannot be tied to a real send does not suppress anyone. It goes to a person. This is slower, but it prevents an attacker from poisoning your suppression list with a forged DSN.
A hard bounce has a second consequence: the replacement email cannot be delivered to that address. Do not reply to the DSN and do not retry the same address. Update the order to needs_alternate_contact and let the support workflow ask the customer for a new address.
4. Make the state durable and idempotent
The agent may see the same DSN more than once: a restart, a retry, or a duplicate delivery. Use a deterministic report key and store it before applying side effects:
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');
}
Three small tables are enough for the demo:
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)
);
Wrap the event insert, the suppression/retry update, and the send-state update in one transaction:
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 computes the hash above; applyOutcome executes the dsn_events, suppressions/retry_state, and email_sends writes. Both are small SQL helpers in the full example.
Soft bounces get a budget, not a permanent ban. The example uses 30, 90, and 270 minutes of backoff. After three retries, the send moves to retry_exhausted and a human decides what to do. The address is still allowed for future, unrelated sends because a full mailbox is not a bad address.
5. Gate the next send
Before calling emails receiving reply or emails send, check the suppression table and the retry window:
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 };
}
The CLI wrapper makes the gate explicit in an agent workflow:
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": ... }
When allowed is false, the agent must not send. For a hard bounce, escalate to the order workflow. For a soft bounce that is not due yet, schedule the retry instead of sleeping inside the tool call.
EngageLab Agent Email's reply API generates from, to, In-Reply-To, and References from the inbound messageUid, so the agent should use the reply endpoint rather than hand-building thread headers. The bounce gate decides whether a reply is allowed; the API handles how the reply stays in the thread.
6. Run the example
The companion example is a zero-dependency Node.js project with synthetic fixtures and a SQLite state store. It runs the full flow offline:
node dsn-triage.mjs demo
node --test dsn-triage.test.mjs
The demo ingests seven reports and prints one line per decision:
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)
The test suite covers ten cases: hard bounce, soft bounce, delivery confirmation, auto-reply, malformed DSN, duplicate DSN, unmatched hard bounce, retry-budget exhaustion, backoff timing, and multiple recipients in one report. It passes on Node.js 26 with no network access and no real email addresses.
Boundaries worth writing down
- A DSN is not delivery truth. No DSN does not mean delivered; a DSN can be delayed, filtered, rewritten, or never sent. Use provider delivery events or signed webhooks as the primary signal, and treat DSN parsing as a fallback.
- DSN routing is configuration-dependent. Confirm that your sending domain's Return-Path/envelope sender points at a mailbox your agent can read. Do not assume every bounce reaches the agent inbox.
-
Do not trust the
Fromheader. Correlate with a Message-ID you generated. Quarantine unmatched reports instead of suppressing the address. - Soft bounces are temporary. A full mailbox or a temporary DNS failure should not permanently block a customer. Use bounded retries and a human escalation path.
-
Use a real MIME parser in production. The parser here is a teaching subset. Fail closed on missing
Final-Recipient,Action, orStatus. - Idempotency keys have limits. If your provider resends the same DSN with a new Message-ID, the soft-bounce attempt count can be inflated. Prefer provider event IDs or a normalized report fingerprint when available.
- SQLite is single-host. The demo uses a local database to show the transaction boundary; multi-instance deployments need a shared store and concurrency control.
-
Keep personal data out of logs and prompts.
Diagnostic-Codecan contain addresses and server details. Redact it before sending it to an LLM, and never let the email body authorize a send. -
Auto-replies are not bounces.
Auto-Submitted: auto-repliedshould not suppress anyone. It may still deserve a separate "do not reply to vacation responders" rule.
Official links
- EngageLab Email CLI source: github.com/Metaverse-Cloud/engagelab-email-cli
- EngageLab Email CLI package: npmjs.com/package/@engagelabemail/cli
- EngageLab Agent Email MCP source: github.com/Metaverse-Cloud/engagelab-email-mcp
- EngageLab Agent Email MCP package: npmjs.com/package/@engagelabemail/mcp
- Smithery directory entry: smithery.ai/servers/engagelab-email/agent-email
- Official account entry (sign in or create an account): engagelab.com/accounts/signin?scene=cli
npx -y @engagelabemail/mcp
npm install -g @engagelabemail/cli
EngageLab Agent Email is email infrastructure for AI agents: send, receive, and reply with thread context. The bounce triage layer in this post is intentionally conservative: it would rather send one report to a human than silently retry a bad address or suppress a real customer.
Top comments (0)