From c1866df2a47f9d32dae5f95e08c54034ab455d27 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Fri, 11 Sep 2026 13:45:18 -0600 Subject: [PATCH] Add email notifications (sendmail) + membership sweep (scheduler): welcome email on webhook, reconcile endpoint, daily sweep --- CloudronManifest.json | 7 ++ Dockerfile | 2 + app/main.py | 202 +++++++++++++++++++++++++++++++++++++++++- sweep.sh | 35 ++++++++ 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 sweep.sh diff --git a/CloudronManifest.json b/CloudronManifest.json index 3c3db05..3464e6c 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -11,6 +11,13 @@ "oidc": { "loginRedirectUri": "/", "logoutRedirectUri": "/" + }, + "sendmail": {}, + "scheduler": { + "membership_sweep": { + "schedule": "0 3 * * *", + "command": "/app/code/sweep.sh" + } } }, "manifestVersion": 2, diff --git a/Dockerfile b/Dockerfile index 2c90177..d2606ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,8 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy app COPY app/ ./app/ +COPY sweep.sh /app/code/sweep.sh +RUN chmod +x /app/code/sweep.sh # Run as root (the base image default). Cloudron's sandboxing (read-only # rootfs, AppArmor, dropped capabilities) still applies to root inside the diff --git a/app/main.py b/app/main.py index 08cb6f1..020b120 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,9 @@ import json import logging import sqlite3 import secrets +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart import httpx import jwt @@ -102,6 +105,17 @@ LOOMIO_ALWAYS_KEEP = [ # Lives in /app/data (Cloudron localstorage addon persists this). DB_PATH = os.environ.get("DB_PATH", "/app/data/members.db") +# --- Email (Cloudron sendmail addon) --- +# The sendmail addon exports SMTP credentials as CLOUDRON_MAIL_* env vars. +SMTP_SERVER = os.environ.get("CLOUDRON_MAIL_SMTP_SERVER", "") +SMTP_PORT = int(os.environ.get("CLOUDRON_MAIL_SMTP_PORT", "2525")) +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") +# Public base URL for the portal (used in email links). +PORTAL_BASE = os.environ.get("PORTAL_BASE", "https://portal.inference.coop") + def _verify_portal_secret(request: Request) -> None: """Reject requests that didn't come through LibreChat (shared secret).""" @@ -213,6 +227,106 @@ async def is_active_member(email: str) -> bool: return email.strip().lower() in members +async def fetch_members() -> list[dict]: + """Return the full active-member list: email, name, slug, role. + + Uses the bot's personal token (admin) so emails are exposed. This is the + authoritative source of truth for reconciliation (provisioning missing + members, deactivating lapsed ones). + """ + if not OC_PERSONAL_TOKEN: + logger.warning("OC_PERSONAL_TOKEN not set; cannot fetch members") + return [] + query = ( + '{ collective(slug: "%s") { members(limit: 100) { nodes { role account { email name slug } } } } }' + % OC_COLLECTIVE_SLUG + ) + async with httpx.AsyncClient() as client: + r = await client.post( + OC_GRAPHQL_URL, + headers={"Personal-Token": OC_PERSONAL_TOKEN}, + json={"query": query}, + ) + if r.status_code != 200: + logger.warning("OC member fetch failed: %s", r.status_code) + return [] + nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", []) + result: list[dict] = [] + for n in nodes: + role = n.get("role", "") + acct = n.get("account", {}) or {} + email = (acct.get("email") or "").strip().lower() + if email and role in ("BACKER", "ADMIN"): + result.append({ + "email": email, + "name": acct.get("name") or email.split("@")[0], + "slug": acct.get("slug") or "", + "role": role, + }) + return result + + +def send_email(to: str, subject: str, text_body: str, html_body: str | None = None) -> bool: + """Send an email via Cloudron's sendmail addon (SMTP). + + Returns True on success, False on failure (logged, not raised — email is + best-effort and must never break the provisioning flow). + """ + if not SMTP_SERVER: + logger.warning("SMTP not configured; skipping email to %s", to) + return False + try: + msg = MIMEMultipart("alternative") + msg["From"] = f"{MAIL_FROM_NAME} <{MAIL_FROM}>" + msg["To"] = to + msg["Subject"] = subject + msg.attach(MIMEText(text_body, "plain", "utf-8")) + if html_body: + msg.attach(MIMEText(html_body, "html", "utf-8")) + + with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=15) as s: + if SMTP_USERNAME: + s.login(SMTP_USERNAME, SMTP_PASSWORD) + s.sendmail(MAIL_FROM, [to], msg.as_string()) + logger.info("Sent email to %s: %s", to, subject) + return True + except Exception as e: + logger.warning("Failed to send email to %s: %s", to, e) + return False + + +def welcome_email(name: str) -> tuple[str, str, str]: + """Build the welcome email (subject, text, html) for a new member.""" + join_url = f"{PORTAL_BASE}/join" + subject = "Welcome to the Inference Cooperative — finish your setup" + text = ( + f"Hi {name},\n\n" + "Thanks for joining the Inference Cooperative!\n\n" + "To finish setting up your account and start using private AI chat, " + "visit the link below and connect your Open Collective account:\n\n" + f"{join_url}\n\n" + "This takes about a minute. You'll then get an account-setup email " + "from our platform.\n\n" + "Questions? Reply to this email or write to info@inference.coop.\n\n" + "— The Inference Cooperative\n" + ) + html = ( + f"

Hi {name},

" + "

Thanks for joining the Inference Cooperative!

" + "

To finish setting up your account and start using private AI chat, " + "click the button below and connect your Open Collective account:

" + f'

' + "Finish setup

" + "

This takes about a minute. You'll then get an account-setup email " + "from our platform.

" + '

Questions? Reply to this email or write to ' + 'info@inference.coop.

' + "

— The Inference Cooperative

" + ) + return subject, text, html + + async def get_active_member_emails() -> list[str]: """Return the list of active member emails from the portal's own database. @@ -488,9 +602,19 @@ async def opencollective_webhook(request: Request, token: str): # - payload has "firstPayment" boolean to distinguish new vs recurring # - "collective.transaction.created" is DEPRECATED (being removed) if event_type in ("order.processed", "new.member", "collective.member.created"): - # New or renewed member → store as pending; they complete via OAuth + # New or renewed member → store as pending; they complete via OAuth. if slug: store_pending_member(slug, name) + # Send the welcome email immediately so the member gets feedback that + # their contribution was received and knows the next step (/join). + # The webhook strips email, so look it up by slug via the admin token. + if slug: + members = await fetch_members() + for m in members: + if m["slug"] == slug: + subject, text, html = welcome_email(m["name"]) + send_email(m["email"], subject, text, html) + break return JSONResponse({"status": "pending", "name": name, "slug": slug}) if event_type in ("collective.member.deleted", "collective.transaction.deleted"): @@ -815,6 +939,82 @@ async def admin_provision(token: str, request: Request): return {"status": "provisioned", "email": email, "user_id": user_id} +async def reconcile_memberships() -> dict: + """Reconcile the portal against the live Open Collective member list. + + - Provisions members who are BACKER/ADMIN on OC but not yet in our DB + (missed webhook, or contributed before the email step existed). + - Deactivates members who are in our DB but no longer BACKER/ADMIN + (lapsed or cancelled membership). + - Syncs Loomio to the resulting active-member set. + + Idempotent and safe to run repeatedly. Returns a summary of actions taken. + """ + active = await fetch_members() + active_emails = {m["email"] for m in active} + + # Currently provisioned members (from our DB). + conn = get_db() + rows = conn.execute("SELECT email, cloudron_user_id FROM members WHERE active = 1").fetchall() + conn.close() + provisioned = {r[0]: r[1] for r in rows} + + provisioned_count = 0 + deactivated_count = 0 + + # 1. Provision missing members. + for m in active: + email = m["email"] + if email not in provisioned: + try: + user_id = await cloudron_create_user(email, m["name"]) + await cloudron_set_group(user_id) + await cloudron_set_active(user_id, True) + key_token = await litellm_create_key(email, MEMBER_BUDGET) + store_member(email, key_token, user_id, m["slug"]) + await cloudron_send_invite(user_id, email) + provisioned_count += 1 + logger.info("Sweep: provisioned %s", email) + except Exception as e: + logger.warning("Sweep: failed to provision %s: %s", email, e) + + # 2. Deactivate lapsed members. + for email, user_id in provisioned.items(): + if email not in active_emails: + try: + if INACTIVE_GROUP_ID and user_id: + await cloudron_set_group(user_id, INACTIVE_GROUP_ID) + deactivate_member(email) + deactivated_count += 1 + logger.info("Sweep: deactivated %s", email) + except Exception as e: + logger.warning("Sweep: failed to deactivate %s: %s", email, e) + + # 3. Sync Loomio. + await sync_loomio_memberships() + + return { + "status": "reconciled", + "provisioned": provisioned_count, + "deactivated": deactivated_count, + } + + +@app.post("/admin/sweep/{token}") +async def admin_sweep(token: str): + """Manually trigger a full membership reconciliation. + + Protected by the same secret token as the webhook. Idempotent — safe to + call repeatedly. This is also what the Cloudron scheduler invokes on a + cron schedule (see the sweep script). + """ + if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN): + raise HTTPException(401, "Invalid token") + + result = await reconcile_memberships() + return result + + @app.get("/") async def index(): return {"service": "Inference Cooperative Member Portal", "version": "0.1.0"} diff --git a/sweep.sh b/sweep.sh new file mode 100644 index 0000000..17c2d08 --- /dev/null +++ b/sweep.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Membership reconciliation sweep — run by the Cloudron scheduler addon. +# +# Calls the portal's own /admin/sweep endpoint (localhost), which reconciles +# the portal against the live Open Collective member list: provisions missing +# members, deactivates lapsed members, and syncs Loomio. +# +# The WEBHOOK_TOKEN env var is shared with the main app (scheduler runs in the +# same launch environment), so we use it to authenticate the call. +# +# Uses Python (guaranteed present in the base image) rather than curl. + +set -e + +if [ -z "$WEBHOOK_TOKEN" ]; then + echo "sweep: WEBHOOK_TOKEN not set; aborting" >&2 + exit 1 +fi + +python3 - "$WEBHOOK_TOKEN" <<'PY' +import sys, urllib.request, urllib.error + +token = sys.argv[1] +url = f"http://127.0.0.1:8000/admin/sweep/{token}" +req = urllib.request.Request(url, method="POST", data=b"", headers={"Content-Type": "application/json"}) +try: + with urllib.request.urlopen(req, timeout=60) as resp: + print("sweep:", resp.read().decode()) +except urllib.error.HTTPError as e: + print(f"sweep: HTTP {e.code}: {e.read().decode()}", file=sys.stderr) + sys.exit(1) +except Exception as e: + print(f"sweep: request failed: {e}", file=sys.stderr) + sys.exit(1) +PY