Add invite email templates and delivery workflow
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user