"""Invite a friend (app/identity/invitations.py): what the sidebar entry and its dialog need — how many invitations are left, and sending one.""" from __future__ import annotations import datetime as dt import logging import re from typing import Annotated from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import raise_api_error from app.db import get_session from app.identity import invitations from app.identity.deps import CurrentUser, require_csrf from app.identity.mailer import MailerError, mailer_config, send_mail from app.identity.service import app_settings from app.models import Invitation logger = logging.getLogger(__name__) router = APIRouter(prefix="/invitations", tags=["invitations"]) DbSession = Annotated[AsyncSession, Depends(get_session)] _EMAIL_RE = re.compile(r"[^@\s<>\"]+@[^@\s<>\"]+\.[^@\s<>\"]+") class SentInvitationOut(BaseModel): invitee_name: str invitee_email: str created_at: str used: bool expired: bool class InvitationsOut(BaseModel): # Invitations are switched on and system mail can send them. available: bool remaining: int per_user: int sent: list[SentInvitationOut] class InvitationIn(BaseModel): name: str = Field(min_length=1, max_length=100) email: str = Field(min_length=3, max_length=320) @router.get("", response_model=InvitationsOut) async def read(user: CurrentUser, db: DbSession) -> InvitationsOut: row = await app_settings(db) now = dt.datetime.now(dt.UTC) return InvitationsOut( available=row.invitations_enabled and mailer_config(row) is not None, remaining=await invitations.remaining(db, user.id), per_user=invitations.PER_USER, sent=[ SentInvitationOut( invitee_name=i.invitee_name, invitee_email=i.invitee_email, created_at=i.created_at.isoformat(), used=i.used_at is not None, expired=i.used_at is None and i.expires_at <= now, ) for i in await invitations.sent(db, user.id) ], ) @router.post("", response_model=InvitationsOut, dependencies=[Depends(require_csrf)]) async def send(body: InvitationIn, user: CurrentUser, db: DbSession) -> InvitationsOut: """Email a friend a single-use sign-up link. Sent before answering, so the user hears at once if it could not go out (and keeps the invitation).""" name = " ".join(body.name.split()) email = body.email.strip() if not name: raise_api_error(400, "invalid_name", "enter your friend's name") if not _EMAIL_RE.fullmatch(email): raise_api_error(400, "invalid_email", "enter a valid email address") row = await app_settings(db) if not row.invitations_enabled: raise_api_error(403, "invitations_disabled", "invitations are switched off") cfg = mailer_config(row) if cfg is None: raise_api_error(503, "mail_not_configured", "the server cannot send email yet") try: invitation, token = await invitations.create(db, user, name=name, email=email) except invitations.InvitationError as exc: raise_api_error(409, exc.code, exc.message) await db.commit() # The token rides in the fragment: never sent to a server, so it stays out # of access logs and Referer headers (as with password-reset links). link = f"{cfg.public_url}/register#invite={token}" mail = invitations.render( row, invitee_name=name, inviter_name=await invitations.inviter_name(db, user), link=link, ) try: await send_mail( cfg, email, mail.subject, mail.text, mail.html, sender=invitations.sender_address(row), reply_to=user.handle, # an answer reaches the friend who invited inline=mail.inline, ) except MailerError as exc: logger.warning("invitation from %s could not be sent: %s", user.handle, exc) # Not sent: the user keeps the invitation. await db.execute(delete(Invitation).where(Invitation.id == invitation.id)) await db.commit() raise_api_error(502, "mail_failed", "the invitation could not be sent — try again later") return await read(user, db)