newsletter: operator accounts are not stale subscribers

info@inference.coop is on the Members list but has no members row, so the
reconcile classified the co-op's own inbox as a stale subscriber and would have
unsubscribed it.

The data model already distinguishes this: OPERATOR_EMAILS (default
info@inference.coop,bot@inference.coop) are infrastructure accounts — the SSO
owner and the bot — which the panel surfaces under "admins" and excludes from
member counts. They live outside the members table by design. The Loomio
reconciliation applies the same rule to the same accounts via LOOMIO_ALWAYS_KEEP
("must always remain in the group").

So operators become the third class here: neither members nor stale. Their
subscription is left exactly as it is (never unsubscribed, and the reconcile
never subscribes, so an operator who unsubscribes stays unsubscribed). The
response reports how many were left, and the log line carries counts only.
This commit is contained in:
Ubuntu committed 2026-10-26 16:54:53 +00:00
1 parent 250ca6e8f0
commit e55d8a3f1e
1 file changed
+24 -12
+24 -12
View File
@@ -417,16 +417,18 @@ async def listmonk_sync(email: str, subscribe: bool) -> None:
async def listmonk_reconcile() -> dict: async def listmonk_reconcile() -> dict:
"""Reconcile the Members newsletter list against the active membership. """Reconcile the Members newsletter list against the active membership.
The newsletter list must mirror the portal's ACTIVE members. Subscribers The newsletter list mirrors the portal's ACTIVE members. Subscribers who are
who are not active members are stale (deleted accounts, never-activated not active members fall into two classes, and only one is acted on:
test accounts, lapsed members) and are removed: - emails that exist in the members table but are inactive, and emails that
- emails that exist in the members table but are inactive are taken off are not members at all, are taken off the list (the subscriber record
the list (the subscriber record stays as a suppression marker); stays as a suppression marker). A DELETE would destroy the subscriber's
- emails that are not members at all are taken off the list too, and the consent history, and a stray test account is indistinguishable from a
record is KEPT. A DELETE would destroy the subscriber's consent real person who subscribed outside the portal, so nobody is purged
history, and a stray test account is indistinguishable from a real automatically;
person who subscribed outside the portal (or whose row was removed and - OPERATOR_EMAILS — infrastructure accounts, which the panel surfaces
who may come back). Nobody is purged automatically. under "admins" and excludes from member counts — are left exactly as
they are. They are neither members nor stale. The Loomio
reconciliation applies the same rule through LOOMIO_ALWAYS_KEEP.
Best-effort: never raises, so a newsletter outage can't break the sweep. Best-effort: never raises, so a newsletter outage can't break the sweep.
""" """
@@ -474,9 +476,14 @@ async def listmonk_reconcile() -> dict:
page += 1 page += 1
removed, emails = 0, [] removed, emails = 0, []
by_reason = {"inactive member": 0, "not a member": 0} by_reason = {"inactive member": 0, "not a member": 0}
# Operator accounts are neither members nor stale; never touch their
# subscription (LOOMIO_ALWAYS_KEEP is the same rule for the forum).
operators_kept = sum(
1 for s in subs if (s.get("email") or "").lower() in OPERATOR_EMAILS
)
for s in subs: for s in subs:
email = (s.get("email") or "").lower() email = (s.get("email") or "").lower()
if not email or email in active: if not email or email in active or email in OPERATOR_EMAILS:
continue continue
sid = s.get("id") sid = s.get("id")
# Both classes leave the list the same way: unsubscribe, keep the # Both classes leave the list the same way: unsubscribe, keep the
@@ -505,7 +512,12 @@ async def listmonk_reconcile() -> dict:
removed, removed,
", ".join(f"{k}: {v}" for k, v in by_reason.items() if v), ", ".join(f"{k}: {v}" for k, v in by_reason.items() if v),
) )
return {"removed": removed, "emails": emails} if operators_kept:
logger.info(
"Listmonk reconcile: left %d operator address(es) on the list",
operators_kept,
)
return {"removed": removed, "emails": emails, "operators_kept": operators_kept}
except Exception as e: except Exception as e:
logger.warning("Listmonk reconcile error: %s", e) logger.warning("Listmonk reconcile error: %s", e)
return {"removed": 0, "emails": [], "error": str(e)} return {"removed": 0, "emails": [], "error": str(e)}