Files
ds-chat/backend/app/services/site_invite_service.py
T
ksmithandClaude Sonnet 5 1a05cf5515 Style outgoing emails instead of plain text (#68)
Every email went through one shared plain-text-only path. Redesigned
send_email/send_test_email around structured paragraphs + an optional
CTA button instead of one pre-formatted string, and render both a
proper styled HTML card (table-based, inline styles -- email clients
strip <style> blocks and don't support CSS variables) and a clean
plain-text fallback from the same input, sent as multipart/alternative.

The HTML is themed per recipient: an email to an existing user renders
in their own selected theme (dark/light/midnight/sunset, or their saved
custom palette), resolved server-side from User.theme/
active_custom_theme_id. Site invites have no account yet to read a
theme from, so they use the default DarkSingularity palette. All five
existing email triggers (site invite, room-added, password reset,
#66's DM notification, admin test email) updated to the new call shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 17:39:08 -06:00

115 lines
3.7 KiB
Python

import secrets
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import InviteStatus, SiteInvite, User
from app.schemas.user import UserCreate
from app.security import hash_token
from app.services.audit import record_audit_log
from app.services.auth_service import register_user
from app.services.email_service import send_email
class SiteInviteNotFoundError(Exception):
pass
class SiteInviteNotPendingError(Exception):
pass
class SiteInviteInvalidError(Exception):
pass
async def create_site_invite(
db: AsyncSession, actor: User, base_url: str, email: str
) -> SiteInvite:
raw_token = secrets.token_urlsafe(32)
invite_id = uuid.uuid4()
invite = SiteInvite(
id=invite_id, email=email, invited_by=actor.id, token_hash=hash_token(raw_token)
)
db.add(invite)
record_audit_log(db, actor, "user.invite", "invite", invite_id, {"email": email})
await db.commit()
await db.refresh(invite)
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
# No theme_user -- the invitee doesn't have an account yet, so there's
# no theme of theirs to use (#68). Default palette, same as any
# logged-out page.
await send_email(
db,
email,
"You're invited to join DS Chat",
[
f"You've been invited to join DS Chat by {actor.username}.",
"This link expires in 7 days.",
],
cta_label="Set up your account",
cta_url=signup_link,
)
return invite
async def list_site_invites(db: AsyncSession) -> list[SiteInvite]:
# Pending only (#61) -- the admin UI's only consumer of this list labels
# it "Pending invites" and had no way to drop a row once it was accepted
# or revoked, since the backend returned every invite ever sent forever.
# An accepted/revoked invite has nothing further to act on here; its
# history already lives in the audit log ("user.invite"/"invite.revoke").
result = await db.execute(
select(SiteInvite)
.where(SiteInvite.status == InviteStatus.pending)
.options(selectinload(SiteInvite.inviter))
.order_by(SiteInvite.created_at.desc())
)
return list(result.scalars().all())
async def revoke_site_invite(db: AsyncSession, actor: User, invite_id: uuid.UUID) -> SiteInvite:
invite = await db.get(SiteInvite, invite_id)
if invite is None:
raise SiteInviteNotFoundError()
if invite.status != InviteStatus.pending:
raise SiteInviteNotPendingError()
invite.status = InviteStatus.revoked
record_audit_log(db, actor, "invite.revoke", "invite", invite.id)
await db.commit()
await db.refresh(invite)
return invite
async def _get_pending_invite_by_token(db: AsyncSession, token: str) -> SiteInvite:
result = await db.execute(
select(SiteInvite).where(SiteInvite.token_hash == hash_token(token))
)
invite = result.scalar_one_or_none()
if invite is None or invite.status != InviteStatus.pending:
raise SiteInviteInvalidError()
if invite.expires_at <= datetime.now(timezone.utc):
raise SiteInviteInvalidError()
return invite
async def validate_signup_token(db: AsyncSession, token: str) -> SiteInvite:
return await _get_pending_invite_by_token(db, token)
async def complete_signup(db: AsyncSession, token: str, username: str, password: str) -> User:
invite = await _get_pending_invite_by_token(db, token)
user = await register_user(
db, UserCreate(username=username, email=invite.email, password=password)
)
invite.status = InviteStatus.accepted
await db.commit()
return user