Engineering · Part 15 · one notify(), done right
Channel-pluggable notifications
Reference documentation for a small, correct notifications system: one
notify() function behind which channels are pluggable, a clean split between
transactional and opt-out-able messages, and a one-click unsubscribe that does not unsubscribe
people by accident.
Notifications tend to sprawl — a send_mail here, a template there, an unsubscribe
link that half-works — until reaching a user is a dozen slightly different code paths. Keep it one
small abstraction from the start. This is a short document because the system is small; its value is
in three decisions that are easy to get subtly wrong.
One function, pluggable channels
Every notification in the app goes through a single entry point. Callers name a kind and some recipients; the function renders a template and sends it — today by email, tomorrow by whatever you add — without any caller changing:
def notify(kind, recipients, ctx=None, *, request=None):
"""Send notification `kind` to each recipient who wants it. The one path to a user."""
category = KINDS[kind] # a registry: kind → category (below)
for user in recipients:
if not _wants(user, kind, category): # honour preferences (below)
continue
_send_email(user, kind, {**(ctx or {}), 'site': base_url(request)})
A caller writes notify('comment_posted', recipients, {'post': post}) and is done.
The KINDS registry maps each kind to a category, so adding a notification is adding a
template and a registry line — never touching the send machinery. And because there is exactly one
send path, adding a second channel (web push, an in-app inbox, a chat message) is a change
inside notify(), invisible to every caller. Start with email — the channel
every user already has and the one your sign-up flow already relies on — and let the abstraction earn
a second channel only when you genuinely need one.
Two categories: transactional always, activity opt-out
The decision that keeps you compliant and trusted is splitting notifications into two categories with different rules:
| Category | Examples | Rule |
|---|---|---|
| account (transactional) | payment failed, trial ending, invitation accepted | always sent — the user needs this to operate their account; not marketing, not suppressible |
| activity | a new comment, a mention, a digest | honours a per-user opt-out and carries an unsubscribe header |
def _wants(user, kind, category):
if category == 'account':
return True # transactional — always
return user.notification_prefs.activity_email # activity — respect the opt-out
The line matters both ethically and legally: transactional mail (a failed payment) must reach a user regardless of marketing preferences, while activity mail must be suppressible. Encoding the distinction as a category on each kind — rather than a per-caller judgement — means the rule is applied uniformly and a new notification is forced to declare which it is.
One-click unsubscribe that a scanner cannot trigger
Activity emails should carry the standard one-click-unsubscribe headers, so a mail client shows a native "unsubscribe" button and honours it without the user visiting a page:
List-Unsubscribe: <https://app/notifications/unsubscribe/<signed-token>>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
The token is signed, so the endpoint works for a signed-out user (they clicked from their inbox) without being forgeable, and the endpoint is exempt from your login gate for the same reason. Now the subtle, important bug to avoid: only a POST may change state. Mail clients, security scanners and link-preview bots routinely GET every URL in an email — so if a GET to the unsubscribe link unsubscribed people, a scanner would silently opt your users out of their own notifications.
def unsubscribe(request, token):
user = verify_signed_token(token) # invalid → 404
if request.method == 'POST': # the one-click POST, or the confirm button
user.notification_prefs.activity_email = False
user.notification_prefs.save()
return render('unsubscribed.html')
return render('unsubscribe_confirm.html', {'token': token}) # a GET only SHOWS a page
A GET renders a confirmation page and mutates nothing; the real client's one-click POST (or the button on that confirmation page) is the only thing that changes the preference. This honours the one-click standard for real mail clients while being inert to the pre-fetching that would otherwise unsubscribe people who never asked to be.
Making the mail actually arrive
Sending is the easy half. Getting mail into an inbox rather than a spam folder is a deliverability and DNS problem, and it is worth doing properly once. Two parts: how you talk to the provider, and what you publish in DNS.
Send over the provider's HTTPS API, not SMTP
Use a reputable email provider (Amazon SES, Postmark, and the like) and send through its
HTTPS API, not SMTP. On a cloud box this pays three dividends. Authentication is
the instance's own IAM role — no SMTP username or password to store or leak. And
because API calls are HTTPS, the box needs no non-443 outbound port: SMTP would force open port 25
or 587, whereas an API send keeps egress locked to 443 (this is the same decision that lets the
firewall deny-by-default outbound — see the security posture document). Give the send role only the
one permission it needs (send email) and nothing else — not even the "read my send
quota" call, so a self-throttling client library can't demand a permission you didn't grant.
The three DNS records, and what each proves
Inbox providers decide whether to trust your mail by checking records you publish in DNS that prove the mail is really from your domain and wasn't tampered with. Three mechanisms, each answering a different question:
| Record | DNS entry | What it proves |
|---|---|---|
| SPF | a TXT on your sending (MAIL FROM) domain listing who may send | the sending server is authorised to send for this domain (checked against the envelope sender's IP) |
| DKIM | CNAMEs that publish the provider's public keys | the message was signed by your domain and not altered in transit (the provider signs each message with the matching private key) |
| DMARC | a TXT at _dmarc.<domain> | your policy: what a receiver should do when SPF/DKIM don't align — and where to send reports |
Walk them in order, because they build on each other:
- SPF lists the servers permitted to send for a domain. A receiver takes the envelope sender's domain, looks up its SPF record, and checks whether the connecting server's IP is listed. It authorises the path, but not the message contents.
- DKIM closes that gap. The provider signs each outgoing message with a private
key; you publish the matching public key as
CNAMErecords (most providers offer "easy DKIM" — they host the keys, you just add the CNAMEs). A receiver verifies the signature, which proves both that the message is genuinely from your domain and that nobody altered it en route. - A custom MAIL FROM subdomain (e.g.
mail.<domain>, needing anMXrecord plus its own SPF) makes the envelope sender align with your visibleFrom:domain — which is what lets SPF contribute to DMARC alignment rather than being ignored. - DMARC ties it together: a policy record stating what a receiver should do when
a message fails to align on either SPF or DKIM —
p=none(monitor only),quarantine, orreject— plus arua=address that collects aggregate reports so you can watch alignment before tightening the policy. DKIM alignment alone satisfies DMARC; publishing SPF and DMARC as well is the belt-and-braces that reaches the strictest inboxes.
; the records to publish (values come from your provider's console / stack outputs)
<domain> TXT "v=spf1 include:amazonses.com -all"
mail.<domain> MX 10 feedback-smtp.<region>.amazonses.com
mail.<domain> TXT "v=spf1 include:amazonses.com -all"
<token>._domainkey CNAME <token>.dkim.amazonses.com ; ×3, the Easy-DKIM keys
_dmarc.<domain> TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@<domain>"
Automate the boring, error-prone part: have your infrastructure provision the verified domain identity, enable DKIM, and configure the MAIL FROM subdomain, then emit the exact records to add as stack outputs so a human copies them into DNS rather than hand-authoring them. One caveat that trips people up: your provider verifies the identity, DKIM and MAIL FROM, but DMARC is receiver-side — no provider validates it for you, so test with a mailbox tester and by sending to real inboxes at the big providers before you trust it.
Handle bounces and complaints, or lose your reputation
The last piece of deliverability is feedback. Route the provider's bounce and complaint notifications (via a configuration set → a notification topic → your alerts) so you learn when mail hard-bounces or someone marks it spam, and so repeat-offending addresses land on a suppression list rather than being retried. A sender that keeps hammering dead addresses gets throttled or blocked by the inbox providers; honouring bounces and complaints is what keeps your domain's reputation — and therefore everyone's mail — deliverable.
The shape to copy
- One
notify(kind, recipients, ctx)entry point; aKINDSregistry maps kind → category; adding a notification is a template + a line, adding a channel is a change insidenotify(). - Two categories: account (transactional, always sent) and activity (honours a per-user opt-out).
- One-click unsubscribe via signed
List-Unsubscribeheaders; the endpoint is signed-token-authenticated and login-exempt; only POST mutates, GET just shows a confirmation — so scanners cannot unsubscribe anyone.
That is the operational and human-facing arc complete. The remaining documents return to the craft of building the UI itself.