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
+121 -3
+121 -3
View File
@@ -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:<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:
"""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:<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)
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,
}