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>
145 lines
5.3 KiB
Python
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)
|