Skip to content

Suppression

A suppression list is the set of addresses your project will not send to, no matter what your application asks for.

It exists because of one number. Amazon reviews accounts whose bounce rate goes over 5%, and a dead address bounces every single time you send to it. Without a list, a signup typo entered once keeps costing you reputation for as long as your application keeps retrying it — and the fix is not to remember harder, it is to stop asking.

SESKit adds addresses to the list on its own, refuses sends to them, and gives you a page to take one off again.

What gets added, and what does not

Event Added?
Hard bounce — bounce_type is Permanent Yes
Complaint — the recipient marked it as spam Yes
Soft bounce — bounce_type is Transient No
Undetermined bounce No

The two "no" rows are the important ones. A transient bounce is a full mailbox or a greylist; the address works again tomorrow. Suppressing it would delete a real recipient for good, a few at a time, silently — and nobody connects "our mail stopped arriving" to a rule that fired weeks ago. Only Permanent is evidence that an address is dead.

See bounce types for what SES means by each.

What happens when you send to one

The send is refused before it reaches SES:

{
  "error": {
    "type": "suppressed_recipient",
    "message": "user@example.com is on this project's suppression list, so nothing was sent. An address lands there after a hard bounce or a complaint, when the recipient unsubscribes, or by hand. The Suppressions page says which, and can take it off if you believe mail can be delivered there again."
  }
}

HTTP 422. to, cc and bcc are all checked, and the whole request fails rather than the suppressed recipients being dropped — sending to the rest would need a second response shape saying who was left out, and a caller who did not read it would believe everyone got the message.

Refusing before SES is the whole point. A message SES never sees cannot bounce, so it cannot count against the rate that gets accounts reviewed. A message you send anyway is one you already know will fail.

Addresses are compared with the display name stripped and the case folded, so Bob <BOB@Example.com> and bob@example.com are the same address to the list. Adding a display name is not a way past it.

The list is per project

SES has its own account-level suppression list. SESKit does not use it, and does not write to it.

The reason is scope. Your AWS credentials resolve to one account, so an account-level list is shared by every project on this instance: a marketing project's complaint would silence a transactional project's password resets to the same person. That is almost never what anyone wants, and it cannot be undone per project because the list has no concept of one.

The tradeoff, stated plainly: mail you send through SES from something that is not SESKit is not filtered by this list.

Removing an address

Suppressions → Allow again, on the dashboard.

Do it when you know why the address bounced and know it is fixed — the mailbox was recreated, the domain's DNS was broken and is not any more, someone asked to be put back on. Do not do it to make a number look better: the address will bounce again and the rate will come back.

Removal is soft. The row stays with the date it was cleared, so "bounced in March, cleared in April, complained in June" is a history you can still read.

Adding an address by hand

Suppressions → Suppress an address.

For the case automation cannot cover: somebody writes in and asks to be left alone, and there is no bounce or complaint to record it. The note field is worth filling in — the next person to read the row will want to know why, and "asked us to stop, ticket #4412" answers that in a way a date cannot.

One-click unsubscribe

Messages carry the headers that put an Unsubscribe button next to your name in Gmail and Outlook:

List-Unsubscribe: <https://mail.example.com/u/eyJ...>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

This is worth more than politeness. A recipient who cannot find the unsubscribe button presses Report spam instead, and a complaint costs a reputation point that an unsubscribe does not.

Pressing it adds the address to this list, with reason unsubscribe.

What it needs

PUBLIC_BASE_URL must be set, because the link has to be reachable from the recipient's mail client. With it unset the headers are left off the message entirely — a button pointing at localhost is worse than no button, since it fails in front of the person whose next move is to report you.

The message must have exactly one recipient. A message carries one List-Unsubscribe header and the link names one address, so with two recipients the link would let either of them unsubscribe whichever one it happened to name. A blind copy counts as a second person. Send separate messages if you need per-recipient unsubscribe.

The link carries the message id, the address, and an HMAC signature over both, keyed per project. Editing the address in the URL invalidates the signature, so nobody can unsubscribe anybody by guessing.

Opening the link does not unsubscribe anyone — it shows a button. Mail clients and security scanners fetch links in messages without being asked, and a link that acted on sight would unsubscribe people who pressed nothing. The unsubscribe happens on the POST, which is what the one-click header tells providers to send.

There is deliberately no expiry. A link in a message somebody kept for two years should still work; an expired unsubscribe link produces exactly the complaint the header exists to prevent.

Unknown and altered links both answer 200 with the same sentence, so the URL cannot be used to find out whether an address or a message exists.

Hearing about it in your own application

A suppression caused by a message emits an email.suppressed event to your webhooks:

{
  "type": "email.suppressed",
  "data": {
    "to": ["user@example.com"],
    "reason": "bounce",
    "caused_by": "evt_01J8XQ..."
  }
}
reason Emits an event?
bounce Yes — a hard bounce
complaint Yes — marked as spam
unsubscribe Yes — the recipient used the one-click link
manual No — somebody added it on the dashboard

caused_by is the event that led to it, or null when there was none — an unsubscribe is the recipient telling SESKit directly.

manual is the exception because an event hangs off a message, and an address somebody typed into the dashboard has no message behind it. If you keep your own mailing list in step with this one, the dashboard is the one path that will not tell you about itself.

Consume this if you keep your own mailing list: it is how you find out that an address left, and it covers every route a message can put one there.

What is not built yet

  • No HTTP API for the list. It is the dashboard and the email.suppressed event today. There is no GET /v1/suppressions to poll and no bulk import.
  • Suppressions do not expire. An address stays until somebody removes it.