Add listmonk newsletter sync (subscribe on provision, unsubscribe on deactivate)
This commit is contained in:
1 parent
ca3eeccc48
commit
2625a6cc83
1 file changed
+68
-3
+68
-3
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user