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"],
}