Bounces and complaints
Why this matters¶
Every email provider watches how much of your mail fails. Amazon SES publishes the thresholds: a bounce rate above 5% puts your account under review, and above 10% it can be paused. Complaints are stricter — 0.1% draws review, 0.5% can pause the account.
A single bad address does not cause that. A loop does. Something in your app keeps mailing an address that no longer exists — a weekly digest, a retry, a re-invitation — and each attempt is another hard bounce against your rate. By the time you notice, your password-reset mail has stopped reaching anybody.
The fix has two halves, and you need both:
- Listen to delivery events, so you know which addresses failed.
- Refuse to send to those addresses, so a failure never repeats.
SpeedPy ships this
It is in speedpycom/. Do not rebuild it — wire it up. This page is now a
setup guide, and the code samples below show what is already there rather
than what to write.
| Half | Where | Applies to |
|---|---|---|
| Enforcement | speedpycom/models/email_events.py, services/email_events.py, email_backends.py, the account-page notice |
any ESP |
| Detection | speedpycom/services/sns.py, views_ses.py, urls_email_events.py |
SES only, opt-in |
POST_OFFICE["BACKENDS"]["default"] is already the suppression guard. On an
ESP other than SES, write the detection half and call
speedpycom.services.email_events.suppress() — everything downstream works
unchanged.
To turn on the SES side, add the URLs (they are deliberately not routed by default) and follow the AWS setup below:
How the pieces connect¶
your app --> SES --> recipient
|
| configuration set
v
SNS topic
|
| HTTPS subscription
v
POST /webhooks/ses/ --> EmailEvent row
--> SuppressedEmail row (hard bounce / complaint only)
|
v
pre-send guard inside Django refuses that address
Two details in that diagram are easy to miss and both cause silent failure:
- SES does not send events just because a topic exists. It sends them only for mail sent through a configuration set that has an event destination.
- The pre-send guard has to live in the email backend, not at each call site. You cannot find every place your app sends mail — allauth, your team invitations, a third-party package — and you should not have to.
AWS setup¶
1. Create the SNS topic¶
SNS → Topics → Create topic. The region must match your SES region.
| Field | Value |
|---|---|
| Type | Standard |
| Name | e.g. myapp-ses-events |
A FIFO topic cannot do this at all
A FIFO topic delivers only to SQS. The HTTPS protocol is not even offered on the subscription screen, and SES event destinations reject FIFO topics too. A topic's type cannot be changed after creation, so if you picked FIFO you must delete it and start again. If the Protocol dropdown shows only "Amazon SQS", you are on a FIFO topic.
Copy the topic ARN.
2. Configure your app with the topic ARN, before subscribing¶
Set the ARN in your app and deploy first:
Your endpoint must reject events from any other topic. Anyone can create an SNS topic and point it at your public URL, and their messages carry a genuine AWS signature — so signature verification alone does not stop them. The topic check is what stops them.
If your endpoint treats an unset ARN as "accept any topic" (a reasonable way to let the subscription handshake happen before you know the ARN), then setting the variable first means that window never opens.
3. Subscribe your endpoint¶
On the topic: Create subscription.
| Field | Value |
|---|---|
| Protocol | HTTPS |
| Endpoint | https://example.com/webhooks/ses/ |
| Enable raw message delivery | off |
Raw message delivery must stay off. It strips the SNS envelope, and the signature lives in the envelope — with it on, you cannot verify anything.
SNS immediately posts a SubscriptionConfirmation. Your endpoint should verify it
and then fetch its SubscribeURL itself, so the subscription confirms in seconds
with no manual step.
4. Create the configuration set¶
SES → Configuration sets → Create set. Then open it and add an event destination pointing at your SNS topic.
Event types to enable: Send, Delivery, Bounce, Complaint, Reject, Delivery delay, Rendering failure.
Do not enable Open and Click
Those two make SES add a tracking pixel to every message and rewrite every link. In transactional mail — password resets, invitations — that is both unnecessary and a privacy problem you then have to disclose.
5. Fix the IAM policy, then point sends at the configuration set¶
This is the step that bites hardest, so it gets its own section below. Do the policy first, then set:
and pass it to Anymail:
ANYMAIL = {
# ... other settings ...
# `or None`, not `or ""`: Anymail tests `is not None`, so an empty string
# would send ConfigurationSetName="" and SES would reject every message.
"AMAZON_SES_CONFIGURATION_SET_NAME": AWS_SES_CONFIGURATION_SET or None,
}
The IAM policy trap¶
A send-only SES policy usually names the sending identity as its only resource:
{
"Effect": "Allow",
"Action": ["ses:SendRawEmail", "ses:SendEmail"],
"Resource": "arn:aws:ses:eu-central-1:123456789012:identity/example.com"
}
The moment you add a configuration set to your sends, SES authorizes the request against two resources — the identity and the configuration set — and that policy fails:
AccessDeniedException - User 'arn:aws:iam::123456789012:user/myapp'
is not authorized to perform 'ses:SendRawEmail' on resource
'arn:aws:ses:eu-central-1:123456789012:configuration-set/myapp-ses-events'
Add the configuration set to Resource:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SendOnlyFromOwnIdentity",
"Effect": "Allow",
"Action": ["ses:SendRawEmail", "ses:SendEmail"],
"Resource": [
"arn:aws:ses:eu-central-1:123456789012:identity/example.com",
"arn:aws:ses:eu-central-1:123456789012:configuration-set/myapp-ses-events"
],
"Condition": {
"StringLike": {"ses:FromAddress": "*@example.com"}
}
}
]
}
A list in Resource does not weaken the restriction. A send is checked against
both resources and both must be allowed, so you still cannot send as another
identity.
Probe before you switch, or you take down all your email
If you set AWS_SES_CONFIGURATION_SET while the policy is still wrong,
every outgoing message fails: signup confirmations, password resets,
invitations. And with post_office it fails quietly — dispatch() records
status=failed in the database and returns normally, so the page still says
"check your email". The traceback only reaches your container log.
Test with the real credentials first, from your laptop:
import boto3
from email.message import EmailMessage
client = boto3.client("sesv2", region_name="eu-central-1",
aws_access_key_id=..., aws_secret_access_key=...)
msg = EmailMessage()
msg["From"] = "support@example.com"
msg["To"] = "success@simulator.amazonses.com"
msg["Subject"] = "probe"
msg.set_content("probe")
client.send_email(
FromEmailAddress="support@example.com",
Destination={"ToAddresses": ["success@simulator.amazonses.com"]},
Content={"Raw": {"Data": msg.as_bytes()}}, # Raw — see the note below
ConfigurationSetName="myapp-ses-events",
)
Use Content.Raw, because that is what Anymail sends. A probe using
Content.Simple authorizes under a different IAM action and can succeed
while the app's real path is still denied — a false pass that is easy to
trust.
Note also that AWS checks authorization before existence. So an
AccessDenied naming your configuration set does not prove the set exists.
Fix the policy, then probe again to learn that.
Why the IAM action name does not match the API call¶
Anymail calls the SESv2 SendEmail operation, so a policy granting only
ses:SendEmail looks right. It is not. Anymail always sends a full MIME message
(Content = {"Raw": {"Data": ...}}), and AWS authorizes SESv2 SendEmail with
Raw content under the IAM action ses:SendRawEmail. Grant both.
Receiving the events¶
Your endpoint is a public URL with no authentication, because the SNS signature is the authentication. That puts the weight on verifying it properly.
Consider not writing this yourself
Verifying SNS signatures correctly is harder than it looks — the list below is what a security review turned up on a careful hand-written implementation. If you only want bounce data, Anymail's tracking webhooks give you normalized events from SES and every other provider, with the signature handling maintained for you. Take the SNS route when you specifically need raw SES payloads or already route AWS notifications through SNS.
Verification requirements, each of which is a real hole if skipped:
- Allowlist the certificate host. SNS hands you a
SigningCertURLand asks you to fetch it. That URL is attacker-controlled input. Match the host against a strict pattern such as^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$, using a full match, before any network call. Without it the endpoint is an SSRF tool. - Do not follow redirects on that fetch.
requests.getfollows them by default, and only your first URL was checked. One open redirect on an AWS host and the certificate — that is, the key your signature check trusts — becomes attacker-chosen. Passallow_redirects=False. - Constrain the rest of the URL too, not just the host: require port 443 and
a path matching SNS's documented
SimpleNotificationService-*.pemshape. Arbitrary paths and query strings on an allowed host are both an SSRF surface and a way to poison your certificate cache with attacker-chosen keys. - Authenticate the certificate, do not merely parse it. Loading a PEM proves nothing. Check its validity dates and that it is really an Amazon SNS signing certificate. A self-signed PEM will otherwise verify a signature perfectly — the attacker made both.
- Refuse an unknown
SignatureVersionrather than falling back to a default. Version1is SHA1, version2is SHA256. - Build the signed string from a fixed field list per message type, not from whatever keys the message happens to contain.
- Check the topic ARN against your configured value, as covered above.
- Validate
SubscribeURLseparately before fetching it. This one is easy to miss: you already validatedSigningCertURL, so the confirmation handler feels safe. It is a different URL from the same message. Apply the same host, scheme, port and redirect rules to it. Otherwise a message that reaches the confirmation path can make your server GEThttp://169.254.169.254/latest/meta-data/…— your cloud metadata service. - Require the inner message to be a JSON object.
json.loadshappily returns a list, a string ornull. If your handler then calls.get()on it, you raise, return 500, and SNS retries that unfixable message forever. - Bound the work per request: cap the request body size, the certificate size, and the recipient count. One notification naming thousands of recipients otherwise becomes thousands of inserts, and if you store the raw payload on every recipient row you store the same large blob thousands of times.
Response codes matter more than they look, because SNS retries any non-2xx and eventually disables an endpoint that keeps failing:
| Situation | Status | Why |
|---|---|---|
| Verified and recorded | 200 | normal |
| Already seen (replay) | 200 | SNS delivers at least once; a repeat is not an error |
| Verified, but the inner message is not parseable | 200 | a retry cannot fix it |
| Signature or topic refused | 403 | permanent; retrying a forgery forever is pointless |
| Certificate fetch failed | 500 | transient; a retry may work |
Store events append-only, keyed on the SNS MessageId with a unique constraint,
so redelivery collides instead of inserting a duplicate. One notification can
name several recipients, so if you write one row per recipient, make the key
deterministic per recipient — for example f"{message_id}#{index}" — rather than
random.
Lock down who can publish to the topic¶
The SNS signature proves that SNS delivered the message. It does not prove that SES wrote the contents. Anyone able to publish to your topic can post handcrafted SES-shaped JSON, and SNS will wrap it in a genuine signature with the expected topic ARN.
The consequence is direct: they choose which addresses your app stops emailing.
{
"notificationType": "Complaint",
"mail": {"messageId": "fabricated", "destination": ["ceo@customer.example"]},
"complaint": {"complainedRecipients": [{"emailAddress": "ceo@customer.example"}]}
}
No amount of application code can tell that apart from a real complaint, so the
boundary has to be the topic's own resource policy: allow sns:Publish only from
ses.amazonaws.com, constrained to your account. The default policy created by
the console grants publish rights to the whole account owner, which is broader
than you want for a topic that drives suppression.
The suppression rules¶
This is the part where a wrong guess costs you real subscribers.
| Event | Suppress? |
|---|---|
Bounce, bounceType=Permanent |
yes — the mailbox does not exist |
| Complaint (any) | yes — they marked it spam; mailing again is worse than useless |
Bounce, bounceType=Transient |
no — mailbox full, server down, greylisting |
Bounce, bounceType=Undetermined |
no |
| Delivery delay | no |
| Out-of-office auto-reply | no |
The trap is the last two rows. An out-of-office reply does arrive as a bounce
event. If you suppress on "bounce" without reading bounceType, then everyone who
goes on holiday stops receiving your email, permanently, and nothing tells you.
@property
def should_suppress(self):
if self.event_type == self.Type.COMPLAINT:
return True
return (
self.event_type == self.Type.BOUNCE
and self.bounce_type == self.BounceType.PERMANENT
)
Keep suppression releasable by an operator but never automatic. People do fix their mailboxes, and a permanent list with no way out becomes a support burden.
The pre-send guard¶
Recording events achieves nothing on its own. The guard is what breaks the loop.
Put it in the backend that post_office delegates to, so it covers every sender in your app without touching any call site:
# project/email_backends.py
class SuppressionAwareEmailBackend(BaseEmailBackend):
def __init__(self, fail_silently=False, **kwargs):
super().__init__(fail_silently=fail_silently)
inner = resolve_email_backend(settings.EMAIL_PROVIDER)
self._inner = import_string(inner)(fail_silently=fail_silently, **kwargs)
def send_messages(self, email_messages):
blocked = suppressed_among(
[a for m in email_messages for a in m.to + m.cc + m.bcc]
)
if not blocked:
return self._inner.send_messages(email_messages)
# strip blocked addresses from to/cc/bcc, drop messages left with none,
# then delegate what remains
...
POST_OFFICE = {
"BACKENDS": {"default": "project.email_backends.SuppressionAwareEmailBackend"},
...
}
Wrapping, rather than subclassing one provider's backend, keeps the guard
identical in development and production — EMAIL_PROVIDER still chooses the real
sender, and your test suite exercises the same code path production uses.
Two design notes:
- Query all recipients across the batch in one query. post_office can hand you many messages at once.
- When every recipient of a message is blocked, return 0 — the true number
sent. It is tempting to return the message count so post_office does not retry,
but check post_office before you do:
Email.dispatchcallsemail_message().send()and ignores the returned count, marking the row sent unless an exception is raised. An inflated count therefore buys nothing and misreports delivery to any direct caller ofsend_mail(), which does use the count. - Log the refusal loudly, and consider a durable record. A log line disappears with rotation, and post_office will have stored the row as "sent", so months later nobody can tell a withheld message from a delivered one.
Testing it¶
Use the SES simulator addresses. They force a known outcome, so you never wait for a real bounce:
| Address | Events you should see | Suppression |
|---|---|---|
success@simulator.amazonses.com |
send, delivery | none |
bounce@simulator.amazonses.com |
send, bounce | yes, hard bounce |
complaint@simulator.amazonses.com |
send, delivery, complaint | yes, complaint |
ooto@simulator.amazonses.com |
send, delivery, bounce | none |
The ooto row is the one worth running. It produces a real bounce event, and a
correct implementation records it and suppresses nothing. If your log shows a
suppression there, your rules read bounce without reading bounceType.
Then test the guard itself: send to an address you just suppressed and confirm nothing reaches SES. A signup confirmation is a good trigger.
One consequence to plan for¶
Once the guard works, a person whose address hard-bounced in the past can sign up, see "check your email", and receive nothing — because your app correctly refused to send. There is no error, because nothing went wrong.
Decide how to handle it before it happens to a customer:
- Detect a suppressed address at the form and say so, rather than promising mail.
- Or let support release the suppression, which needs an admin surface for it.
Beyond SES¶
The shape is the same for other providers; only the transport differs. Mailgun, Postmark, SendGrid and Resend all post webhooks directly, with their own signature schemes, and Anymail can normalize them for you through its event tracking signals.
If you are on Anymail already, its tracking webhooks are less work than hand-verifying SNS. The SES-through-SNS route is worth the extra effort mainly when you want the raw SES event payloads, or you are already routing other AWS notifications through SNS.