Add invite email templates and delivery workflow

This commit is contained in:
2026-03-01 15:44:46 +13:00
parent c205df4367
commit 12d3777e76
10 changed files with 1383 additions and 23 deletions

View File

@@ -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'

View File

@@ -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,
),

View File

@@ -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}")

View File

@@ -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}")

View File

@@ -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": (
"<h1>You have been invited</h1>"
"<p>You have been invited to <strong>{{app_name}}</strong>.</p>"
"<p><strong>Invite code:</strong> {{invite_code}}<br />"
"<strong>Invited by:</strong> {{inviter_username}}<br />"
"<strong>Invite label:</strong> {{invite_label}}<br />"
"<strong>Expires:</strong> {{invite_expires_at}}<br />"
"<strong>Remaining uses:</strong> {{invite_remaining_uses}}</p>"
"<p>{{invite_description}}</p>"
"<p>{{message}}</p>"
"<p><a href=\"{{invite_link}}\">Accept invite and create account</a></p>"
"<p><a href=\"{{how_it_works_url}}\">How it works</a></p>"
"<p class=\"meta\">Build {{build_number}}</p>"
),
},
"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": (
"<h1>Welcome</h1>"
"<p>Your {{app_name}} account is ready, <strong>{{username}}</strong>.</p>"
"<p><strong>Role:</strong> {{role}}</p>"
"<p><a href=\"{{app_url}}\">Open {{app_name}}</a><br />"
"<a href=\"{{how_it_works_url}}\">Read how it works</a></p>"
"<p>{{message}}</p>"
),
},
"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": (
"<h1>Account warning</h1>"
"<p>Hello <strong>{{username}}</strong>,</p>"
"<p>This is a warning regarding your {{app_name}} account.</p>"
"<p><strong>Reason:</strong> {{reason}}</p>"
"<p>{{message}}</p>"
"<p>If you need help, contact the admin.</p>"
),
},
"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": (
"<h1>Account status changed</h1>"
"<p>Hello <strong>{{username}}</strong>,</p>"
"<p>Your {{app_name}} account has been banned or removed.</p>"
"<p><strong>Reason:</strong> {{reason}}</p>"
"<p>{{message}}</p>"
),
},
}
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"<br\s*/?>", "\n", value, flags=re.IGNORECASE)
text = re.sub(r"</p>", "\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"],
}

View File

@@ -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<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
@@ -152,6 +191,15 @@ export default function AdminInviteManagementPage() {
const [masterInviteSelection, setMasterInviteSelection] = useState('')
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [emailTemplates, setEmailTemplates] = useState<InviteEmailTemplate[]>([])
const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null)
const [selectedTemplateKey, setSelectedTemplateKey] = useState<InviteEmailTemplateKey>('invited')
const [templateForm, setTemplateForm] = useState({
subject: '',
body_text: '',
body_html: '',
})
const [emailSendForm, setEmailSendForm] = useState<InviteEmailSendForm>(defaultInviteEmailSendForm())
const [traceFilter, setTraceFilter] = useState('')
const [traceScope, setTraceScope] = useState<InviteTraceScope>('all')
const [traceView, setTraceView] = useState<InviteTraceView>('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<string, Invite>()
@@ -813,6 +1019,20 @@ export default function AdminInviteManagementPage() {
<span>users with custom expiry</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Email templates</span>
<div className="invite-admin-summary-row__value">
<strong>{emailTemplates.length}</strong>
<span>{invitesWithRecipient} invites with recipient email</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">SMTP email</span>
<div className="invite-admin-summary-row__value">
<strong>{emailConfigured?.configured ? 'Ready' : 'Needs setup'}</strong>
<span>{emailConfigured?.detail ?? 'Email settings unavailable'}</span>
</div>
</div>
</div>
</div>
</div>
@@ -866,6 +1086,15 @@ export default function AdminInviteManagementPage() {
>
Trace map
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'emails'}
className={activeTab === 'emails' ? 'is-active' : ''}
onClick={() => setActiveTab('emails')}
>
Email
</button>
</div>
<div className="admin-inline-actions invite-admin-tab-actions">
<button type="button" className="ghost-button" onClick={loadData} disabled={loading}>
@@ -1229,6 +1458,7 @@ export default function AdminInviteManagementPage() {
</span>
<span>Remaining: {invite.remaining_uses ?? 'Unlimited'}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
<span>Created: {formatDate(invite.created_at)}</span>
</div>
</div>
@@ -1236,6 +1466,9 @@ export default function AdminInviteManagementPage() {
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
Copy link
</button>
<button type="button" className="ghost-button" onClick={() => prepareInviteEmail(invite)}>
Email invite
</button>
<button type="button" className="ghost-button" onClick={() => editInvite(invite)}>
Edit
</button>
@@ -1371,6 +1604,47 @@ export default function AdminInviteManagementPage() {
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, recipient_email: e.target.value }))
}
placeholder="person@example.com"
/>
</label>
<label>
<span>Delivery note</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(e) =>
setInviteForm((current) => ({ ...current, message: e.target.value }))
}
placeholder="Optional message appended to the invite email"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, send_email: e.target.checked }))
}
/>
Send You have been invited email after saving
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
@@ -1404,6 +1678,249 @@ export default function AdminInviteManagementPage() {
</div>
)}
{activeTab === 'emails' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<div className="user-directory-panel-header">
<div>
<h2>Email templates</h2>
<p className="lede">
Edit the invite lifecycle emails and keep the SMTP-driven messaging flow in one place.
</p>
</div>
</div>
{!emailConfigured?.configured && (
<div className="status-banner">
{emailConfigured?.detail ?? 'Configure SMTP under Notifications before sending invite emails.'}
</div>
)}
<div className="invite-email-template-picker" role="tablist" aria-label="Email templates">
{emailTemplates.map((template) => (
<button
key={template.key}
type="button"
className={selectedTemplateKey === template.key ? 'is-active' : ''}
onClick={() => selectEmailTemplate(template.key)}
>
{template.label}
</button>
))}
</div>
{selectedTemplate ? (
<div className="invite-email-template-meta">
<h3>{selectedTemplate.label}</h3>
<p className="lede">{selectedTemplate.description}</p>
</div>
) : null}
<form
className="admin-form compact-form invite-form-layout invite-email-template-form"
onSubmit={(event) => {
event.preventDefault()
void saveEmailTemplate()
}}
>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Subject</span>
<small>Rendered with the same placeholder variables as the body.</small>
</div>
<div className="invite-form-row-control">
<input
value={templateForm.subject}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, subject: event.target.value }))
}
placeholder="Email subject"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Plain text body</span>
<small>Used for mail clients that prefer text only.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={12}
value={templateForm.body_text}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, body_text: event.target.value }))
}
placeholder="Plain text email body"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>HTML body</span>
<small>Optional rich HTML version. Basic HTML is supported.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={14}
value={templateForm.body_html}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, body_html: event.target.value }))
}
placeholder="<h1>Hello</h1><p>HTML email body</p>"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Placeholders</span>
<small>Use these anywhere in the subject or body.</small>
</div>
<div className="invite-form-row-control invite-email-placeholder-list">
{(selectedTemplate?.placeholders ?? []).map((placeholder) => (
<code key={placeholder}>{`{{${placeholder}}}`}</code>
))}
</div>
</div>
<div className="admin-inline-actions">
<button type="submit" disabled={templateSaving}>
{templateSaving ? 'Saving…' : 'Save template'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void resetEmailTemplate()}
disabled={templateResetting}
>
{templateResetting ? 'Resetting…' : 'Reset to default'}
</button>
</div>
</form>
</div>
<div className="admin-panel invite-admin-form-panel">
<h2>Send email</h2>
<p className="lede">
Send invite, welcome, warning, or banned emails using a saved invite, a username, or a manual email address.
</p>
<form onSubmit={sendEmailTemplate} className="admin-form compact-form invite-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Template</span>
<small>Select which lifecycle email to send.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Template</span>
<select
value={emailSendForm.template_key}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
template_key: event.target.value as InviteEmailTemplateKey,
}))
}
>
{emailTemplates.map((template) => (
<option key={template.key} value={template.key}>
{template.label}
</option>
))}
</select>
</label>
<label>
<span>Recipient email</span>
<input
type="email"
value={emailSendForm.recipient_email}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="Optional if invite/user already has one"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Context</span>
<small>Link the email to an invite or username to fill placeholders automatically.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Invite</span>
<select
value={emailSendForm.invite_id}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
invite_id: event.target.value,
}))
}
>
<option value="">None</option>
{invites.map((invite) => (
<option key={invite.id} value={invite.id}>
{invite.code}
{invite.label ? ` - ${invite.label}` : ''}
</option>
))}
</select>
</label>
<label>
<span>Username</span>
<input
value={emailSendForm.username}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
username: event.target.value,
}))
}
placeholder="Optional user lookup"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Reason / note</span>
<small>Used by warning and banned templates, and appended to other emails.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Reason</span>
<input
value={emailSendForm.reason}
onChange={(event) =>
setEmailSendForm((current) => ({ ...current, reason: event.target.value }))
}
placeholder="Optional reason"
/>
</label>
<label>
<span>Message</span>
<textarea
rows={6}
value={emailSendForm.message}
onChange={(event) =>
setEmailSendForm((current) => ({ ...current, message: event.target.value }))
}
placeholder="Optional message"
/>
</label>
</div>
</div>
<div className="admin-inline-actions">
<button type="submit" disabled={emailSending || !emailConfigured?.configured}>
{emailSending ? 'Sending…' : 'Send email'}
</button>
</div>
</form>
</div>
</div>
)}
{activeTab === 'trace' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">

View File

@@ -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;
}

View File

@@ -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() {
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="friend@example.com"
/>
</label>
<label>
<span>Delivery note</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(event) =>
setInviteForm((current) => ({
...current,
message: event.target.value,
}))
}
placeholder="Optional note to include in the email"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
send_email: event.target.checked,
}))
}
/>
Send "You have been invited" email after saving
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Limits</span>
@@ -700,6 +774,7 @@ export default function ProfilePage() {
</p>
)}
<div className="admin-meta-row">
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
<span>
Uses: {invite.use_count}
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}

View File

@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "magent-frontend",
"private": true,
"version": "2802262051",
"version": "0103261543",
"scripts": {
"dev": "next dev",
"build": "next build",