newsletter reconcile: Members list mirrors ACTIVE members
Root cause of test accounts receiving the newsletter: LISTMONK_API_USER was set EMPTY in the portal env - env-present beats the code default, so every listmonk_sync authenticated with an empty user and 403'd silently. Fixed both the env (set to portal-sync) and the code (empty env falls back to default). New: listmonk_reconcile() - the Members list must mirror ACTIVE members. Stale subscribers are removed: members marked inactive are taken off the list (record kept as suppression marker); addresses that are not members at all (test/deleted accounts) are deleted outright. Wired as step 4 of the nightly membership sweep + POST /admin/newsletter/reconcile for on-demand runs.
This commit is contained in:
1 parent
e4da4da2c6
commit
d8d80d9594
1 file changed
+111
-2
+111
-2
@@ -234,8 +234,12 @@ PORTAL_BASE = os.environ.get("PORTAL_BASE", "https://portal.inference.coop")
|
||||
# Listmonk newsletter integration. The portal keeps the "Members" list in sync:
|
||||
# subscribe on provision, unsubscribe on deactivation. Auth uses listmonk's
|
||||
# native API-user token scheme (Authorization: token <user>:<token>).
|
||||
LISTMONK_BASE = os.environ.get("LISTMONK_BASE", "https://newsletter.inference.coop")
|
||||
LISTMONK_API_USER = os.environ.get("LISTMONK_API_USER", "portal-sync")
|
||||
LISTMONK_BASE = os.environ.get("LISTMONK_BASE") or "https://newsletter.inference.coop"
|
||||
# NOTE: `or` (not default-arg) — an env var set to EMPTY must fall back to the
|
||||
# default. An empty LISTMONK_API_USER made every sync 403 silently (the token
|
||||
# alone is not a valid listmonk credential) while listmonk_sync's best-effort
|
||||
# error handling swallowed it — that is how stale accounts stayed subscribed.
|
||||
LISTMONK_API_USER = os.environ.get("LISTMONK_API_USER") or "portal-sync"
|
||||
LISTMONK_API_TOKEN = os.environ.get("LISTMONK_API_TOKEN", "")
|
||||
LISTMONK_LIST_ID = int(os.environ.get("LISTMONK_LIST_ID", "3")) # "Members" list
|
||||
|
||||
@@ -410,6 +414,90 @@ async def listmonk_sync(email: str, subscribe: bool) -> None:
|
||||
logger.warning("Listmonk sync error for %s: %s", email, e)
|
||||
|
||||
|
||||
async def listmonk_reconcile() -> dict:
|
||||
"""Reconcile the Members newsletter list against the active membership.
|
||||
|
||||
The newsletter list must mirror the portal's ACTIVE members. Subscribers
|
||||
who are not active members are stale (deleted accounts, never-activated
|
||||
test accounts, lapsed members) and are removed:
|
||||
- emails that exist in the members table but are inactive are taken off
|
||||
the list (the subscriber record stays as a suppression marker);
|
||||
- emails that are not members at all are deleted outright (stray or
|
||||
test accounts should not persist in the mailing system).
|
||||
|
||||
Best-effort: never raises, so a newsletter outage can't break the sweep.
|
||||
"""
|
||||
if not LISTMONK_API_TOKEN:
|
||||
logger.warning("Listmonk not configured; skipping newsletter reconcile")
|
||||
return {"removed": 0, "emails": [], "error": "not configured"}
|
||||
auth = {"Authorization": f"token {LISTMONK_API_USER}:{LISTMONK_API_TOKEN}"}
|
||||
conn = get_db()
|
||||
active = {
|
||||
row[0].lower()
|
||||
for row in conn.execute("SELECT email FROM members WHERE active = 1")
|
||||
}
|
||||
member_emails = {
|
||||
row[0].lower() for row in conn.execute("SELECT email FROM members")
|
||||
}
|
||||
conn.close()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
# Fetch the full subscriber set for the list (paginated).
|
||||
subs, page = [], 1
|
||||
while True:
|
||||
r = await client.get(
|
||||
f"{LISTMONK_BASE}/api/subscribers",
|
||||
headers=auth,
|
||||
params={"list_id": LISTMONK_LIST_ID, "page": page, "per_page": 100},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
logger.warning(
|
||||
"Listmonk reconcile: subscriber fetch failed: %s", r.status_code
|
||||
)
|
||||
return {"removed": 0, "emails": [], "error": f"fetch {r.status_code}"}
|
||||
data = r.json().get("data", {})
|
||||
results = data.get("results", [])
|
||||
subs += results
|
||||
if len(subs) >= (data.get("total") or 0) or not results:
|
||||
break
|
||||
page += 1
|
||||
removed, emails = 0, []
|
||||
for s in subs:
|
||||
email = (s.get("email") or "").lower()
|
||||
if not email or email in active:
|
||||
continue
|
||||
sid = s.get("id")
|
||||
if email in member_emails:
|
||||
# Inactive member: take off the list, keep the record.
|
||||
r = await client.put(
|
||||
f"{LISTMONK_BASE}/api/subscribers/{sid}",
|
||||
headers=auth,
|
||||
json={"lists": []},
|
||||
)
|
||||
else:
|
||||
# Not a member at all (deleted/test account): remove it.
|
||||
r = await client.delete(
|
||||
f"{LISTMONK_BASE}/api/subscribers/{sid}",
|
||||
headers=auth,
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
removed += 1
|
||||
emails.append(email)
|
||||
else:
|
||||
logger.warning(
|
||||
"Listmonk reconcile: remove %s failed: %s", email, r.status_code
|
||||
)
|
||||
if removed:
|
||||
logger.info(
|
||||
"Listmonk reconcile: removed %d stale subscribers: %s",
|
||||
removed,
|
||||
", ".join(emails),
|
||||
)
|
||||
return {"removed": removed, "emails": emails}
|
||||
except Exception as e:
|
||||
logger.warning("Listmonk reconcile error: %s", e)
|
||||
return {"removed": 0, "emails": [], "error": str(e)}
|
||||
|
||||
|
||||
def _verify_portal_secret(request: Request) -> None:
|
||||
"""Reject requests that didn't come through LibreChat (shared secret)."""
|
||||
@@ -3258,11 +3346,16 @@ async def reconcile_memberships() -> dict:
|
||||
await sync_loomio_memberships()
|
||||
await sync_matrix_space()
|
||||
|
||||
# 4. Reconcile the newsletter list against active membership (removes
|
||||
# deleted accounts, never-activated test accounts, lapsed members).
|
||||
newsletter = await listmonk_reconcile()
|
||||
|
||||
return {
|
||||
"status": "reconciled",
|
||||
"provisioned": provisioned_count,
|
||||
"deactivated": deactivated_count,
|
||||
"reinvited": reinvited_count,
|
||||
"newsletter_removed": newsletter.get("removed", 0),
|
||||
}
|
||||
|
||||
|
||||
@@ -3281,6 +3374,22 @@ async def admin_sweep(request: Request):
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/admin/newsletter/reconcile")
|
||||
@limiter.limit("20/minute")
|
||||
async def admin_newsletter_reconcile(request: Request):
|
||||
"""Remove stale subscribers from the Members newsletter list.
|
||||
|
||||
The newsletter list must mirror ACTIVE members: deleted accounts,
|
||||
never-activated test accounts, and lapsed members are removed (see
|
||||
listmonk_reconcile for the exact rules). Also runs automatically as
|
||||
step 4 of the nightly membership sweep.
|
||||
|
||||
Authenticated by the X-Admin-Token header.
|
||||
"""
|
||||
_verify_admin_token(request)
|
||||
return await listmonk_reconcile()
|
||||
|
||||
|
||||
def _verify_sync_token(request: Request) -> None:
|
||||
"""Authenticate the LibreChat key-sync feed via X-Sync-Token.
|
||||
|
||||
|
||||
Reference in new issue
Block a user