Skip to content

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:

  1. Listen to delivery events, so you know which addresses failed.
  2. 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:

urlpatterns = [
    ...
    path("", include("speedpycom.urls_email_events")),
]

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:

SES_EVENT_TOPIC_ARN=arn:aws:sns:eu-central-1:123456789012:myapp-ses-events

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:

AWS_SES_CONFIGURATION_SET=myapp-ses-events

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 SigningCertURL and 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.get follows 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. Pass allow_redirects=False.
  • Constrain the rest of the URL too, not just the host: require port 443 and a path matching SNS's documented SimpleNotificationService-*.pem shape. 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 SignatureVersion rather than falling back to a default. Version 1 is SHA1, version 2 is 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 SubscribeURL separately before fetching it. This one is easy to miss: you already validated SigningCertURL, 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 GET http://169.254.169.254/latest/meta-data/… — your cloud metadata service.
  • Require the inner message to be a JSON object. json.loads happily returns a list, a string or null. 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.dispatch calls email_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 of send_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.