"""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)