Files
www/membership/members/newsletter.py
T
claudeandClaude Opus 5.5 98e09627ed Membership: newsletter opt-in at signup and on the profile
An unticked checkbox on registration subscribes the verified address to
the listmonk list (preconfirmed) once the account exists. The profile
page gets a subscribe/unsubscribe toggle, and verified email changes
follow through to listmonk. Newsletter outages never block accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:27:40 -05:00

145 lines
5.3 KiB
Python

"""listmonk API integration for the eom.dev newsletter.
Only addresses this app has already verified are sent. The API user can see
and manage subscribers on the newsletter list and nothing else. Every call is
best effort: membership must never fail because the newsletter is unavailable.
"""
import json
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
SUBSCRIBED = "subscribed"
UNSUBSCRIBED = "unsubscribed"
BLOCKED = "blocked"
_list_id = None
def enabled():
return settings.LISTMONK_ENABLED
def _request(method, path, *, params=None, payload=None):
# Origin and credentials come only from settings; no redirects, so the
# token is never forwarded. TLS verification cannot be disabled.
with requests.request(
method,
settings.LISTMONK_URL + path,
params=params,
json=payload,
headers={"Authorization": f"token {settings.LISTMONK_API_USER}:{settings.LISTMONK_API_TOKEN}"},
timeout=(5, 10),
allow_redirects=False,
stream=True,
) as response:
content = bytearray()
for chunk in response.iter_content(16384):
content.extend(chunk)
if len(content) > 1048576:
raise ValueError("listmonk response exceeds the allowed size.")
if response.status_code == 409:
raise Conflict()
if response.status_code != 200:
raise ValueError(f"listmonk returned HTTP {response.status_code}.")
data = json.loads(content)
if not isinstance(data, dict) or "data" not in data:
raise ValueError("listmonk returned an invalid response.")
return data["data"]
class Conflict(Exception):
pass
def list_id():
"""Resolve the configured list name once; the API user only sees its own lists."""
global _list_id
if _list_id is None:
lists = _request("GET", "/api/lists", params={"per_page": "all", "minimal": "true"})
results = lists if isinstance(lists, list) else lists.get("results", [])
matches = [item["id"] for item in results if isinstance(item, dict) and item.get("name") == settings.LISTMONK_LIST_NAME and isinstance(item.get("id"), int)]
if len(matches) != 1:
raise ValueError("Newsletter list is missing or ambiguous.")
_list_id = matches[0]
return _list_id
def _find(email):
email = email.strip().casefold()
data = _request("GET", "/api/subscribers", params={"search": email, "per_page": "50"})
for subscriber in data.get("results", []) if isinstance(data, dict) else []:
if isinstance(subscriber, dict) and str(subscriber.get("email", "")).casefold() == email:
return subscriber
return None
def _state(subscriber):
if subscriber is None:
return UNSUBSCRIBED
if subscriber.get("status") == "blocklisted":
return BLOCKED
lid = list_id()
for entry in subscriber.get("lists") or []:
if entry.get("id") == lid and entry.get("subscription_status") in ("confirmed", "unconfirmed"):
return SUBSCRIBED
return UNSUBSCRIBED
def status(email):
"""SUBSCRIBED, UNSUBSCRIBED or BLOCKED; raises if listmonk is unreachable."""
list_id()
return _state(_find(email))
def subscribe(email, *, uid, name=""):
"""Add a verified address to the list, preconfirmed. Respects the blocklist."""
lid = list_id()
subscriber = _find(email)
if subscriber is None:
try:
_request("POST", "/api/subscribers", payload={
"email": email,
"name": name or uid,
"status": "enabled",
"lists": [lid],
"attribs": {"source": "membership", "uid": uid},
"preconfirm_subscriptions": True,
})
return SUBSCRIBED
except Conflict:
# Exists, but only on lists this API user cannot see.
logger.warning("Newsletter subscriber exists outside the newsletter list")
raise
if _state(subscriber) == BLOCKED:
return BLOCKED
_request("PUT", "/api/subscribers/lists", payload={"ids": [subscriber["id"]], "action": "add", "target_list_ids": [lid], "status": "confirmed"})
return SUBSCRIBED
def unsubscribe(email):
subscriber = _find(email)
if subscriber is not None:
_request("PUT", "/api/subscribers/lists", payload={"ids": [subscriber["id"]], "action": "unsubscribe", "target_list_ids": [list_id()]})
return UNSUBSCRIBED
def change_email(old, new):
"""Follow a verified account e-mail change so mail goes to the new address."""
subscriber = _find(old)
if subscriber is None or old.strip().casefold() == new.strip().casefold():
return
existing = _find(new)
if existing is None:
_request("PATCH", f"/api/subscribers/{int(subscriber['id'])}", payload={"email": new})
return
# The new address is already known (e.g. it used the public form), so an
# in-place rename would collide. Carry the choice over, then retire the old.
if _state(subscriber) == SUBSCRIBED:
subscribe(new, uid=(subscriber.get("attribs") or {}).get("uid", ""))
unsubscribe(old)