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

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