"""v0.16.0 / §6 / §10 — owner-only invite for per-RFC PR or PR-less discussion (roadmap item #12). The RFC's owner can invite a specific email to one of two per-RFC roles: * `contributor` — may open PRs against this RFC AND post in its discussion (PR-permission strictly includes discussion-permission). * `discussant` — may post in this RFC's PR-less discussion only. Non-invited users keep the v0.6.0 anonymous-read contract: they can read but cannot write/discuss the RFC. Reads are not narrowed by this item. Endpoints: * `POST /api/rfcs/{slug}/invitations` — owner: create + email * `GET /api/rfcs/{slug}/invitations` — owner: list pending/accepted * `POST /api/rfcs/{slug}/invitations/{id}/revoke` — owner: revoke * `GET /api/invitations/accept` — token lookup (signed-in user) * `POST /api/invitations/accept` — token redeem (signed-in user) The accept endpoints are deliberately platform-scoped (not nested under the RFC slug) because the user clicking the email link only has the token and may not even know the slug yet. The GET shape lets the frontend show a confirmation page ("RFC invited you to be a — accept?") before the POST commits the membership. Permission gates (composed with `require_contributor`): * Issue / list / revoke: `auth.can_invite_to_rfc` — RFC owner or platform admin/owner. * Accept: any platform-granted signed-in user; the gate is the token, not the role. The token also constrains which email the accept lands under — the accepting user's email must match the invitation's invitee_email (case-insensitive). This prevents an invited-but-not-the-account-holder situation from minting a collaborator row under the wrong identity. Email shape: a single plain-text body sent via the existing SMTP path (reuses `EmailConfig.from_env()` like `email_otc.py` does). No unsubscribe footer — the email is transactional and per-invite, not a recurring notification. No tracking pixel. Admin-page hook: when an accept lands and the user's `permission_state` is still `pending`, that signals to the admin's `/admin/users` queue that the user is here because they accepted a per-RFC invitation — informing (not deciding) the admin's platform-grant call. v0.16.0 surfaces this via additive columns on the existing `GET /api/admin/users` listing (see `api_admin.py`'s diff in the same release) — no new endpoint, no restructure. """ from __future__ import annotations import logging import secrets import smtplib from email.message import EmailMessage from email.utils import formataddr from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field from . import auth, db from .email import EmailConfig, _SENT log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Pydantic bodies # --------------------------------------------------------------------------- class CreateInvitationBody(BaseModel): """The owner picks an email and a role-in-RFC. No custom-message field — that belongs to item #16's platform-level invite surface, not here. We validate the email with a deliberately narrow pattern rather than `pydantic.EmailStr` to avoid pulling in `email-validator` as a dependency (and v0.7.0's OTC body does the same — see `OTCRequestBody`'s shape). The validation here is intentionally permissive: a local-part, an `@`, and a domain part with no whitespace. Operator-side typo catching is the job of the email transport; the framework only guards against obviously malformed input.""" invitee_email: str = Field(min_length=3, max_length=320, pattern=r"^[^\s@]+@[^\s@]+$") role_in_rfc: str = Field(pattern="^(contributor|discussant)$") class AcceptInvitationBody(BaseModel): token: str = Field(min_length=1, max_length=200) # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- # 30-day TTL matches the device-trust window the framework already # ships (v0.11.0). A pending invitation past this is rejected at the # accept endpoint regardless of the row's `status` column. INVITATION_TTL_DAYS = 30 # --------------------------------------------------------------------------- # Router # --------------------------------------------------------------------------- def make_router() -> APIRouter: router = APIRouter() # --------------------------------------------------------------- # POST /api/rfcs//invitations # The owner creates an invitation. The endpoint mints the token, # writes the row, and dispatches the email synchronously. A failure # to send the email does NOT roll back the row — the owner can # share the link directly out-of-band if SMTP is briefly down (the # `GET /api/rfcs//invitations` response carries the token # for that fallback). # --------------------------------------------------------------- @router.post("/api/rfcs/{slug}/invitations") async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) rfc = _require_rfc(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException( 403, "Only the RFC's owner can invite collaborators", ) return issue_invitation( slug=slug, inviter_user_id=viewer.user_id, inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner", invitee_email=body.invitee_email, role_in_rfc=body.role_in_rfc, rfc_title=rfc["title"], ) # --------------------------------------------------------------- # GET /api/rfcs//invitations # The owner's listing of every invitation on the RFC, regardless # of status. Carries the token (for the resend / re-share path). # --------------------------------------------------------------- @router.get("/api/rfcs/{slug}/invitations") async def list_invitations(slug: str, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) _require_rfc(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException( 403, "Only the RFC's owner can view invitations", ) rows = db.conn().execute( """ SELECT i.id, i.invitee_email, i.role_in_rfc, i.status, i.token, i.expires_at, i.created_at, i.accepted_at, i.inviter_user_id, i.accepted_by_user_id, u_inviter.display_name AS inviter_display, u_inviter.gitea_login AS inviter_login, u_accept.display_name AS accepted_by_display, u_accept.gitea_login AS accepted_by_login FROM rfc_invitations i LEFT JOIN users u_inviter ON u_inviter.id = i.inviter_user_id LEFT JOIN users u_accept ON u_accept.id = i.accepted_by_user_id WHERE i.rfc_slug = ? ORDER BY i.id DESC """, (slug,), ).fetchall() return { "items": [ { "id": r["id"], "invitee_email": r["invitee_email"], "role_in_rfc": r["role_in_rfc"], "status": _effective_status(r), "token": r["token"], "expires_at": r["expires_at"], "created_at": r["created_at"], "accepted_at": r["accepted_at"], "inviter_display": r["inviter_display"], "inviter_login": r["inviter_login"], "accepted_by_display": r["accepted_by_display"], "accepted_by_login": r["accepted_by_login"], } for r in rows ], } # --------------------------------------------------------------- # POST /api/rfcs//invitations//revoke # Revokes a pending invitation. Already-accepted invitations # cannot be "revoked" from this surface — the corresponding # collaborator-removal surface is a §19.2 candidate; v0.16.0 # only lifts the *pending* link. # --------------------------------------------------------------- @router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke") async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) _require_rfc(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException( 403, "Only the RFC's owner can revoke invitations", ) row = db.conn().execute( "SELECT id, status FROM rfc_invitations WHERE id = ? AND rfc_slug = ?", (invitation_id, slug), ).fetchone() if row is None: raise HTTPException(404, "Invitation not found") if row["status"] != "pending": raise HTTPException( 409, f"Invitation is {row['status']}; only pending invitations can be revoked", ) db.conn().execute( "UPDATE rfc_invitations SET status = 'revoked' WHERE id = ?", (invitation_id,), ) return {"ok": True, "id": invitation_id, "status": "revoked"} # --------------------------------------------------------------- # GET /api/invitations/accept?token=... # Lookup-only — returns what the invitation grants so the # frontend can render a confirmation page before the POST. The # token is required; no token, no peek. # --------------------------------------------------------------- @router.get("/api/invitations/accept") async def preview_invitation(token: str, request: Request) -> dict[str, Any]: viewer = auth.require_user(request) row = _lookup_invitation_by_token(token) if row is None: raise HTTPException(404, "Invitation not found") effective = _effective_status(row) rfc = db.conn().execute( "SELECT slug, title FROM cached_rfcs WHERE slug = ?", (row["rfc_slug"],), ).fetchone() return { "rfc_slug": row["rfc_slug"], "rfc_title": rfc["title"] if rfc else row["rfc_slug"], "role_in_rfc": row["role_in_rfc"], "status": effective, "invitee_email": row["invitee_email"], "email_matches_you": (viewer.email or "").strip().lower() == row["invitee_email"].strip().lower(), "expires_at": row["expires_at"], } # --------------------------------------------------------------- # POST /api/invitations/accept # The accept gesture: token → collaborator row. # # Requires: # * an authenticated user (no token-only acceptance — we want # the per-user audit trail), # * a valid (pending, non-expired, non-revoked) invitation, # * the accepting user's email matches invitee_email # (case-insensitive). # # On success the row's status flips to 'accepted' and a # rfc_collaborators row is inserted (or upgraded if the user # already had a lower role). Idempotent: re-accepting the same # already-accepted invitation reads as a 200 no-op with # `changed=false`. # --------------------------------------------------------------- @router.post("/api/invitations/accept") async def accept_invitation(body: AcceptInvitationBody, request: Request) -> dict[str, Any]: viewer = auth.require_user(request) row = _lookup_invitation_by_token(body.token) if row is None: raise HTTPException(404, "Invitation not found") effective = _effective_status(row) if effective == "revoked": raise HTTPException(409, "Invitation was revoked") if effective == "expired": raise HTTPException(409, "Invitation has expired") # Email match — case-insensitive. Empty viewer email cannot # accept (an OAuth-only user with no captured email shape). viewer_email = (viewer.email or "").strip().lower() invitee_email = row["invitee_email"].strip().lower() if not viewer_email or viewer_email != invitee_email: raise HTTPException( 403, "This invitation was sent to a different email; sign in with that address", ) if effective == "accepted": # Idempotent re-accept — surface the existing collaborator # row without writing anything new. collab = db.conn().execute( "SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?", (row["rfc_slug"], viewer.user_id), ).fetchone() return { "ok": True, "changed": False, "rfc_slug": row["rfc_slug"], "role_in_rfc": collab["role_in_rfc"] if collab else row["role_in_rfc"], } # First-time accept. Flip the invitation; upsert the # collaborator. We do the upsert with ON CONFLICT so a # user who already held a lower role gets upgraded, never # downgraded (the MAX-style precedence is contributor > # discussant; lower roles never overwrite higher). with db.tx() as c: c.execute( """ UPDATE rfc_invitations SET status = 'accepted', accepted_at = datetime('now'), accepted_by_user_id = ? WHERE id = ? """, (viewer.user_id, row["id"]), ) existing = c.execute( "SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?", (row["rfc_slug"], viewer.user_id), ).fetchone() target_role = _max_role( existing["role_in_rfc"] if existing else None, row["role_in_rfc"], ) if existing is None: c.execute( """ INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, invitation_id) VALUES (?, ?, ?, ?) """, (row["rfc_slug"], viewer.user_id, target_role, row["id"]), ) elif existing["role_in_rfc"] != target_role: c.execute( """ UPDATE rfc_collaborators SET role_in_rfc = ?, invitation_id = ? WHERE rfc_slug = ? AND user_id = ? """, (target_role, row["id"], row["rfc_slug"], viewer.user_id), ) return { "ok": True, "changed": True, "rfc_slug": row["rfc_slug"], "role_in_rfc": target_role, } return router # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _require_rfc(slug: str, viewer): """The invitation surface only operates on a known, non-withdrawn RFC. We refuse 404 on unknown and 409 on withdrawn — mirrors the discussion endpoints' `_require_rfc_readable` shape. The §22.5 visibility gate is subtractive: a gated project's entries 404 to non-members (§22.7).""" row = db.conn().execute( "SELECT slug, title, state, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,), ).fetchone() if row is None: raise HTTPException(404, "RFC not found") auth.require_project_readable(viewer, row["project_id"]) if row["state"] == "withdrawn": raise HTTPException(409, "RFC is withdrawn") return row def _lookup_invitation_by_token(token: str): return db.conn().execute( """ SELECT id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc, status, token, expires_at, created_at, accepted_at, accepted_by_user_id FROM rfc_invitations WHERE token = ? """, (token,), ).fetchone() def _effective_status(row) -> str: """The row's column status is the authoritative truth except for `expired` — that is derived from `expires_at` at read time so an unattended cron isn't required to flip rows. A revoked-then- expired row reads as `revoked` (the explicit gesture wins).""" column_status = row["status"] if column_status != "pending": return column_status # Compare via SQL so the comparison is in sqlite-time, matching the # `datetime('now')` insert. A simpler same-process comparison would # work too, but routing through the DB keeps the timezone handling # consistent with the inserts. is_past = db.conn().execute( "SELECT datetime(?) <= datetime('now') AS past", (row["expires_at"],), ).fetchone()["past"] return "expired" if is_past else "pending" def issue_invitation( *, slug: str, inviter_user_id: int, inviter_display: str, invitee_email: str, role_in_rfc: str, rfc_title: str, ) -> dict: """Mint + persist + email one ``rfc_invitations`` row. The single chokepoint for issuing an invitation: the owner's manual `POST /api/rfcs/{slug}/invitations` endpoint and roadmap #28 Part 3's accept path both route through here, so the dup-guard, token mint, insert, and transactional email stay identical. Refuses (409) re-inviting an email that already has a pending invitation on this RFC at the same role. A different-role re-invite is allowed (the discussant → contributor upgrade) — the new row supersedes the old in the listing's natural ordering, and acceptance of either picks up the corresponding role. Returns the new row's dict (including the raw token, for the owner's out-of-band share / the caller's record-keeping). A send failure logs and returns; the row stays so the owner can recover via the listing. """ invitee_email = invitee_email.strip() existing = db.conn().execute( """ SELECT id FROM rfc_invitations WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE AND role_in_rfc = ? AND status = 'pending' LIMIT 1 """, (slug, invitee_email, role_in_rfc), ).fetchone() if existing: raise HTTPException( 409, f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC", ) token = _mint_token() cur = db.conn().execute( """ INSERT INTO rfc_invitations (rfc_slug, inviter_user_id, invitee_email, role_in_rfc, token, expires_at) VALUES (?, ?, ?, ?, ?, datetime('now', ?)) """, (slug, inviter_user_id, invitee_email, role_in_rfc, token, f"+{INVITATION_TTL_DAYS} days"), ) invitation_id = cur.lastrowid _send_invitation_email( to_address=invitee_email, inviter_display=inviter_display, rfc_title=rfc_title, role_in_rfc=role_in_rfc, token=token, ) return { "id": invitation_id, "rfc_slug": slug, "invitee_email": invitee_email, "role_in_rfc": role_in_rfc, "status": "pending", "token": token, } def _mint_token() -> str: """A 256-bit URL-safe token. The token shape is opaque to the consumer; the email link encodes it as a query param.""" return secrets.token_urlsafe(32) def _max_role(existing: str | None, new: str) -> str: """contributor strictly dominates discussant. A re-accept that would lower the role is a no-op (the existing role survives).""" precedence = {"discussant": 0, "contributor": 1} if existing is None: return new if precedence.get(new, 0) > precedence.get(existing, 0): return new return existing # --------------------------------------------------------------------------- # Email dispatch — transactional, no preferences honored # --------------------------------------------------------------------------- def _send_invitation_email( *, to_address: str, inviter_display: str, rfc_title: str, role_in_rfc: str, token: str, ) -> bool: """Compose and send the invitation email. Like `email_otc.send_otc_email`, this writes its own envelope and reuses `EmailConfig.from_env()` for the SMTP plumbing. The `_SENT` buffer is appended either way so integration tests can assert on the outbound shape without a real SMTP server. Returns True on the happy path / dev fallback; False on SMTP failure. The caller does not roll back the invitation row on failure — the owner has the token in the create response and on the listing surface for an out-of-band share. """ cfg = EmailConfig.from_env() subject = f"{inviter_display} invited you to {rfc_title} on {cfg.from_name}" role_label = ( "open PRs against the RFC and join its discussion" if role_in_rfc == "contributor" else "join the RFC's discussion" ) link = f"{cfg.app_url}/invitations/accept?token={token}" body = ( f"{inviter_display} invited you to {rfc_title} on {cfg.from_name} as {role_in_rfc}.\n\n" f"This invitation lets you {role_label}.\n\n" f"Click to accept (you'll be asked to sign in first if you aren't already):\n\n" f" {link}\n\n" f"The invitation expires in {INVITATION_TTL_DAYS} days. If you weren't expecting\n" f"this, you can safely ignore the email.\n\n" f"---\n" f"{cfg.from_name} · {cfg.app_url}\n" ) envelope = { "to": to_address, "from": formataddr((cfg.from_name, cfg.from_address)), "subject": subject, "body": body, "kind": "rfc_invitation", } _SENT.append(envelope) if not cfg.enabled: log.info("invitation email disabled (EMAIL_ENABLED=0): to=%s", to_address) return True if not cfg.smtp_host: # Dev fallback — surface the link at INFO so the operator can # complete an accept flow without an SMTP relay. log.info( "invitation email (stdout fallback): to=%s rfc=%s role=%s link=%s", to_address, rfc_title, role_in_rfc, link, ) return True try: msg = EmailMessage() msg["From"] = envelope["from"] msg["To"] = to_address msg["Subject"] = subject msg.set_content(body) smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) try: if cfg.smtp_starttls: smtp.starttls() if cfg.smtp_user: smtp.login(cfg.smtp_user, cfg.smtp_password) smtp.send_message(msg) finally: smtp.quit() return True except Exception: log.exception("invitation email send failed: to=%s", to_address) return False