Guide

AI Agent Email Bounce Detection and Retry

Your agent sent 500 emails overnight and 23 bounced. Whether it retries them is not a judgment call to hand a language model: classify each bounce by DSN class digit, back off the soft ones, suppress the hard ones, and escalate the leftovers to a human.

Written by Pouya Sanooei Software Engineer

VerifiedCLI 3.1.17 · Gmail, Outlook · last tested June 9, 2026

Command references used in this guide: nylas webhook create, nylas webhook server, and nylas email send.

Why should bounce retry logic live outside the AI agent's decision loop?

AI agent email bounce retry must be deterministic code the agent calls, not a question the model answers. A sampled decision gives different verdicts on identical bounces, and one wrong verdict re-sends to a dead address. Classification, backoff, and suppression are table lookups: the agent can't prompt its way past a rule it doesn't control.

The stakes are reputation, not convenience. Since February 2024, Google's email sender guidelines require bulk senders to keep spam rates below 0.3% (with under 0.1% recommended), and mailbox providers read repeated sends to hard-bounced addresses as the same list-hygiene neglect. An agent that decides retries in its prompt will eventually hammer a 550 address 10 times in an hour. A rule table never will, because the same input always produces the same verdict. Wrap the rules around the send tool, give the model only the wrapper, and the worst a confused agent can do is request a send the wrapper refuses.

How does an AI agent detect email bounces with webhooks?

The message.bounce_detected and message.send_failed webhook triggers cover both failure paths: an asynchronous bounce after the message was accepted, and a send that failed at submission. Subscribing to them pushes failures to your handler instead of making the agent poll daemon mail, and the Message category exposes 8 triggers in total.

The nylas webhook create command registers the subscription with a URL and a trigger list; it requires an API key. During development, nylas webhook server --no-tunnel runs a loopback-only HTTP listener so you can inspect event payloads from local tooling before deploying a real endpoint. Run nylas webhook triggers to list every available trigger by category.

# Subscribe the bounce handler to both failure paths
nylas webhook create \
  --url https://agent.example.com/webhooks/bounces \
  --triggers message.bounce_detected,message.send_failed \
  --description "Agent bounce feed"

# Inspect payloads locally before deploying (loopback only, no prompt)
nylas webhook server --no-tunnel

Treat each delivery as untrusted input and verify its HMAC signature before acting; nylas webhook verify --payload-file --signature --secret checks one offline. The handler's only job is to extract the recipient and status code, then hand both to the classifier below. No model call happens anywhere in this path.

How do you classify a soft vs hard bounce from SMTP codes?

The first digit of the enhanced status code decides everything. RFC 3463, published in January 2003, defines 4.XXX.XXX as “Persistent Transient Failure” (a soft bounce worth retrying) and 5.XXX.XXX as “Permanent Failure” (a hard bounce that never recovers).

That single digit has been stable across providers for over 20 years, unlike the human-readable bounce prose, so a shell case statement is the whole classifier. A 452 4.2.2 mailbox-full reply earns a retry; Gmail's no-such-user reply is documented as 550 5.1.1 and earns permanent suppression. Watch one edge: 5.2.2 marks a mailbox permanently over quota, hard despite sounding temporary — the soft vs hard bounce guide covers it in depth.

#!/usr/bin/env bash
# classify <recipient> <enhanced-status-code>  e.g. classify bob@x.com 5.1.1
classify() {
  case "$2" in
    4.*) echo "soft"  ;;  # transient: schedule a backoff retry
    5.*) echo "hard"  ;;  # permanent: suppress forever
    *)   echo "human" ;;  # unparseable: escalate, never guess
  esac
}

The fallthrough branch matters as much as the first two. A bounce the classifier can't parse goes to a human, not back to the agent — guessing on 1 ambiguous code out of 100 is how suppression lists rot.

How do you retry soft bounces with exponential backoff?

Retry a soft bounce at most 3 times on a widening schedule: 30 minutes, 2 hours, then 8 hours. If the third attempt bounces, suppress the address. The 30-minute floor comes from RFC 5321 section 4.5.4.1, which says the retry interval “SHOULD be at least 30 minutes.”

Here is the payoff teased in the TL;DR: the --schedule flag on nylas email send accepts durations like 30m or 8h, so each retry is queued at send time and no cron daemon or sleep loop exists anywhere. Tagging the attempt number with --metadata makes the count durable on the message itself, so the next bounce webhook knows whether the cap of 3 is reached.

# retry <recipient> <attempt-number> — called on each 4.x.x bounce
retry() {
  case "$2" in
    1) DELAY=30m ;;
    2) DELAY=2h  ;;
    3) DELAY=8h  ;;
    *) echo "$1" >> suppressed.txt; return ;;  # cap reached: suppress
  esac
  nylas email send \
    --to "$1" \
    --subject "$SUBJECT" \
    --body "$BODY" \
    --schedule "$DELAY" \
    --metadata attempt="$2" \
    --yes
}

The widening gaps are deliberate: a mailbox full at 09:00 is often empty by lunch, and 3 attempts spread across 10.5 hours give it that chance without pestering the receiving server. The same bounded-attempt discipline applies to plain network failures, covered in the reliable email automation guide.

When should an email bounce escalate from the agent to a human?

Escalate when the rules run out: a soft bounce that survives all 3 retries, a status code the classifier can't parse, or a batch where more than 5% of recipients bounce, which usually means a stale list rather than 25 individual dead mailboxes. Everything else resolves silently through retry or suppression.

Suppression is the gate that makes the whole system safe: before any send, the wrapper checks the recipient against suppressed.txt and refuses matches. One 5.1.1 is enough to add an address — Microsoft's Exchange Online NDR documentation describes these codes as final verdicts, not requests to try harder. The escalation itself is one more send, this time to a person.

# Gate every agent send through the suppression list
grep -qxF "$RECIPIENT" suppressed.txt && { echo "suppressed, skipping"; exit 0; }

# Escalate a rule-exhausted bounce to a human
nylas email send \
  --to ops@example.com \
  --subject "Bounce escalation: $RECIPIENT" \
  --body "3 retries exhausted (last code: $STATUS). Verify the address manually." \
  --yes

Notice what the agent never sees: the suppression file, the attempt counter, the schedule table. It calls a send wrapper and gets back “sent,” “queued for retry,” or “refused.” That interface is the entire containment story, and it costs about 30 lines of shell.

Next steps