diff --git a/app/main.py b/app/main.py
index b5bd702..3e16b4e 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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]:
Welcome, {name}
Thanks for joining the Inference Cooperative.
Finish setting up your account to start using private AI chat.
- Finish setup
- This takes about a minute. You'll then get an account-setup email from our platform.
+ Finish setup
+ This takes about a minute.
@@ -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):