From 3d258e9a2dcd38380ffcd3efa551077fe43d5352 Mon Sep 17 00:00:00 2001 From: Inferencebot Date: Wed, 7 Oct 2026 09:19:24 -0600 Subject: [PATCH] Loomio sync: only invite activated members (avoid invite email before account setup) --- app/main.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/app/main.py b/app/main.py index 2b7784e..0daafbc 100644 --- a/app/main.py +++ b/app/main.py @@ -976,6 +976,14 @@ async def sync_loomio_memberships() -> None: Uses POST /api/b2/memberships with remove_absent=1, which adds new members and removes anyone not in the list. Operator accounts (LOOMIO_ALWAYS_KEEP) are always included so they are never removed. + + Only members whose Cloudron account is already set up (inviteAccepted) are + invited. Loomio's b2/memberships endpoint has no "silent invite" flag — it + unconditionally emails a membership_created notification — so inviting a + not-yet-activated member sends them a Loomio invite email before they've + even created their account. Filtering to activated members prevents that; + unactivated members are simply picked up on a later sync once they finish + account setup. """ if not LOOMIO_API_KEY or not LOOMIO_GROUP_ID: logger.warning("Loomio not configured; skipping membership sync") @@ -987,6 +995,12 @@ async def sync_loomio_memberships() -> None: if keep not in emails: emails.append(keep) + # Only invite members who have completed Cloudron account setup. The + # operator accounts (LOOMIO_ALWAYS_KEEP) bypass this filter — they must + # stay in the group regardless of inviteAccepted (the bootstrap owner + # account shows inviteAccepted: false forever even though it's functional). + emails = await filter_loomio_activated(emails) + async with httpx.AsyncClient() as client: r = await client.post( f"{LOOMIO_BASE}/api/b2/memberships", @@ -1014,6 +1028,35 @@ async def sync_loomio_memberships() -> None: logger.warning("Loomio username fix pass failed: %s", e) +async def filter_loomio_activated(emails: list[str]) -> list[str]: + """Return the subset of `emails` whose Cloudron account is set up, plus any + operator accounts (LOOMIO_ALWAYS_KEEP), which must always pass through. + + Fetches the Cloudron user list once and builds an inviteAccepted set keyed + by email. Members who are provisioned but not yet activated are dropped so + Loomio doesn't email them an invite before they've set up their account. + """ + activated: set[str] = set() + try: + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.get( + f"{CLOUDRON_API}/api/v1/users?per_page=100", + headers=cloudron_headers(), + ) + r.raise_for_status() + for u in r.json().get("users", []): + if u.get("inviteAccepted") and u.get("email"): + activated.add(u["email"]) + except Exception as e: + # If we can't determine activation state, fail open (invite everyone) + # rather than silently dropping members from Loomio. + logger.warning("filter_loomio_activated: cloudron users fetch failed: %s", e) + return emails + + keep = set(LOOMIO_ALWAYS_KEEP) + return [e for e in emails if e in keep or e in activated] + + def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None: conn = get_db() # Preserve an existing slug if the caller passed none — re-provisioning an