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>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user