GiggleGigs is a job board I run: Django + DRF on the backend, React + Vite on the front, Celery for background work, all in Docker. It sends the usual transactional email: verification, password resets, application updates, messages, and a daily job alert digest for seekers.
All of that went through Mailgun's free tier, which caps you at 100 emails a day. That was fine until it wasn't. As the seeker count grew, the daily digest alone could eat the whole quota, and every new signup needs a verification email on top of that.
So I moved to Amazon SES. This post covers what that took, including a few gotchas that cost me time.
The goal
- Swap the email transport from Mailgun to SES (SESv2 API).
- Send from my own domain, with a custom MAIL FROM subdomain.
- Handle bounces and complaints properly. SES watches your bounce and complaint rates closely, and you get put on review if they climb.
- Keep Mailgun one env var away, so a rollback needs no code change.
- No change to templates or email content. Transport swap plus suppression handling, nothing else.
Step 1: The AWS side
In the SES console, in the region you'll send from:
- Verify your sending domain as an identity with Easy DKIM, then add the three CNAME records at your DNS provider.
-
Set a custom MAIL FROM domain on a subdomain. That's one MX record and one SPF TXT record. Your Return-Path then aligns with your domain instead of
amazonses.com, which helps DMARC. - Request production access. New SES accounts start in the sandbox, where you can only send to verified addresses. Do this early, because approval isn't instant.
You get two nice emails when DNS checks out: "DKIM setup SUCCESS" and "Custom MAIL FROM Domain Setup SUCCESS". Then it's time to write code.
Step 2: Choosing the provider with an env var
The project already used django-anymail, which made this mostly a configuration change. To make the provider testable without loading all of production.py (Sentry, Redis, S3, and so on), I put the choice in a small helper:
# config/email_providers.py
from django.core.exceptions import ImproperlyConfigured
SES_BACKEND = "anymail.backends.amazon_ses.EmailBackend"
MAILGUN_BACKEND = "anymail.backends.mailgun.EmailBackend"
def email_provider_settings(env):
provider = env("EMAIL_PROVIDER", default="ses").strip().lower()
if provider == "ses":
return SES_BACKEND, {
"AMAZON_SES_CLIENT_PARAMS": {
"region_name": env("SES_REGION"),
"aws_access_key_id": env("SES_ACCESS_KEY_ID"),
"aws_secret_access_key": env("SES_SECRET_ACCESS_KEY"),
},
"AMAZON_SES_CONFIGURATION_SET_NAME": env("SES_CONFIGURATION_SET", default=None),
"WEBHOOK_SECRET": env("ANYMAIL_WEBHOOK_SECRET"),
}
if provider == "mailgun":
return MAILGUN_BACKEND, {
"MAILGUN_API_KEY": env("MAILGUN_API_KEY"),
"MAILGUN_SENDER_DOMAIN": env("MAILGUN_DOMAIN"),
}
raise ImproperlyConfigured(f'EMAIL_PROVIDER must be "ses" or "mailgun", got "{provider}"')
A few deliberate choices:
- Dedicated SES credentials. The app already has AWS keys for S3 media storage. Separate variable names mean a least-privilege, SES-only IAM user, and no chance of one set quietly picking up the other.
-
WEBHOOK_SECREThas no default. If it's missing in production, the app fails at startup instead of serving an unauthenticated webhook. -
Rollback is
EMAIL_PROVIDER=mailgunand a restart.
In production.py it's a single line:
EMAIL_DELIVERY_BACKEND, ANYMAIL = email_provider_settings(env)
EMAIL_BACKEND = "notifications.email_backends.SuppressionFilterBackend"
EMAIL_BACKEND isn't SES directly. More on that in a moment.
Gotcha #1: The [amazon-ses] extra wouldn't install
The obvious change was django-anymail[mailgun] → django-anymail[amazon-ses]. The Docker build failed with a pip ResolutionImpossible.
In anymail 15.x, the amazon-ses extra pulls in boto3 and amazon-s3-encryption-client-python. That second package requires cryptography>=48, which clashed with the rest of my dependency tree. It's only used for encrypted inbound mail stored in S3, which I don't receive. Anymail imports it lazily, so I skipped the extra and installed plain django-anymail plus a pinned boto3 instead.
I also moved anymail from production.txt to base.txt. Tests use Anymail's test backend, and the webhook URLs need to import in every environment.
Step 3: Bounces, complaints, and a suppression list
SES reports bounces and complaints through SNS. Anymail has a tracking webhook that receives those SNS notifications, turns them into normalized events, and fires a tracking signal.
First, mount the URLs:
# config/urls.py
path("anymail/", include("anymail.urls")),
Then a small model to remember who not to email:
class EmailSuppression(models.Model):
class Reasons(models.TextChoices):
BOUNCED = "bounced", _("Bounced")
COMPLAINED = "complained", _("Complained")
email = models.EmailField(_("Email"), unique=True)
reason = models.CharField(_("Reason"), max_length=20, choices=Reasons.choices)
event_payload = models.JSONField(_("Event Payload"), default=dict, blank=True)
created_at = models.DateTimeField(_("Created At"), auto_now_add=True)
And a receiver:
@receiver(tracking)
def suppress_bounced_and_complained(sender, event, esp_name, **kwargs):
handle_tracking_event(event)
Gotcha #2: Anymail calls every SES bounce "bounced"
I only wanted to suppress hard bounces (address doesn't exist). A full mailbox or a temporary DNS failure is a transient bounce and shouldn't lock someone out for good.
Anymail normalizes both kinds to event_type="bounced" with reject_reason="bounced". The difference only shows up in the raw SES payload, so you have to check it there:
def is_hard_bounce(event):
esp_event = event.esp_event if isinstance(event.esp_event, dict) else {}
return esp_event.get("bounce", {}).get("bounceType") == "Permanent"
def handle_tracking_event(event):
if not event.recipient:
return None
payload = event.esp_event if isinstance(event.esp_event, dict) else {}
if event.event_type == "complained":
return record_suppression(event.recipient, EmailSuppression.Reasons.COMPLAINED, payload)
if event.event_type == "bounced":
if is_hard_bounce(event):
return record_suppression(event.recipient, EmailSuppression.Reasons.BOUNCED, payload)
logger.info("Ignoring non-permanent bounce for %s: %s", event.recipient, event.description)
return None
Complaints always suppress. Someone hitting "Report spam" is the clearest signal you'll get.
Gotcha #3: The webhook host
My first draft pointed the SNS subscription at the main domain. But the main domain serves the React SPA; Django lives on a separate API subdomain behind the reverse proxy. The subscription URL has to target whichever host actually reaches Django, at Anymail's /anymail/amazon_ses/tracking/ path, with the basic-auth credentials embedded in the URL.
Check your reverse proxy before you paste a URL into SNS. Also leave raw message delivery off, because Anymail expects the SNS envelope.
Gotcha #4: 401, not 400
I wrote a test asserting that a request with no basic auth gets a 400. It got a 401. That's on purpose: SNS doesn't send credentials up front. It only retries with them after a 401 challenge. A webhook that answered 400 there would never receive a single notification. Wrong credentials still get a 400. Anymail also auto-confirms the SNS subscription once the auth checks out.
Step 4: Enforcing suppression in one place
Emails go out from all over the codebase: allauth (verification, password reset), a notification helper, job alerts, team invitations, billing receipts. Adding if is_suppressed(email) to each one would be fragile. Someone adds a new email next month, and suppression silently stops applying to it.
Instead I wrapped the real backend:
class SuppressionFilterBackend(BaseEmailBackend):
def __init__(self, fail_silently=False, **kwargs):
super().__init__(fail_silently=fail_silently)
self.delivery = get_connection(settings.EMAIL_DELIVERY_BACKEND, fail_silently=fail_silently, **kwargs)
def open(self):
return self.delivery.open()
def close(self):
return self.delivery.close()
def send_messages(self, email_messages):
if not email_messages:
return 0
if not suppression_bypassed():
email_messages = filter_suppressed_messages(email_messages)
if not email_messages:
return 0
return self.delivery.send_messages(email_messages)
filter_suppressed_messages runs one query for all recipients across a batch, removes suppressed addresses from to/cc/bcc, and drops any message left with no recipients. Every send_mail() call in the project now honors the list without knowing it exists.
The exception: requests the user explicitly made
Here's a case I didn't want to get wrong. Say someone's mailbox was full last month, or they clicked "spam" by accident. Now they're suppressed and trying to reset their password. Silently dropping that email locks them out of their own account.
So emails the user explicitly requests (a password reset, or clicking "resend verification email") still go out. The automatic verification email sent at signup does not. I used a ContextVar, so the decision is made where the request is handled, not inside the mail layer:
_bypass_suppression = ContextVar("email_suppression_bypass", default=False)
@contextmanager
def allow_suppressed_recipients():
token = _bypass_suppression.set(True)
try:
yield
finally:
_bypass_suppression.reset(token)
# PasswordResetRequestAPIView
with allow_suppressed_recipients():
form.save(request)
allauth sends these emails synchronously inside the request, so the context variable is still set when the backend runs.
Step 5: Staying under the SES send rate
SES gives every account a maximum send rate (emails per second). The job alert digest used to be one Celery task looping over every seeker and sending inline. A rate_limit on that task would throttle the task, not the emails it sends.
So the digest task now creates each seeker's in-app notification and queues one email task per seeker:
BULK_EMAIL_TASK_OPTIONS = {
"rate_limit": settings.EMAIL_BULK_SEND_RATE, # e.g. "10/s", read from an env var
"autoretry_for": (Exception,),
"retry_backoff": 60,
"retry_backoff_max": 60 * 60,
"max_retries": 5,
}
@shared_task(**BULK_EMAIL_TASK_OPTIONS)
def send_job_alert_email(notification_id, email_subject, email_context):
notification = Notification.objects.select_related("recipient").filter(pk=notification_id).first()
if notification is None:
return False
return send_notification_email(
notification,
email_subject,
email_template="notifications/email/job_alert.html",
email_context=email_context,
fail_silently=False,
)
It's still one email per seeker covering all their matches, never one email per job, and a test enforces that. Celery applies rate_limit per worker, not globally. With one worker that's effectively a global limit; with several, divide the rate between them.
Gotcha #5: Django's test runner replaces your email backend
My suppression tests passed for the webhook and failed for the backend. Emails to suppressed addresses were landing in mail.outbox anyway.
Django's setup_test_environment() (which pytest-django calls) forces EMAIL_BACKEND to locmem, whatever your test settings say. My wrapper never ran. The fix is an autouse fixture:
# conftest.py
@pytest.fixture(autouse=True)
def email_backend(settings):
# Django's test setup forces EMAIL_BACKEND to locmem, which would bypass
# the suppression filter. Restore it; it delivers to Anymail's test
# backend (EMAIL_DELIVERY_BACKEND), which still fills mail.outbox.
settings.EMAIL_BACKEND = "notifications.email_backends.SuppressionFilterBackend"
with this in the test settings:
EMAIL_DELIVERY_BACKEND = "anymail.backends.test.EmailBackend"
The test settings also define a throwaway WEBHOOK_SECRET, so the webhook tests can send valid and invalid basic auth.
Anymail's test backend still appends to mail.outbox, so all 344 existing tests passed unchanged.
What the tests cover
- The provider toggle picks the SES or Mailgun backend, and rejects unknown values or a missing webhook secret.
- Real SNS-shaped payloads posted to the webhook: a permanent bounce suppresses, a complaint suppresses, a transient bounce doesn't.
- The webhook rejects missing auth (401) and wrong auth (400).
- The backend removes suppressed recipients, drops messages with none left, and respects the explicit-request bypass.
- A password reset still reaches a suppressed address.
- Job alerts skip suppressed seekers (the in-app notification still arrives) and send one digest per seeker.
360 tests pass, and coverage stayed at 85%.
Checking it before deploy
Before deploying, I built the production image and loaded the production settings with dummy SES credentials, to confirm the chain resolves to the suppression wrapper, then Anymail's SES backend, then a sesv2 boto3 client, without sending anything.
After deploy, the real check is Django's built-in sendtestemail management command, run inside the production container with your own inbox as the recipient.
Takeaways
- Treat the swap as config if you can. django-anymail made changing providers mostly a settings change. Putting the choice behind a testable function made it a safe one.
- Handle bounces and complaints from day one. SES will hold you to it, and a suppression list is about 100 lines of code.
- Enforce rules at the transport layer, not at every call site. A backend wrapper covers email paths you haven't written yet.
- Read your library's normalization. "bounced" didn't mean "hard bounce".
- Keep a rollback. Mailgun stays configured for a week of clean SES sending, then it goes.
GiggleGigs is a job board for gig and contract work: gigglegigs.com.
Top comments (2)
after leaving Mailgun the easy miss is suppression. SES only does it if you add a configuration set with a bounce and complaint event destination, plus the account-level suppression list
Good call. Suppression is the easy thing to drop in a migration like this. Two notes from setting it up: bounce and complaint notifications can also be wired straight to SNS on the verified identity, so a configuration set is one way to get events, not the only one. And the account-level suppression list is on by default for bounces and complaints on SES accounts created since late 2019, though it's worth checking it's actually enabled. In my setup the two work together. SES's own list blocks sends at the AWS side, and the app keeps its own EmailSuppression table so it can skip suppressed addresses before calling SES, and so it can still deliver a password reset the user explicitly asked for. I'll add a line to the post about the account-level list.