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:

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

bounceTypebounceSubTypeMeaningAction
PermanentGeneralAddress rejects all mail (e.g. 550 5.1.1)Mark hard-bounced; remove from all send lists
PermanentNoEmailAddress does not exist at domainMark hard-bounced; SES auto-suppresses
PermanentSuppressedAddress was on SES global suppression listMark hard-bounced; do not attempt re-send
PermanentOnAccountSuppressionListAddress was on your account suppression listNo action — your suppression list is working correctly
TransientMailboxFullRecipient mailbox over quotaRetry after 24–48 hours; abandon after 3 attempts
TransientMessageTooLargeMessage exceeds recipient server size limitReduce message size; inline images → hosted URLs
TransientContentRejectedContent filtered by recipient serverReview content for spam triggers; avoid heavy HTML
TransientAttachmentRejectedAttachment type blocked by recipient serverConvert to link; avoid executable attachments
UndeterminedUndeterminedServer returned ambiguous responseLog 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.