Guide · AWS SES v2
MCP Server AWS SES Suppression List — bounce and complaint handling
Your SES account health — and therefore your ability to continue sending email — depends on keeping bounce rate below 5% and complaint rate below 0.1%. SES automatically adds hard-bounce and complaint addresses to its account-level suppression list; subsequent sends to those addresses are silently rejected with no email sent and no bounce event generated. If you ignore suppression and try to brute-force past it by removing entries and re-sending, AWS suspends the account. The correct architecture is: subscribe to SES feedback notifications via SNS to receive real-time bounce and complaint events, process those events to mark addresses as undeliverable in your own database, and query the suppression list before any send that might hit a stale address. This page covers the SDK operations for all three steps.
TL;DR
SES auto-adds hard bounce and complaint addresses to the account suppression list. Use GetSuppressedDestinationCommand to check a single address; ListSuppressedDestinationsCommand to paginate all suppressed addresses (filter by Reasons: ['BOUNCE' | 'COMPLAINT']). Wire SNS feedback notifications via a configuration set's event destination to receive bounce/complaint JSON in real time — process these in a Lambda and mark addresses in your DB. Never call DeleteSuppressedDestinationCommand on a hard-bounced address without first confirming the address is valid via a double-opt-in re-confirmation flow.
How the SES suppression list works
SES maintains two layers of suppression. The global SES suppression list is managed by AWS across all customers — if an address has opted out of all SES mail, AWS blocks it regardless of your account settings. Your account-level suppression list is specific to your account and region; you can read, write, and delete entries via SDK. When you send to a suppressed address, SES returns HTTP 200 from SendEmailCommand with a valid MessageId — but no email is delivered and no feedback event is emitted. This silent success makes it easy to miss suppression silently losing mail.
Automatic suppression happens for:
- Hard bounces (
Permanentbounce subtype) — the address does not exist. SES adds it immediately. - Complaints — the recipient marked the email as spam. SES adds it immediately.
Soft bounces (Transient) are not auto-suppressed. You handle those in your own retry logic — typically retry after 24 hours up to 3 times before treating as permanent.
Checking and managing suppressed addresses
import {
SESv2Client,
GetSuppressedDestinationCommand,
ListSuppressedDestinationsCommand,
PutSuppressedDestinationCommand,
DeleteSuppressedDestinationCommand,
SuppressionListReason,
} from '@aws-sdk/client-sesv2';
const ses = new SESv2Client({ region: process.env.AWS_REGION ?? 'us-east-1' });
// Check whether a single address is suppressed before sending
async function isSuppressed(email: string): Promise {
try {
await ses.send(new GetSuppressedDestinationCommand({ EmailAddress: email }));
return true; // found in suppression list
} catch (err: unknown) {
if (
typeof err === 'object' &&
err !== null &&
'name' in err &&
(err as { name: string }).name === 'NotFoundException'
) {
return false; // not suppressed
}
throw err; // unexpected error
}
}
// Add an address to the suppression list manually (e.g., user unsubscribe request)
async function suppressAddress(email: string, reason: SuppressionListReason): Promise {
await ses.send(new PutSuppressedDestinationCommand({
EmailAddress: email,
Reason: reason, // SuppressionListReason.COMPLAINT or SuppressionListReason.BOUNCE
}));
}
// Called when user clicks "unsubscribe" in email footer
await suppressAddress('user@example.com', SuppressionListReason.COMPLAINT);
// List all suppressed addresses — paginate with NextToken
async function listSuppressedAddresses(
reason?: SuppressionListReason,
): Promise> {
const results: Array<{ email: string; reason: string; suppressedAt: Date }> = [];
let nextToken: string | undefined;
do {
const response = await ses.send(new ListSuppressedDestinationsCommand({
Reasons: reason ? [reason] : undefined, // filter by BOUNCE or COMPLAINT; omit for all
PageSize: 100,
NextToken: nextToken,
}));
for (const entry of response.SuppressedDestinationSummaries ?? []) {
results.push({
email: entry.EmailAddress!,
reason: entry.Reason!,
suppressedAt: entry.LastUpdateTime!,
});
}
nextToken = response.NextToken;
} while (nextToken);
return results;
}
// CAUTION: Removing a suppressed address is permanent in-session
// Only do this after confirmed opt-in re-confirmation from the user
async function removeSuppression(email: string): Promise {
await ses.send(new DeleteSuppressedDestinationCommand({ EmailAddress: email }));
}
Receiving bounce and complaint events via SNS
To process bounces and complaints in real time, configure a configuration set event destination that publishes BOUNCE and COMPLAINT events to an SNS topic. SES delivers a JSON envelope to the topic; a Lambda subscribed to the topic processes the event and updates your database. This architecture is covered in the SES configuration sets guide — here we focus on parsing the event payload.
// Lambda handler subscribed to SES-feedback SNS topic
interface SNSRecord {
Sns: { Message: string };
}
interface SESSNSEnvelope {
notificationType: 'Bounce' | 'Complaint' | 'Delivery';
bounce?: SESSNSBounce;
complaint?: SESSNSComplaint;
mail: { messageId: string; destination: string[] };
}
interface SESSNSBounce {
bounceType: 'Permanent' | 'Transient' | 'Undetermined';
bounceSubType: string; // 'General' | 'NoEmail' | 'Suppressed' | 'MailboxFull' | ...
bouncedRecipients: Array<{
emailAddress: string;
action: string;
status: string; // SMTP status code e.g. "5.1.1"
diagnosticCode: string;
}>;
timestamp: string;
feedbackId: string;
}
interface SESSNSComplaint {
complainedRecipients: Array<{ emailAddress: string }>;
complaintFeedbackType?: string; // 'abuse' | 'fraud' | 'virus' | 'other'
userAgent?: string; // mailbox provider feedback loop identifier
timestamp: string;
feedbackId: string;
}
export async function handler(event: { Records: SNSRecord[] }): Promise {
for (const record of event.Records) {
const envelope: SESSNSEnvelope = JSON.parse(record.Sns.Message);
if (envelope.notificationType === 'Bounce' && envelope.bounce) {
await processBounce(envelope.bounce, envelope.mail.messageId);
} else if (envelope.notificationType === 'Complaint' && envelope.complaint) {
await processComplaint(envelope.complaint, envelope.mail.messageId);
}
// 'Delivery' events are purely informational — no action required
}
}
async function processBounce(bounce: SESSNSBounce, messageId: string): Promise {
for (const recipient of bounce.bouncedRecipients) {
if (bounce.bounceType === 'Permanent') {
// Hard bounce: address does not exist — mark permanently undeliverable in your DB
await db.markEmailHardBounced(recipient.emailAddress, {
messageId,
bounceSubType: bounce.bounceSubType,
diagnosticCode: recipient.diagnosticCode,
timestamp: bounce.timestamp,
});
// SES already suppressed this address — your DB record is a local mirror
} else if (bounce.bounceType === 'Transient') {
// Soft bounce: mailbox full, server busy — log and schedule retry (24h+)
await db.recordSoftBounce(recipient.emailAddress, {
messageId,
bounceSubType: bounce.bounceSubType, // 'MailboxFull' | 'MessageTooLarge' | 'ContentRejected'
retryAfter: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
}
}
}
async function processComplaint(complaint: SESSNSComplaint, messageId: string): Promise {
for (const recipient of complaint.complainedRecipients) {
// Suppress immediately — user has flagged as spam; never email again without fresh opt-in
await db.markEmailComplaint(recipient.emailAddress, {
messageId,
feedbackType: complaint.complaintFeedbackType,
timestamp: complaint.timestamp,
});
// SES already suppressed this address
}
}
Bounce type reference
| bounceType | bounceSubType | Meaning | Action |
|---|---|---|---|
| Permanent | General | Address rejects all mail (e.g. 550 5.1.1) | Mark hard-bounced; remove from all send lists |
| Permanent | NoEmail | Address does not exist at domain | Mark hard-bounced; SES auto-suppresses |
| Permanent | Suppressed | Address was on SES global suppression list | Mark hard-bounced; do not attempt re-send |
| Permanent | OnAccountSuppressionList | Address was on your account suppression list | No action — your suppression list is working correctly |
| Transient | MailboxFull | Recipient mailbox over quota | Retry after 24–48 hours; abandon after 3 attempts |
| Transient | MessageTooLarge | Message exceeds recipient server size limit | Reduce message size; inline images → hosted URLs |
| Transient | ContentRejected | Content filtered by recipient server | Review content for spam triggers; avoid heavy HTML |
| Transient | AttachmentRejected | Attachment type blocked by recipient server | Convert to link; avoid executable attachments |
| Undetermined | Undetermined | Server returned ambiguous response | Log and treat as soft bounce; retry once after 24h |
Keeping account health in bounds
AWS monitors your bounce and complaint rates with a 24-hour rolling window. If bounce rate exceeds 5% or complaint rate exceeds 0.1%, AWS first issues a warning; at 10% bounce or 0.3% complaint rate, the account is automatically suspended. Recovery requires a support request and a written remediation plan.
Read your current health metrics via GetAccountCommand. SES also exposes these in CloudWatch under the AWS/SES namespace metrics (Reputation.BounceRate and Reputation.ComplaintRate).
import { GetAccountCommand } from '@aws-sdk/client-sesv2';
const account = await ses.send(new GetAccountCommand({}));
const reputationMetrics = account.Details?.ReputationMetrics;
if (reputationMetrics) {
console.log({
bounceRate: reputationMetrics.BounceRate, // decimal fraction e.g. 0.021 = 2.1%
complaintRate: reputationMetrics.ComplaintRate, // decimal fraction e.g. 0.0004 = 0.04%
lastFreshStart: reputationMetrics.LastFreshStart, // date reputation window reset
});
if ((reputationMetrics.BounceRate ?? 0) > 0.03) {
console.warn('WARNING: Bounce rate approaching 5% threshold — audit recipient list immediately');
}
if ((reputationMetrics.ComplaintRate ?? 0) > 0.0005) {
console.warn('WARNING: Complaint rate approaching 0.1% threshold — review content and consent');
}
}
Preventive measures that keep rates low: use double opt-in for every subscriber, clean your list by removing addresses inactive for 6+ months before a large campaign, include a one-click unsubscribe link that calls PutSuppressedDestinationCommand immediately, and never purchase third-party email lists.