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