Provision on webhook (firstPayment) with own welcome email + invite_link; drop OAuth dependency
This commit is contained in:
1 parent
14e838a798
commit
eaeda98bac
1 file changed
+64
-42
+64
-42
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user