Wraps Logo
DocsHome
Guide

Suppression Lists

SES maintains its own list of addresses it will refuse to send to — separate from, and invisible to, your application's unsubscribe logic. Understanding both layers is what keeps the two in sync.

4 min read

You Have Two Lists

This is the part that catches people out. Suppression happens in two independent places, and neither one tells the other:

The SES account-level suppression list

Managed by AWS in your account. SES adds addresses to it automatically when they hard bounce or complain, and then silently refuses to deliver to them.

Two reason codes exist: BOUNCE and COMPLAINT. There is no third.

Your application's list

Your own unsubscribes, preference-center opt-outs, and soft-bounce thresholds. SES knows nothing about any of it.

A user who unsubscribes in your UI is not on the SES list. If your code has a bug and sends anyway, SES will deliver it.

Suppressed sends still cost you

When you send to an address on the SES list, SES accepts the API call, bills you for the message, and returns a Permanent bounce with subtype OnAccountSuppressionList instead of delivering it. It does not count against your bounce rate, but it is not free and it is not delivery.

Managing the SES List

The Wraps SDK exposes the account-level list directly. Because it lives in your AWS account, these calls hit SES with your credentials — Wraps never proxies your suppression data.

TypeScriptsuppression.ts
import { WrapsEmail } from "@wraps.dev/email";const email = new WrapsEmail({ region: "us-east-1" });// Check if an address is suppressedconst entry = await email.suppression.get("bounced@example.com");if (entry) {  console.log(`Suppressed: ${entry.reason} on ${entry.lastUpdated}`);}// Manually suppress an address. Only "BOUNCE" and "COMPLAINT" are valid —// SES has no other reason codes.await email.suppression.add("bad-actor@example.com", "COMPLAINT");// Remove from the suppression list (e.g. after the user re-confirms)await email.suppression.remove("reactivated@example.com");// List suppressed addresses with filtersconst { entries, nextToken } = await email.suppression.list({  reason: "BOUNCE",  startDate: new Date("2024-01-01"),  maxResults: 100,});for (const e of entries) {  console.log(`${e.email} - ${e.reason} - ${e.lastUpdated}`);}

You can also browse and search the list from the Wraps dashboard, or from an agent via the list_suppressions MCP tool.

Filter Before a Campaign

Batch sends are where suppression drift shows up. Checking first turns a pile of billed non-deliveries into a clean send:

TypeScriptpreflight.ts
import { WrapsEmail } from "@wraps.dev/email";const email = new WrapsEmail({ region: "us-east-1" });// Before a large campaign, drop addresses SES already refuses. Sending to// them produces Permanent/OnAccountSuppressionList bounces that cost you// money and clutter your event history for no delivery.async function filterSuppressed(recipients: string[]) {  const checks = await Promise.all(    recipients.map(async (address) => ({      address,      suppressed: (await email.suppression.get(address)) !== null,    })),  );  return checks.filter((c) => !c.suppressed).map((c) => c.address);}const sendable = await filterSuppressed(campaignRecipients);

When to Remove an Address

Removal is a real operation with real consequences — you are overriding a signal AWS recorded on your behalf. The reason code tells you whether that is defensible:

ReasonSafe to remove?Why
BOUNCESometimes, with re-confirmationAddresses do get fixed — a typo corrected, a mailbox recreated. Make the user prove it first.
COMPLAINTEssentially neverSomeone marked you as spam. Sending again is the behavior that gets sending paused.
TypeScriptreinstate.ts
import { WrapsEmail } from "@wraps.dev/email";const email = new WrapsEmail({ region: "us-east-1" });// A user says "I'm not getting your emails." Do this, not a blind remove:export async function reinstate(address: string) {  const entry = await email.suppression.get(address);  if (!entry) return { ok: true, reason: "not suppressed" };  // Never re-enable a COMPLAINT. Someone marked you as spam; sending  // again is how accounts get shut down.  if (entry.reason === "COMPLAINT") {    return { ok: false, reason: "complaint — do not resend" };  }  // For BOUNCE, require the user to re-confirm the address first.  await email.suppression.remove(address);  return { ok: true, reason: "reinstated after re-confirmation" };}

Checklist

  • Your unsubscribe flow writes to your own list — SES does not see it
  • Hard bounces and complaints from your webhook also update your own list
  • Large campaigns filter against the SES list before sending
  • COMPLAINT suppressions are never removed programmatically
  • BOUNCE removals require the user to re-confirm the address

Next Steps

Bounce Handling

What puts addresses on the list in the first place, and how to react to each event type.

Bounce Handling
SDK Reference

Full API surface for email.suppression and the rest of the SDK.

SDK Reference