Provision on webhook (firstPayment) with own welcome email + invite_link; drop OAuth dependency

This commit is contained in:
inference-bot committed 2026-09-11 14:07:51 -06:00
1 parent 14e838a798
commit eaeda98bac
1 file changed
+64 -42
+64 -42
View File
@@ -295,23 +295,24 @@ def send_email(to: str, subject: str, text_body: str, html_body: str | None = No
return False
def welcome_email(name: str) -> tuple[str, str, str]:
def welcome_email(name: str, setup_url: str) -> tuple[str, str, str]:
"""Build the welcome email (subject, text, html) for a new member.
`setup_url` is the Cloudron account-setup link (from invite_link), which
lets the member create their account directly — no OAuth step needed.
The HTML uses a centered, card-style layout matching the landing page
(cream background, forest-green button, Figtree-like system fonts).
Email HTML is table-based for client compatibility; no external assets.
"""
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"
"click the link below to create your account:\n\n"
f"{setup_url}\n\n"
"This takes about a minute.\n\n"
"Questions? Reply to this email or write to info@inference.coop.\n\n"
"— The Inference Cooperative\n"
)
@@ -357,8 +358,8 @@ def welcome_email(name: str) -> tuple[str, str, str]:
<h1 style="margin:0 0 12px;font-size:24px;font-weight:700;color:#2d3327;">Welcome, {name}</h1>
<p style="margin:0 0 8px;font-size:16px;color:#6b7a62;line-height:1.5;">Thanks for joining the <strong style="color:#2d3327;">Inference Cooperative</strong>.</p>
<p style="margin:0 0 28px;font-size:15px;color:#6b7a62;line-height:1.5;">Finish setting up your account to start using private AI chat.</p>
<a href="{join_url}" style="display:inline-block;background:#5b8c5a;color:#ffffff;text-decoration:none;padding:14px 36px;border-radius:10px;font-size:16px;font-weight:600;">Finish setup</a>
<p style="margin:28px 0 0;font-size:13px;color:#94a08c;line-height:1.5;">This takes about a minute. You'll then get an account-setup email from our platform.</p>
<a href="{setup_url}" style="display:inline-block;background:#5b8c5a;color:#ffffff;text-decoration:none;padding:14px 36px;border-radius:10px;font-size:16px;font-weight:600;">Finish setup</a>
<p style="margin:28px 0 0;font-size:13px;color:#94a08c;line-height:1.5;">This takes about a minute.</p>
</td>
</tr>
<tr>
@@ -551,6 +552,47 @@ async def cloudron_send_invite(user_id: str, email: str) -> None:
r.raise_for_status()
async def cloudron_get_invite_link(user_id: str) -> str:
"""Return the account-setup link WITHOUT sending Cloudron's own email.
We use this so we can send our own branded email (with the setup link
embedded) instead of Cloudron's default invite email.
"""
async with httpx.AsyncClient() as client:
r = await client.get(
f"{CLOUDRON_API}/api/v1/users/{user_id}/invite_link",
headers=cloudron_headers(),
)
r.raise_for_status()
return r.json().get("inviteLink", "")
async def provision_member(email: str, name: str, slug: str = "") -> str:
"""Provision a member end-to-end and send our own welcome email.
Creates the Cloudron user (members group), issues a LiteLLM key, 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.
"""
user_id = await cloudron_create_user(email, 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, 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)
await sync_loomio_memberships()
logger.info("Provisioned member %s (user_id=%s)", email, user_id)
return user_id
async def litellm_find_key_by_alias(alias: str) -> str | None:
"""Find an existing key's token by its alias (key/list returns tokens)."""
async with httpx.AsyncClient() as client:
@@ -651,20 +693,21 @@ 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.
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).
# New member → provision immediately and send our own welcome email.
# The webhook strips email, so look it up by slug via the admin token.
if slug:
# Only provision on firstPayment (order.processed also fires on monthly
# renewals, which should NOT re-send the welcome email).
first_payment = data.get("firstPayment", True)
if slug and first_payment:
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)
try:
await provision_member(m["email"], m["name"], m["slug"])
except Exception as e:
logger.warning("Webhook: failed to provision %s: %s", m["email"], e)
break
return JSONResponse({"status": "pending", "name": name, "slug": slug})
return JSONResponse({"status": "provisioned", "name": name, "slug": slug})
if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
# Lapsed member → move to the inactive group (lose access, keep data).
@@ -900,16 +943,8 @@ async def oauth_callback(request: Request):
</html>"""
return Response(content=html, media_type="text/html", status_code=403)
# Provision the member
user_id = await cloudron_create_user(email, 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, slug)
await cloudron_send_invite(user_id, email)
await sync_loomio_memberships()
logger.info("Provisioned member %s (user_id=%s)", email, user_id)
# Provision the member (sends our own welcome email with the setup link).
await provision_member(email, name, slug)
html = """<!DOCTYPE html>
<html lang="en">
@@ -976,15 +1011,7 @@ async def admin_provision(token: str, request: Request):
if not email:
raise HTTPException(400, "Missing email")
user_id = await cloudron_create_user(email, 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, "")
await cloudron_send_invite(user_id, email)
await sync_loomio_memberships()
logger.info("Manually provisioned member %s (user_id=%s)", email, user_id)
user_id = await provision_member(email, name, "")
return {"status": "provisioned", "email": email, "user_id": user_id}
@@ -1016,12 +1043,7 @@ async def reconcile_memberships() -> dict:
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)
await provision_member(email, m["name"], m["slug"])
provisioned_count += 1
logger.info("Sweep: provisioned %s", email)
except Exception as e: