From 12d3777e760c8ed124785b8d673b2ecd41bf946f Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Sun, 1 Mar 2026 15:44:46 +1300 Subject: [PATCH] Add invite email templates and delivery workflow --- backend/app/build_info.py | 2 +- backend/app/db.py | 28 +- backend/app/routers/admin.py | 184 +++++++++- backend/app/routers/auth.py | 79 +++- backend/app/services/invite_email.py | 463 +++++++++++++++++++++++ frontend/app/admin/invites/page.tsx | 525 ++++++++++++++++++++++++++- frontend/app/globals.css | 42 +++ frontend/app/profile/page.tsx | 77 +++- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- 10 files changed, 1383 insertions(+), 23 deletions(-) create mode 100644 backend/app/services/invite_email.py diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 91d32d5..eb45758 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,4 +1,4 @@ -BUILD_NUMBER = "2802262051" +BUILD_NUMBER = "0103261543" CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Seerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Seerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container' diff --git a/backend/app/db.py b/backend/app/db.py index c4f18d9..d458487 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -210,6 +210,7 @@ def init_db() -> None: use_count INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, expires_at TEXT, + recipient_email TEXT, created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -362,6 +363,10 @@ def init_db() -> None: conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT") except sqlite3.OperationalError: pass + try: + conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT") + except sqlite3.OperationalError: + pass try: conn.execute( """ @@ -1063,9 +1068,10 @@ def _row_to_signup_invite(row: Any) -> Dict[str, Any]: "use_count": use_count, "enabled": bool(row[8]), "expires_at": expires_at, - "created_by": row[10], - "created_at": row[11], - "updated_at": row[12], + "recipient_email": row[10], + "created_by": row[11], + "created_at": row[12], + "updated_at": row[13], "is_expired": is_expired, "remaining_uses": remaining_uses, "is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0), @@ -1077,7 +1083,7 @@ def list_signup_invites() -> list[Dict[str, Any]]: rows = conn.execute( """ SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, - expires_at, created_by, created_at, updated_at + expires_at, recipient_email, created_by, created_at, updated_at FROM signup_invites ORDER BY created_at DESC, id DESC """ @@ -1090,7 +1096,7 @@ def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]: row = conn.execute( """ SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, - expires_at, created_by, created_at, updated_at + expires_at, recipient_email, created_by, created_at, updated_at FROM signup_invites WHERE id = ? """, @@ -1106,7 +1112,7 @@ def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]: row = conn.execute( """ SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, - expires_at, created_by, created_at, updated_at + expires_at, recipient_email, created_by, created_at, updated_at FROM signup_invites WHERE code = ? COLLATE NOCASE """, @@ -1127,6 +1133,7 @@ def create_signup_invite( max_uses: Optional[int] = None, enabled: bool = True, expires_at: Optional[str] = None, + recipient_email: Optional[str] = None, created_by: Optional[str] = None, ) -> Dict[str, Any]: timestamp = datetime.now(timezone.utc).isoformat() @@ -1135,9 +1142,9 @@ def create_signup_invite( """ INSERT INTO signup_invites ( code, label, description, profile_id, role, max_uses, use_count, enabled, - expires_at, created_by, created_at, updated_at + expires_at, recipient_email, created_by, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) """, ( code, @@ -1148,6 +1155,7 @@ def create_signup_invite( max_uses, 1 if enabled else 0, expires_at, + recipient_email, created_by, timestamp, timestamp, @@ -1171,6 +1179,7 @@ def update_signup_invite( max_uses: Optional[int], enabled: bool, expires_at: Optional[str], + recipient_email: Optional[str], ) -> Optional[Dict[str, Any]]: timestamp = datetime.now(timezone.utc).isoformat() with _connect() as conn: @@ -1178,7 +1187,7 @@ def update_signup_invite( """ UPDATE signup_invites SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?, - enabled = ?, expires_at = ?, updated_at = ? + enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ? WHERE id = ? """, ( @@ -1190,6 +1199,7 @@ def update_signup_invite( max_uses, 1 if enabled else 0, expires_at, + recipient_email, timestamp, invite_id, ), diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index bc0e4e4..9912a1a 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -79,6 +79,14 @@ from ..services.user_cache import ( save_jellyseerr_users_cache, clear_user_import_caches, ) +from ..services.invite_email import ( + TEMPLATE_KEYS as INVITE_EMAIL_TEMPLATE_KEYS, + get_invite_email_templates, + reset_invite_email_template, + save_invite_email_template, + send_templated_email, + smtp_email_config_ready, +) import logging from ..logging_config import configure_logging from ..routers import requests as requests_router @@ -240,10 +248,20 @@ def _user_inviter_details(user: Optional[Dict[str, Any]]) -> Optional[Dict[str, "created_at": invite.get("created_at"), "enabled": invite.get("enabled"), "is_usable": invite.get("is_usable"), + "recipient_email": invite.get("recipient_email"), }, } +def _resolve_user_invite(user: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not user: + return None + invite_code = user.get("invited_by_code") + if not isinstance(invite_code, str) or not invite_code.strip(): + return None + return get_signup_invite_by_code(invite_code.strip()) + + def _build_invite_trace_payload() -> Dict[str, Any]: users = get_all_users() invites = list_signup_invites() @@ -1077,6 +1095,7 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str "jellyfin": {"status": "skipped", "detail": "Jellyfin not configured"}, "jellyseerr": {"status": "skipped", "detail": "Seerr not configured or no linked user ID"}, "invites": {"status": "pending", "disabled": 0}, + "email": {"status": "skipped", "detail": "No email action required"}, } if action == "ban": @@ -1093,6 +1112,19 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str else: result["invites"] = {"status": "ok", "disabled": 0} + if action in {"ban", "remove"}: + try: + invite = _resolve_user_invite(user) + email_result = await send_templated_email( + "banned", + invite=invite, + user=user, + reason="Account banned" if action == "ban" else "Account removed", + ) + result["email"] = {"status": "ok", **email_result} + except Exception as exc: + result["email"] = {"status": "error", "detail": str(exc)} + if jellyfin.configured(): try: jellyfin_user = await jellyfin.find_user_by_name(username) @@ -1138,7 +1170,7 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str if any( isinstance(system, dict) and system.get("status") == "error" - for system in (result.get("jellyfin"), result.get("jellyseerr")) + for system in (result.get("jellyfin"), result.get("jellyseerr"), result.get("email")) ): result["status"] = "partial" return result @@ -1549,6 +1581,99 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]: } +@router.get("/invites/email/templates") +async def get_invite_email_template_settings() -> Dict[str, Any]: + ready, detail = smtp_email_config_ready() + return { + "status": "ok", + "email": { + "configured": ready, + "detail": detail, + }, + "templates": list(get_invite_email_templates().values()), + } + + +@router.put("/invites/email/templates/{template_key}") +async def update_invite_email_template_settings(template_key: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if template_key not in INVITE_EMAIL_TEMPLATE_KEYS: + raise HTTPException(status_code=404, detail="Email template not found") + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + subject = _normalize_optional_text(payload.get("subject")) + body_text = _normalize_optional_text(payload.get("body_text")) + body_html = _normalize_optional_text(payload.get("body_html")) + if not subject: + raise HTTPException(status_code=400, detail="subject is required") + if not body_text and not body_html: + raise HTTPException(status_code=400, detail="At least one email body is required") + template = save_invite_email_template( + template_key, + subject=subject, + body_text=body_text or "", + body_html=body_html or "", + ) + return {"status": "ok", "template": template} + + +@router.delete("/invites/email/templates/{template_key}") +async def reset_invite_email_template_settings(template_key: str) -> Dict[str, Any]: + if template_key not in INVITE_EMAIL_TEMPLATE_KEYS: + raise HTTPException(status_code=404, detail="Email template not found") + template = reset_invite_email_template(template_key) + return {"status": "ok", "template": template} + + +@router.post("/invites/email/send") +async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + template_key = str(payload.get("template_key") or "").strip().lower() + if template_key not in INVITE_EMAIL_TEMPLATE_KEYS: + raise HTTPException(status_code=400, detail="template_key is invalid") + + invite: Optional[Dict[str, Any]] = None + invite_id = payload.get("invite_id") + if invite_id not in (None, ""): + try: + invite = get_signup_invite_by_id(int(invite_id)) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="invite_id must be a number") from exc + if not invite: + raise HTTPException(status_code=404, detail="Invite not found") + + user: Optional[Dict[str, Any]] = None + username = _normalize_optional_text(payload.get("username")) + if username: + user = get_user_by_username(username) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if invite is None: + invite = _resolve_user_invite(user) + + recipient_email = _normalize_optional_text(payload.get("recipient_email")) + message = _normalize_optional_text(payload.get("message")) + reason = _normalize_optional_text(payload.get("reason")) + + try: + result = await send_templated_email( + template_key, + invite=invite, + user=user, + recipient_email=recipient_email, + message=message, + reason=reason, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + return { + "status": "ok", + "template_key": template_key, + **result, + } + + @router.get("/invites/trace") async def get_invite_trace() -> Dict[str, Any]: return {"status": "ok", "trace": _build_invite_trace_payload()} @@ -1569,6 +1694,9 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = role = _normalize_role_or_none(payload.get("role")) max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") expires_at = _parse_optional_expires_at(payload.get("expires_at")) + recipient_email = _normalize_optional_text(payload.get("recipient_email")) + send_email = bool(payload.get("send_email")) + delivery_message = _normalize_optional_text(payload.get("message")) try: invite = create_signup_invite( code=code, @@ -1579,11 +1707,35 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = max_uses=max_uses, enabled=enabled, expires_at=expires_at, + recipient_email=recipient_email, created_by=current_user.get("username"), ) except sqlite3.IntegrityError as exc: raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc - return {"status": "ok", "invite": invite} + email_result = None + email_error = None + if send_email: + try: + email_result = await send_templated_email( + "invited", + invite=invite, + user=current_user, + recipient_email=recipient_email, + message=delivery_message, + ) + except Exception as exc: + email_error = str(exc) + return { + "status": "partial" if email_error else "ok", + "invite": invite, + "email": ( + {"status": "ok", **email_result} + if email_result + else {"status": "error", "detail": email_error} + if email_error + else None + ), + } @router.put("/invites/{invite_id}") @@ -1601,6 +1753,9 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any] role = _normalize_role_or_none(payload.get("role")) max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") expires_at = _parse_optional_expires_at(payload.get("expires_at")) + recipient_email = _normalize_optional_text(payload.get("recipient_email")) + send_email = bool(payload.get("send_email")) + delivery_message = _normalize_optional_text(payload.get("message")) try: invite = update_signup_invite( invite_id, @@ -1612,12 +1767,35 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any] max_uses=max_uses, enabled=enabled, expires_at=expires_at, + recipient_email=recipient_email, ) except sqlite3.IntegrityError as exc: raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc if not invite: raise HTTPException(status_code=404, detail="Invite not found") - return {"status": "ok", "invite": invite} + email_result = None + email_error = None + if send_email: + try: + email_result = await send_templated_email( + "invited", + invite=invite, + recipient_email=recipient_email, + message=delivery_message, + ) + except Exception as exc: + email_error = str(exc) + return { + "status": "partial" if email_error else "ok", + "invite": invite, + "email": ( + {"status": "ok", **email_result} + if email_result + else {"status": "error", "detail": email_error} + if email_error + else None + ), + } @router.delete("/invites/{invite_id}") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 7b3e2ca..22a0b11 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone from collections import defaultdict, deque +import logging import secrets import string import time @@ -48,8 +49,10 @@ from ..services.user_cache import ( match_jellyseerr_user_id, save_jellyfin_users_cache, ) +from ..services.invite_email import send_templated_email router = APIRouter(prefix="/auth", tags=["auth"]) +logger = logging.getLogger(__name__) SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" STREAM_TOKEN_TTL_SECONDS = 120 @@ -356,6 +359,7 @@ def _serialize_self_invite(invite: dict) -> dict: "remaining_uses": invite.get("remaining_uses"), "enabled": bool(invite.get("enabled")), "expires_at": invite.get("expires_at"), + "recipient_email": invite.get("recipient_email"), "is_expired": bool(invite.get("is_expired")), "is_usable": bool(invite.get("is_usable")), "created_at": invite.get("created_at"), @@ -427,6 +431,7 @@ def _serialize_self_service_master_invite(invite: dict | None) -> dict | None: "label": invite.get("label"), "description": invite.get("description"), "profile_id": invite.get("profile_id"), + "recipient_email": invite.get("recipient_email"), "profile": ( {"id": profile.get("id"), "name": profile.get("name")} if isinstance(profile, dict) @@ -770,6 +775,16 @@ async def signup(payload: dict) -> dict: ): set_user_jellyseerr_id(username, matched_jellyseerr_user_id) created_user = get_user_by_username(username) + if created_user: + try: + await send_templated_email( + "welcome", + invite=invite, + user=created_user, + ) + except Exception as exc: + # Welcome email delivery is best-effort and must not break signup. + logger.warning("Welcome email send skipped for %s: %s", username, exc) _assert_user_can_login(created_user) token = create_access_token(username, role) set_last_login(username) @@ -858,10 +873,15 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_ label = payload.get("label") description = payload.get("description") + recipient_email = payload.get("recipient_email") if label is not None: label = str(label).strip() or None if description is not None: description = str(description).strip() or None + if recipient_email is not None: + recipient_email = str(recipient_email).strip() or None + send_email = bool(payload.get("send_email")) + delivery_message = str(payload.get("message") or "").strip() or None master_invite = _get_self_service_master_invite() if master_invite: @@ -892,9 +912,34 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_ max_uses=max_uses, enabled=enabled, expires_at=expires_at, + recipient_email=recipient_email, created_by=username, ) - return {"status": "ok", "invite": _serialize_self_invite(invite)} + email_result = None + email_error = None + if send_email: + try: + email_result = await send_templated_email( + "invited", + invite=invite, + user=current_user, + recipient_email=recipient_email, + message=delivery_message, + ) + except Exception as exc: + email_error = str(exc) + status_value = "partial" if email_error else "ok" + return { + "status": status_value, + "invite": _serialize_self_invite(invite), + "email": ( + {"status": "ok", **email_result} + if email_result + else {"status": "error", "detail": email_error} + if email_error + else None + ), + } @router.put("/profile/invites/{invite_id}") @@ -919,10 +964,15 @@ async def update_profile_invite( label = payload.get("label", existing.get("label")) description = payload.get("description", existing.get("description")) + recipient_email = payload.get("recipient_email", existing.get("recipient_email")) if label is not None: label = str(label).strip() or None if description is not None: description = str(description).strip() or None + if recipient_email is not None: + recipient_email = str(recipient_email).strip() or None + send_email = bool(payload.get("send_email")) + delivery_message = str(payload.get("message") or "").strip() or None master_invite = _get_self_service_master_invite() if master_invite: @@ -948,10 +998,35 @@ async def update_profile_invite( max_uses=max_uses, enabled=enabled, expires_at=expires_at, + recipient_email=recipient_email, ) if not invite: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") - return {"status": "ok", "invite": _serialize_self_invite(invite)} + email_result = None + email_error = None + if send_email: + try: + email_result = await send_templated_email( + "invited", + invite=invite, + user=current_user, + recipient_email=recipient_email, + message=delivery_message, + ) + except Exception as exc: + email_error = str(exc) + status_value = "partial" if email_error else "ok" + return { + "status": status_value, + "invite": _serialize_self_invite(invite), + "email": ( + {"status": "ok", **email_result} + if email_result + else {"status": "error", "detail": email_error} + if email_error + else None + ), + } @router.delete("/profile/invites/{invite_id}") diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py new file mode 100644 index 0000000..8c907e4 --- /dev/null +++ b/backend/app/services/invite_email.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import asyncio +import html +import json +import logging +import re +import smtplib +from email.message import EmailMessage +from email.utils import formataddr +from typing import Any, Dict, Optional + +from ..build_info import BUILD_NUMBER +from ..config import settings as env_settings +from ..db import delete_setting, get_setting, set_setting +from ..runtime import get_runtime_settings + +logger = logging.getLogger(__name__) + +TEMPLATE_SETTING_PREFIX = "invite_email_template_" +TEMPLATE_KEYS = ("invited", "welcome", "warning", "banned") +EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +PLACEHOLDER_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") + +TEMPLATE_METADATA: Dict[str, Dict[str, Any]] = { + "invited": { + "label": "You have been invited", + "description": "Sent when an invite link is created and emailed to a recipient.", + }, + "welcome": { + "label": "Welcome / How it works", + "description": "Sent after an invited user completes signup.", + }, + "warning": { + "label": "Warning", + "description": "Manual warning template for account or behavior notices.", + }, + "banned": { + "label": "Banned", + "description": "Sent when an account is banned or removed.", + }, +} + +TEMPLATE_PLACEHOLDERS = [ + "app_name", + "app_url", + "build_number", + "how_it_works_url", + "invite_code", + "invite_description", + "invite_expires_at", + "invite_label", + "invite_link", + "invite_remaining_uses", + "inviter_username", + "message", + "reason", + "recipient_email", + "role", + "username", +] + +DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = { + "invited": { + "subject": "{{app_name}} invite for {{recipient_email}}", + "body_text": ( + "You have been invited to {{app_name}}.\n\n" + "Invite code: {{invite_code}}\n" + "Signup link: {{invite_link}}\n" + "Invited by: {{inviter_username}}\n" + "Invite label: {{invite_label}}\n" + "Expires: {{invite_expires_at}}\n" + "Remaining uses: {{invite_remaining_uses}}\n\n" + "{{invite_description}}\n\n" + "{{message}}\n\n" + "How it works: {{how_it_works_url}}\n" + "Build: {{build_number}}\n" + ), + "body_html": ( + "

You have been invited

" + "

You have been invited to {{app_name}}.

" + "

Invite code: {{invite_code}}
" + "Invited by: {{inviter_username}}
" + "Invite label: {{invite_label}}
" + "Expires: {{invite_expires_at}}
" + "Remaining uses: {{invite_remaining_uses}}

" + "

{{invite_description}}

" + "

{{message}}

" + "

Accept invite and create account

" + "

How it works

" + "

Build {{build_number}}

" + ), + }, + "welcome": { + "subject": "Welcome to {{app_name}}", + "body_text": ( + "Welcome to {{app_name}}, {{username}}.\n\n" + "Your account is ready.\n" + "Open: {{app_url}}\n" + "How it works: {{how_it_works_url}}\n" + "Role: {{role}}\n\n" + "{{message}}\n" + ), + "body_html": ( + "

Welcome

" + "

Your {{app_name}} account is ready, {{username}}.

" + "

Role: {{role}}

" + "

Open {{app_name}}
" + "Read how it works

" + "

{{message}}

" + ), + }, + "warning": { + "subject": "{{app_name}} account warning", + "body_text": ( + "Hello {{username}},\n\n" + "This is a warning regarding your {{app_name}} account.\n\n" + "Reason: {{reason}}\n\n" + "{{message}}\n\n" + "If you need help, contact the admin.\n" + ), + "body_html": ( + "

Account warning

" + "

Hello {{username}},

" + "

This is a warning regarding your {{app_name}} account.

" + "

Reason: {{reason}}

" + "

{{message}}

" + "

If you need help, contact the admin.

" + ), + }, + "banned": { + "subject": "{{app_name}} account status changed", + "body_text": ( + "Hello {{username}},\n\n" + "Your {{app_name}} account has been banned or removed.\n\n" + "Reason: {{reason}}\n\n" + "{{message}}\n" + ), + "body_html": ( + "

Account status changed

" + "

Hello {{username}},

" + "

Your {{app_name}} account has been banned or removed.

" + "

Reason: {{reason}}

" + "

{{message}}

" + ), + }, +} + + +def _template_setting_key(template_key: str) -> str: + return f"{TEMPLATE_SETTING_PREFIX}{template_key}" + + +def _is_valid_email(value: object) -> bool: + if not isinstance(value, str): + return False + candidate = value.strip() + if not candidate: + return False + return bool(EMAIL_PATTERN.match(candidate)) + + +def _normalize_email(value: object) -> Optional[str]: + if not _is_valid_email(value): + return None + return str(value).strip() + + +def _normalize_display_text(value: object, fallback: str = "") -> str: + if value is None: + return fallback + if isinstance(value, str): + trimmed = value.strip() + return trimmed if trimmed else fallback + return str(value) + + +def _template_context_value(value: object, fallback: str = "") -> str: + if value is None: + return fallback + if isinstance(value, str): + return value.strip() + return str(value) + + +def _safe_template_context(context: Dict[str, object]) -> Dict[str, str]: + safe: Dict[str, str] = {} + for key in TEMPLATE_PLACEHOLDERS: + safe[key] = _template_context_value(context.get(key), "") + return safe + + +def _render_template_string(template: str, context: Dict[str, str], *, escape_html: bool = False) -> str: + if not isinstance(template, str): + return "" + + def _replace(match: re.Match[str]) -> str: + key = match.group(1) + value = context.get(key, "") + return html.escape(value) if escape_html else value + + return PLACEHOLDER_PATTERN.sub(_replace, template) + + +def _strip_html_for_text(value: str) -> str: + text = re.sub(r"", "\n", value, flags=re.IGNORECASE) + text = re.sub(r"

", "\n\n", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", "", text) + return html.unescape(text).strip() + + +def _build_default_base_url() -> str: + runtime = get_runtime_settings() + for candidate in ( + runtime.magent_application_url, + runtime.magent_proxy_base_url, + env_settings.cors_allow_origin, + ): + normalized = _normalize_display_text(candidate) + if normalized: + return normalized.rstrip("/") + port = int(getattr(runtime, "magent_application_port", 3000) or 3000) + return f"http://localhost:{port}" + + +def build_invite_email_context( + *, + invite: Optional[Dict[str, Any]] = None, + user: Optional[Dict[str, Any]] = None, + recipient_email: Optional[str] = None, + message: Optional[str] = None, + reason: Optional[str] = None, + overrides: Optional[Dict[str, object]] = None, +) -> Dict[str, str]: + app_url = _build_default_base_url() + invite_code = _normalize_display_text(invite.get("code") if invite else None, "Not set") + invite_link = f"{app_url}/signup?code={invite_code}" if invite_code != "Not set" else f"{app_url}/signup" + remaining_uses = invite.get("remaining_uses") if invite else None + resolved_recipient = _normalize_email(recipient_email) + if not resolved_recipient and invite: + resolved_recipient = _normalize_email(invite.get("recipient_email")) + if not resolved_recipient and user: + resolved_recipient = resolve_user_delivery_email(user) + + context: Dict[str, object] = { + "app_name": env_settings.app_name, + "app_url": app_url, + "build_number": BUILD_NUMBER, + "how_it_works_url": f"{app_url}/how-it-works", + "invite_code": invite_code, + "invite_description": _normalize_display_text(invite.get("description") if invite else None, "No extra details."), + "invite_expires_at": _normalize_display_text(invite.get("expires_at") if invite else None, "Never"), + "invite_label": _normalize_display_text(invite.get("label") if invite else None, "No label"), + "invite_link": invite_link, + "invite_remaining_uses": ( + "Unlimited" if remaining_uses in (None, "") else _normalize_display_text(remaining_uses) + ), + "inviter_username": _normalize_display_text( + invite.get("created_by") if invite else (user.get("username") if user else None), + "Admin", + ), + "message": _normalize_display_text(message, ""), + "reason": _normalize_display_text(reason, "Not specified"), + "recipient_email": _normalize_display_text(resolved_recipient, "No email supplied"), + "role": _normalize_display_text(user.get("role") if user else None, "user"), + "username": _normalize_display_text(user.get("username") if user else None, "there"), + } + if isinstance(overrides, dict): + context.update(overrides) + return _safe_template_context(context) + + +def get_invite_email_templates() -> Dict[str, Dict[str, Any]]: + templates: Dict[str, Dict[str, Any]] = {} + for template_key in TEMPLATE_KEYS: + template = dict(DEFAULT_TEMPLATES[template_key]) + raw_value = get_setting(_template_setting_key(template_key)) + if raw_value: + try: + stored = json.loads(raw_value) + except (TypeError, json.JSONDecodeError): + stored = {} + if isinstance(stored, dict): + for field in ("subject", "body_text", "body_html"): + if isinstance(stored.get(field), str): + template[field] = stored[field] + templates[template_key] = { + "key": template_key, + "label": TEMPLATE_METADATA[template_key]["label"], + "description": TEMPLATE_METADATA[template_key]["description"], + "placeholders": TEMPLATE_PLACEHOLDERS, + **template, + } + return templates + + +def get_invite_email_template(template_key: str) -> Dict[str, Any]: + if template_key not in TEMPLATE_KEYS: + raise ValueError(f"Unknown email template: {template_key}") + return get_invite_email_templates()[template_key] + + +def save_invite_email_template( + template_key: str, + *, + subject: str, + body_text: str, + body_html: str, +) -> Dict[str, Any]: + if template_key not in TEMPLATE_KEYS: + raise ValueError(f"Unknown email template: {template_key}") + payload = { + "subject": subject, + "body_text": body_text, + "body_html": body_html, + } + set_setting(_template_setting_key(template_key), json.dumps(payload)) + return get_invite_email_template(template_key) + + +def reset_invite_email_template(template_key: str) -> Dict[str, Any]: + if template_key not in TEMPLATE_KEYS: + raise ValueError(f"Unknown email template: {template_key}") + delete_setting(_template_setting_key(template_key)) + return get_invite_email_template(template_key) + + +def render_invite_email_template( + template_key: str, + *, + invite: Optional[Dict[str, Any]] = None, + user: Optional[Dict[str, Any]] = None, + recipient_email: Optional[str] = None, + message: Optional[str] = None, + reason: Optional[str] = None, + overrides: Optional[Dict[str, object]] = None, +) -> Dict[str, str]: + template = get_invite_email_template(template_key) + context = build_invite_email_context( + invite=invite, + user=user, + recipient_email=recipient_email, + message=message, + reason=reason, + overrides=overrides, + ) + body_html = _render_template_string(template["body_html"], context, escape_html=True) + body_text = _render_template_string(template["body_text"], context, escape_html=False) + if not body_text.strip() and body_html.strip(): + body_text = _strip_html_for_text(body_html) + subject = _render_template_string(template["subject"], context, escape_html=False) + return { + "subject": subject.strip(), + "body_text": body_text.strip(), + "body_html": body_html.strip(), + } + + +def resolve_user_delivery_email(user: Optional[Dict[str, Any]], invite: Optional[Dict[str, Any]] = None) -> Optional[str]: + if not isinstance(user, dict): + return _normalize_email(invite.get("recipient_email") if isinstance(invite, dict) else None) + username_email = _normalize_email(user.get("username")) + if username_email: + return username_email + if isinstance(invite, dict): + invite_email = _normalize_email(invite.get("recipient_email")) + if invite_email: + return invite_email + return None + + +def smtp_email_config_ready() -> tuple[bool, str]: + runtime = get_runtime_settings() + if not runtime.magent_notify_enabled: + return False, "Notifications are disabled." + if not runtime.magent_notify_email_enabled: + return False, "Email notifications are disabled." + if not _normalize_display_text(runtime.magent_notify_email_smtp_host): + return False, "SMTP host is not configured." + if not _normalize_email(runtime.magent_notify_email_from_address): + return False, "From email address is not configured." + return True, "ok" + + +def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body_html: str) -> None: + runtime = get_runtime_settings() + host = _normalize_display_text(runtime.magent_notify_email_smtp_host) + port = int(runtime.magent_notify_email_smtp_port or 587) + username = _normalize_display_text(runtime.magent_notify_email_smtp_username) + password = _normalize_display_text(runtime.magent_notify_email_smtp_password) + from_address = _normalize_email(runtime.magent_notify_email_from_address) + from_name = _normalize_display_text(runtime.magent_notify_email_from_name, env_settings.app_name) + use_tls = bool(runtime.magent_notify_email_use_tls) + use_ssl = bool(runtime.magent_notify_email_use_ssl) + if not host or not from_address: + raise RuntimeError("SMTP email settings are incomplete.") + + message = EmailMessage() + message["Subject"] = subject + message["From"] = formataddr((from_name, from_address)) + message["To"] = recipient_email + message.set_content(body_text or _strip_html_for_text(body_html)) + if body_html.strip(): + message.add_alternative(body_html, subtype="html") + + if use_ssl: + with smtplib.SMTP_SSL(host, port, timeout=20) as smtp: + if username and password: + smtp.login(username, password) + smtp.send_message(message) + return + + with smtplib.SMTP(host, port, timeout=20) as smtp: + smtp.ehlo() + if use_tls: + smtp.starttls() + smtp.ehlo() + if username and password: + smtp.login(username, password) + smtp.send_message(message) + + +async def send_templated_email( + template_key: str, + *, + invite: Optional[Dict[str, Any]] = None, + user: Optional[Dict[str, Any]] = None, + recipient_email: Optional[str] = None, + message: Optional[str] = None, + reason: Optional[str] = None, + overrides: Optional[Dict[str, object]] = None, +) -> Dict[str, str]: + ready, detail = smtp_email_config_ready() + if not ready: + raise RuntimeError(detail) + + resolved_email = _normalize_email(recipient_email) + if not resolved_email: + resolved_email = resolve_user_delivery_email(user, invite) + if not resolved_email: + raise RuntimeError("No valid recipient email is available for this action.") + + rendered = render_invite_email_template( + template_key, + invite=invite, + user=user, + recipient_email=resolved_email, + message=message, + reason=reason, + overrides=overrides, + ) + await asyncio.to_thread( + _send_email_sync, + recipient_email=resolved_email, + subject=rendered["subject"], + body_text=rendered["body_text"], + body_html=rendered["body_html"], + ) + logger.info("Email template sent: template=%s recipient=%s", template_key, resolved_email) + return { + "recipient_email": resolved_email, + "subject": rendered["subject"], + } diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index 324cbce..3182269 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -43,6 +43,7 @@ type Invite = { remaining_uses?: number | null enabled: boolean expires_at?: string | null + recipient_email?: string | null is_expired?: boolean is_usable?: boolean created_at?: string | null @@ -58,6 +59,9 @@ type InviteForm = { max_uses: string enabled: boolean expires_at: string + recipient_email: string + send_email: boolean + message: string } type ProfileForm = { @@ -69,10 +73,30 @@ type ProfileForm = { is_active: boolean } -type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' +type InviteEmailTemplateKey = 'invited' | 'welcome' | 'warning' | 'banned' +type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' | 'emails' type InviteTraceScope = 'all' | 'invited' | 'direct' type InviteTraceView = 'list' | 'graph' +type InviteEmailTemplate = { + key: InviteEmailTemplateKey + label: string + description: string + placeholders: string[] + subject: string + body_text: string + body_html: string +} + +type InviteEmailSendForm = { + template_key: InviteEmailTemplateKey + recipient_email: string + invite_id: string + username: string + message: string + reason: string +} + type InviteTraceRow = { username: string role: string @@ -102,6 +126,18 @@ const defaultInviteForm = (): InviteForm => ({ max_uses: '', enabled: true, expires_at: '', + recipient_email: '', + send_email: false, + message: '', +}) + +const defaultInviteEmailSendForm = (): InviteEmailSendForm => ({ + template_key: 'invited', + recipient_email: '', + invite_id: '', + username: '', + message: '', + reason: '', }) const defaultProfileForm = (): ProfileForm => ({ @@ -137,6 +173,9 @@ export default function AdminInviteManagementPage() { const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false) const [bulkInviteAccessBusy, setBulkInviteAccessBusy] = useState(false) const [invitePolicySaving, setInvitePolicySaving] = useState(false) + const [templateSaving, setTemplateSaving] = useState(false) + const [templateResetting, setTemplateResetting] = useState(false) + const [emailSending, setEmailSending] = useState(false) const [error, setError] = useState(null) const [status, setStatus] = useState(null) @@ -152,6 +191,15 @@ export default function AdminInviteManagementPage() { const [masterInviteSelection, setMasterInviteSelection] = useState('') const [invitePolicy, setInvitePolicy] = useState(null) const [activeTab, setActiveTab] = useState('bulk') + const [emailTemplates, setEmailTemplates] = useState([]) + const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null) + const [selectedTemplateKey, setSelectedTemplateKey] = useState('invited') + const [templateForm, setTemplateForm] = useState({ + subject: '', + body_text: '', + body_html: '', + }) + const [emailSendForm, setEmailSendForm] = useState(defaultInviteEmailSendForm()) const [traceFilter, setTraceFilter] = useState('') const [traceScope, setTraceScope] = useState('all') const [traceView, setTraceView] = useState('graph') @@ -161,6 +209,23 @@ export default function AdminInviteManagementPage() { return `${window.location.origin}/signup` }, []) + const loadTemplateEditor = ( + templateKey: InviteEmailTemplateKey, + templates: InviteEmailTemplate[] + ) => { + const template = templates.find((item) => item.key === templateKey) ?? templates[0] ?? null + if (!template) { + setTemplateForm({ subject: '', body_text: '', body_html: '' }) + return + } + setSelectedTemplateKey(template.key) + setTemplateForm({ + subject: template.subject ?? '', + body_text: template.body_text ?? '', + body_html: template.body_html ?? '', + }) + } + const handleAuthResponse = (response: Response) => { if (response.status === 401) { clearToken() @@ -183,11 +248,12 @@ export default function AdminInviteManagementPage() { setError(null) try { const baseUrl = getApiBase() - const [inviteRes, profileRes, usersRes, policyRes] = await Promise.all([ + const [inviteRes, profileRes, usersRes, policyRes, emailTemplateRes] = await Promise.all([ authFetch(`${baseUrl}/admin/invites`), authFetch(`${baseUrl}/admin/profiles`), authFetch(`${baseUrl}/admin/users`), authFetch(`${baseUrl}/admin/invites/policy`), + authFetch(`${baseUrl}/admin/invites/email/templates`), ]) if (!inviteRes.ok) { if (handleAuthResponse(inviteRes)) return @@ -205,11 +271,16 @@ export default function AdminInviteManagementPage() { if (handleAuthResponse(policyRes)) return throw new Error(`Failed to load invite policy (${policyRes.status})`) } - const [inviteData, profileData, usersData, policyData] = await Promise.all([ + if (!emailTemplateRes.ok) { + if (handleAuthResponse(emailTemplateRes)) return + throw new Error(`Failed to load email templates (${emailTemplateRes.status})`) + } + const [inviteData, profileData, usersData, policyData, emailTemplateData] = await Promise.all([ inviteRes.json(), profileRes.json(), usersRes.json(), policyRes.json(), + emailTemplateRes.json(), ]) const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) @@ -219,6 +290,10 @@ export default function AdminInviteManagementPage() { setMasterInviteSelection( nextPolicy?.master_invite_id == null ? '' : String(nextPolicy.master_invite_id) ) + const nextTemplates = Array.isArray(emailTemplateData?.templates) ? emailTemplateData.templates : [] + setEmailTemplates(nextTemplates) + setEmailConfigured(emailTemplateData?.email ?? null) + loadTemplateEditor(selectedTemplateKey, nextTemplates) try { const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`) if (jellyfinRes.ok) { @@ -264,6 +339,9 @@ export default function AdminInviteManagementPage() { max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '', enabled: invite.enabled !== false, expires_at: invite.expires_at ?? '', + recipient_email: invite.recipient_email ?? '', + send_email: false, + message: '', }) setStatus(null) setError(null) @@ -285,6 +363,9 @@ export default function AdminInviteManagementPage() { max_uses: inviteForm.max_uses || null, enabled: inviteForm.enabled, expires_at: inviteForm.expires_at || null, + recipient_email: inviteForm.recipient_email || null, + send_email: inviteForm.send_email, + message: inviteForm.message || null, } const url = inviteEditingId == null @@ -300,8 +381,19 @@ export default function AdminInviteManagementPage() { const text = await response.text() throw new Error(text || 'Save failed') } - setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.') resetInviteEditor() + const data = await response.json() + if (data?.email?.status === 'ok') { + setStatus( + `${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.` + ) + } else if (data?.email?.status === 'error') { + setStatus( + `${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}` + ) + } else { + setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.') + } await loadData() } catch (err) { console.error(err) @@ -349,6 +441,117 @@ export default function AdminInviteManagementPage() { } } + const prepareInviteEmail = (invite: Invite) => { + setEmailSendForm({ + template_key: 'invited', + recipient_email: invite.recipient_email ?? '', + invite_id: String(invite.id), + username: '', + message: '', + reason: '', + }) + setActiveTab('emails') + setStatus( + invite.recipient_email + ? `Invite ${invite.code} is ready to email to ${invite.recipient_email}.` + : `Invite ${invite.code} does not have a saved recipient yet. Add one and send from the email panel.` + ) + setError(null) + } + + const selectEmailTemplate = (templateKey: InviteEmailTemplateKey) => { + setSelectedTemplateKey(templateKey) + loadTemplateEditor(templateKey, emailTemplates) + } + + const saveEmailTemplate = async () => { + setTemplateSaving(true) + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(templateForm), + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Template save failed') + } + setStatus(`Saved ${selectedTemplateKey} email template.`) + await loadData() + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not save email template.') + } finally { + setTemplateSaving(false) + } + } + + const resetEmailTemplate = async () => { + if (!window.confirm(`Reset the ${selectedTemplateKey} template to its default content?`)) return + setTemplateResetting(true) + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, { + method: 'DELETE', + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Template reset failed') + } + setStatus(`Reset ${selectedTemplateKey} template to default.`) + await loadData() + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not reset email template.') + } finally { + setTemplateResetting(false) + } + } + + const sendEmailTemplate = async (event: React.FormEvent) => { + event.preventDefault() + setEmailSending(true) + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/invites/email/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + template_key: emailSendForm.template_key, + recipient_email: emailSendForm.recipient_email || null, + invite_id: emailSendForm.invite_id || null, + username: emailSendForm.username || null, + message: emailSendForm.message || null, + reason: emailSendForm.reason || null, + }), + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Email send failed') + } + const data = await response.json() + setStatus(`Sent ${emailSendForm.template_key} email to ${data?.recipient_email ?? 'recipient'}.`) + if (emailSendForm.template_key === 'invited') { + await loadData() + } + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not send email.') + } finally { + setEmailSending(false) + } + } + const resetProfileEditor = () => { setProfileEditingId(null) setProfileForm(defaultProfileForm()) @@ -588,8 +791,11 @@ export default function AdminInviteManagementPage() { const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length const usableInvites = invites.filter((invite) => invite.is_usable !== false).length const disabledInvites = invites.filter((invite) => invite.enabled === false).length + const invitesWithRecipient = invites.filter((invite) => Boolean(String(invite.recipient_email || '').trim())).length const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length const masterInvite = invitePolicy?.master_invite ?? null + const selectedTemplate = + emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null const inviteTraceRows = useMemo(() => { const inviteByCode = new Map() @@ -813,6 +1019,20 @@ export default function AdminInviteManagementPage() { users with custom expiry +
+ Email templates +
+ {emailTemplates.length} + {invitesWithRecipient} invites with recipient email +
+
+
+ SMTP email +
+ {emailConfigured?.configured ? 'Ready' : 'Needs setup'} + {emailConfigured?.detail ?? 'Email settings unavailable'} +
+
@@ -866,6 +1086,15 @@ export default function AdminInviteManagementPage() { > Trace map +
@@ -1236,6 +1466,9 @@ export default function AdminInviteManagementPage() { + @@ -1371,6 +1604,47 @@ export default function AdminInviteManagementPage() { +
+
+ Delivery + Save a recipient email and optionally send the invite immediately. +
+
+ +