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 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. """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 The HTML uses a centered, card-style layout matching the landing page
(cream background, forest-green button, Figtree-like system fonts). (cream background, forest-green button, Figtree-like system fonts).
Email HTML is table-based for client compatibility; no external assets. 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" subject = "Welcome to the Inference Cooperative — finish your setup"
text = ( text = (
f"Hi {name},\n\n" f"Hi {name},\n\n"
"Thanks for joining the Inference Cooperative!\n\n" "Thanks for joining the Inference Cooperative!\n\n"
"To finish setting up your account and start using private AI chat, " "To finish setting up your account and start using private AI chat, "
"visit the link below and connect your Open Collective account:\n\n" "click the link below to create your account:\n\n"
f"{join_url}\n\n" f"{setup_url}\n\n"
"This takes about a minute. You'll then get an account-setup email " "This takes about a minute.\n\n"
"from our platform.\n\n"
"Questions? Reply to this email or write to info@inference.coop.\n\n" "Questions? Reply to this email or write to info@inference.coop.\n\n"
"— The Inference Cooperative\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> <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 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> <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> <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. You'll then get an account-setup email from our platform.</p> <p style="margin:28px 0 0;font-size:13px;color:#94a08c;line-height:1.5;">This takes about a minute.</p>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -551,6 +552,47 @@ async def cloudron_send_invite(user_id: str, email: str) -> None:
r.raise_for_status() 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: async def litellm_find_key_by_alias(alias: str) -> str | None:
"""Find an existing key's token by its alias (key/list returns tokens).""" """Find an existing key's token by its alias (key/list returns tokens)."""
async with httpx.AsyncClient() as client: 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 # - payload has "firstPayment" boolean to distinguish new vs recurring
# - "collective.transaction.created" is DEPRECATED (being removed) # - "collective.transaction.created" is DEPRECATED (being removed)
if event_type in ("order.processed", "new.member", "collective.member.created"): if event_type in ("order.processed", "new.member", "collective.member.created"):
# New or renewed member → store as pending; they complete via OAuth. # New member → provision immediately and send our own welcome email.
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. # 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() members = await fetch_members()
for m in members: for m in members:
if m["slug"] == slug: if m["slug"] == slug:
subject, text, html = welcome_email(m["name"]) try:
send_email(m["email"], subject, text, html) 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 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"): if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
# Lapsed member → move to the inactive group (lose access, keep data). # Lapsed member → move to the inactive group (lose access, keep data).
@@ -900,16 +943,8 @@ async def oauth_callback(request: Request):
</html>""" </html>"""
return Response(content=html, media_type="text/html", status_code=403) return Response(content=html, media_type="text/html", status_code=403)
# Provision the member # Provision the member (sends our own welcome email with the setup link).
user_id = await cloudron_create_user(email, name) await provision_member(email, name, slug)
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)
html = """<!DOCTYPE html> html = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -976,15 +1011,7 @@ async def admin_provision(token: str, request: Request):
if not email: if not email:
raise HTTPException(400, "Missing email") raise HTTPException(400, "Missing email")
user_id = await cloudron_create_user(email, name) user_id = await provision_member(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)
return {"status": "provisioned", "email": email, "user_id": user_id} return {"status": "provisioned", "email": email, "user_id": user_id}
@@ -1016,12 +1043,7 @@ async def reconcile_memberships() -> dict:
email = m["email"] email = m["email"]
if email not in provisioned: if email not in provisioned:
try: try:
user_id = await cloudron_create_user(email, m["name"]) await provision_member(email, m["name"], m["slug"])
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 provisioned_count += 1
logger.info("Sweep: provisioned %s", email) logger.info("Sweep: provisioned %s", email)
except Exception as e: except Exception as e: