Idempotent provisioning + auto re-invite of never-activated members

- provision_member reuses the stored LiteLLM key instead of minting a duplicate
  alias (LiteLLM 400s on member:<email>); fixes re-provisioning of existing members
- store_member preserves an existing slug when none is passed
- add welcome_sent_at + REINVITE_AFTER_DAYS; nightly sweep re-invites
  active members who never completed account setup, throttled to 3 days
This commit is contained in:
inference-bot committed 2026-09-22 16:07:00 -06:00
1 parent 2625a6cc83
commit fd7442e1a4
1 file changed
+120 -2
+120 -2
View File
@@ -20,6 +20,7 @@ import re
import sqlite3 import sqlite3
import secrets import secrets
import smtplib import smtplib
from datetime import datetime, timezone
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart 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). # regardless of their $10/15/20 contribution. Governance decision (Loomio).
MEMBER_BUDGET = float(os.environ.get("MEMBER_BUDGET", "15.0")) 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. # Models every member key/team may access.
MEMBER_MODELS = ["deepseek-v4-1-flash", "gpt-oss-120b", "glm-5-3-flash"] 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. # This is the flexible counter to the fixed $15/month allowance.
if "balance" not in cols: if "balance" not in cols:
conn.execute("ALTER TABLE members ADD COLUMN balance REAL") 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 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: def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None:
conn = get_db() 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( conn.execute(
"INSERT INTO members (email, key_token, cloudron_user_id, slug, active) " "INSERT INTO members (email, key_token, cloudron_user_id, slug, active) "
"VALUES (?, ?, ?, ?, 1) " "VALUES (?, ?, ?, ?, 1) "
@@ -517,6 +535,33 @@ def get_member_key(email: str) -> str | None:
return row[0] if row else 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:<email>`
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: def get_member_by_slug(slug: str) -> dict | None:
"""Look up a member (email, key_token, cloudron_user_id) by their OC slug.""" """Look up a member (email, key_token, cloudron_user_id) by their OC slug."""
conn = get_db() conn = get_db()
@@ -644,6 +689,25 @@ async def cloudron_get_invite_link(user_id: str) -> str:
return r.json().get("inviteLink", "") 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: async def provision_member(email: str, name: str, slug: str = "") -> str:
"""Provision a member end-to-end and send our own welcome email. """Provision a member end-to-end and send our own welcome email.
@@ -652,14 +716,23 @@ async def provision_member(email: str, name: str, slug: str = "") -> str:
stores the mapping, and sends our branded welcome email containing the stores the mapping, and sends our branded welcome email containing the
Cloudron account-setup link (instead of Cloudron's default invite email). Cloudron account-setup link (instead of Cloudron's default invite email).
Idempotent: if the member already exists, reuses the existing user/key. Idempotent: if the member already has a stored LiteLLM key, reuse it rather
Returns the Cloudron user id. than minting a new one (LiteLLM rejects a duplicate `member:<email>` 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) user_id = await cloudron_create_user(email, name)
await cloudron_set_group(user_id) await cloudron_set_group(user_id)
await cloudron_set_active(user_id, True) await cloudron_set_active(user_id, True)
balance = get_member_balance(email) balance = get_member_balance(email)
team_id = await litellm_get_or_create_team(email, balance) team_id = await litellm_get_or_create_team(email, balance)
# 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) key_token = await litellm_create_key(email, balance, team_id)
store_member(email, key_token, user_id, slug) store_member(email, key_token, user_id, slug)
@@ -667,6 +740,7 @@ async def provision_member(email: str, name: str, slug: str = "") -> str:
setup_url = await cloudron_get_invite_link(user_id) setup_url = await cloudron_get_invite_link(user_id)
subject, text, html = welcome_email(name, setup_url) subject, text, html = welcome_email(name, setup_url)
send_email(email, subject, text, html) send_email(email, subject, text, html)
touch_welcome_sent(email)
# Subscribe to the member newsletter (single opt-in; members consented by joining). # Subscribe to the member newsletter (single opt-in; members consented by joining).
await listmonk_sync(email, subscribe=True) await listmonk_sync(email, subscribe=True)
@@ -1392,6 +1466,7 @@ async def reconcile_memberships() -> dict:
""" """
active = await fetch_members() active = await fetch_members()
active_emails = {m["email"] for m in active} 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, # 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 # 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: except Exception as e:
logger.warning("Sweep: failed to deactivate %s: %s", email, 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. # 3. Sync Loomio.
await sync_loomio_memberships() await sync_loomio_memberships()
@@ -1441,6 +1558,7 @@ async def reconcile_memberships() -> dict:
"status": "reconciled", "status": "reconciled",
"provisioned": provisioned_count, "provisioned": provisioned_count,
"deactivated": deactivated_count, "deactivated": deactivated_count,
"reinvited": reinvited_count,
} }