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.
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.
"""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())
/**
* 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.
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
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
- SES bounce rate creeping toward account review
- SES bounces and complaints are invisible with no event destination
- MX record points at a host that no longer accepts mail
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.
- Using the Amazon SES account-level suppression list — AWS docs
- Amazon SES global suppression list — AWS docs
- boto3 sesv2 list_suppressed_destinations
- boto3 sesv2 put_account_suppression_attributes
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.