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() {
+
+
Status
@@ -1404,6 +1678,249 @@ export default function AdminInviteManagementPage() {
)}
+ {activeTab === 'emails' && (
+
+
+
+
+
Email templates
+
+ Edit the invite lifecycle emails and keep the SMTP-driven messaging flow in one place.
+
+
+
+ {!emailConfigured?.configured && (
+
+ {emailConfigured?.detail ?? 'Configure SMTP under Notifications before sending invite emails.'}
+
+ )}
+
+ {emailTemplates.map((template) => (
+
+ ))}
+
+ {selectedTemplate ? (
+
+
{selectedTemplate.label}
+
{selectedTemplate.description}
+
+ ) : null}
+
+
+
+
+
Send email
+
+ Send invite, welcome, warning, or banned emails using a saved invite, a username, or a manual email address.
+
+
+
+
+ )}
+
{activeTab === 'trace' && (
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 4fe750f..6c497db 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -4641,6 +4641,48 @@ button:hover:not(:disabled) {
gap: 10px;
}
+.invite-email-template-picker {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.invite-email-template-picker button {
+ border-radius: 8px;
+}
+
+.invite-email-template-picker button.is-active {
+ border-color: rgba(135, 182, 255, 0.4);
+ background: rgba(86, 132, 220, 0.14);
+ color: #eef2f7;
+}
+
+.invite-email-template-meta {
+ display: grid;
+ gap: 4px;
+ margin-bottom: 10px;
+}
+
+.invite-email-template-meta h3 {
+ margin: 0;
+}
+
+.invite-email-placeholder-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.invite-email-placeholder-list code {
+ padding: 6px 8px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.04);
+ color: #d6dde8;
+ font-size: 0.78rem;
+}
+
.admin-panel > h2 + .lede {
margin-top: -2px;
}
diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx
index a725ffb..5ba2cb5 100644
--- a/frontend/app/profile/page.tsx
+++ b/frontend/app/profile/page.tsx
@@ -53,6 +53,7 @@ type OwnedInvite = {
code: string
label?: string | null
description?: string | null
+ recipient_email?: string | null
max_uses?: number | null
use_count: number
remaining_uses?: number | null
@@ -87,9 +88,12 @@ type OwnedInviteForm = {
code: string
label: string
description: string
+ recipient_email: string
max_uses: string
expires_at: string
enabled: boolean
+ send_email: boolean
+ message: string
}
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
@@ -98,9 +102,12 @@ const defaultOwnedInviteForm = (): OwnedInviteForm => ({
code: '',
label: '',
description: '',
+ recipient_email: '',
max_uses: '',
expires_at: '',
enabled: true,
+ send_email: false,
+ message: '',
})
const formatDate = (value?: string | null) => {
@@ -250,9 +257,12 @@ export default function ProfilePage() {
code: invite.code ?? '',
label: invite.label ?? '',
description: invite.description ?? '',
+ recipient_email: invite.recipient_email ?? '',
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
expires_at: invite.expires_at ?? '',
enabled: invite.enabled !== false,
+ send_email: false,
+ message: '',
})
}
@@ -292,9 +302,12 @@ export default function ProfilePage() {
code: inviteForm.code || null,
label: inviteForm.label || null,
description: inviteForm.description || null,
+ recipient_email: inviteForm.recipient_email || null,
max_uses: inviteForm.max_uses || null,
expires_at: inviteForm.expires_at || null,
enabled: inviteForm.enabled,
+ send_email: inviteForm.send_email,
+ message: inviteForm.message || null,
}),
}
)
@@ -307,7 +320,18 @@ export default function ProfilePage() {
const text = await response.text()
throw new Error(text || 'Invite save failed')
}
- setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
+ const data = await response.json().catch(() => ({}))
+ if (data?.email?.status === 'ok') {
+ setInviteStatus(
+ `${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
+ )
+ } else if (data?.email?.status === 'error') {
+ setInviteStatus(
+ `${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
+ )
+ } else {
+ setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
+ }
resetInviteEditor()
await reloadInvites()
} catch (err) {
@@ -603,6 +627,56 @@ export default function ProfilePage() {
+
+
Limits
@@ -700,6 +774,7 @@ export default function ProfilePage() {
)}
+ Recipient: {invite.recipient_email || 'Not set'}
Uses: {invite.use_count}
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 2ff9fdb..ef1c6e1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "magent-frontend",
- "version": "2802262051",
+ "version": "0103261543",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "magent-frontend",
- "version": "2802262051",
+ "version": "0103261543",
"dependencies": {
"next": "16.1.6",
"react": "19.2.4",
diff --git a/frontend/package.json b/frontend/package.json
index a01a3fd..6a94d45 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "magent-frontend",
"private": true,
- "version": "2802262051",
+ "version": "0103261543",
"scripts": {
"dev": "next dev",
"build": "next build",