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): """ 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 = """ @@ -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: