Bounce and complaint handling looks completely different for an agent than for a bulk newsletter pipeline. Your agent sends transactional, often conversational messages — OTPs, meeting confirmations, task updates — to individual recipients it has built context around. A bad bounce rate doesn't just hurt deliverability; it can halt an entire workflow mid-thread and leave a user waiting for a response that never arrives.
This guide covers bounce classification, complaint feedback loops, suppression list architecture, and how to wire all of it into an agent that sends reliably without manual ops intervention.
What bounces actually mean at the protocol level
A bounce is a delivery failure reported by the receiving mail server via an SMTP status code. The critical distinction is between bounce types, because they require different handling.
Hard bounces (5xx) indicate permanent failure — the address doesn't exist, the domain has no MX record, or the receiving server has explicitly rejected the sender's domain. Common codes:
550 5.1.1— mailbox does not exist550 5.1.2— bad destination mailbox address521 5.2.1— mailbox disabled550 5.7.1— message rejected due to policy (often DMARC failure)
Soft bounces (4xx) are temporary. Full mailbox, rate-limiting, transient connection issue. These may resolve.
452 4.2.2— mailbox full421 4.7.0— temporary service unavailable451 4.4.2— timeout
The SMTP session that produces these codes happens between the sending MTA and the receiving MTA — not between your application and the API. By the time a bounce event reaches your webhook, your email provider has already classified it based on those raw SMTP responses.
Bounce rate math
Bounce rate = (hard bounces / emails sent) × 100
Google's Postmaster Tools documentation states that sustained hard bounce rates above 2% will cause mail to be deferred or rejected by Gmail. Most email providers enforce an internal threshold of 5% — above that, sending is paused automatically. High bounce rates signal to ISPs that an address list hasn't been validated, which they treat as evidence of harvested or scraped data.
For agent email, hitting a bounce threshold is operationally catastrophic. An agent that sends a magic link and gets a 550 back can't complete the auth flow. If that failure pushes the bounce rate past the threshold, the next 50 users get no email at all.
Complaint feedback loops
Spam complaints are generated when a user clicks "Mark as Spam" in their mail client. The receiving provider (Gmail, Yahoo, Outlook) sends a structured report back to the originating sender via a Feedback Loop (FBL), specifically an ARF (Abuse Reporting Format) message, essentially an email containing a copy of the original with headers identifying it as a complaint.
Gmail's FBL only sends complaint data to senders using authenticated custom domains with DKIM. Outlook operates their SNDS (Smart Network Data Services) program. Yahoo runs their own FBL. If you're sending from a shared domain without custom authentication, you often receive no complaint data — the provider just starts filtering your mail silently.
Complaint rate thresholds are strict:
| Provider | Safe zone | Concern | Action taken |
|---|---|---|---|
| Gmail | < 0.10% | 0.10%–0.30% | Deliverability degrades |
| Gmail | > 0.30% | — | Mail rejected or spam-foldered |
| Most ESPs | > 0.30% | — | Account paused |
Complaint rate = (spam complaints / delivered emails) × 100
The 0.3% threshold sounds forgiving. It isn't. If your agent sends 1,000 emails and 4 users mark them as spam, you're over the limit.
Why agent email has a different risk profile
Bulk senders can A/B test sending patterns and slowly tune list hygiene. An AI agent faces a structurally different problem.
Address input is unpredictable. The agent often receives an email address from user input, a parsed document, an API response, or an LLM extraction. None of those sources validate deliverability. An LLM pulling an email from a PDF will occasionally produce john.doe@companyname.com when the real address is jdoe@companyname.com.
Volume spikes are sudden. A human email marketer ramps a campaign over days. An agent triggered by a user action can attempt to email 500 addresses in 10 minutes if the trigger fires repeatedly.
The agent has no memory of prior failures. Without an explicit suppression layer, the same agent can retry a hard-bounced address on its next invocation, because nothing told it what happened last time.
Multiple agent instances share reputation. Run 10 agent instances behind one sending domain, and a bug in one that hammers invalid addresses degrades deliverability for the other nine.
Building a suppression list
A suppression list is a persistent store of addresses your system must never send to again. It needs to be checked before the send API call, not after.
Minimum schema:
CREATE TABLE email_suppressions (
address TEXT NOT NULL,
reason TEXT NOT NULL CHECK (reason IN ('hard_bounce', 'complaint', 'manual', 'unsubscribe')),
source TEXT, -- which agent or send ID triggered this
suppressed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
raw_code TEXT, -- original SMTP code, e.g. '550 5.1.1'
PRIMARY KEY (address)
);
CREATE INDEX idx_suppressions_address ON email_suppressions (lower(address));
The lookup must be case-insensitive. User@Example.com and user@example.com are the same address per RFC 5321 (the local-part is technically case-sensitive, but no real mail server treats it that way).
Pre-send suppression check
import { mails } from "@mailsai/sdk";
import { db } from "./db";
async function sendWithSuppressionCheck(
to: string,
subject: string,
body_text: string
): Promise<{ sent: boolean; reason?: string }> {
const suppressed = await db.query(
`SELECT reason FROM email_suppressions WHERE lower(address) = lower($1)`,
[to]
);
if (suppressed.rows.length > 0) {
console.warn(`Suppressed send to ${to}: ${suppressed.rows[0].reason}`);
return { sent: false, reason: suppressed.rows[0].reason };
}
const hello = mails.agent("hello");
await hello.send({ to, subject, body_text });
return { sent: true };
}
This is the core pattern. Everything else — FBL processing, bounce webhook handling — feeds into this suppression table.
Processing bounce and complaint webhooks
Your email provider delivers bounce and complaint events as webhook POST requests to an endpoint you register. The payload structure varies by provider, but every bounce event contains:
- The recipient address
- The bounce type (hard/soft)
- The SMTP status code
- A timestamp
- Usually a
message_idcorrelating back to your original send
Processing logic for a generic bounce webhook handler:
import { createClient } from "@mailsai/sdk";
import { db } from "./db";
const client = createClient();
export async function handleBounceWebhook(
rawBody: string,
signatureHeader: string
): Promise<void> {
// Verify the webhook is authentic
const event = mails.webhooks.verify(rawBody, signatureHeader, process.env.MAILS_WEBHOOK_SECRET!);
if (!event) {
throw new Error("Invalid webhook signature");
}
if (event.type === "message.bounced") {
const { address, bounce_type, smtp_code } = event.data;
if (bounce_type === "hard") {
// Hard bounce: suppress immediately, permanently
await db.query(
`INSERT INTO email_suppressions (address, reason, source, raw_code)
VALUES (lower($1), 'hard_bounce', $2, $3)
ON CONFLICT (address) DO NOTHING`,
[address, event.source_message_id, smtp_code]
);
} else if (bounce_type === "soft") {
// Soft bounce: log for rate tracking, don't suppress yet
await db.query(
`INSERT INTO email_soft_bounces (address, smtp_code, bounced_at)
VALUES (lower($1), $2, NOW())`,
[address, smtp_code]
);
// If 3+ soft bounces in 7 days, promote to suppression
await promoteRepeatedSoftBounce(address);
}
}
if (event.type === "message.complained") {
const { address } = event.data;
await db.query(
`INSERT INTO email_suppressions (address, reason, source)
VALUES (lower($1), 'complaint', $2)
ON CONFLICT (address) DO NOTHING`,
[address, event.source_message_id]
);
}
}
async function promoteRepeatedSoftBounce(address: string): Promise<void> {
const result = await db.query(
`SELECT COUNT(*) FROM email_soft_bounces
WHERE lower(address) = lower($1)
AND bounced_at > NOW() - INTERVAL '7 days'`,
[address]
);
if (parseInt(result.rows[0].count) >= 3) {
await db.query(
`INSERT INTO email_suppressions (address, reason)
VALUES (lower($1), 'hard_bounce')
ON CONFLICT (address) DO NOTHING`,
[address]
);
}
}
Monitoring agent-level bounce rates
Per-agent bounce rate tracking matters when you run multiple agents. A single misbehaving agent — one whose address source is unreliable — can generate enough hard bounces to affect the whole workspace.
Platforms like Mails.ai track sent, delivered, bounced, complained, and replies per agent, which gives you the raw data. But you still need alerting logic that fires before you hit a threshold, not after.
-- Bounce rate per agent over the last 24 hours
SELECT
agent_id,
COUNT(*) FILTER (WHERE event_type = 'sent') AS sent,
COUNT(*) FILTER (WHERE event_type = 'bounced' AND bounce_type = 'hard') AS hard_bounces,
ROUND(
100.0 * COUNT(*) FILTER (WHERE event_type = 'bounced' AND bounce_type = 'hard')
/ NULLIF(COUNT(*) FILTER (WHERE event_type = 'sent'), 0),
2
) AS bounce_rate_pct
FROM send_events
WHERE created_at > NOW() - INTERVAL '24 hours'
GROUP BY agent_id
HAVING COUNT(*) FILTER (WHERE event_type = 'sent') > 10
ORDER BY bounce_rate_pct DESC;
Set an alert at 2% — half the automatic threshold — so you have time to diagnose the address source before anything gets paused.
Address validation before send
The most effective bounce prevention isn't processing bounces faster. It's not sending to bad addresses in the first place.
Syntax validation catches malformed addresses before they hit the network. The RFC 5322 grammar is complex enough that you should use a battle-tested library rather than a regex. In Node.js, validator.js's isEmail() covers the common cases. Reject anything that fails.
MX record lookup verifies the domain has a functioning mail server. A DNS query for the recipient domain's MX records costs roughly 50ms and eliminates an entire class of hard bounces — domains with no mail infrastructure at all.
import dns from "dns/promises";
async function domainHasMX(email: string): Promise<boolean> {
try {
const domain = email.split("@")[1];
const records = await dns.resolveMx(domain);
return records.length > 0;
} catch {
return false; // NXDOMAIN or no MX = likely invalid
}
}
Note: MX validation only confirms the domain accepts mail in general. It doesn't confirm the specific mailbox exists — that requires an SMTP VRFY probe, which most servers block. For high-stakes sends (account creation, billing notifications), a real-time email verification API like ZeroBounce or NeverBounce is worth the cost.
The feedback loop between classification and complaints
For agents that process inbound email, there's a useful signal available before the sending side even matters: if a contact who has previously complained to their mail provider emails your agent, that's a strong indicator of prior friction. Proper inbound email classification surfaces intent signals — decline, ask_question, follow_up — that can inform whether the agent should keep emailing a contact at all.
An agent that receives a decline intent on an inbound message and keeps sending follow-ups is one complaint away from a rate problem. Thread that classification signal into your suppression logic explicitly:
hello.onMessage(async (event) => {
if (event.intent === "decline") {
await db.query(
`INSERT INTO email_suppressions (address, reason, source)
VALUES (lower($1), 'unsubscribe', $2)
ON CONFLICT (address) DO NOTHING`,
[event.data.from.address, event.id]
);
}
});
Flow: bounce event to suppression
sequenceDiagram participant Agent participant SendingMTA as Sending MTA participant RecvMTA as Receiving MTA participant Webhook as Bounce Webhook participant DB as Suppression DB Agent->>SendingMTA: POST /v1/messages SendingMTA->>RecvMTA: SMTP DATA RecvMTA-->>SendingMTA: 550 5.1.1 mailbox does not exist SendingMTA->>Webhook: POST bounce event Webhook->>DB: INSERT address reason hard_bounce Note over DB: Next send attempt Agent->>DB: SELECT suppression for address DB-->>Agent: suppressed hard_bounce Agent->>Agent: skip send
Frequently Asked Questions
How quickly should I suppress a hard-bounced address?
Immediately — within the same webhook processing request. There is no scenario where retrying a hard-bounced address makes sense. The SMTP 5xx code is a definitive rejection from the receiving server. Any delay between the bounce event and suppression risks a second send attempt, which increments your bounce count again.
What's the difference between a complaint and an unsubscribe?
An unsubscribe is a deliberate opt-out via a List-Unsubscribe header or a link in the email body. A complaint is a spam report filed with the mail provider, which the provider relays to you via a Feedback Loop. Both require suppression, but complaints carry a heavier deliverability penalty — they signal to Gmail and Yahoo that your sending behavior is unwanted, and that signal affects your domain's reputation independently of what you do with the address afterward.
Should I suppress soft bounces the same way?
Not immediately. A single soft bounce can be transient — a temporarily full mailbox or a brief server outage. The practical approach is to log soft bounces and promote an address to hard suppression after 3 or more soft bounces within a rolling 7-day window. This avoids suppressing valid addresses while still protecting your bounce rate from chronic failures.
How do I handle bounce events when one agent's volume is affecting the whole workspace?
This is a genuine architectural challenge. Per-agent bounce rate tracking (see the SQL query above) identifies the offending agent. Operationally, you need circuit-breaker logic: if a specific agent's bounce rate exceeds 2% in a 24-hour window, pause that agent's sends before the workspace-level threshold is hit. A dedicated IP provides workspace-level isolation from other senders on shared infrastructure, but it doesn't isolate agents from each other within your own workspace.
What SMTP codes should always trigger immediate hard suppression?
Codes in the 5.1.x and 5.2.x families are the clearest signals: 5.1.1 (bad destination mailbox), 5.1.2 (bad destination mailbox address), 5.2.1 (mailbox disabled), and 5.2.2 (mailbox full — technically soft but often permanent for inactive accounts). 5.7.1 is trickier: it can mean DMARC/policy rejection, which is a sender problem, not a recipient problem. Log it separately and investigate your authentication setup before suppressing the address.
Is it legal to keep a suppression list permanently?
In most jurisdictions, yes — and in some it's required. CAN-SPAM (US) and GDPR (EU) both contemplate legitimate suppression lists. Under GDPR, maintaining a suppression list to honor opt-outs is considered processing for a legitimate interest and doesn't require re-consent. Deleting a suppression record because a user later "consents" would be a compliance mistake — you'd lose the record of the prior opt-out and risk re-adding the address to active sends.
Related guides: SPF DKIM DMARC for Automated Email Senders, Email Deliverability for Automated and AI Senders, Detecting Auto-Replies to Prevent Agent Email Loops and Agent Email Address: Why Every AI Agent Needs One.
Explore the product: Inbound email parsing, Sender reputation, Email classification, All features and Pricing.
