Add email invitation system with SMTP configuration and invite token workflow

Implemented comprehensive email invitation functionality for new users. Added SMTP settings management in admin panel with configuration UI for host, port, credentials, sender details, and TLS options. Created invite token system with 48-hour expiration and single-use enforcement. Added mailer module with HTML email templates in German theme styling (radial gradient backgrounds, gold accents, bordered cards). Implemented invite
This commit is contained in:
2026-08-02 10:24:34 +02:00
parent ca4648b25a
commit d96b75ca82
12 changed files with 418 additions and 9 deletions
+63
View File
@@ -0,0 +1,63 @@
import html
import smtplib
import ssl
from email.message import EmailMessage
from email.utils import formataddr
from .models import AppSettings, User
def _settings_ready(settings: AppSettings | None) -> bool:
return bool(settings and settings.smtp_host and settings.smtp_from_email)
def send_html_email(settings: AppSettings, recipient: str, subject: str, body_html: str, body_text: str):
if not _settings_ready(settings):
raise ValueError("SMTP settings are incomplete")
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr((settings.smtp_from_name or "Cluedo HP", settings.smtp_from_email))
message["To"] = recipient
message.set_content(body_text)
message.add_alternative(body_html, subtype="html")
context = ssl.create_default_context()
with smtplib.SMTP(settings.smtp_host, settings.smtp_port or 587, timeout=20) as server:
server.ehlo()
if settings.smtp_use_tls:
server.starttls(context=context)
server.ehlo()
if settings.smtp_username:
server.login(settings.smtp_username, settings.smtp_password)
server.send_message(message)
def send_user_invite(settings: AppSettings, user: User, invite_url: str):
name = html.escape(user.display_name or user.email)
safe_url = html.escape(invite_url, quote=True)
from_name = html.escape(settings.smtp_from_name or "Cluedo HP")
body_html = f"""
<!doctype html>
<html><body style="margin:0;background:#0b0b0f;color:#f5efdc;font-family:Georgia,serif;">
<div style="padding:36px 16px;background:radial-gradient(circle at top,#312b24 0,#0b0b0f 58%);">
<div style="max-width:580px;margin:0 auto;border:1px solid #6f603f;border-radius:22px;overflow:hidden;background:#17161b;box-shadow:0 18px 55px rgba(0,0,0,.55);">
<div style="padding:28px 30px;text-align:center;background:linear-gradient(135deg,#29231c,#141319);border-bottom:1px solid #6f603f;">
<div style="font-size:13px;letter-spacing:3px;text-transform:uppercase;color:#cbb982;">{from_name}</div>
<div style="margin-top:10px;font-size:28px;font-weight:bold;color:#e9d8a6;">Eine Einladung wartet</div>
</div>
<div style="padding:30px;line-height:1.55;">
<div style="font-size:20px;color:#e9d8a6;">Hallo {name},</div>
<p>du wurdest eingeladen, dem digitalen Zauber-Detektiv-Notizbogen beizutreten.</p>
<p>Richte über den folgenden Button dein persönliches Passwort ein:</p>
<p style="text-align:center;margin:28px 0;"><a href="{safe_url}" style="display:inline-block;padding:14px 24px;border-radius:12px;background:#b69a5c;color:#171319;text-decoration:none;font-weight:bold;">Einladung annehmen</a></p>
<p style="font-size:13px;color:#b8ae98;">Der Link ist 48 Stunden gültig und kann nur einmal verwendet werden.</p>
<p style="font-size:12px;color:#8e8777;word-break:break-all;">{safe_url}</p>
</div>
<div style="padding:16px 30px;color:#8e8777;font-size:12px;border-top:1px solid #302c2b;text-align:center;">Deine Notizen bleiben privat.</div>
</div>
</div>
</body></html>
"""
body_text = f"Hallo {user.display_name or user.email},\n\nbitte richte dein Passwort ein: {invite_url}\n\nDer Link ist 48 Stunden gültig."
send_html_email(settings, user.email, "Deine Einladung zum Zauber-Detektiv-Notizbogen", body_html, body_text)
+2
View File
@@ -12,6 +12,7 @@ from .routes.auth import router as auth_router
from .routes.admin import router as admin_router
from .routes.games import router as games_router
from .routes.setup import router as setup_router
from .routes.invites import router as invites_router
app = FastAPI(title="Cluedo Sheet")
@@ -31,6 +32,7 @@ app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(games_router)
app.include_router(setup_router)
app.include_router(invites_router)
def _rand_join_code(n: int = 6) -> str:
+25
View File
@@ -41,6 +41,31 @@ class User(Base):
display_name: Mapped[str] = mapped_column(String, default="")
class AppSettings(Base):
__tablename__ = "app_settings"
id: Mapped[str] = mapped_column(String, primary_key=True, default="default")
smtp_host: Mapped[str] = mapped_column(String, default="")
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
smtp_username: Mapped[str] = mapped_column(String, default="")
smtp_password: Mapped[str] = mapped_column(String, default="")
smtp_from_email: Mapped[str] = mapped_column(String, default="")
smtp_from_name: Mapped[str] = mapped_column(String, default="Cluedo HP")
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
app_base_url: Mapped[str] = mapped_column(String, default="http://localhost:8081")
class InviteToken(Base):
__tablename__ = "invite_tokens"
__table_args__ = (UniqueConstraint("token_hash", name="uq_invite_token_hash"),)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
token_hash: Mapped[str] = mapped_column(String, index=True)
expires_at: Mapped[str] = mapped_column(DateTime(timezone=True))
used_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Game(Base):
__tablename__ = "games"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+111 -6
View File
@@ -1,7 +1,12 @@
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import User, Role
from ..mailer import send_html_email, send_user_invite
from ..models import AppSettings, InviteToken, User, Role
from ..security import hash_password, get_session_user_id
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -37,18 +42,47 @@ def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
password = data.get("password") or ""
display_name = (data.get("display_name") or "").strip()
if not email or not password:
raise HTTPException(400, "email/password required")
if not email or not display_name:
raise HTTPException(400, "name/email required")
if db.query(User).filter(User.email == email).first():
raise HTTPException(409, "email exists")
role = data.get("role") or Role.user.value
role = data.get("role")
if not role:
raise HTTPException(400, "role required")
if role not in (Role.admin.value, Role.user.value):
raise HTTPException(400, "invalid role")
settings = db.query(AppSettings).filter(AppSettings.id == "default").first() or AppSettings(id="default")
invite_sent = not password
raw_token = None
if invite_sent:
if not settings.smtp_host or not settings.smtp_from_email:
raise HTTPException(400, "configure SMTP settings before sending invites")
raw_token = secrets.token_urlsafe(32)
password = secrets.token_urlsafe(32)
u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name)
db.add(u); db.commit()
return {"ok": True, "id": u.id}
db.add(u)
db.flush()
if invite_sent:
invite = InviteToken(
user_id=u.id,
token_hash=hashlib.sha256(raw_token.encode()).hexdigest(),
expires_at=datetime.now(timezone.utc) + timedelta(hours=48),
)
db.add(invite)
invite_url = f"{(settings.app_base_url or 'http://localhost:8081').rstrip('/')}/invite/{raw_token}"
try:
send_user_invite(settings, u, invite_url)
except Exception as exc:
db.rollback()
raise HTTPException(502, f"invite email could not be sent: {exc}")
db.commit()
return {"ok": True, "id": u.id, "invite_sent": invite_sent}
@router.delete("/users/{user_id}")
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
@@ -106,3 +140,74 @@ def update_user(req: Request, user_id: str, data: dict, db: Session = Depends(ge
db.add(user)
db.commit()
return {"ok": True}
def get_settings(db: Session) -> AppSettings:
settings = db.query(AppSettings).filter(AppSettings.id == "default").first()
if not settings:
settings = AppSettings(id="default")
db.add(settings)
db.commit()
db.refresh(settings)
return settings
@router.get("/settings/smtp")
def read_smtp_settings(req: Request, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
return {
"smtp_host": settings.smtp_host,
"smtp_port": settings.smtp_port,
"smtp_username": settings.smtp_username,
"smtp_password_configured": bool(settings.smtp_password),
"smtp_from_email": settings.smtp_from_email,
"smtp_from_name": settings.smtp_from_name,
"smtp_use_tls": settings.smtp_use_tls,
"app_base_url": settings.app_base_url,
}
@router.patch("/settings/smtp")
def update_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
settings.smtp_host = (data.get("smtp_host") or "").strip()
try:
settings.smtp_port = int(data.get("smtp_port") or 587)
except (TypeError, ValueError):
raise HTTPException(400, "SMTP port must be a number")
settings.smtp_username = (data.get("smtp_username") or "").strip()
settings.smtp_from_email = (data.get("smtp_from_email") or "").strip()
settings.smtp_from_name = (data.get("smtp_from_name") or "Cluedo HP").strip()
settings.smtp_use_tls = bool(data.get("smtp_use_tls", True))
settings.app_base_url = (data.get("app_base_url") or "http://localhost:8081").strip().rstrip("/")
if "smtp_password" in data and data.get("smtp_password"):
settings.smtp_password = data["smtp_password"]
if settings.smtp_host and not settings.smtp_from_email:
raise HTTPException(400, "from email required when SMTP is configured")
db.add(settings)
db.commit()
return {"ok": True}
@router.post("/settings/smtp/test")
def test_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
recipient = (data.get("recipient") or "").strip()
if not recipient:
raise HTTPException(400, "recipient required")
try:
send_html_email(
settings,
recipient,
"SMTP-Test Cluedo HP",
"<div style='font-family:Georgia,serif;padding:24px;background:#17161b;color:#f5efdc'><h2 style='color:#e9d8a6'>SMTP funktioniert</h2><p>Der Einladungsversand ist bereit.</p></div>",
"SMTP funktioniert. Der Einladungsversand ist bereit.",
)
except Exception as exc:
raise HTTPException(502, f"SMTP test failed: {exc}")
return {"ok": True}
+47
View File
@@ -0,0 +1,47 @@
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import InviteToken, User
from ..security import hash_password, make_session_value, set_session
router = APIRouter(prefix="/auth/invite", tags=["invites"])
def get_invite(token: str, db: Session):
token_hash = hashlib.sha256(token.encode()).hexdigest()
invite = db.query(InviteToken).filter(InviteToken.token_hash == token_hash).first()
expires_at = invite.expires_at if invite else None
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if not invite or invite.used_at or expires_at <= datetime.now(timezone.utc):
raise HTTPException(410, "invite is invalid or expired")
user = db.query(User).filter(User.id == invite.user_id, User.disabled == False).first()
if not user:
raise HTTPException(410, "invite is invalid or expired")
return invite, user
@router.get("/{token}")
def invite_info(token: str, db: Session = Depends(get_db)):
_invite, user = get_invite(token, db)
return {"valid": True, "display_name": user.display_name, "email": user.email, "role": user.role}
@router.post("/{token}")
def accept_invite(token: str, data: dict, resp: Response, db: Session = Depends(get_db)):
invite, user = get_invite(token, db)
password = data.get("password") or ""
if len(password) < 8:
raise HTTPException(400, "password too short (min 8)")
user.password_hash = hash_password(password)
invite.used_at = datetime.now(timezone.utc)
db.add(user)
db.add(invite)
db.commit()
set_session(resp, make_session_value(user.id))
return {"ok": True}