Diagnostic Amazon SES

SES suppression list silently blocks a real customer

The customer swears the password reset never arrived. Your logs show the send succeeded. SES returned a message ID and no error. Nothing is in the spam folder, and the address is spelled correctly. What actually happened is that SES accepted the call, matched the address against your account-level suppression list, and dropped the message before it ever left AWS — because six months ago that address hard bounced once, while the mailbox was full.

Amazon SES v2 API Python and Node.js Fixable through the API
The short answer

SES keeps an account-level suppression list. By default it adds every address that hard bounces or files a complaint, and it then silently drops future sends to that address. The API call still succeeds and still returns a message ID, so nothing in your application logs looks wrong.

Check with GetSuppressedDestination, and remove with DeleteSuppressedDestination. But removing an address that genuinely bounces will put it straight back and charge the bounce against your reputation, so the script below reports first and only deletes what you tell it to.

The problem in plain words

You send a transactional email through SES. The SDK returns a MessageId. Your application records a success. The recipient never receives anything, and no bounce or complaint event arrives either, because nothing was ever delivered to bounce.

This is by design and it is easy to miss: suppression happens after SES accepts the request. From the caller's point of view the send worked. The only place the truth exists is the suppression list itself and, if you have wired it up, a Rendering Failure-adjacent event stream that most teams never configure.

It bites hardest on transactional mail — password resets, receipts, invitations — where a single undelivered message is a support ticket rather than a rounding error in a campaign.

Why it happens

Three things combine.

The default is aggressive. A new SES account has account-level suppression enabled for both BOUNCE and COMPLAINT. One hard bounce is enough. A full mailbox, a temporarily misconfigured MX, a typo the user later corrected — any of these can produce a hard bounce that suppresses the address permanently.

Suppression is account-wide, not per identity. An address suppressed by a marketing blast is also suppressed for your password reset. The two have nothing to do with each other operationally, but they share one list.

The failure is invisible by default. Unless the sending call goes through a configuration set with an event destination, there is no signal anywhere that the message was suppressed. Guide five in this section covers wiring that up, and it is the single change that makes this class of problem visible instead of mysterious.

How to fix it

Check the one address first

Before touching anything, confirm the diagnosis for the address the customer reported. GetSuppressedDestination returns the reason and the date, which tells you whether this was a bounce or a complaint — and those two want very different responses.

aws sesv2 get-suppressed-destination --email-address customer@example.com

A NotFoundException means the address is not suppressed and your problem is somewhere else. A result with "Reason": "COMPLAINT" means they marked you as spam: do not remove it, and do not mail them again.

Decide bounce by bounce, never in bulk

A BOUNCE reason is worth investigating; a COMPLAINT reason almost never is. The distinction matters because removing an address that still bounces re-suppresses it and the bounce counts against the account bounce rate that AWS uses to decide whether to keep sending your mail at all.

The script below defaults to reporting. You pass explicit addresses to remove.

Verify the mailbox exists before you remove

If the original bounce was a full mailbox or a dead domain, the address will bounce again the moment you retry. Confirm the domain still has a working MX and, where you can, that the user has actually corrected the address, before removing the entry.

Reconsider the account-wide default

If your account sends both marketing and transactional mail, account-level suppression is a blunt instrument. PutAccountSuppressionAttributes lets you narrow it to COMPLAINT only, and you then handle bounces yourself per list. That is a real decision with real risk — you become responsible for not re-mailing dead addresses — so the script only reports the current setting rather than changing it.

How to check it worked

Re-run the check for the address. It should return NotFoundException. Then send one real message to it and watch for the delivery event rather than trusting the MessageId:

# should now raise NotFoundException
aws sesv2 get-suppressed-destination --email-address customer@example.com

# and the send should produce a Delivery event, not silence
aws sesv2 send-email --from-email-address you@yourdomain.com \
  --destination ToAddresses=customer@example.com \
  --content 'Simple={Subject={Data=test},Body={Text={Data=test}}}'

If it bounces again, the address is genuinely bad. Leave it suppressed.

The full code

The script lists the suppression list with paging, groups by reason so complaints and bounces are never confused, and removes only addresses you name explicitly. It stays in dry run until you pass --apply, and it refuses outright to remove an address suppressed for a complaint.

ses_suppression_audit.py
"""Audit the SES account-level suppression list and remove named addresses.

Reports by default. Removal requires --apply AND an explicit address, because
deleting an entry that still bounces re-suppresses it and the bounce counts
against the account reputation.
"""
import argparse
import logging
import sys

import boto3
from botocore.exceptions import ClientError

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ses_suppression_audit")

COMPLAINT = "COMPLAINT"


def list_suppressed(client, reasons=None):
    """Yield every suppressed destination, following pagination."""
    kwargs = {"PageSize": 1000}
    if reasons:
        kwargs["Reasons"] = reasons
    token = None
    while True:
        if token:
            kwargs["NextToken"] = token
        page = client.list_suppressed_destinations(**kwargs)
        for item in page.get("SuppressedDestinationSummaries", []):
            yield item
        token = page.get("NextToken")
        if not token:
            return


def describe(client, address):
    """Return the suppression record for one address, or None."""
    try:
        return client.get_suppressed_destination(
            EmailAddress=address)["SuppressedDestination"]
    except ClientError as exc:
        if exc.response["Error"]["Code"] == "NotFoundException":
            return None
        raise


def should_remove(record):
    """Pure decision function. No API calls, so it is trivial to test.

    A complaint means the recipient pressed 'this is spam'. Removing that entry
    and mailing them again is how an account gets shut down, so it is never
    eligible no matter what the operator passed on the command line.
    """
    if record is None:
        return False, "not suppressed"
    if record.get("Reason") == COMPLAINT:
        return False, "suppressed for a complaint; do not re-mail"
    return True, "suppressed for a bounce; safe to remove if the mailbox is fixed"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--region", default="us-east-1")
    ap.add_argument("--remove", nargs="*", default=[],
                    help="addresses to remove; each is checked individually")
    ap.add_argument("--apply", action="store_true", help="actually delete")
    args = ap.parse_args()

    ses = boto3.client("sesv2", region_name=args.region)

    account = ses.get_account()
    supp = account.get("SuppressionAttributes", {}).get("SuppressedReasons", [])
    log.info("account-level suppression is on for: %s", supp or "nothing")

    counts = {}
    for item in list_suppressed(ses):
        counts[item["Reason"]] = counts.get(item["Reason"], 0) + 1
    log.info("suppression list holds %s", counts or "no addresses")

    exit_code = 0
    for address in args.remove:
        record = describe(ses, address)
        ok, reason = should_remove(record)
        if not ok:
            log.warning("SKIP %s -- %s", address, reason)
            exit_code = 1
            continue
        log.info("%s %s -- %s (suppressed %s)",
                 "REMOVING" if args.apply else "WOULD REMOVE",
                 address, reason, record.get("LastUpdateTime"))
        if args.apply:
            ses.delete_suppressed_destination(EmailAddress=address)

    if not args.apply and args.remove:
        log.info("dry run -- pass --apply to actually delete")
    return exit_code


if __name__ == "__main__":
    sys.exit(main())
ses-suppression-audit.mjs
/**
 * Audit the SES account-level suppression list and remove named addresses.
 *
 * Reports by default. Removal requires --apply AND an explicit address, because
 * deleting an entry that still bounces re-suppresses it and the bounce counts
 * against the account reputation.
 */
import {
  SESv2Client,
  GetAccountCommand,
  ListSuppressedDestinationsCommand,
  GetSuppressedDestinationCommand,
  DeleteSuppressedDestinationCommand,
} from '@aws-sdk/client-sesv2';

const COMPLAINT = 'COMPLAINT';

async function* listSuppressed(client) {
  let NextToken;
  do {
    const page = await client.send(
      new ListSuppressedDestinationsCommand({ PageSize: 1000, NextToken }),
    );
    yield* page.SuppressedDestinationSummaries ?? [];
    NextToken = page.NextToken;
  } while (NextToken);
}

async function describe(client, address) {
  try {
    const out = await client.send(
      new GetSuppressedDestinationCommand({ EmailAddress: address }),
    );
    return out.SuppressedDestination;
  } catch (err) {
    if (err.name === 'NotFoundException') return null;
    throw err;
  }
}

/**
 * Pure decision function. No API calls, so it is trivial to test.
 *
 * A complaint means the recipient pressed 'this is spam'. Removing that entry
 * and mailing them again is how an account gets shut down, so it is never
 * eligible no matter what the operator passed on the command line.
 */
export function shouldRemove(record) {
  if (!record) return { ok: false, reason: 'not suppressed' };
  if (record.Reason === COMPLAINT) {
    return { ok: false, reason: 'suppressed for a complaint; do not re-mail' };
  }
  return { ok: true, reason: 'suppressed for a bounce; safe to remove if the mailbox is fixed' };
}

async function main() {
  const args = process.argv.slice(2);
  const apply = args.includes('--apply');
  const removeAt = args.indexOf('--remove');
  const remove = removeAt === -1 ? [] : args.slice(removeAt + 1).filter((a) => !a.startsWith('--'));
  const client = new SESv2Client({ region: process.env.AWS_REGION ?? 'us-east-1' });

  const account = await client.send(new GetAccountCommand({}));
  console.log('account-level suppression is on for:',
    account.SuppressionAttributes?.SuppressedReasons ?? 'nothing');

  const counts = {};
  for await (const item of listSuppressed(client)) {
    counts[item.Reason] = (counts[item.Reason] ?? 0) + 1;
  }
  console.log('suppression list holds', Object.keys(counts).length ? counts : 'no addresses');

  let exitCode = 0;
  for (const address of remove) {
    const record = await describe(client, address);
    const { ok, reason } = shouldRemove(record);
    if (!ok) {
      console.warn(`SKIP ${address} -- ${reason}`);
      exitCode = 1;
      continue;
    }
    console.log(`${apply ? 'REMOVING' : 'WOULD REMOVE'} ${address} -- ${reason}`);
    if (apply) {
      await client.send(new DeleteSuppressedDestinationCommand({ EmailAddress: address }));
    }
  }
  if (!apply && remove.length) console.log('dry run -- pass --apply to actually delete');
  process.exit(exitCode);
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Add a test

The decision is separated from the API on purpose, so the rule that protects you — never remove a complaint — can be tested without an AWS account or a mock.

test_ses_suppression_audit.py
import pytest
from ses_suppression_audit import should_remove


def test_missing_address_is_not_removable():
    ok, reason = should_remove(None)
    assert ok is False
    assert "not suppressed" in reason


def test_complaint_is_never_removable():
    ok, reason = should_remove({"Reason": "COMPLAINT"})
    assert ok is False, "removing a complaint and re-mailing is how accounts get shut down"


def test_bounce_is_removable():
    ok, _ = should_remove({"Reason": "BOUNCE"})
    assert ok is True


@pytest.mark.parametrize("reason", ["COMPLAINT", "complaint".upper()])
def test_complaint_case_is_exact(reason):
    """SES returns the reason uppercase; this guards the comparison."""
    ok, _ = should_remove({"Reason": reason})
    assert ok is False
ses-suppression-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shouldRemove } from './ses-suppression-audit.mjs';

test('a missing address is not removable', () => {
  const { ok, reason } = shouldRemove(null);
  assert.equal(ok, false);
  assert.match(reason, /not suppressed/);
});

test('a complaint is never removable', () => {
  const { ok } = shouldRemove({ Reason: 'COMPLAINT' });
  assert.equal(ok, false, 'removing a complaint and re-mailing is how accounts get shut down');
});

test('a bounce is removable', () => {
  const { ok } = shouldRemove({ Reason: 'BOUNCE' });
  assert.equal(ok, true);
});

FAQ

Why did the send succeed if the message was never delivered?

SES applies the suppression list after it accepts the request. The API returns a MessageId and no error, so the caller sees success. The message is dropped inside SES and never reaches the recipient's mail server, which is also why no bounce arrives — there was no delivery attempt to bounce.

Is the account-level suppression list the same as the global suppression list?

No. The global list is managed by AWS across all customers and you cannot edit it. The account-level list is yours, and it is the one you can read and delete from with the SES v2 API. An address can be on the global list and not yours; if you send to it, SES will attempt delivery, and a resulting bounce still counts against your bounce rate.

Should I just remove everything on the list?

No. Every address on it bounced or complained at least once. Removing them all and re-sending recreates the bounces, drives your bounce rate up, and risks the account being put under review. Remove individual addresses you have a specific reason to believe are now valid.

Can I stop SES suppressing bounces automatically?

Yes, with PutAccountSuppressionAttributes you can narrow suppression to complaints only. That makes you responsible for not re-mailing dead addresses yourself, which is a real operational burden — do it only if you already maintain your own bounce handling.

How do I find out this is happening without a customer complaint?

Attach a configuration set with an event destination to your sends. That publishes delivery, bounce, complaint and rejection events to SNS, CloudWatch or Kinesis Firehose, which turns an invisible drop into a log line. It is the subject of a separate note in this section.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.