Building a Throttled, Self-Auditing Email Engine in Django
How DjangoPlay's `mailer` app runs every outgoing email through one engine, three layers of rate limits, and an audit trail that tells you whether it actually got sent.
CB
DjangoPlay sends a lot of different emails: a signup confirmation, a password reset link, a support ticket acknowledgment, a bug report receipt, an export-ready notification. Early on, each of those was its own little pile of code — render a template, attach a logo, call .send(), hope for the best. That works until you need to add rate limiting, or an unsubscribe link, or you want to know why a user says they never got an email. Then you're hunting through five different places for five slightly different versions of the same logic.
mailer is the app I built to fix that. It's a small, focused Django app — one of DjangoPlay's domain apps, sitting next to things like users and finance — whose only job is: given a template name, a recipient, and some context, send exactly one well-formed email, and know afterward whether it worked. Every other app in DjangoPlay that needs to email someone goes through it. Here's what that path looks like:
1. One Engine, Not Five Copies
Before mailer existed, email-sending logic lived inside an authentication adapter class, mixed in with login and signup logic that had nothing to do with email. The fix was to pull all of it out into one class, EmailEngine, whose send() method is the single entrypoint every flow calls:
# mailer/engine/engine.py
class EmailEngine:
"""
This class focuses ONLY on:
- default context injection
- unsubscribe enforcement
- template resolution
- inline image attachment
- building & sending emails
"""
@staticmethod
def send(prefix: str, email: str, context: dict, request=None, user=None):
...Whether you're sending a password reset or a signup confirmation, you call the same method with a different template prefix and a different context dictionary. That means a fix to how unsubscribe links are built, or how the logo gets attached, happens in one place and every email benefits — nobody has to remember to update it five times.
2. Stopping Abuse Before It Starts
Email is one of the easiest things to abuse — someone scripts a form submission in a loop and now you're sending a thousand password reset emails to the same inbox, or worse, using your app as a way to spam someone else's address. mailer checks this before anything gets rendered, with three layers stacked from broadest to narrowest:
# mailer/throttling/flow_throttle.py
DEFAULTS = {
"burst_ip": {"max": 5, "window_seconds": 300},
"per_email": {"max": 3, "window_seconds": 24 * 3600},
"per_ip": {"max": 50, "window_seconds": 24 * 3600},
}- Burst IP — no more than 5 requests from one IP in 5 minutes. Catches a script hammering the form right now.
- Per IP — no more than 50 from one IP per day. Catches slower, sustained abuse from one source.
- Per user or email — no more than 3 of a given email type per day for one person. Catches someone repeatedly triggering their own reset link.
All three are just Redis counters with a time-to-live, checked in order — IP checks first, since they're the cheapest way to stop the most obvious abuse before it even reaches a specific person's inbox.
3. Respecting "Don't Email Me"
Rate limiting stops spam from bots. Unsubscribe rules stop unwanted email to real users who've asked to be left alone — and this has to be checked centrally, or someone eventually forgets to check it for a new email type. EmailEngine.send() always calls this before rendering anything:
# mailer/engine/unsubscribe.py
@staticmethod
def is_allowed(user, prefix: str) -> bool:
if prefix in SYSTEM_EMAIL_PREFIXES:
return True # password resets, verifications: never blocked
if getattr(user, "is_unsubscribed", False):
return False # global opt-out
prefs = ... # per-category preferences, e.g. "product updates": False
if category and prefs.get(category) is False:
return False
return TrueNotice the first check: a handful of prefixes — password reset, email verification — are exempt from unsubscribe entirely. That's deliberate. If someone can't unsubscribe from the email that lets them get back into their own account, that's not a preference, that's a bug.
4. Knowing What Actually Happened
Because emails are sent from Celery tasks, they run in the background and nobody's watching them fail in real time. So every send gets a row in the database first, before we even try:
# mailer/flows/password_reset.py
delivery = EmailDelivery.objects.create(
task_id=self.request.id,
template_prefix=T.PASSWORD_RESET_EMAIL,
to_email=user.email,
)
try:
EmailEngine.send(prefix=T.PASSWORD_RESET_EMAIL, email=user.email, context={...})
delivery.mark_sent()
except Exception as exc:
delivery.mark_failed(reason=str(exc))
raiseEmailDelivery just tracks three states — queued, sent, failed — plus a failure reason and timestamps. It doesn't know anything about signup or password resets; it's pure infrastructure. But it means the answer to "did this email actually go out?" is a database query, not a guess based on server logs from three days ago.
Want the Full Picture?
This post covers the send path for a single email. For the full list of email flows, template structure, and inline image handling, see the mailer documentation ↗.
What I'd Tell You If You're Building Something Similar
- One send path, no exceptions. The moment a second code path can send an email, your rate limits and unsubscribe rules have a hole in them.
- Check identity before you check content. Throttling and unsubscribe rules run before a single template renders — there's no reason to spend that work on a request you're going to block anyway.
- A background job that sends something needs a receipt. If a task can silently fail, give it a database row that says so, or you'll be debugging "the user says they never got it" with nothing but a hunch.