diff --git a/app/main.py b/app/main.py index 69b4602..f9e651a 100644 --- a/app/main.py +++ b/app/main.py @@ -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 :). +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: