Add listmonk newsletter sync (subscribe on provision, unsubscribe on deactivate)

This commit is contained in:
inference-bot committed 2026-09-16 15:44:56 -06:00
1 parent ca3eeccc48
commit 2625a6cc83
1 file changed
+68 -3
+68 -3
View File
@@ -142,13 +142,73 @@ SMTP_USERNAME = os.environ.get("CLOUDRON_MAIL_SMTP_USERNAME", "")
SMTP_PASSWORD = os.environ.get("CLOUDRON_MAIL_SMTP_PASSWORD", "")
MAIL_FROM = os.environ.get("CLOUDRON_MAIL_FROM", "info@inference.coop")
MAIL_FROM_NAME = os.environ.get("CLOUDRON_MAIL_FROM_DISPLAY_NAME", "Inference Cooperative")
# Reply-to address. The sendmail addon only authorizes sending from the app's
# own address (portal.app@inference.coop), so the From header is pinned there;
# we set Reply-To so member replies land at the co-op inbox instead.
# Reply-to address so member replies land at the co-op inbox.
MAIL_REPLY_TO = os.environ.get("MAIL_REPLY_TO", "info@inference.coop")
# Public base URL for the portal (used in email links).
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_API_TOKEN = os.environ.get("LISTMONK_API_TOKEN", "")
LISTMONK_LIST_ID = int(os.environ.get("LISTMONK_LIST_ID", "3")) # "Members" list
async def listmonk_sync(email: str, subscribe: bool) -> None:
"""Subscribe or unsubscribe a member to the newsletter list.
Best-effort: never raises, so a newsletter outage can't break provisioning.
"""
if not LISTMONK_API_TOKEN:
logger.warning("Listmonk not configured; skipping sync for %s", email)
return
auth = {"Authorization": f"token {LISTMONK_API_USER}:{LISTMONK_API_TOKEN}"}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
if subscribe:
r = await client.post(
f"{LISTMONK_BASE}/api/subscribers",
headers=auth,
json={
"email": email,
"name": "",
"status": "enabled",
"lists": [LISTMONK_LIST_ID],
"preconfirm_subscriptions": True,
},
)
else:
# Look up the subscriber id by email, then blocklist by id.
# (listmonk's blocklist endpoint takes an id, not an email.)
q = await client.get(
f"{LISTMONK_BASE}/api/subscribers",
headers=auth,
params={"query": f"subscribers.email='{email}'"},
)
if q.status_code != 200:
logger.warning("Listmonk lookup for %s failed: %s", email, q.status_code)
return
results = q.json().get("data", {}).get("results", [])
if not results:
return # not subscribed; nothing to do
sid = results[0]["id"]
r = await client.put(
f"{LISTMONK_BASE}/api/subscribers/{sid}/blocklist",
headers=auth,
)
if r.status_code not in (200, 201):
logger.warning(
"Listmonk sync %s for %s failed: %s",
"subscribe" if subscribe else "unsubscribe",
email,
r.status_code,
)
except Exception as e:
logger.warning("Listmonk sync error for %s: %s", email, e)
def _verify_portal_secret(request: Request) -> None:
"""Reject requests that didn't come through LibreChat (shared secret)."""
@@ -608,6 +668,9 @@ async def provision_member(email: str, name: str, slug: str = "") -> str:
subject, text, html = welcome_email(name, setup_url)
send_email(email, subject, text, html)
# Subscribe to the member newsletter (single opt-in; members consented by joining).
await listmonk_sync(email, subscribe=True)
await sync_loomio_memberships()
logger.info("Provisioned member %s (user_id=%s)", email, user_id)
return user_id
@@ -868,6 +931,7 @@ async def opencollective_webhook(request: Request, token: str):
await cloudron_set_group(member["cloudron_user_id"], INACTIVE_GROUP_ID)
# Mark inactive in our DB (key injector will refuse requests)
deactivate_member(member["email"])
await listmonk_sync(member["email"], subscribe=False)
await sync_loomio_memberships()
logger.info("Deactivated member %s (slug=%s)", member["email"], slug)
return JSONResponse({"status": "deactivated", "email": member["email"]})
@@ -1364,6 +1428,7 @@ async def reconcile_memberships() -> dict:
if INACTIVE_GROUP_ID and user_id:
await cloudron_set_group(user_id, INACTIVE_GROUP_ID)
deactivate_member(email)
await listmonk_sync(email, subscribe=False)
deactivated_count += 1
logger.info("Sweep: deactivated %s", email)
except Exception as e: