Engineering · Part 15 · one notify(), done right

Channel-pluggable notifications

platform

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:

CategoryExamplesRule
account (transactional)payment failed, trial ending, invitation acceptedalways sent — the user needs this to operate their account; not marketing, not suppressible
activitya new comment, a mention, a digesthonours 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:

RecordDNS entryWhat it proves
SPFa TXT on your sending (MAIL FROM) domain listing who may sendthe sending server is authorised to send for this domain (checked against the envelope sender's IP)
DKIMCNAMEs that publish the provider's public keysthe message was signed by your domain and not altered in transit (the provider signs each message with the matching private key)
DMARCa 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 CNAME records (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 an MX record plus its own SPF) makes the envelope sender align with your visible From: 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, or reject — plus a rua= 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

  1. One notify(kind, recipients, ctx) entry point; a KINDS registry maps kind → category; adding a notification is a template + a line, adding a channel is a change inside notify().
  2. Two categories: account (transactional, always sent) and activity (honours a per-user opt-out).
  3. One-click unsubscribe via signed List-Unsubscribe headers; 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.


← All posts