Deliverability & Compliance¶
The module ships the pieces a real sender needs to stay out of spam folders and honor opt-outs: a promotional/transactional distinction, a suppression list, optional opt-in enforcement, one-click unsubscribe (RFC 8058), a physical mailing-address footer (CAN-SPAM), and a bounce/complaint webhook.
Promotional vs. transactional¶
Every EmailNotification has an is_promotional flag (newsletters are
always promotional):
Transactional (
is_promotional=False, the default) — password resets, receipts, alerts. Sent as batched messages. The suppression list is ignored so these always reach the user.Promotional (
is_promotional=True) — marketing / newsletters. Sent one message per recipient, each carrying its own one-click unsubscribe header and a visible footer. Suppressed (and, when opt-in is required, non-consented) addresses are dropped before sending.
Suppression list¶
EmailSuppression holds addresses that must not receive promotional email.
It is populated by one-click unsubscribes, the webhook, or manually. Addresses
are stored and matched case-insensitively (normalized to lower case), so an
unsubscribe as User@Example.com still suppresses a later user@example.com
send.
from djgentelella.async_notification.models import EmailSuppression
EmailSuppression.objects.create(email='user@example.com', reason='complaint')
# reason ∈ {'unsubscribe', 'complaint', 'bounce', 'manual'}
Opt-in (double opt-in)¶
When ASYNC_NOTIFICATION_REQUIRE_OPTIN = True, promotional mail only reaches
addresses with a granted EmailConsent record (also matched
case-insensitively):
from djgentelella.async_notification.models import EmailConsent
EmailConsent.objects.create(email='user@example.com', granted=True,
source='signup-form')
One-click unsubscribe (RFC 8058)¶
Promotional messages include List-Unsubscribe and
List-Unsubscribe-Post: List-Unsubscribe=One-Click headers built from a
signed, per-recipient token (django.core.signing, HMAC — unguessable and
tamper-proof). A mailbox provider POSTs the token URL; the address is added to
the suppression list. A person clicking the footer link gets a confirmation page
whose button POSTs the same URL. GET is side-effect-free (safe for scanners
and link prefetch); only POST suppresses.
Set ASYNC_NOTIFICATION_BASE_URL to an HTTPS origin so the one-click URL
can be built (Gmail/Yahoo ignore an http:// one-click target); the Sites
framework is used as a fallback. ASYNC_NOTIFICATION_UNSUBSCRIBE_MAILTO
adds a mailto: fallback, and ASYNC_NOTIFICATION_MAILING_ADDRESS fills the
CAN-SPAM footer.
Suppression webhook¶
async_notification:suppression_webhook lets a mail provider report bounces
and complaints. It is disabled (404) unless
ASYNC_NOTIFICATION_WEBHOOK_SECRET is set. The secret must be sent in the
X-Webhook-Secret header (never the query string, which leaks into logs) and
is compared in constant time.
curl -X POST https://app.example.com/async_notification/webhook/suppression/ \
-H "X-Webhook-Secret: $SECRET" \
-H "Content-Type: application/json" \
-d '{"email": "bounced@example.com", "reason": "bounce"}'
Compliance settings¶
# HTTPS origin used to build the one-click unsubscribe link.
ASYNC_NOTIFICATION_BASE_URL = 'https://app.example.com'
# mailto: fallback for List-Unsubscribe (None disables it).
ASYNC_NOTIFICATION_UNSUBSCRIBE_MAILTO = 'unsubscribe@example.com'
# Physical mailing address shown in the promotional footer (CAN-SPAM).
ASYNC_NOTIFICATION_MAILING_ADDRESS = 'Acme Inc, 123 Main St, City'
# List-Id header value for newsletters.
ASYNC_NOTIFICATION_LIST_ID = 'News <news.example.com>'
# Max age (seconds) for unsubscribe tokens; None = never expire.
ASYNC_NOTIFICATION_UNSUBSCRIBE_MAX_AGE = None
# Throttle: max messages per minute (0 = no limit).
ASYNC_NOTIFICATION_RATE_LIMIT = 0
# Require a granted EmailConsent before sending promotional email.
ASYNC_NOTIFICATION_REQUIRE_OPTIN = False
# Shared secret for the suppression webhook (None disables the endpoint).
ASYNC_NOTIFICATION_WEBHOOK_SECRET = None