diff --git a/app/main.py b/app/main.py index f9e651a..3a4c044 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,7 @@ import re import sqlite3 import secrets import smtplib +from datetime import datetime, timezone from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart @@ -96,6 +97,12 @@ OC_PERSONAL_TOKEN = os.environ.get("OC_PERSONAL_TOKEN", "") # regardless of their $10/15/20 contribution. Governance decision (Loomio). MEMBER_BUDGET = float(os.environ.get("MEMBER_BUDGET", "15.0")) +# Days to wait before re-sending the welcome/invite email to a member who was +# provisioned but never completed account setup (inviteAccepted=False). The +# nightly sweep re-invites such members, throttled by this interval so they +# aren't emailed every single night. +REINVITE_AFTER_DAYS = float(os.environ.get("REINVITE_AFTER_DAYS", "3")) + # Models every member key/team may access. MEMBER_MODELS = ["deepseek-v4-1-flash", "gpt-oss-120b", "glm-5-3-flash"] @@ -266,6 +273,10 @@ def get_db() -> sqlite3.Connection: # This is the flexible counter to the fixed $15/month allowance. if "balance" not in cols: conn.execute("ALTER TABLE members ADD COLUMN balance REAL") + # Migration: welcome_sent_at — timestamp of the last welcome/invite email, + # used to throttle re-invites of members who never activated. + if "welcome_sent_at" not in cols: + conn.execute("ALTER TABLE members ADD COLUMN welcome_sent_at TEXT") return conn @@ -497,6 +508,13 @@ async def sync_loomio_memberships() -> None: def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None: conn = get_db() + # Preserve an existing slug if the caller passed none — re-provisioning an + # already-provisioned member must not wipe their OC slug (the deactivation + # webhook maps slugs back to members). + if not slug: + row = conn.execute("SELECT slug FROM members WHERE email = ?", (email,)).fetchone() + if row and row[0]: + slug = row[0] conn.execute( "INSERT INTO members (email, key_token, cloudron_user_id, slug, active) " "VALUES (?, ?, ?, ?, 1) " @@ -517,6 +535,33 @@ def get_member_key(email: str) -> str | None: return row[0] if row else None +def get_stored_key(email: str) -> str | None: + """Return the stored LiteLLM key token regardless of active status. + + Unlike get_member_key (which filters active=1), this returns the key even + for a lapsed member being reactivated, so provisioning can reuse it rather + than mint a duplicate alias (LiteLLM rejects a duplicate `member:` + alias with 400). + """ + conn = get_db() + row = conn.execute( + "SELECT key_token FROM members WHERE email = ?", (email,) + ).fetchone() + conn.close() + return (row[0] if row and row[0] else None) + + +def touch_welcome_sent(email: str) -> None: + """Record that a welcome/invite email was just sent for this member.""" + conn = get_db() + conn.execute( + "UPDATE members SET welcome_sent_at = datetime('now') WHERE email = ?", + (email,), + ) + conn.commit() + conn.close() + + def get_member_by_slug(slug: str) -> dict | None: """Look up a member (email, key_token, cloudron_user_id) by their OC slug.""" conn = get_db() @@ -644,6 +689,25 @@ async def cloudron_get_invite_link(user_id: str) -> str: return r.json().get("inviteLink", "") +async def cloudron_is_activated(user_id: str) -> bool: + """Return True if the user has completed account setup (inviteAccepted). + + `inviteAccepted` flips true once the member clicks their setup link and + chooses a username/password. Until then they're provisioned but not able to + log in — the state we re-invite for. + """ + async with httpx.AsyncClient() as client: + r = await client.get( + f"{CLOUDRON_API}/api/v1/users", + headers=cloudron_headers(), + ) + r.raise_for_status() + for u in r.json().get("users", []): + if u.get("id") == user_id: + return bool(u.get("inviteAccepted")) + return False + + async def provision_member(email: str, name: str, slug: str = "") -> str: """Provision a member end-to-end and send our own welcome email. @@ -652,21 +716,31 @@ async def provision_member(email: str, name: str, slug: str = "") -> str: stores the mapping, and sends our branded welcome email containing the Cloudron account-setup link (instead of Cloudron's default invite email). - Idempotent: if the member already exists, reuses the existing user/key. - Returns the Cloudron user id. + Idempotent: if the member already has a stored LiteLLM key, reuse it rather + than minting a new one (LiteLLM rejects a duplicate `member:` alias + with 400 — the bug that previously made re-provisioning of existing members + fail). Re-sending this way also re-sends the welcome email, so it doubles + as the manual "re-invite" path. """ user_id = await cloudron_create_user(email, name) await cloudron_set_group(user_id) await cloudron_set_active(user_id, True) balance = get_member_balance(email) team_id = await litellm_get_or_create_team(email, balance) - key_token = await litellm_create_key(email, balance, team_id) + + # Reuse an existing key if we already have one (the sk- value is stored in + # our DB at creation time and is the authoritative token for this member's + # chat key). Only mint a fresh key when there is none. + key_token = get_stored_key(email) + if not key_token: + key_token = await litellm_create_key(email, balance, team_id) store_member(email, key_token, user_id, slug) # Send our own welcome email with the account-setup link (no OAuth step). setup_url = await cloudron_get_invite_link(user_id) subject, text, html = welcome_email(name, setup_url) send_email(email, subject, text, html) + touch_welcome_sent(email) # Subscribe to the member newsletter (single opt-in; members consented by joining). await listmonk_sync(email, subscribe=True) @@ -1392,6 +1466,7 @@ async def reconcile_memberships() -> dict: """ active = await fetch_members() active_emails = {m["email"] for m in active} + active_by_email = {m["email"]: m for m in active} # FAIL-SAFE: if the OC fetch returned nothing (token expired, OC outage, # or a query error), we must NOT deactivate everyone — that would be a @@ -1434,6 +1509,48 @@ async def reconcile_memberships() -> dict: except Exception as e: logger.warning("Sweep: failed to deactivate %s: %s", email, e) + # 2b. Re-invite never-activated members (provisioned but no account setup). + # A member who was provisioned (e.g. webhook fired while the OC token was + # missing the `email` scope, or the invite email went to spam) may sit + # active-but-unactivated indefinitely. Re-send their welcome email, but + # throttle by REINVITE_AFTER_DAYS so we don't spam them nightly. + reinvited_count = 0 + conn = get_db() + reinvite_rows = conn.execute( + "SELECT email, cloudron_user_id, welcome_sent_at FROM members WHERE active = 1" + ).fetchall() + conn.close() + now = datetime.now(timezone.utc) + for email, user_id, sent_at in reinvite_rows: + if not user_id: + continue + # Skip if already activated. + try: + if await cloudron_is_activated(user_id): + continue + except Exception as e: + logger.warning("Sweep: activation check failed for %s: %s", email, e) + continue + # Throttle: only re-invite if the last send was > REINVITE_AFTER_DAYS ago. + if sent_at: + try: + last = datetime.fromisoformat(sent_at) + # stored as UTC naive (datetime('now') is UTC); attach tz + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + if (now - last).total_seconds() < REINVITE_AFTER_DAYS * 86400: + continue + except Exception: + pass + # Re-send the welcome email (reuses the stored key — no duplicate alias). + try: + name = active_by_email.get(email, {}).get("name") or email.split("@")[0] + await provision_member(email, name, "") + reinvited_count += 1 + logger.info("Sweep: re-invited %s (never activated)", email) + except Exception as e: + logger.warning("Sweep: failed to re-invite %s: %s", email, e) + # 3. Sync Loomio. await sync_loomio_memberships() @@ -1441,6 +1558,7 @@ async def reconcile_memberships() -> dict: "status": "reconciled", "provisioned": provisioned_count, "deactivated": deactivated_count, + "reinvited": reinvited_count, }