Add opt-in monthly email recaps with scheduling and delivery history
This commit is contained in:
@@ -733,6 +733,8 @@ def init_db() -> None:
|
|||||||
conn.execute("PRAGMA optimize")
|
conn.execute("PRAGMA optimize")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
from .services.recap_store import init_schema as init_recap_schema
|
||||||
|
init_recap_schema(conn)
|
||||||
_backfill_auth_providers()
|
_backfill_auth_providers()
|
||||||
ensure_admin_user()
|
ensure_admin_user()
|
||||||
_backfill_request_repairs()
|
_backfill_request_repairs()
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ from .routers.portal import router as portal_router
|
|||||||
from .routers.operations import router as operations_router
|
from .routers.operations import router as operations_router
|
||||||
from .routers.insights import router as insights_router
|
from .routers.insights import router as insights_router
|
||||||
from .routers.identities import router as identities_router
|
from .routers.identities import router as identities_router
|
||||||
|
from .routers.recaps import router as recaps_router
|
||||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||||
from .services.issue_resolution import run_issue_confirmation_loop
|
from .services.issue_resolution import run_issue_confirmation_loop
|
||||||
|
from .services.email_recaps import run_email_recap_loop
|
||||||
from .services.operation_progress import (
|
from .services.operation_progress import (
|
||||||
begin_operation,
|
begin_operation,
|
||||||
finish_operation,
|
finish_operation,
|
||||||
@@ -267,6 +269,7 @@ async def startup() -> None:
|
|||||||
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
||||||
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
||||||
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
||||||
|
_launch_background_task("email-recaps", run_email_recap_loop)
|
||||||
logger.info("startup complete")
|
logger.info("startup complete")
|
||||||
|
|
||||||
|
|
||||||
@@ -284,3 +287,4 @@ app.include_router(portal_router)
|
|||||||
app.include_router(operations_router)
|
app.include_router(operations_router)
|
||||||
app.include_router(insights_router)
|
app.include_router(insights_router)
|
||||||
app.include_router(identities_router)
|
app.include_router(identities_router)
|
||||||
|
app.include_router(recaps_router)
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from ..auth import get_current_user, require_admin
|
||||||
|
from ..services import email_recaps as recaps, recap_store as store
|
||||||
|
|
||||||
|
|
||||||
|
def no_cache(response: Response):
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["email-recaps"], dependencies=[Depends(no_cache)])
|
||||||
|
|
||||||
|
|
||||||
|
class StrictPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class Preference(StrictPayload):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RecapSettings(StrictPayload):
|
||||||
|
enabled: bool
|
||||||
|
day: int = Field(ge=1, le=28)
|
||||||
|
hour: int = Field(ge=0, le=23)
|
||||||
|
public_url: str = Field(max_length=500)
|
||||||
|
|
||||||
|
@field_validator("public_url")
|
||||||
|
@classmethod
|
||||||
|
def origin_only(cls, value: str) -> str:
|
||||||
|
value = value.strip().rstrip('/')
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
url = urlsplit(value)
|
||||||
|
port = url.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Enter the public Magent address, such as https://magent.example.com.") from exc
|
||||||
|
if (url.scheme not in {"http", "https"} or not url.hostname or url.username or url.password
|
||||||
|
or url.path or url.query or url.fragment or any(char.isspace() or ord(char) < 33 for char in value)
|
||||||
|
or any(char in value for char in '<>"\\') or (port is not None and port < 1)):
|
||||||
|
raise ValueError("Enter a http(s) Magent address without a path, credentials or query.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmail(StrictPayload):
|
||||||
|
month: str | None = Field(default=None, pattern=r"^[0-9]{4}-[0-9]{2}$")
|
||||||
|
request_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class TokenAction(StrictPayload):
|
||||||
|
token: str = Field(min_length=40, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
|
||||||
|
action: Literal["confirm", "unsubscribe"]
|
||||||
|
|
||||||
|
|
||||||
|
def error(exc: recaps.RecapError):
|
||||||
|
raise HTTPException(exc.status, exc.detail) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile/email-recaps")
|
||||||
|
def preferences(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
try:
|
||||||
|
return recaps.preferences(user)
|
||||||
|
except recaps.RecapError as exc:
|
||||||
|
error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/profile/email-recaps")
|
||||||
|
async def preference(payload: Preference, user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
try:
|
||||||
|
if payload.enabled:
|
||||||
|
return await recaps.subscribe(user)
|
||||||
|
store.disable(recaps.current_account(user)["id"])
|
||||||
|
return recaps.preferences(user)
|
||||||
|
except recaps.RecapError as exc:
|
||||||
|
error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email-recaps/check")
|
||||||
|
def check_token(payload: TokenAction) -> dict:
|
||||||
|
try:
|
||||||
|
return recaps.token_action(payload.token, payload.action)
|
||||||
|
except recaps.RecapError as exc:
|
||||||
|
error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email-recaps/confirm")
|
||||||
|
def apply_token(payload: TokenAction) -> dict:
|
||||||
|
try:
|
||||||
|
return recaps.token_action(payload.token, payload.action, apply=True)
|
||||||
|
except recaps.RecapError as exc:
|
||||||
|
error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/email-recaps")
|
||||||
|
def overview(offset: int = Query(default=0, ge=0), user: dict = Depends(require_admin)) -> dict:
|
||||||
|
ready, detail = recaps.delivery_ready()
|
||||||
|
months = recaps.month_periods(None, datetime.now(timezone.utc))["available_months"][1:]
|
||||||
|
return {"settings": store.settings(), "ready": ready, "detail": detail, "months": months,
|
||||||
|
"worker_enabled": recaps.worker_enabled(), **store.history(offset=offset)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/email-recaps")
|
||||||
|
def settings(payload: RecapSettings, user: dict = Depends(require_admin)) -> dict:
|
||||||
|
if payload.enabled:
|
||||||
|
# Validate against the proposed URL without writing any partial settings.
|
||||||
|
ready, detail = recaps.smtp_email_config_ready()
|
||||||
|
runtime = recaps.get_runtime_settings()
|
||||||
|
if not payload.public_url or not ready or not recaps.worker_enabled() or not runtime.jellystat_base_url or not runtime.jellystat_api_key:
|
||||||
|
raise HTTPException(409, "Set the public address, enable SMTP email and connect Jellystat before starting the schedule." if ready else detail)
|
||||||
|
return store.save_settings(payload.model_dump(), datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/email-recaps/preview")
|
||||||
|
async def preview(month: str | None = Query(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$"), user: dict = Depends(require_admin)) -> dict:
|
||||||
|
try:
|
||||||
|
return await recaps.preview(user, month)
|
||||||
|
except recaps.RecapError as exc:
|
||||||
|
error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/email-recaps/test", status_code=202)
|
||||||
|
def test_email(payload: TestEmail, user: dict = Depends(require_admin)) -> dict:
|
||||||
|
try:
|
||||||
|
return recaps.queue_test(user, payload.month, str(payload.request_id))
|
||||||
|
except recaps.RecapError as exc:
|
||||||
|
error(exc)
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""Opt-in monthly recaps. Scheduling and delivery are safe to run in multiple workers."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from .. import db
|
||||||
|
from ..clients.jellystat import HistoryLimitError, JellystatError
|
||||||
|
from ..runtime import get_runtime_settings
|
||||||
|
from . import recap_email as mail, recap_store as store
|
||||||
|
from .invite_email import smtp_email_config_ready
|
||||||
|
from .jellyfin_identity import linked_user_id, source_key
|
||||||
|
from .monthly_reports import get_monthly_report, month_periods
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RecapError(Exception):
|
||||||
|
def __init__(self, detail: str, status: int = 409):
|
||||||
|
self.detail, self.status = detail, status
|
||||||
|
super().__init__(detail)
|
||||||
|
|
||||||
|
|
||||||
|
def worker_enabled() -> bool:
|
||||||
|
return os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() != "false"
|
||||||
|
|
||||||
|
|
||||||
|
def delivery_ready() -> tuple[bool, str]:
|
||||||
|
config = store.settings()
|
||||||
|
if not config["public_url"]:
|
||||||
|
return False, "Set the public Magent address for email links."
|
||||||
|
ready, detail = smtp_email_config_ready()
|
||||||
|
if not ready:
|
||||||
|
return False, detail
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
if not runtime.jellystat_base_url or not runtime.jellystat_api_key:
|
||||||
|
return False, "Connect Jellystat to generate viewing recaps."
|
||||||
|
if not worker_enabled():
|
||||||
|
return False, "Background automation is paused on this server."
|
||||||
|
return True, "Email delivery is configured."
|
||||||
|
|
||||||
|
|
||||||
|
def current_account(user: dict) -> dict:
|
||||||
|
account = db.get_user_by_username(user.get("username", ""))
|
||||||
|
if not account or account.get("is_blocked") or account.get("is_expired"):
|
||||||
|
raise RecapError("This account cannot receive viewing recaps.", 403)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def binding_matches(sub: dict, account: dict) -> bool:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
return bool(account and not account.get("is_blocked") and not account.get("is_expired")
|
||||||
|
and mail.valid_email(account.get("email"))
|
||||||
|
and account["email"].strip().casefold() == sub["email"].strip().casefold()
|
||||||
|
and source_key(runtime.jellyfin_base_url) == sub["identity_source"]
|
||||||
|
and linked_user_id(account["username"], runtime.jellyfin_base_url) == sub["identity_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def active_subscription(account: dict) -> dict | None:
|
||||||
|
sub = store.subscription(account["id"])
|
||||||
|
if sub and sub["state"] != "off" and not binding_matches(sub, account):
|
||||||
|
store.disable(account["id"])
|
||||||
|
sub = store.subscription(account["id"])
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
def preferences(user: dict) -> dict:
|
||||||
|
account = current_account(user)
|
||||||
|
sub = active_subscription(account)
|
||||||
|
config = store.settings()
|
||||||
|
ready, detail = delivery_ready()
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
linked = bool(linked_user_id(account["username"], runtime.jellyfin_base_url))
|
||||||
|
email = mail.valid_email(account.get("email"))
|
||||||
|
state = sub["state"] if sub else "off"
|
||||||
|
if state == "pending" and sub["confirmation_expires"] <= time.time():
|
||||||
|
state = "expired"
|
||||||
|
return {"state": state, "email": account.get("email"), "can_subscribe": ready and linked and bool(email),
|
||||||
|
"detail": detail if not ready else "Save a valid email address in your profile." if not email else
|
||||||
|
"Your Jellyfin account needs a saved identity link." if not linked else "Your monthly story, in your inbox.",
|
||||||
|
"schedule_enabled": config["enabled"], "next_send_at": config["next_send_at"],
|
||||||
|
"day": config["day"], "hour": config["hour"], "timezone": "UTC",
|
||||||
|
"resend_after": (sub["requested_at"] + 300) if sub else None}
|
||||||
|
|
||||||
|
|
||||||
|
async def subscribe(user: dict) -> dict:
|
||||||
|
account = current_account(user)
|
||||||
|
preference = preferences(user)
|
||||||
|
if preference["state"] == "enabled":
|
||||||
|
return preference
|
||||||
|
if not preference["can_subscribe"]:
|
||||||
|
raise RecapError(preference["detail"])
|
||||||
|
config = store.settings()
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
try:
|
||||||
|
token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
|
||||||
|
linked_user_id(account["username"], runtime.jellyfin_base_url), time.time())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RecapError(str(exc), 429) from exc
|
||||||
|
url = f"{config['public_url']}/email-recaps#" + urlencode({"action": "confirm", "token": token})
|
||||||
|
rendered = mail.render_confirmation(account["username"], url)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(mail.send_email, account["email"].strip(), rendered,
|
||||||
|
mail.message_id(uuid.uuid4().hex, config["public_url"]))
|
||||||
|
except mail.DeliveryError as exc:
|
||||||
|
raise RecapError("Could not confirm delivery of the verification email. Check your inbox; you can request another in five minutes.", 502) from exc
|
||||||
|
return {**preferences(user), "message": "Check your inbox and confirm within 24 hours to turn on monthly recaps."}
|
||||||
|
|
||||||
|
|
||||||
|
def token_action(token: str, action: str, *, apply: bool = False) -> dict:
|
||||||
|
sub = store.token_subscription(token, action)
|
||||||
|
if not sub:
|
||||||
|
raise RecapError("This email link is invalid or has already been used. Open Profile to manage your recaps.", 410)
|
||||||
|
if action == "unsubscribe":
|
||||||
|
if apply:
|
||||||
|
store.disable(sub["user_id"])
|
||||||
|
return {"action": action, "state": "off" if apply or sub["state"] == "off" else "ready"}
|
||||||
|
account = db.get_user_by_id(sub["user_id"])
|
||||||
|
if (sub["state"] != "pending" or sub["confirmation_expires"] <= time.time()
|
||||||
|
or not binding_matches(sub, account)):
|
||||||
|
raise RecapError("This confirmation has expired or your account details changed. Request a new link from Profile.", 410)
|
||||||
|
if apply and not store.confirm(sub, time.time()):
|
||||||
|
raise RecapError("This confirmation is no longer available. Request a new link from Profile.", 410)
|
||||||
|
return {"action": action, "state": "enabled" if apply else "ready"}
|
||||||
|
|
||||||
|
|
||||||
|
def completed_month(month: str | None) -> str:
|
||||||
|
try:
|
||||||
|
period = month_periods(month, datetime.now(timezone.utc))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RecapError(str(exc), 422) from exc
|
||||||
|
if period["is_partial"]:
|
||||||
|
raise RecapError("Choose a completed month for an email recap.", 422)
|
||||||
|
return period["month"]
|
||||||
|
|
||||||
|
|
||||||
|
async def preview(user: dict, month: str | None) -> dict:
|
||||||
|
account = current_account(user)
|
||||||
|
selected = completed_month(month)
|
||||||
|
config = store.settings()
|
||||||
|
if not config["public_url"]:
|
||||||
|
raise RecapError("Save the public Magent address before previewing an email.")
|
||||||
|
try:
|
||||||
|
report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180)
|
||||||
|
except HistoryLimitError as exc:
|
||||||
|
raise RecapError("This report exceeds Jellystat's history limit. No partial recap was generated.", 422) from exc
|
||||||
|
except (JellystatError, TimeoutError) as exc:
|
||||||
|
raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc
|
||||||
|
if report["state"] != "ready":
|
||||||
|
raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.")
|
||||||
|
return {"month": selected, "email": account.get("email"), **mail.render_recap(
|
||||||
|
report, account["username"], config["public_url"], config["public_url"] + "/profile#monthly-recaps")}
|
||||||
|
|
||||||
|
|
||||||
|
def queue_test(user: dict, month: str | None, request_id: str) -> dict:
|
||||||
|
account = current_account(user)
|
||||||
|
ready, detail = delivery_ready()
|
||||||
|
if not ready:
|
||||||
|
raise RecapError(detail)
|
||||||
|
sub = active_subscription(account)
|
||||||
|
if not sub or sub["state"] != "enabled":
|
||||||
|
raise RecapError("Turn on email recaps and confirm your email in Profile before sending a personal test.")
|
||||||
|
selected = completed_month(month)
|
||||||
|
try:
|
||||||
|
delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()["public_url"], time.time())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RecapError(str(exc), 429) from exc
|
||||||
|
return {"id": delivery_id, "message": "Test queued for your confirmed email. Check delivery history for the result."}
|
||||||
|
|
||||||
|
|
||||||
|
def eligible_delivery(delivery: dict) -> tuple[dict, dict]:
|
||||||
|
account = db.get_user_by_id(delivery["user_id"])
|
||||||
|
sub = active_subscription(account) if account else None
|
||||||
|
config = store.settings()
|
||||||
|
ready, _ = delivery_ready()
|
||||||
|
if (not ready or not sub or sub["state"] != "enabled" or sub["version"] != delivery["subscription_version"]
|
||||||
|
or sub["email"] != delivery["email"] or not binding_matches(sub, account)
|
||||||
|
or config["public_url"] != delivery["public_url"]
|
||||||
|
or (delivery["kind"] == "scheduled" and not config["enabled"])):
|
||||||
|
raise mail.DeliveryCancelled()
|
||||||
|
return account, sub
|
||||||
|
|
||||||
|
|
||||||
|
async def process_delivery(delivery: dict) -> None:
|
||||||
|
state, detail, delay = "failed", "Could not prepare the recap. Check the report and email settings.", 0
|
||||||
|
try:
|
||||||
|
account, sub = eligible_delivery(delivery)
|
||||||
|
report = await asyncio.wait_for(get_monthly_report(account, delivery["month"]), timeout=180)
|
||||||
|
if report["state"] != "ready" or report["is_partial"]:
|
||||||
|
raise mail.DeliveryError("failed", "A complete personal report is not available.")
|
||||||
|
unsubscribe = f"{delivery['public_url']}/email-recaps#" + urlencode({"action": "unsubscribe", "token": sub["unsubscribe_token"]})
|
||||||
|
rendered = mail.render_recap(report, account["username"], delivery["public_url"], unsubscribe, test=delivery["kind"] == "test")
|
||||||
|
|
||||||
|
def before_data():
|
||||||
|
eligible_delivery(delivery)
|
||||||
|
if not store.begin_sending(delivery, time.time()):
|
||||||
|
raise mail.DeliveryCancelled()
|
||||||
|
|
||||||
|
await asyncio.to_thread(mail.send_email, delivery["email"], rendered,
|
||||||
|
mail.message_id(delivery["id"], delivery["public_url"]), before_data)
|
||||||
|
state, detail = "sent", "Accepted by the mail server."
|
||||||
|
except mail.DeliveryCancelled:
|
||||||
|
state, detail = "cancelled", "Consent, account details or email configuration changed."
|
||||||
|
except HistoryLimitError:
|
||||||
|
state, detail = "failed", "Jellystat's history limit was reached. No partial recap was sent."
|
||||||
|
except (JellystatError, TimeoutError):
|
||||||
|
state, detail = "retry", "Viewing history is temporarily unavailable."
|
||||||
|
except mail.DeliveryError as exc:
|
||||||
|
state, detail = exc.state, exc.detail
|
||||||
|
except Exception as exc:
|
||||||
|
# Do not expose provider errors or private report content in history/logs.
|
||||||
|
logger.error("recap delivery error id=%s type=%s", delivery["id"], type(exc).__name__)
|
||||||
|
row = store.read_one("SELECT state FROM email_recap_deliveries WHERE id=?", (delivery["id"],))
|
||||||
|
if row and row["state"] == "sending":
|
||||||
|
state, detail = "unknown", "Delivery outcome is unknown; check the mail server."
|
||||||
|
if state == "retry":
|
||||||
|
if delivery["attempts"] >= 3:
|
||||||
|
state, detail = "failed", detail + " Stopped after three attempts."
|
||||||
|
else:
|
||||||
|
delay = 300 if delivery["attempts"] == 1 else 1800
|
||||||
|
store.finish(delivery, state, detail, time.time(), delay)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_once() -> None:
|
||||||
|
store.enqueue_due(datetime.now(timezone.utc))
|
||||||
|
for _ in range(10):
|
||||||
|
delivery = store.claim_delivery(time.time())
|
||||||
|
if not delivery:
|
||||||
|
break
|
||||||
|
await process_delivery(delivery)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_email_recap_loop() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await run_once()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("email recap worker failed type=%s", type(exc).__name__)
|
||||||
|
await asyncio.sleep(30)
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Personal recap email rendering and SMTP delivery with explicit acceptance tracking."""
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from contextlib import suppress
|
||||||
|
from datetime import datetime
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.policy import SMTP as SMTP_POLICY
|
||||||
|
from email.utils import formataddr, formatdate
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryError(Exception):
|
||||||
|
def __init__(self, state: str, detail: str):
|
||||||
|
self.state, self.detail = state, detail
|
||||||
|
super().__init__(detail)
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryCancelled(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def valid_email(value: str | None) -> str | None:
|
||||||
|
value = str(value or "").strip()
|
||||||
|
if (len(value) <= 254 and re.fullmatch(r"[^@\s<>;,\"\\]+@[^@\s<>;,\"\\]+\.[^@\s<>;,\"\\]+", value)
|
||||||
|
and all(32 < ord(char) < 127 for char in value)):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def month_label(value: str) -> str:
|
||||||
|
return datetime.strptime(value, "%Y-%m").strftime("%B %Y")
|
||||||
|
|
||||||
|
|
||||||
|
def number(value: float) -> str:
|
||||||
|
return f"{value:,.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
def document(*, title: str, intro: str, content: str, action: str, url: str, footer: str) -> str:
|
||||||
|
esc = html.escape
|
||||||
|
return f'''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="dark"><title>{esc(title)}</title><style>@media(max-width:280px){{.email-metrics td{{display:block!important;width:auto!important;padding:16px 0!important}}.email-metrics tr{{display:block!important}}}}</style></head>
|
||||||
|
<body style="margin:0;padding:0;background:#131315;color:#e5e1e4;font-family:Arial,Helvetica,sans-serif">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#131315"><tr><td align="center" style="padding:24px 12px">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;table-layout:fixed;background:#1c1b1d;border:1px solid #363338;border-radius:16px">
|
||||||
|
<tr><td style="padding:32px 24px 8px;color:#c7bdff;font-size:12px;letter-spacing:2px;font-weight:bold">MAGENT <span style="color:#918b98;letter-spacing:0">/ YOUR MONTH IN VIEWING</span></td></tr>
|
||||||
|
<tr><td style="padding:12px 24px"><h1 style="margin:0 0 16px;font-size:32px;line-height:1.2;color:#f3eef6">{esc(title)}</h1><p style="margin:0;color:#bdb6c3;font-size:15px;line-height:1.7;overflow-wrap:anywhere">{esc(intro)}</p></td></tr>
|
||||||
|
<tr><td style="padding:12px 24px">{content}</td></tr>
|
||||||
|
<tr><td style="padding:20px 24px 32px"><a href="{esc(url, quote=True)}" style="display:inline-block;padding:15px 22px;border-radius:8px;background:#c7bdff;color:#211b30;text-decoration:none;font-size:14px;font-weight:bold">{esc(action)} ↗</a></td></tr>
|
||||||
|
</table><table role="presentation" width="600" style="width:100%;max-width:600px"><tr><td style="padding:22px 18px;color:#a69fac;font-size:12px;line-height:1.7;text-align:center">{footer}</td></tr></table>
|
||||||
|
</td></tr></table></body></html>'''
|
||||||
|
|
||||||
|
|
||||||
|
def render_confirmation(username: str, url: str) -> dict:
|
||||||
|
title = "Your month, delivered."
|
||||||
|
intro = f"Hi {username}, confirm this email address to receive your personal monthly viewing recap from Magent."
|
||||||
|
text = f"{intro}\n\nConfirm email recaps: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email. No viewing history will be emailed until you confirm."
|
||||||
|
body = document(title=title, intro=intro,
|
||||||
|
content='<p style="color:#bdb6c3;font-size:14px;line-height:1.7">Minutes watched, movies, episodes, your longest run and requests — with a link to your full monthly report.</p>',
|
||||||
|
action="Confirm email recaps", url=url,
|
||||||
|
footer="This link expires in 24 hours. If you did not request this, ignore this email.<br>No viewing history will be emailed until you confirm.")
|
||||||
|
return {"subject": "Confirm your Magent email recaps", "body_text": text, "body_html": body}
|
||||||
|
|
||||||
|
|
||||||
|
def render_recap(report: dict, username: str, public_url: str, unsubscribe_url: str, *, test: bool = False) -> dict:
|
||||||
|
esc = html.escape
|
||||||
|
month = month_label(report["month"])
|
||||||
|
previous = month_label(report["comparison_month"])
|
||||||
|
summary = report["summary"]
|
||||||
|
metrics = (("Minutes watched", "minutes", summary["minutes"]), ("Movies played", "movies", summary["movies"]),
|
||||||
|
("Episodes played", "episodes", summary["episodes"]), ("Requests made", "requests", report["requests"]["total"]))
|
||||||
|
cells, lines = [], []
|
||||||
|
for label, key, value in metrics:
|
||||||
|
change = report["changes"][key]
|
||||||
|
difference = change["difference"]
|
||||||
|
comparison = ("No change" if difference == 0 else f"{'+' if difference > 0 else '−'}{number(abs(difference))}")
|
||||||
|
if change["percent"] is not None and difference:
|
||||||
|
comparison += f" ({'+' if difference > 0 else '−'}{abs(change['percent']):g}%)"
|
||||||
|
comparison += f" from {previous}"
|
||||||
|
lines.append(f"{label}: {number(value)}. {comparison}.")
|
||||||
|
cells.append(f'<td width="50%" valign="top" style="padding:16px 10px;border-bottom:1px solid #363338"><span style="color:#bdb6c3;font-size:12px">{label}</span><br><strong style="display:block;margin:10px 0;color:#e0d8ff;font-size:30px">{number(value)}</strong><span style="color:#a69fac;font-size:11px;line-height:1.6">{esc(comparison)}</span></td>')
|
||||||
|
content = '<table role="presentation" class="email-metrics" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed"><tr>' + ''.join(cells[:2]) + '</tr><tr>' + ''.join(cells[2:]) + '</tr></table>'
|
||||||
|
habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run"
|
||||||
|
content += f'<p style="color:#e5e1e4;font-size:14px;line-height:1.7;margin:24px 0">{esc(habit)}</p>'
|
||||||
|
top = report.get("top_titles", [])[:3]
|
||||||
|
if top:
|
||||||
|
content += '<h2 style="font-size:18px;color:#e5e1e4;margin:24px 0 8px">Your most watched</h2>'
|
||||||
|
for item in top:
|
||||||
|
content += f'<p style="font-size:14px;line-height:1.6;color:#e5e1e4;margin:12px 0;overflow-wrap:anywhere">{esc(item["title"])}<br><span style="font-size:12px;color:#a69fac">{number(item["minutes"])} minutes · {number(item["plays"])} plays</span></p>'
|
||||||
|
else:
|
||||||
|
content += '<p style="font-size:14px;color:#bdb6c3;line-height:1.7">No viewing was recorded this month. Your requests are still included.</p>'
|
||||||
|
report_url = f"{public_url}/insights/reports?month={report['month']}"
|
||||||
|
intro = f"Hi {username}, here’s your {month} in viewing. A little look back at the stories you spent time with."
|
||||||
|
footer = f'You opted in to personal monthly recaps from Magent.<br>Based on retained Jellystat history. Calendar months use UTC; request statuses are current.<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from recaps</a> · <a href="{esc(public_url + "/profile#monthly-recaps", quote=True)}" style="color:#c7bdff">Email preferences</a>'
|
||||||
|
if test:
|
||||||
|
intro = "This is your test recap. " + intro
|
||||||
|
body = document(title=month, intro=intro, content=content, action="Explore your full report", url=report_url, footer=footer)
|
||||||
|
text = '\n'.join([intro, '', *lines, '', habit, '', 'Most watched:',
|
||||||
|
*(f"{item['title']}: {number(item['minutes'])} minutes" for item in top), '',
|
||||||
|
f"Your full report: {report_url}", '', 'Based on retained Jellystat history. Calendar months use UTC; request statuses are current.',
|
||||||
|
f"Unsubscribe from recaps: {unsubscribe_url}", f"Email preferences: {public_url}/profile#monthly-recaps"])
|
||||||
|
return {"subject": f"{'[Test] ' if test else ''}Your {month} in viewing · Magent", "body_text": text, "body_html": body}
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(recipient: str, rendered: dict, message_id: str, before_data=lambda: None) -> None:
|
||||||
|
"""Return only after SMTP accepts DATA. Never retry an ambiguous DATA disconnect.
|
||||||
|
|
||||||
|
A stable Message-ID aids diagnosis; it is not an SMTP deduplication guarantee.
|
||||||
|
See RFC 5321 §4.5.3.2.6 and Python's smtplib exception definitions.
|
||||||
|
"""
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
sender = valid_email(runtime.magent_notify_email_from_address)
|
||||||
|
if not sender or not valid_email(recipient):
|
||||||
|
raise DeliveryError("failed", "A valid sender and recipient email are required.")
|
||||||
|
message = EmailMessage(policy=SMTP_POLICY)
|
||||||
|
message["From"] = formataddr((str(runtime.magent_notify_email_from_name or "Magent").replace('\r', '').replace('\n', ''), sender))
|
||||||
|
message["To"], message["Subject"] = recipient, rendered["subject"]
|
||||||
|
message["Date"], message["Message-ID"] = formatdate(localtime=False), message_id
|
||||||
|
message["Auto-Submitted"], message["X-Auto-Response-Suppress"] = "auto-generated", "All"
|
||||||
|
message.set_content(rendered["body_text"])
|
||||||
|
message.add_alternative(rendered["body_html"], subtype="html")
|
||||||
|
payload = message.as_bytes()
|
||||||
|
smtp, stage = None, "connect"
|
||||||
|
try:
|
||||||
|
kwargs = {"timeout": 30, "local_hostname": sender.split('@', 1)[1]}
|
||||||
|
if runtime.magent_notify_email_use_ssl:
|
||||||
|
smtp = smtplib.SMTP_SSL(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port,
|
||||||
|
context=ssl.create_default_context(), **kwargs)
|
||||||
|
else:
|
||||||
|
smtp = smtplib.SMTP(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port, **kwargs)
|
||||||
|
smtp.ehlo_or_helo_if_needed()
|
||||||
|
if runtime.magent_notify_email_use_tls and not runtime.magent_notify_email_use_ssl:
|
||||||
|
smtp.starttls(context=ssl.create_default_context())
|
||||||
|
smtp.ehlo()
|
||||||
|
if runtime.magent_notify_email_smtp_username:
|
||||||
|
smtp.login(runtime.magent_notify_email_smtp_username, runtime.magent_notify_email_smtp_password)
|
||||||
|
code, reply = smtp.mail(sender)
|
||||||
|
if code != 250:
|
||||||
|
raise smtplib.SMTPResponseException(code, reply)
|
||||||
|
code, reply = smtp.rcpt(recipient)
|
||||||
|
if code not in (250, 251):
|
||||||
|
raise smtplib.SMTPResponseException(code, reply)
|
||||||
|
before_data()
|
||||||
|
stage = "data"
|
||||||
|
code, reply = smtp.data(payload)
|
||||||
|
if code != 250:
|
||||||
|
raise smtplib.SMTPDataError(code, reply)
|
||||||
|
stage = "accepted"
|
||||||
|
except smtplib.SMTPResponseException as exc:
|
||||||
|
state = "retry" if 400 <= exc.smtp_code < 500 else "failed"
|
||||||
|
raise DeliveryError(state, f"Mail server returned SMTP {exc.smtp_code}.") from exc
|
||||||
|
except (ssl.SSLError, smtplib.SMTPNotSupportedError, UnicodeError, ValueError) as exc:
|
||||||
|
raise DeliveryError("failed", "Check the SMTP security and sender settings.") from exc
|
||||||
|
except (OSError, smtplib.SMTPException) as exc:
|
||||||
|
state = "unknown" if stage == "data" else "retry"
|
||||||
|
detail = "Mail server acceptance is unknown; check its logs before taking further action." if state == "unknown" else "Could not reach or finish connecting to the mail server."
|
||||||
|
raise DeliveryError(state, detail) from exc
|
||||||
|
finally:
|
||||||
|
if smtp:
|
||||||
|
# A failed QUIT after a 250 DATA response must not turn an accepted email into a retry.
|
||||||
|
with suppress(Exception):
|
||||||
|
smtp.quit()
|
||||||
|
with suppress(Exception):
|
||||||
|
smtp.close()
|
||||||
|
|
||||||
|
|
||||||
|
def message_id(delivery_id: str, public_url: str) -> str:
|
||||||
|
host = urlsplit(public_url).hostname or "magent.local"
|
||||||
|
return f"<magent-recap-{delivery_id}@{host}>"
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""Durable consent, schedule and delivery records for personal email recaps."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from contextlib import closing, contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .. import db
|
||||||
|
from .monthly_reports import shift_month
|
||||||
|
|
||||||
|
|
||||||
|
def init_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
for statement in (
|
||||||
|
"""CREATE TABLE IF NOT EXISTS email_recap_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1), enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
day INTEGER NOT NULL DEFAULT 2, hour INTEGER NOT NULL DEFAULT 9,
|
||||||
|
public_url TEXT NOT NULL DEFAULT '', next_send_at REAL)""",
|
||||||
|
"INSERT OR IGNORE INTO email_recap_settings (id) VALUES (1)",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS email_recap_subscriptions (
|
||||||
|
user_id INTEGER PRIMARY KEY, state TEXT NOT NULL, email TEXT NOT NULL,
|
||||||
|
identity_source TEXT NOT NULL, identity_id TEXT NOT NULL, version TEXT NOT NULL,
|
||||||
|
confirmation_hash TEXT UNIQUE, confirmation_expires REAL, requested_at REAL NOT NULL,
|
||||||
|
confirmed_at REAL, unsubscribe_token TEXT NOT NULL UNIQUE)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS email_recap_deliveries (
|
||||||
|
id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL,
|
||||||
|
month TEXT NOT NULL, kind TEXT NOT NULL, email TEXT NOT NULL,
|
||||||
|
subscription_version TEXT NOT NULL, public_url TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at REAL NOT NULL, updated_at REAL NOT NULL, next_attempt_at REAL NOT NULL,
|
||||||
|
claim TEXT, lease_until REAL, detail TEXT NOT NULL DEFAULT '')""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_email_recap_queue ON email_recap_deliveries (state, next_attempt_at)",
|
||||||
|
"""CREATE TRIGGER IF NOT EXISTS email_recap_account_changed AFTER UPDATE OF email, is_blocked ON users
|
||||||
|
WHEN LOWER(TRIM(COALESCE(NEW.email, ''))) != LOWER(TRIM(COALESCE(OLD.email, '')))
|
||||||
|
OR NEW.is_blocked = 1
|
||||||
|
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
|
||||||
|
confirmed_at = NULL WHERE user_id = NEW.id; END""",
|
||||||
|
"""CREATE TRIGGER IF NOT EXISTS email_recap_account_deleted AFTER DELETE ON users
|
||||||
|
BEGIN DELETE FROM email_recap_subscriptions WHERE user_id = OLD.id;
|
||||||
|
UPDATE email_recap_deliveries SET state = 'cancelled', detail = 'Account removed.'
|
||||||
|
WHERE user_id = OLD.id AND state IN ('queued', 'retry', 'preparing'); END""",
|
||||||
|
"""CREATE TRIGGER IF NOT EXISTS email_recap_identity_changed AFTER UPDATE ON jellyfin_user_links
|
||||||
|
WHEN NEW.jellyfin_user_id != OLD.jellyfin_user_id OR NEW.source != OLD.source
|
||||||
|
OR NEW.local_user_id != OLD.local_user_id
|
||||||
|
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
|
||||||
|
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
|
||||||
|
"""CREATE TRIGGER IF NOT EXISTS email_recap_identity_deleted AFTER DELETE ON jellyfin_user_links
|
||||||
|
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
|
||||||
|
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
|
||||||
|
):
|
||||||
|
conn.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction():
|
||||||
|
with closing(db._connect()) as conn, conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
def read_one(sql: str, args=()) -> dict | None:
|
||||||
|
with closing(db._connect()) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
row = conn.execute(sql, args).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def settings() -> dict:
|
||||||
|
row = read_one("SELECT * FROM email_recap_settings WHERE id = 1")
|
||||||
|
return {key: (bool(value) if key == "enabled" else value) for key, value in row.items() if key != "id"}
|
||||||
|
|
||||||
|
|
||||||
|
def next_due(now: datetime, day: int, hour: int) -> datetime:
|
||||||
|
due = shift_month(now, 0).replace(day=day, hour=hour)
|
||||||
|
return due if due > now else shift_month(now, 1).replace(day=day, hour=hour)
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(values: dict, now: datetime) -> dict:
|
||||||
|
with transaction() as conn:
|
||||||
|
old = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id = 1").fetchone())
|
||||||
|
changed = any(old[key] != values[key] for key in ("day", "hour", "public_url"))
|
||||||
|
due = old["next_send_at"]
|
||||||
|
if not values["enabled"]:
|
||||||
|
due = None
|
||||||
|
elif not old["enabled"] or changed:
|
||||||
|
due = next_due(now, values["day"], values["hour"]).timestamp()
|
||||||
|
conn.execute("UPDATE email_recap_settings SET enabled=?, day=?, hour=?, public_url=?, next_send_at=? WHERE id=1",
|
||||||
|
(values["enabled"], values["day"], values["hour"], values["public_url"], due))
|
||||||
|
if not values["enabled"] or changed:
|
||||||
|
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Schedule paused or changed.', updated_at=?
|
||||||
|
WHERE kind='scheduled' AND state IN ('queued', 'retry', 'preparing')""", (now.timestamp(),))
|
||||||
|
return settings()
|
||||||
|
|
||||||
|
|
||||||
|
def subscription(user_id: int) -> dict | None:
|
||||||
|
return read_one("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def disable(user_id: int) -> None:
|
||||||
|
with transaction() as conn:
|
||||||
|
conn.execute("UPDATE email_recap_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=?", (user_id,))
|
||||||
|
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Email recaps turned off.'
|
||||||
|
WHERE user_id=? AND state IN ('queued', 'retry', 'preparing')""", (user_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def request_confirmation(user: dict, source: str, identity: str, now: float) -> str:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
with transaction() as conn:
|
||||||
|
old = conn.execute("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user["id"],)).fetchone()
|
||||||
|
if old and old["requested_at"] > now - 300:
|
||||||
|
raise ValueError("Please wait five minutes before requesting another confirmation email.")
|
||||||
|
conn.execute("""INSERT INTO email_recap_subscriptions
|
||||||
|
(user_id, state, email, identity_source, identity_id, version, confirmation_hash,
|
||||||
|
confirmation_expires, requested_at, confirmed_at, unsubscribe_token)
|
||||||
|
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, NULL, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET state='pending', email=excluded.email,
|
||||||
|
identity_source=excluded.identity_source, identity_id=excluded.identity_id, version=excluded.version,
|
||||||
|
confirmation_hash=excluded.confirmation_hash, confirmation_expires=excluded.confirmation_expires,
|
||||||
|
requested_at=excluded.requested_at, confirmed_at=NULL, unsubscribe_token=excluded.unsubscribe_token""",
|
||||||
|
(user["id"], user["email"].strip(), source, identity, uuid.uuid4().hex,
|
||||||
|
hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def token_subscription(token: str, action: str) -> dict | None:
|
||||||
|
if action == "confirm":
|
||||||
|
return read_one("SELECT * FROM email_recap_subscriptions WHERE confirmation_hash=?",
|
||||||
|
(hashlib.sha256(token.encode()).hexdigest(),))
|
||||||
|
return read_one("SELECT * FROM email_recap_subscriptions WHERE unsubscribe_token=?", (token,))
|
||||||
|
|
||||||
|
|
||||||
|
def confirm(sub: dict, now: float) -> bool:
|
||||||
|
with transaction() as conn:
|
||||||
|
# Recheck address and blocked state in the same transaction as the consent write.
|
||||||
|
result = conn.execute("""UPDATE email_recap_subscriptions SET state='enabled', confirmed_at=?, confirmation_hash=NULL
|
||||||
|
WHERE user_id=? AND version=? AND state='pending' AND confirmation_expires>?
|
||||||
|
AND EXISTS (SELECT 1 FROM users WHERE users.id=user_id AND is_blocked=0
|
||||||
|
AND LOWER(TRIM(users.email))=LOWER(TRIM(email_recap_subscriptions.email)))""",
|
||||||
|
(now, sub["user_id"], sub["version"], now))
|
||||||
|
return result.rowcount == 1
|
||||||
|
|
||||||
|
|
||||||
|
def _enqueue(conn, sub: dict, month: str, kind: str, key: str, public_url: str, now: float) -> str:
|
||||||
|
delivery_id = uuid.uuid4().hex
|
||||||
|
conn.execute("""INSERT OR IGNORE INTO email_recap_deliveries
|
||||||
|
(id, dedupe_key, user_id, month, kind, email, subscription_version, public_url,
|
||||||
|
created_at, updated_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(delivery_id, key, sub["user_id"], month, kind, sub["email"], sub["version"], public_url, now, now, now))
|
||||||
|
return conn.execute("SELECT id FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_test(sub: dict, month: str, request_id: str, public_url: str, now: float) -> str:
|
||||||
|
key = f"test:{sub['user_id']}:{request_id}"
|
||||||
|
with transaction() as conn:
|
||||||
|
existing = conn.execute("SELECT id FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
return existing[0]
|
||||||
|
recent = conn.execute("SELECT 1 FROM email_recap_deliveries WHERE user_id=? AND kind='test' AND created_at>?",
|
||||||
|
(sub["user_id"], now - 300)).fetchone()
|
||||||
|
if recent:
|
||||||
|
raise ValueError("Please wait five minutes between test emails.")
|
||||||
|
return _enqueue(conn, sub, month, "test", key, public_url, now)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_due(now: datetime) -> int:
|
||||||
|
with transaction() as conn:
|
||||||
|
config = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id=1").fetchone())
|
||||||
|
if not config["enabled"] or not config["next_send_at"] or config["next_send_at"] > now.timestamp():
|
||||||
|
return 0
|
||||||
|
# After long downtime, send only the latest due recap; never backfill a pile of old emails.
|
||||||
|
due = shift_month(now, 0).replace(day=config["day"], hour=config["hour"])
|
||||||
|
if due > now:
|
||||||
|
due = shift_month(now, -1).replace(day=config["day"], hour=config["hour"])
|
||||||
|
month = shift_month(due, -1).strftime("%Y-%m")
|
||||||
|
subs = conn.execute("SELECT * FROM email_recap_subscriptions WHERE state='enabled' AND confirmed_at<=?", (due.timestamp(),)).fetchall()
|
||||||
|
before = conn.total_changes
|
||||||
|
for sub in subs:
|
||||||
|
_enqueue(conn, dict(sub), month, "scheduled", f"scheduled:{sub['user_id']}:{month}", config["public_url"], now.timestamp())
|
||||||
|
count = conn.total_changes - before
|
||||||
|
conn.execute("UPDATE email_recap_settings SET next_send_at=? WHERE id=1",
|
||||||
|
(next_due(now, config["day"], config["hour"]).timestamp(),))
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def claim_delivery(now: float) -> dict | None:
|
||||||
|
with transaction() as conn:
|
||||||
|
# A crashed worker could already have handed DATA to SMTP. Do not resend it automatically.
|
||||||
|
conn.execute("""UPDATE email_recap_deliveries SET state='unknown', detail='Delivery interrupted after sending began; check the mail server.', updated_at=?
|
||||||
|
WHERE state='sending' AND lease_until<?""", (now, now))
|
||||||
|
conn.execute("""UPDATE email_recap_deliveries SET state=CASE WHEN attempts>=3 THEN 'failed' ELSE 'retry' END,
|
||||||
|
next_attempt_at=?, updated_at=?, detail='Report preparation interrupted.'
|
||||||
|
WHERE state='preparing' AND lease_until<?""", (now, now, now))
|
||||||
|
row = conn.execute("""SELECT * FROM email_recap_deliveries WHERE state IN ('queued', 'retry') AND next_attempt_at<=?
|
||||||
|
ORDER BY created_at, id LIMIT 1""", (now,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
claim = uuid.uuid4().hex
|
||||||
|
conn.execute("""UPDATE email_recap_deliveries SET state='preparing', claim=?, lease_until=?,
|
||||||
|
attempts=attempts+1, updated_at=? WHERE id=?""", (claim, now + 1800, now, row["id"]))
|
||||||
|
return dict(conn.execute("SELECT * FROM email_recap_deliveries WHERE id=?", (row["id"],)).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def begin_sending(delivery: dict, now: float) -> bool:
|
||||||
|
with transaction() as conn:
|
||||||
|
# Consent may have changed while the report or SMTP connection was being prepared.
|
||||||
|
result = conn.execute("""UPDATE email_recap_deliveries SET state='sending', updated_at=?, lease_until=?
|
||||||
|
WHERE id=? AND claim=? AND state='preparing'
|
||||||
|
AND EXISTS (SELECT 1 FROM email_recap_subscriptions s JOIN users u ON u.id=s.user_id
|
||||||
|
JOIN jellyfin_user_links j ON j.local_user_id=u.id AND j.source=s.identity_source
|
||||||
|
WHERE s.user_id=email_recap_deliveries.user_id AND s.state='enabled'
|
||||||
|
AND s.version=email_recap_deliveries.subscription_version AND u.is_blocked=0
|
||||||
|
AND LOWER(TRIM(u.email))=LOWER(TRIM(s.email)) AND j.jellyfin_user_id=s.identity_id)
|
||||||
|
AND EXISTS (SELECT 1 FROM email_recap_settings c WHERE c.id=1 AND c.public_url=email_recap_deliveries.public_url
|
||||||
|
AND (email_recap_deliveries.kind='test' OR c.enabled=1))""", (now, now + 1800, delivery["id"], delivery["claim"]))
|
||||||
|
return result.rowcount == 1
|
||||||
|
|
||||||
|
|
||||||
|
def finish(delivery: dict, state: str, detail: str, now: float, delay: int = 0) -> None:
|
||||||
|
with transaction() as conn:
|
||||||
|
conn.execute("""UPDATE email_recap_deliveries SET state=?, detail=?, updated_at=?, next_attempt_at=?, lease_until=NULL
|
||||||
|
WHERE id=? AND claim=? AND state IN ('preparing', 'sending')""",
|
||||||
|
(state, detail, now, now + delay, delivery["id"], delivery["claim"]))
|
||||||
|
|
||||||
|
|
||||||
|
def history(limit: int = 50, offset: int = 0) -> dict:
|
||||||
|
with closing(db._connect()) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute("""SELECT d.id, d.month, d.kind, d.email, d.state, d.attempts, d.created_at, d.updated_at,
|
||||||
|
d.next_attempt_at, d.detail, u.username FROM email_recap_deliveries d LEFT JOIN users u ON u.id=d.user_id
|
||||||
|
ORDER BY d.created_at DESC, d.id LIMIT ? OFFSET ?""", (limit, offset)).fetchall()
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM email_recap_deliveries").fetchone()[0]
|
||||||
|
subscribers = conn.execute("SELECT COUNT(*) FROM email_recap_subscriptions WHERE state='enabled'").fetchone()[0]
|
||||||
|
return {"deliveries": [dict(row) for row in rows], "total": total, "subscribers": subscribers}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
import socketserver
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from email import policy
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from backend.app import db
|
||||||
|
from backend.app.auth import get_current_user
|
||||||
|
from backend.app.clients.jellystat import HistoryLimitError, JellystatError
|
||||||
|
from backend.app.routers import recaps as router
|
||||||
|
from backend.app.services import email_recaps as recaps, recap_email as mail, recap_store as store
|
||||||
|
from backend.app.services.jellyfin_identity import link_user, source_key
|
||||||
|
from backend.app.services.monthly_reports import change, month_periods, shift_month
|
||||||
|
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_report():
|
||||||
|
periods = month_periods(None, datetime.now(timezone.utc))
|
||||||
|
summary = dict(minutes=1500, movies=8, episodes=24, plays=35, active_days=20, longest_streak=6)
|
||||||
|
changes = {key: change(value, round(value / 2)) for key, value in summary.items()}
|
||||||
|
changes['requests'] = change(3, 2)
|
||||||
|
return {**periods, 'state': 'ready', 'summary': summary, 'changes': changes, 'requests': {'total': 3},
|
||||||
|
'top_titles': [{'title': 'Severance', 'type': 'series', 'minutes': 460, 'plays': 10},
|
||||||
|
{'title': 'Arrival', 'type': 'movie', 'minutes': 116, 'plays': 1}],
|
||||||
|
'recent': [{'artwork_url': '/insights/artwork/SECRET?token=PRIVATE-TOKEN'}]}
|
||||||
|
|
||||||
|
|
||||||
|
def runtime():
|
||||||
|
return SimpleNamespace(jellyfin_base_url='http://jellyfin', jellystat_base_url='http://jellystat',
|
||||||
|
jellystat_api_key='PRIVATE-STATS-KEY', magent_notify_enabled=True, magent_notify_email_enabled=True,
|
||||||
|
magent_notify_email_smtp_host='127.0.0.1', magent_notify_email_smtp_port=1,
|
||||||
|
magent_notify_email_smtp_username='', magent_notify_email_smtp_password='',
|
||||||
|
magent_notify_email_from_address='magent@example.test', magent_notify_email_from_name='Magent',
|
||||||
|
magent_notify_email_use_tls=False, magent_notify_email_use_ssl=False)
|
||||||
|
|
||||||
|
|
||||||
|
class RecapFixture(TempDatabaseMixin):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
db.create_user('viewer', 'Example-Password123!', role='admin', email='viewer@example.test')
|
||||||
|
link_user('viewer', 'jf-viewer', 'http://jellyfin')
|
||||||
|
self.user = db.get_user_by_username('viewer')
|
||||||
|
self.runtime = runtime()
|
||||||
|
for target, name, value in [(recaps, 'get_runtime_settings', self.runtime), (mail, 'get_runtime_settings', self.runtime),
|
||||||
|
(recaps, 'smtp_email_config_ready', (True, 'ok'))]:
|
||||||
|
mocked = patch.object(target, name, return_value=value)
|
||||||
|
mocked.start(); self.addCleanup(mocked.stop)
|
||||||
|
env = patch.dict('os.environ', {'BACKGROUND_TASKS_ENABLED': 'true'})
|
||||||
|
env.start(); self.addCleanup(env.stop)
|
||||||
|
self.config = dict(enabled=False, day=2, hour=9, public_url='https://beta.example.test')
|
||||||
|
store.save_settings(self.config, datetime.now(timezone.utc))
|
||||||
|
self.report = fixture_report()
|
||||||
|
|
||||||
|
def subscribe(self, timestamp=None):
|
||||||
|
now = time.time() if timestamp is None else timestamp
|
||||||
|
token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', now)
|
||||||
|
sub = store.subscription(self.user['id'])
|
||||||
|
self.assertTrue(store.confirm(sub, now + 1))
|
||||||
|
return store.subscription(self.user['id']), token
|
||||||
|
|
||||||
|
def queue(self, sub=None, request_id='request-1'):
|
||||||
|
if sub is None:
|
||||||
|
sub, _ = self.subscribe()
|
||||||
|
return store.enqueue_test(sub, self.report['month'], request_id, self.config['public_url'], time.time())
|
||||||
|
|
||||||
|
def delivery(self, delivery_id):
|
||||||
|
return store.read_one('SELECT * FROM email_recap_deliveries WHERE id=?', (delivery_id,))
|
||||||
|
|
||||||
|
|
||||||
|
class RecapConsentTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_opt_in_only_emails_confirmation_and_check_link_does_not_confirm(self):
|
||||||
|
with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
|
||||||
|
result = await recaps.subscribe(self.user)
|
||||||
|
self.assertEqual(result['state'], 'pending')
|
||||||
|
report.assert_not_called()
|
||||||
|
recipient, rendered, _ = sender.call_args.args
|
||||||
|
self.assertEqual(recipient, 'viewer@example.test')
|
||||||
|
self.assertNotIn('Severance', rendered['body_html'])
|
||||||
|
url = re.search(r'https://[^\s]+', rendered['body_text']).group(0)
|
||||||
|
token = parse_qs(urlsplit(url).fragment)['token'][0]
|
||||||
|
self.assertNotIn(token, store.subscription(self.user['id'])['confirmation_hash'])
|
||||||
|
self.assertEqual(recaps.token_action(token, 'confirm')['state'], 'ready')
|
||||||
|
self.assertEqual(store.subscription(self.user['id'])['state'], 'pending')
|
||||||
|
self.assertEqual(recaps.token_action(token, 'confirm', apply=True)['state'], 'enabled')
|
||||||
|
with self.assertRaises(recaps.RecapError):
|
||||||
|
recaps.token_action(token, 'confirm', apply=True)
|
||||||
|
with self.assertRaises(recaps.RecapError):
|
||||||
|
recaps.token_action(token, 'unsubscribe', apply=True)
|
||||||
|
|
||||||
|
async def test_confirmation_failure_is_pending_and_resend_is_rate_limited(self):
|
||||||
|
with patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'unknown')):
|
||||||
|
with self.assertRaises(recaps.RecapError) as exc:
|
||||||
|
await recaps.subscribe(self.user)
|
||||||
|
self.assertEqual(exc.exception.status, 502)
|
||||||
|
self.assertEqual(recaps.preferences(self.user)['state'], 'pending')
|
||||||
|
with patch.object(mail, 'send_email') as sender:
|
||||||
|
with self.assertRaises(recaps.RecapError) as exc:
|
||||||
|
await recaps.subscribe(self.user)
|
||||||
|
self.assertEqual(exc.exception.status, 429)
|
||||||
|
sender.assert_not_called()
|
||||||
|
|
||||||
|
def test_unsubscribe_is_public_idempotent_and_cancels_queued_email(self):
|
||||||
|
sub, _ = self.subscribe()
|
||||||
|
delivery_id = self.queue(sub)
|
||||||
|
token = sub['unsubscribe_token']
|
||||||
|
self.assertEqual(recaps.token_action(token, 'unsubscribe')['state'], 'ready')
|
||||||
|
self.assertEqual(self.delivery(delivery_id)['state'], 'queued')
|
||||||
|
recaps.token_action(token, 'unsubscribe', apply=True)
|
||||||
|
self.assertEqual(recaps.token_action(token, 'unsubscribe', apply=True)['state'], 'off')
|
||||||
|
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
|
||||||
|
|
||||||
|
def test_expired_confirmation_does_not_subscribe(self):
|
||||||
|
token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time() - 90000)
|
||||||
|
self.assertEqual(recaps.preferences(self.user)['state'], 'expired')
|
||||||
|
with self.assertRaises(recaps.RecapError):
|
||||||
|
recaps.token_action(token, 'confirm', apply=True)
|
||||||
|
|
||||||
|
def test_email_change_back_does_not_restore_consent(self):
|
||||||
|
self.subscribe()
|
||||||
|
db.set_user_email('viewer', 'changed@example.test')
|
||||||
|
db.set_user_email('viewer', 'viewer@example.test')
|
||||||
|
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
|
||||||
|
|
||||||
|
def test_changed_link_or_source_requires_new_consent(self):
|
||||||
|
self.subscribe()
|
||||||
|
with store.transaction() as conn:
|
||||||
|
conn.execute("UPDATE jellyfin_user_links SET jellyfin_user_id='new-identity' WHERE local_user_id=?", (self.user['id'],))
|
||||||
|
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
|
||||||
|
with store.transaction() as conn:
|
||||||
|
conn.execute("UPDATE email_recap_subscriptions SET state='enabled'")
|
||||||
|
self.runtime.jellyfin_base_url = 'http://other-jellyfin'
|
||||||
|
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
|
||||||
|
|
||||||
|
def test_missing_email_or_stored_identity_cannot_subscribe(self):
|
||||||
|
db.set_user_email('viewer', None)
|
||||||
|
self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
|
||||||
|
db.set_user_email('viewer', 'viewer@example.test')
|
||||||
|
with store.transaction() as conn:
|
||||||
|
conn.execute('DELETE FROM jellyfin_user_links')
|
||||||
|
self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
|
||||||
|
|
||||||
|
def test_confirmation_rechecks_email_atomically(self):
|
||||||
|
store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time())
|
||||||
|
old = store.subscription(self.user['id'])
|
||||||
|
db.set_user_email('viewer', 'different@example.test')
|
||||||
|
self.assertFalse(store.confirm(old, time.time()))
|
||||||
|
|
||||||
|
|
||||||
|
class RecapScheduleTests(RecapFixture, unittest.TestCase):
|
||||||
|
def test_defaults_are_paused_and_no_users_are_opted_in(self):
|
||||||
|
self.assertFalse(store.settings()['enabled'])
|
||||||
|
self.assertEqual(store.history()['subscribers'], 0)
|
||||||
|
self.assertEqual(store.enqueue_due(datetime.now(timezone.utc)), 0)
|
||||||
|
|
||||||
|
def test_utc_next_send_month_end_leap_year_and_new_year(self):
|
||||||
|
for now, expected in [
|
||||||
|
(datetime(2026, 12, 31, tzinfo=timezone.utc), '2027-01-02T09:00:00+00:00'),
|
||||||
|
(datetime(2024, 2, 29, tzinfo=timezone.utc), '2024-03-02T09:00:00+00:00'),
|
||||||
|
(datetime(2026, 9, 2, 8, tzinfo=timezone.utc), '2026-09-02T09:00:00+00:00'),
|
||||||
|
(datetime(2026, 9, 2, 9, tzinfo=timezone.utc), '2026-10-02T09:00:00+00:00')]:
|
||||||
|
self.assertEqual(store.next_due(now, 2, 9).isoformat(), expected)
|
||||||
|
|
||||||
|
def test_schedule_catches_up_once_and_excludes_late_subscribers(self):
|
||||||
|
before = datetime(2026, 8, 30, tzinfo=timezone.utc)
|
||||||
|
self.subscribe(before.timestamp())
|
||||||
|
config = store.save_settings({**self.config, 'enabled': True}, before)
|
||||||
|
self.assertEqual(config['next_send_at'], datetime(2026, 9, 2, 9, tzinfo=timezone.utc).timestamp())
|
||||||
|
db.create_user('late', 'Example-Password123!', email='late@example.test')
|
||||||
|
late = db.get_user_by_username('late')
|
||||||
|
store.request_confirmation(late, 'source', 'late-id', datetime(2026, 9, 2, 10, tzinfo=timezone.utc).timestamp())
|
||||||
|
store.confirm(store.subscription(late['id']), datetime(2026, 9, 2, 11, tzinfo=timezone.utc).timestamp())
|
||||||
|
now = datetime(2026, 9, 5, tzinfo=timezone.utc)
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
counts = list(pool.map(store.enqueue_due, [now] * 4))
|
||||||
|
self.assertEqual(sum(counts), 1)
|
||||||
|
rows = store.history()['deliveries']
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]['month'], '2026-08')
|
||||||
|
self.assertEqual(rows[0]['email'], 'viewer@example.test')
|
||||||
|
# Revisit the same due date after a restart: the durable unique key still wins.
|
||||||
|
with store.transaction() as conn:
|
||||||
|
conn.execute('UPDATE email_recap_settings SET next_send_at=?', (config['next_send_at'],))
|
||||||
|
self.assertEqual(store.enqueue_due(now), 0)
|
||||||
|
|
||||||
|
def test_long_downtime_does_not_backfill_multiple_months(self):
|
||||||
|
before = datetime(2026, 5, 1, tzinfo=timezone.utc)
|
||||||
|
self.subscribe(before.timestamp())
|
||||||
|
store.save_settings({**self.config, 'enabled': True}, before)
|
||||||
|
self.assertEqual(store.enqueue_due(datetime(2026, 9, 9, tzinfo=timezone.utc)), 1)
|
||||||
|
self.assertEqual(store.history()['deliveries'][0]['month'], '2026-08')
|
||||||
|
|
||||||
|
def test_enable_after_due_date_waits_and_pause_cancels_pending_monthlies(self):
|
||||||
|
now = datetime(2026, 9, 9, tzinfo=timezone.utc)
|
||||||
|
self.subscribe(now.timestamp())
|
||||||
|
result = store.save_settings({**self.config, 'enabled': True}, now)
|
||||||
|
self.assertEqual(result['next_send_at'], datetime(2026, 10, 2, 9, tzinfo=timezone.utc).timestamp())
|
||||||
|
self.assertEqual(store.enqueue_due(now), 0)
|
||||||
|
store.enqueue_due(datetime(2026, 10, 3, tzinfo=timezone.utc))
|
||||||
|
store.save_settings(self.config, now)
|
||||||
|
self.assertEqual(store.history()['deliveries'][0]['state'], 'cancelled')
|
||||||
|
self.assertIsNone(store.settings()['next_send_at'])
|
||||||
|
|
||||||
|
|
||||||
|
class RecapDeliveryTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def run_claim(self):
|
||||||
|
delivery = store.claim_delivery(time.time())
|
||||||
|
self.assertIsNotNone(delivery)
|
||||||
|
await recaps.process_delivery(delivery)
|
||||||
|
|
||||||
|
async def test_private_report_is_delivered_once_using_confirmed_account(self):
|
||||||
|
delivery_id = self.queue()
|
||||||
|
sent = []
|
||||||
|
def capture(recipient, rendered, message_id, before_data):
|
||||||
|
before_data()
|
||||||
|
self.assertEqual(self.delivery(delivery_id)['state'], 'sending')
|
||||||
|
sent.append((recipient, rendered, message_id))
|
||||||
|
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email', side_effect=capture):
|
||||||
|
await recaps.run_once()
|
||||||
|
await recaps.run_once()
|
||||||
|
self.assertEqual(len(sent), 1)
|
||||||
|
self.assertEqual(sent[0][0], 'viewer@example.test')
|
||||||
|
self.assertIn(f'?month={self.report["month"]}', sent[0][1]['body_html'])
|
||||||
|
self.assertNotIn('PRIVATE-TOKEN', json.dumps(sent))
|
||||||
|
self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
|
||||||
|
self.assertEqual(self.delivery(delivery_id)['state'], 'sent')
|
||||||
|
self.assertNotIn('unsubscribe_token', json.dumps(store.history()))
|
||||||
|
|
||||||
|
def test_concurrent_claim_and_test_deduplication(self):
|
||||||
|
sub, _ = self.subscribe()
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
ids = list(pool.map(lambda _: self.queue(sub), range(4)))
|
||||||
|
rows = list(pool.map(lambda _: store.claim_delivery(time.time()), range(4)))
|
||||||
|
self.assertEqual(len(set(ids)), 1)
|
||||||
|
self.assertEqual(sum(row is not None for row in rows), 1)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.queue(sub, 'another-click')
|
||||||
|
|
||||||
|
async def test_unsubscribe_or_email_change_during_report_prevents_sending(self):
|
||||||
|
delivery_id = self.queue()
|
||||||
|
async def report(*args):
|
||||||
|
db.set_user_email('viewer', 'other@example.test')
|
||||||
|
return self.report
|
||||||
|
def transport(recipient, rendered, message_id, before_data):
|
||||||
|
before_data()
|
||||||
|
self.fail('Private data must not reach SMTP DATA after an address change')
|
||||||
|
with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
|
||||||
|
await self.run_claim()
|
||||||
|
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
|
||||||
|
|
||||||
|
async def test_blocked_expired_and_deleted_accounts_are_not_sent(self):
|
||||||
|
for kind in ['blocked', 'expired', 'deleted']:
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
# Each subcase starts with a fresh account and confirmed subscription.
|
||||||
|
db.create_user(kind, 'Example-Password123!', email=f'{kind}@example.test')
|
||||||
|
account = db.get_user_by_username(kind)
|
||||||
|
link_user(kind, f'jf-{kind}', 'http://jellyfin')
|
||||||
|
store.request_confirmation(account, source_key('http://jellyfin'), f'jf-{kind}', time.time())
|
||||||
|
store.confirm(store.subscription(account['id']), time.time())
|
||||||
|
delivery_id = self.queue(store.subscription(account['id']), kind)
|
||||||
|
with store.transaction() as conn:
|
||||||
|
if kind == 'blocked': conn.execute('UPDATE users SET is_blocked=1 WHERE id=?', (account['id'],))
|
||||||
|
elif kind == 'expired': conn.execute("UPDATE users SET expires_at='2000-01-01T00:00:00+00:00' WHERE id=?", (account['id'],))
|
||||||
|
else: conn.execute('DELETE FROM users WHERE id=?', (account['id'],))
|
||||||
|
with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
|
||||||
|
await recaps.run_once()
|
||||||
|
sender.assert_not_called(); report.assert_not_called()
|
||||||
|
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
|
||||||
|
|
||||||
|
async def test_known_temporary_failure_retries_three_times_with_stable_id(self):
|
||||||
|
delivery_id = self.queue()
|
||||||
|
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('retry', 'SMTP 451')) as sender:
|
||||||
|
for attempt in range(1, 4):
|
||||||
|
await self.run_claim()
|
||||||
|
row = self.delivery(delivery_id)
|
||||||
|
self.assertEqual(row['attempts'], attempt)
|
||||||
|
self.assertEqual(row['state'], 'failed' if attempt == 3 else 'retry')
|
||||||
|
if attempt < 3:
|
||||||
|
self.assertGreater(row['next_attempt_at'], time.time() + 250)
|
||||||
|
with store.transaction() as conn:
|
||||||
|
conn.execute('UPDATE email_recap_deliveries SET next_attempt_at=0 WHERE id=?', (delivery_id,))
|
||||||
|
self.assertEqual(len(set(call.args[2] for call in sender.call_args_list)), 1)
|
||||||
|
self.assertIsNone(store.claim_delivery(time.time()))
|
||||||
|
|
||||||
|
async def test_ambiguous_smtp_failure_never_automatically_retries(self):
|
||||||
|
delivery_id = self.queue()
|
||||||
|
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'Check mail logs')):
|
||||||
|
await self.run_claim()
|
||||||
|
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
|
||||||
|
self.assertIsNone(store.claim_delivery(time.time() + 86400))
|
||||||
|
|
||||||
|
def test_stale_worker_claims_are_recovered_without_resending_uncertain_mail(self):
|
||||||
|
delivery_id = self.queue()
|
||||||
|
first = store.claim_delivery(time.time())
|
||||||
|
second = store.claim_delivery(time.time() + 1801)
|
||||||
|
self.assertNotEqual(first['claim'], second['claim'])
|
||||||
|
self.assertFalse(store.begin_sending(first, time.time()))
|
||||||
|
self.assertTrue(store.begin_sending(second, time.time()))
|
||||||
|
store.claim_delivery(time.time() + 1801)
|
||||||
|
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
|
||||||
|
store.finish(first, 'sent', 'Old worker', time.time())
|
||||||
|
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
|
||||||
|
|
||||||
|
async def test_partial_or_over_limit_report_is_not_emailed(self):
|
||||||
|
delivery_id = self.queue()
|
||||||
|
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(side_effect=HistoryLimitError('limit'))), patch.object(mail, 'send_email') as sender:
|
||||||
|
await self.run_claim()
|
||||||
|
sender.assert_not_called()
|
||||||
|
self.assertEqual(self.delivery(delivery_id)['state'], 'failed')
|
||||||
|
|
||||||
|
|
||||||
|
class RecapApiTests(RecapFixture, unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router.router)
|
||||||
|
self.app = app
|
||||||
|
self.client = TestClient(app)
|
||||||
|
self.addCleanup(self.client.close)
|
||||||
|
|
||||||
|
def login(self, role='admin'):
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {**self.user, 'role': role}
|
||||||
|
|
||||||
|
def test_authentication_roles_and_recipient_override(self):
|
||||||
|
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 401)
|
||||||
|
self.assertEqual(self.client.get('/profile/email-recaps').status_code, 401)
|
||||||
|
self.login('user')
|
||||||
|
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 403)
|
||||||
|
self.assertEqual(self.client.get('/admin/email-recaps/preview').status_code, 403)
|
||||||
|
self.assertEqual(self.client.post('/admin/email-recaps/test', json={}).status_code, 403)
|
||||||
|
self.login()
|
||||||
|
result = self.client.get('/admin/email-recaps')
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
self.assertEqual(result.headers['cache-control'], 'no-store')
|
||||||
|
self.assertNotIn('PRIVATE-STATS-KEY', result.text)
|
||||||
|
result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'recipient_email': 'other@example.test'})
|
||||||
|
self.assertEqual(result.status_code, 422)
|
||||||
|
result = self.client.put('/profile/email-recaps', json={'enabled': False, 'user_id': 5})
|
||||||
|
self.assertEqual(result.status_code, 422)
|
||||||
|
|
||||||
|
def test_url_and_schedule_validation_do_not_write_partial_settings(self):
|
||||||
|
self.login()
|
||||||
|
for value in ['javascript:alert(1)', 'https://user:secret@example.test', 'https://example.test/path', 'https://example.test?token=secret', 'https://example.test#token', 'https://example.test:0', 'https://example.test\\evil']:
|
||||||
|
result = self.client.put('/admin/email-recaps', json={**self.config, 'public_url': value})
|
||||||
|
self.assertEqual(result.status_code, 422, value)
|
||||||
|
for field, value in [('day', 0), ('day', 29), ('hour', 24)]:
|
||||||
|
self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, field: value}).status_code, 422)
|
||||||
|
with patch.object(recaps, 'smtp_email_config_ready', return_value=(False, 'Email is disabled.')):
|
||||||
|
self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, 'enabled': True}).status_code, 409)
|
||||||
|
self.assertEqual(store.settings()['public_url'], self.config['public_url'])
|
||||||
|
self.assertFalse(store.settings()['enabled'])
|
||||||
|
|
||||||
|
def test_preview_uses_own_report_and_test_requires_confirmed_email(self):
|
||||||
|
self.login()
|
||||||
|
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email') as sender:
|
||||||
|
result = self.client.get('/admin/email-recaps/preview')
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
|
||||||
|
self.assertNotIn('PRIVATE-TOKEN', result.text)
|
||||||
|
sender.assert_not_called()
|
||||||
|
payload = {'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': self.report['month']}
|
||||||
|
self.assertEqual(self.client.post('/admin/email-recaps/test', json=payload).status_code, 409)
|
||||||
|
self.subscribe()
|
||||||
|
with patch.object(mail, 'send_email') as sender:
|
||||||
|
first = self.client.post('/admin/email-recaps/test', json=payload)
|
||||||
|
second = self.client.post('/admin/email-recaps/test', json=payload)
|
||||||
|
self.assertEqual(first.status_code, 202)
|
||||||
|
self.assertEqual(first.json()['id'], second.json()['id'])
|
||||||
|
sender.assert_not_called()
|
||||||
|
|
||||||
|
def test_partial_month_test_rejected_and_public_get_does_not_mutate(self):
|
||||||
|
self.login(); sub, token = self.subscribe()
|
||||||
|
result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': datetime.now(timezone.utc).strftime('%Y-%m')})
|
||||||
|
self.assertEqual(result.status_code, 422)
|
||||||
|
self.assertEqual(self.client.get('/email-recaps/confirm').status_code, 405)
|
||||||
|
result = self.client.post('/email-recaps/check', json={'action': 'unsubscribe', 'token': sub['unsubscribe_token']})
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
self.assertEqual(store.subscription(self.user['id'])['state'], 'enabled')
|
||||||
|
|
||||||
|
|
||||||
|
class RecapEmailTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.runtime = runtime()
|
||||||
|
patched = patch.object(mail, 'get_runtime_settings', return_value=self.runtime)
|
||||||
|
patched.start(); self.addCleanup(patched.stop)
|
||||||
|
self.rendered = mail.render_recap(fixture_report(), 'Viewer', 'https://beta.example.test', 'https://beta.example.test/email-recaps#action=unsubscribe&token=fixture')
|
||||||
|
|
||||||
|
def fake_smtp(self):
|
||||||
|
smtp = MagicMock()
|
||||||
|
smtp.mail.return_value = (250, b'OK')
|
||||||
|
smtp.rcpt.return_value = (250, b'OK')
|
||||||
|
smtp.data.return_value = (250, b'Accepted')
|
||||||
|
return smtp
|
||||||
|
|
||||||
|
def test_render_escapes_names_and_titles_and_includes_no_artwork_credentials(self):
|
||||||
|
report = fixture_report()
|
||||||
|
report['top_titles'][0]['title'] = '<img src=x onerror=alert(1)>'
|
||||||
|
rendered = mail.render_recap(report, '<script>alert(1)</script>', 'https://beta.example.test', 'https://beta.example.test/email-recaps#token=example')
|
||||||
|
self.assertNotIn('<script>', rendered['body_html'])
|
||||||
|
self.assertNotIn('<img src=x', rendered['body_html'])
|
||||||
|
self.assertIn('<script>', rendered['body_html'])
|
||||||
|
self.assertNotIn('PRIVATE-TOKEN', str(rendered))
|
||||||
|
self.assertIn('Unsubscribe', rendered['body_text'])
|
||||||
|
self.assertIn('UTC', rendered['body_text'])
|
||||||
|
self.assertIn('1,500', rendered['body_html'])
|
||||||
|
|
||||||
|
def test_mailbox_validation_rejects_injection_and_multiple_recipients(self):
|
||||||
|
for value in ['a@example.test\r\nBcc:b@example.test', 'a@example.test,b@example.test', 'Name <a@example.test>', 'x@', 'a;b@example.test']:
|
||||||
|
self.assertIsNone(mail.valid_email(value))
|
||||||
|
|
||||||
|
def test_smtp_acceptance_survives_quit_error_and_preserves_mime_message_id(self):
|
||||||
|
smtp = self.fake_smtp()
|
||||||
|
smtp.quit.side_effect = smtplib.SMTPServerDisconnected('after acceptance')
|
||||||
|
before = MagicMock()
|
||||||
|
with patch.object(mail.smtplib, 'SMTP', return_value=smtp):
|
||||||
|
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>', before)
|
||||||
|
before.assert_called_once()
|
||||||
|
message = BytesParser(policy=policy.default).parsebytes(smtp.data.call_args.args[0])
|
||||||
|
self.assertEqual(message['Message-ID'], '<stable@example.test>')
|
||||||
|
self.assertEqual(message['To'], 'viewer@example.test')
|
||||||
|
self.assertIsNone(message['Bcc'])
|
||||||
|
self.assertIn('1,500', message.get_body(('plain',)).get_content())
|
||||||
|
self.assertIn('<!doctype html>', message.get_body(('html',)).get_content())
|
||||||
|
|
||||||
|
def test_temporary_permanent_and_ambiguous_delivery_failures(self):
|
||||||
|
for operation, failure, expected in [
|
||||||
|
('mail', (451, b'temporary PRIVATE-KEY'), 'retry'), ('rcpt', (550, b'bad recipient'), 'failed'),
|
||||||
|
('data', (451, b'retry'), 'retry'), ('data', smtplib.SMTPServerDisconnected('lost after DATA'), 'unknown'),
|
||||||
|
('rcpt', smtplib.SMTPServerDisconnected('lost before DATA'), 'retry')]:
|
||||||
|
smtp = self.fake_smtp()
|
||||||
|
if isinstance(failure, Exception): getattr(smtp, operation).side_effect = failure
|
||||||
|
else: getattr(smtp, operation).return_value = failure
|
||||||
|
with self.subTest(operation=operation, expected=expected), patch.object(mail.smtplib, 'SMTP', return_value=smtp):
|
||||||
|
with self.assertRaises(mail.DeliveryError) as exc:
|
||||||
|
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>')
|
||||||
|
self.assertEqual(exc.exception.state, expected)
|
||||||
|
self.assertNotIn('PRIVATE-KEY', exc.exception.detail)
|
||||||
|
|
||||||
|
def test_consent_cancellation_happens_before_smtp_data(self):
|
||||||
|
smtp = self.fake_smtp()
|
||||||
|
with patch.object(mail.smtplib, 'SMTP', return_value=smtp), self.assertRaises(mail.DeliveryCancelled):
|
||||||
|
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>', MagicMock(side_effect=mail.DeliveryCancelled))
|
||||||
|
smtp.data.assert_not_called()
|
||||||
|
|
||||||
|
def test_real_smtp_is_captured_locally_without_external_delivery(self):
|
||||||
|
messages = []
|
||||||
|
class Capture(socketserver.StreamRequestHandler):
|
||||||
|
def handle(self):
|
||||||
|
self.wfile.write(b'220 local capture\r\n')
|
||||||
|
while line := self.rfile.readline():
|
||||||
|
command = line.split(b' ', 1)[0].strip().upper()
|
||||||
|
if command in (b'EHLO', b'HELO'):
|
||||||
|
self.wfile.write(b'250-localhost\r\n250 SIZE 1000000\r\n')
|
||||||
|
elif command == b'DATA':
|
||||||
|
self.wfile.write(b'354 Send content\r\n')
|
||||||
|
data = []
|
||||||
|
while (part := self.rfile.readline()) != b'.\r\n':
|
||||||
|
if not part: return
|
||||||
|
data.append(part[1:] if part.startswith(b'..') else part)
|
||||||
|
messages.append(b''.join(data))
|
||||||
|
self.wfile.write(b'250 Captured\r\n')
|
||||||
|
elif command == b'QUIT':
|
||||||
|
self.wfile.write(b'221 Bye\r\n'); return
|
||||||
|
else:
|
||||||
|
self.wfile.write(b'250 OK\r\n')
|
||||||
|
with socketserver.TCPServer(('127.0.0.1', 0), Capture) as server:
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
self.runtime.magent_notify_email_smtp_port = server.server_address[1]
|
||||||
|
try:
|
||||||
|
mail.send_email('viewer@example.test', self.rendered, '<local-capture@example.test>')
|
||||||
|
finally:
|
||||||
|
server.shutdown(); thread.join(timeout=5)
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
parsed = BytesParser(policy=policy.default).parsebytes(messages[0])
|
||||||
|
self.assertEqual(parsed['Message-ID'], '<local-capture@example.test>')
|
||||||
|
self.assertIn('Severance', parsed.get_body(('html',)).get_content())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -54,11 +54,34 @@ Open **My Stats → Monthly reports** (`/insights/reports`). The default is the
|
|||||||
- Periods include their start and exclude their end. Midnight activity belongs to exactly one month; leap years and December/January boundaries use calendar arithmetic. Missing prior activity has no percentage increase, rather than an infinite or invented percentage.
|
- Periods include their start and exclude their end. Midnight activity belongs to exactly one month; leap years and December/January boundaries use calendar arithmetic. Missing prior activity has no percentage increase, rather than an infinite or invented percentage.
|
||||||
- Reports use the same stored Jellyfin identity resolution as My Stats, including administrator-confirmed links. They accept only a month, never a browser-supplied user ID or server scope. Requests use the authenticated account's Seerr ID under the existing ownership rules.
|
- Reports use the same stored Jellyfin identity resolution as My Stats, including administrator-confirmed links. They accept only a month, never a browser-supplied user ID or server scope. Requests use the authenticated account's Seerr ID under the existing ownership rules.
|
||||||
- `GET /insights/reports/monthly` returns the report; `GET /insights/reports/monthly.csv` downloads its summary, comparisons, daily totals, leading titles, players, streaming methods, transcoding and request counts. Both require authentication and return `Cache-Control: no-store`. CSV text cells are escaped and formula-like values are prefixed to prevent spreadsheet execution. Exports omit account IDs, artwork tokens and upstream credentials.
|
- `GET /insights/reports/monthly` returns the report; `GET /insights/reports/monthly.csv` downloads its summary, comparisons, daily totals, leading titles, players, streaming methods, transcoding and request counts. Both require authentication and return `Cache-Control: no-store`. CSV text cells are escaped and formula-like values are prefixed to prevent spreadsheet execution. Exports omit account IDs, artwork tokens and upstream credentials.
|
||||||
- Reports are generated on demand from retained Jellystat history and Magent's available request cache. They are not immutable historical snapshots. Request statuses are current, and historical totals can change with retention or library metadata. No report database, email delivery or scheduler is added.
|
- Reports are generated on demand from retained Jellystat history and Magent's available request cache. They are not immutable historical snapshots. Request statuses are current, and historical totals can change with retention or library metadata.
|
||||||
- One bounded history read covers the selected and comparison months. Playback summaries are cached for 60 seconds in at most 128 entries, separated by identity, connection and month. Request totals are refreshed independently. Upstream errors or history limits fail the report without presenting a partial result.
|
- One bounded history read covers the selected and comparison months. Playback summaries are cached for 60 seconds in at most 128 entries, separated by identity, connection and month. Request totals are refreshed independently. Upstream errors or history limits fail the report without presenting a partial result.
|
||||||
|
|
||||||
`backend/tests/test_monthly_reports.py` covers calendar boundaries, matched partial periods, ownership, cache isolation, comparisons and safe CSV exports. `scripts/review_monthly_reports_ui.cjs` checks the report controls and layouts with fixtures only.
|
`backend/tests/test_monthly_reports.py` covers calendar boundaries, matched partial periods, ownership, cache isolation, comparisons and safe CSV exports. `scripts/review_monthly_reports_ui.cjs` checks the report controls and layouts with fixtures only.
|
||||||
|
|
||||||
|
## Personal monthly email recaps
|
||||||
|
|
||||||
|
**Settings → Monthly email recaps** (`/admin/recaps`) controls the public Magent address, monthly schedule, personal preview, test emails and delivery history. The dark email design matches My Stats and includes viewing/request totals, changes against the previous month, the longest run and top three titles. The full-report link preserves its month through sign-in. A plain-text alternative is included; private artwork tokens and service credentials are never embedded in an email.
|
||||||
|
|
||||||
|
New installations start with scheduled delivery paused and no subscriptions. Set this environment's public Magent origin (for Beta, `https://beta.grizzlyflix.co.nz`), check **Email & notifications**, preview your own report and confirm your email in **Profile → Monthly recaps** before sending yourself a test. Test emails use the same queue and are allowed while the monthly schedule is paused. They can only go to the signed-in administrator's confirmed profile email. Previewing never sends email, and the preview's preference links do not contain a live unsubscribe token.
|
||||||
|
|
||||||
|
Users choose **Email me my monthly recap** in Profile and confirm ownership of their profile email through a link that expires in 24 hours. The confirmation email contains no viewing data. Opening a confirmation or unsubscribe link only checks it; the user must press the action button. Unsubscribe works without signing in and is also available in Profile. Link tokens travel in URL fragments, then in a redacted JSON `token` field. Confirmation tokens are stored as hashes and consumed on use. Unsubscribe tokens are random, scoped to the current subscription and rotated on a new opt-in.
|
||||||
|
|
||||||
|
Subscriptions are bound to the Magent account, confirmed email and stored Jellyfin source/user ID. The background worker does not infer links from emails or playback names. Email changes (even if later changed back), blocked accounts, deleted/replaced identity links and changed Jellyfin sources invalidate consent. Expired and deleted accounts are excluded. The worker checks the current binding again immediately before handing a message to SMTP; unsubscribing cancels queued/preparing messages. Email already handed to the mail server cannot be recalled.
|
||||||
|
|
||||||
|
The schedule uses a selected day from 1–28 and an hour in **UTC**, defaulting to day 2 at 09:00. Starting, resuming or changing a schedule begins at its next future occurrence; it does not immediately email an old report. Each occurrence covers the preceding complete UTC calendar month and includes subscribers confirmed by that scheduled time. After an outage, only the latest due occurrence is caught up. Earlier missed months and late subscribers are not backfilled. Pausing cancels queued scheduled deliveries. `BACKGROUND_TASKS_ENABLED=false` also disables recap automation. The worker checks the durable queue every 30 seconds.
|
||||||
|
|
||||||
|
SQLite stores the schedule, consent and delivery metadata in `email_recap_settings`, `email_recap_subscriptions` and `email_recap_deliveries`. Report bodies are generated at delivery and are not stored in the queue. Keep the existing Magent database persistent across deployments and back it up with the application's other data. The migration is additive; no existing user is opted in and no identity is merged.
|
||||||
|
|
||||||
|
- A unique account/month key prevents duplicate scheduled recaps across workers, refreshes and restarts. Test requests carry an idempotency key and have a five-minute account cooldown. Confirmation requests also have a five-minute account cooldown.
|
||||||
|
- Queue claims are transactional. Report preparation has a three-minute timeout. Known temporary SMTP rejections and temporary history failures retry after five minutes, then thirty minutes, with at most three attempts. Permanent failures and history limits stop without sending a partial report.
|
||||||
|
- The worker records SMTP acceptance separately from connection teardown. A failed QUIT after acceptance does not cause a retry. A disconnect while submitting DATA, or an interrupted worker that had begun sending, is marked **Needs review** and is not automatically resent. Inspect the mail server for the stable `magent-recap-<delivery ID>` Message-ID before deciding whether any follow-up is needed. A stable Message-ID helps investigation; SMTP does not promise deduplication. See [RFC 5321 §4.5.3.2.6](https://www.rfc-editor.org/rfc/rfc5321#section-4.5.3.2.6) and the [Python SMTP exception definitions](https://docs.python.org/3/library/smtplib.html).
|
||||||
|
- Delivery history contains recipient, month, type, attempts, timestamps and a sanitized outcome. It reports mail-server acceptance, not inbox placement or read receipts. No automatic retry button is offered for uncertain deliveries.
|
||||||
|
|
||||||
|
The APIs are `/profile/email-recaps`, `/admin/email-recaps`, `/admin/email-recaps/preview`, `/admin/email-recaps/test`, and the public token-only `/email-recaps/check` and `/email-recaps/confirm` actions. Admin APIs enforce the administrator role; personal APIs use the signed-in account. Payloads reject recipient/user overrides.
|
||||||
|
|
||||||
|
`backend/tests/test_email_recaps.py` covers consent, identity changes, scheduling boundaries, concurrent claims, duplicate suppression, retries, interruption recovery, SMTP acceptance, access control and escaping. Its SMTP capture listens only on localhost and never delivers external mail. `scripts/review_email_recaps_ui.cjs` intercepts every API request and checks desktop/mobile layouts, preview isolation, preferences, scheduling, delivery history and public links. Set `REVIEW_EMAIL_FIXTURE` to a JSON file returned by `recap_email.render_recap`, with `month` and `email` added, plus the usual `REVIEW_BASE`, `REVIEW_PLAYWRIGHT` and optional `REVIEW_DIR`.
|
||||||
|
|
||||||
## Artwork and transcoding
|
## Artwork and transcoding
|
||||||
|
|
||||||
Recently watched uses Jellystat's `NowPlayingItemId`, which identifies the movie or series, to load a Jellyfin primary poster. Magent proxies the image through an authenticated endpoint using a short-lived signature bound to the viewer, item and Jellyfin connection. Jellyfin credentials stay on the backend. Missing or deleted artwork falls back to a media tile. Thumbnail responses are privately cached.
|
Recently watched uses Jellystat's `NowPlayingItemId`, which identifies the movie or series, to load a Jellyfin primary poster. Magent proxies the image through an authenticated endpoint using a short-lived signature bound to the viewer, item and Jellyfin connection. Jellyfin credentials stay on the backend. Missing or deleted artwork falls back to a media tile. Thumbnail responses are privately cached.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
|
|||||||
{ title: 'Preferences & access', description: 'Set the experience for your users and how issues are followed up.', items: [
|
{ title: 'Preferences & access', description: 'Set the experience for your users and how issues are followed up.', items: [
|
||||||
{ href: '/admin/site', label: 'Site & sign-in', description: 'Announcements and login options' },
|
{ href: '/admin/site', label: 'Site & sign-in', description: 'Announcements and login options' },
|
||||||
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates' },
|
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates' },
|
||||||
|
{ href: '/admin/recaps', label: 'Monthly email recaps', description: 'Personal viewing emails, schedule and delivery history' },
|
||||||
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure' },
|
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure' },
|
||||||
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention' },
|
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention' },
|
||||||
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions' },
|
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions' },
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import AdminShell from '../../ui/AdminShell'
|
||||||
|
import { authFetch, getApiBase } from '../../lib/auth'
|
||||||
|
import '../../email-recaps/recaps.css'
|
||||||
|
|
||||||
|
type Settings = { enabled: boolean; day: number; hour: number; public_url: string; next_send_at?: number | null }
|
||||||
|
type Delivery = { id: string; month: string; kind: string; email: string; username: string | null; state: string; attempts: number; created_at: number; updated_at: number; next_attempt_at: number; detail: string }
|
||||||
|
type Overview = { settings: Settings; ready: boolean; detail: string; months: string[]; deliveries: Delivery[]; total: number; subscribers: number; worker_enabled: boolean }
|
||||||
|
type Preview = { month: string; subject: string; body_html: string; body_text: string; email: string | null }
|
||||||
|
const monthLabel = (month: string) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' })
|
||||||
|
const dateLabel = (value?: number | null) => value ? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC` : 'Not scheduled'
|
||||||
|
const stateLabels: Record<string, string> = { queued: 'Queued', preparing: 'Preparing report', sending: 'Sending', sent: 'Accepted by mail server', retry: 'Retry scheduled', failed: 'Failed', unknown: 'Needs review', cancelled: 'Cancelled' }
|
||||||
|
|
||||||
|
export default function EmailRecapsAdminPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [data, setData] = useState<Overview | null>(null)
|
||||||
|
const [settings, setSettings] = useState<Settings>({ enabled: false, day: 2, hour: 9, public_url: '' })
|
||||||
|
const [month, setMonth] = useState('')
|
||||||
|
const [preview, setPreview] = useState<Preview | null>(null)
|
||||||
|
const [previewMode, setPreviewMode] = useState<'html' | 'text'>('html')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [revision, setRevision] = useState(0)
|
||||||
|
const testRequest = useRef<{ month: string; id: string } | null>(null)
|
||||||
|
const initialized = useRef(false)
|
||||||
|
const previewController = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const responseData = useCallback(async (response: Response) => {
|
||||||
|
if (response.status === 401) { router.replace('/login?next=%2Fadmin%2Frecaps'); throw new Error('Sign in to continue.') }
|
||||||
|
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') }
|
||||||
|
const result = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not complete this action. Check your settings and try again.')
|
||||||
|
return result
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController()
|
||||||
|
void authFetch(`${getApiBase()}/admin/email-recaps?offset=${offset}`, { signal: abort.signal }).then(responseData).then((result: Overview) => {
|
||||||
|
if (abort.signal.aborted) return
|
||||||
|
setData(result)
|
||||||
|
if (!initialized.current) { setSettings(result.settings); setMonth(result.months[0] || ''); initialized.current = true }
|
||||||
|
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
|
||||||
|
return () => abort.abort()
|
||||||
|
}, [offset, revision, responseData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.deliveries.some((delivery) => ['queued', 'preparing', 'sending', 'retry'].includes(delivery.state))) return
|
||||||
|
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [data])
|
||||||
|
useEffect(() => () => previewController.current?.abort(), [])
|
||||||
|
|
||||||
|
const dirty = !!data && (settings.enabled !== data.settings.enabled || settings.day !== data.settings.day || settings.hour !== data.settings.hour || settings.public_url !== data.settings.public_url)
|
||||||
|
const save = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (busy) return
|
||||||
|
setBusy('save'); setError(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const { enabled, day, hour, public_url } = settings
|
||||||
|
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, day, hour, public_url }) })) as Settings
|
||||||
|
setSettings(result); setData((current) => current ? { ...current, settings: result } : current)
|
||||||
|
setPreview(null)
|
||||||
|
setNotice(result.enabled ? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.` : 'Settings saved. Scheduled delivery is paused.')
|
||||||
|
setRevision((value) => value + 1)
|
||||||
|
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save the schedule.') }
|
||||||
|
finally { setBusy('') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPreview = async () => {
|
||||||
|
if (busy || !month) return
|
||||||
|
const abort = new AbortController()
|
||||||
|
previewController.current?.abort(); previewController.current = abort
|
||||||
|
setBusy('preview'); setError(''); setNotice(''); setPreview(null)
|
||||||
|
try {
|
||||||
|
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/preview?month=${month}`, { signal: abort.signal })) as Preview
|
||||||
|
if (!abort.signal.aborted) setPreview(result)
|
||||||
|
} catch (err) { if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not prepare your preview.') }
|
||||||
|
finally { if (!abort.signal.aborted) setBusy('') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendTest = async () => {
|
||||||
|
if (busy || !preview || preview.month !== month) return
|
||||||
|
if (!testRequest.current || testRequest.current.month !== month) testRequest.current = { month, id: crypto.randomUUID() }
|
||||||
|
setBusy('test'); setError(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ month, request_id: testRequest.current.id }) }))
|
||||||
|
setNotice(result.message); testRequest.current = null; setOffset(0); setRevision((value) => value + 1)
|
||||||
|
} catch (err) { setError(err instanceof Error ? err.message : 'Could not queue your test.') }
|
||||||
|
finally { setBusy('') }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AdminShell title="Monthly email recaps" subtitle="Give each user a personal look back at their month in viewing." actions={<a className="ghost-button" href="/admin/notifications">Email settings ↗</a>}>
|
||||||
|
<div className="recap-admin">
|
||||||
|
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||||
|
{notice && <p className="status-banner" role="status">{notice}</p>}
|
||||||
|
{!data && !error && <p role="status">Loading email recaps…</p>}
|
||||||
|
{!data && error && <button className="ghost-button" type="button" onClick={() => { setError(''); setRevision((value) => value + 1) }}>Try again</button>}
|
||||||
|
{data && <>
|
||||||
|
<div className="recap-overview-strip"><div><span className={`recap-pill ${data.settings.enabled ? 'is-enabled' : ''}`}>{data.settings.enabled ? 'Schedule running' : 'Schedule paused'}</span><p>{data.settings.enabled ? `Next send ${dateLabel(data.settings.next_send_at)}` : 'Start the schedule when you’re ready for monthly delivery.'}</p></div><div className="recap-subscriber-count"><strong>{data.subscribers}</strong><span>confirmed {data.subscribers === 1 ? 'subscriber' : 'subscribers'}</span></div></div>
|
||||||
|
<div className="recap-admin-grid">
|
||||||
|
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">Set the rhythm</span><h2>Monthly schedule</h2></div></div>
|
||||||
|
<p>Send the previous month’s report to users who have opted in and confirmed their email. All report periods and send times use UTC.</p>
|
||||||
|
<form className="recap-schedule-form" onSubmit={save}>
|
||||||
|
<label htmlFor="recap-public-url">Public Magent address<input id="recap-public-url" type="url" placeholder="https://magent.example.com" maxLength={500} value={settings.public_url} onChange={(event) => setSettings({ ...settings, public_url: event.target.value })} disabled={!!busy} required={settings.enabled} /><small>The address users open from this environment’s emails.</small></label>
|
||||||
|
<div className="recap-schedule-fields"><label htmlFor="recap-day">Day of the month<select id="recap-day" value={settings.day} onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 28 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select></label><label htmlFor="recap-hour">Send time (UTC)<select id="recap-hour" value={settings.hour} onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 24 }, (_, hour) => <option key={hour} value={hour}>{String(hour).padStart(2, '0')}:00 UTC</option>)}</select></label></div>
|
||||||
|
<label className="recap-checkbox"><input type="checkbox" checked={settings.enabled} disabled={!!busy} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /><span>Enable scheduled monthly recaps</span></label>
|
||||||
|
<p className="recap-muted">Starting or changing the schedule begins at its next future send time. Pausing cancels queued monthly emails.</p>
|
||||||
|
<button className="account-primary" type="submit" disabled={!!busy || !dirty}>{busy === 'save' ? 'Saving…' : 'Save schedule'}</button>
|
||||||
|
</form>
|
||||||
|
{!data.ready && <p className="recap-setup-note">{data.detail} <a href="/admin/notifications">Review email settings ↗</a></p>}
|
||||||
|
</section>
|
||||||
|
<section className="admin-panel recap-panel"><span className="recap-eyebrow">Make it yours</span><h2>Preview your recap</h2><p>See your own viewing highlights in the email design. A test goes only to your confirmed profile email.</p>
|
||||||
|
<label className="recap-month-label" htmlFor="recap-month">Report month<select id="recap-month" value={month} disabled={!!busy} onChange={(event) => { setMonth(event.target.value); setPreview(null); testRequest.current = null }}>{data.months.map((value) => <option key={value} value={value}>{monthLabel(value)}</option>)}</select></label>
|
||||||
|
<div className="recap-actions"><button type="button" className="account-primary" onClick={() => void loadPreview()} disabled={!!busy || dirty || !month}>{busy === 'preview' ? 'Preparing preview…' : 'Preview my recap'}</button><button type="button" className="account-secondary" onClick={() => void sendTest()} disabled={!!busy || dirty || !preview || !data.ready}>{busy === 'test' ? 'Queuing test…' : 'Send test to me'}</button></div>
|
||||||
|
{dirty && <p className="recap-muted">Save your settings before previewing or sending a test.</p>}
|
||||||
|
<div className="recap-preview-guidance"><h3>One email. Your month.</h3><ul><li>Minutes, movies, episodes and requests</li><li>Changes from the previous month</li><li>Most watched titles and your longest run</li><li>A link to the full report and easy unsubscribe</li></ul><a href="/profile#monthly-recaps">Confirm your email in Profile ↗</a></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{preview && <section className="admin-panel recap-panel recap-preview"><div className="recap-section-heading"><div><span className="recap-eyebrow">Email preview</span><h2>{preview.subject}</h2><p>For {preview.email || 'your profile email'} · Preview links use your saved public address.</p></div><div className="recap-mode-buttons"><button type="button" aria-pressed={previewMode === 'html'} onClick={() => setPreviewMode('html')}>Email design</button><button type="button" aria-pressed={previewMode === 'text'} onClick={() => setPreviewMode('text')}>Plain text</button></div></div>{previewMode === 'html' ? <iframe title="Monthly recap email preview" sandbox="" referrerPolicy="no-referrer" srcDoc={preview.body_html} /> : <pre className="recap-plain-preview">{preview.body_text}</pre>}</section>}
|
||||||
|
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">From queue to inbox</span><h2>Delivery history</h2><p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p></div><button type="button" className="ghost-button" disabled={!!busy} onClick={() => { setError(''); setRevision((value) => value + 1) }}>Refresh history</button></div>
|
||||||
|
{data.deliveries.length ? <><div className="recap-history-scroll"><table className="recap-history"><thead><tr><th scope="col">Recipient</th><th scope="col">Report</th><th scope="col">Delivery</th><th scope="col">Updated</th></tr></thead><tbody>{data.deliveries.map((delivery) => <tr key={delivery.id}><td><strong>{delivery.username || 'Removed account'}</strong><small>{delivery.email}</small></td><td>{monthLabel(delivery.month)}<small>{delivery.kind === 'test' ? 'Test email' : 'Scheduled recap'}</small></td><td><span className={`recap-pill ${delivery.state === 'sent' ? 'is-enabled' : ['failed', 'unknown'].includes(delivery.state) ? 'is-attention' : ''}`}>{stateLabels[delivery.state] || delivery.state}</span><small>{delivery.attempts} {delivery.attempts === 1 ? 'attempt' : 'attempts'} · {delivery.detail || 'Waiting for the next worker check.'}</small>{delivery.state === 'retry' && <small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>}{delivery.state === 'unknown' && <small>Automatic retries are stopped to avoid a duplicate email.</small>}</td><td>{dateLabel(delivery.updated_at)}</td></tr>)}</tbody></table></div><div className="recap-pagination"><span>{offset + 1}–{Math.min(offset + 50, data.total)} of {data.total}</span><div className="recap-actions"><button className="ghost-button" type="button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>Previous</button><button className="ghost-button" type="button" disabled={offset + 50 >= data.total} onClick={() => setOffset(offset + 50)}>Next</button></div></div></> : <div className="recap-empty"><span aria-hidden="true">✉</span><h3>Your first recap starts here</h3><p>Preview your email, send yourself a test, then start the monthly schedule. Delivery results will appear here.</p></div>}
|
||||||
|
</section>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</AdminShell>
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getApiBase } from '../lib/auth'
|
||||||
|
import BrandingLogo from '../ui/BrandingLogo'
|
||||||
|
import './recaps.css'
|
||||||
|
|
||||||
|
type LinkAction = { action: 'confirm' | 'unsubscribe'; token: string }
|
||||||
|
|
||||||
|
export default function EmailRecapLinkPage() {
|
||||||
|
const [link, setLink] = useState<LinkAction | null>(null)
|
||||||
|
const [state, setState] = useState('loading')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let controller: AbortController | null = null
|
||||||
|
const checkLink = () => {
|
||||||
|
controller?.abort()
|
||||||
|
const abort = new AbortController()
|
||||||
|
controller = abort
|
||||||
|
setError(''); setState('loading'); setLink(null)
|
||||||
|
// Fragments stay out of web-server access logs and referrers. Opening the link only checks it.
|
||||||
|
const params = new URLSearchParams(window.location.hash.slice(1))
|
||||||
|
const action = params.get('action')
|
||||||
|
const token = params.get('token') || ''
|
||||||
|
if ((action !== 'confirm' && action !== 'unsubscribe') || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
|
||||||
|
setError('This email link is incomplete. Open Profile to manage your monthly recaps.'); setState('error'); return
|
||||||
|
}
|
||||||
|
const payload = { action, token } as LinkAction
|
||||||
|
setLink(payload)
|
||||||
|
void fetch(`${getApiBase()}/email-recaps/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: abort.signal, credentials: 'omit' }).then(async (response) => {
|
||||||
|
const result = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not check this email link. Please open it again.')
|
||||||
|
if (!abort.signal.aborted) setState(result.state)
|
||||||
|
}).catch((err: Error) => { if (!abort.signal.aborted) { setError(err.message); setState('error') } })
|
||||||
|
}
|
||||||
|
checkLink()
|
||||||
|
window.addEventListener('hashchange', checkLink)
|
||||||
|
return () => { controller?.abort(); window.removeEventListener('hashchange', checkLink) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
if (!link || busy) return
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getApiBase()}/email-recaps/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(link), credentials: 'omit' })
|
||||||
|
const result = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your preference. Please try again.')
|
||||||
|
setState(result.state)
|
||||||
|
window.history.replaceState(null, '', '/email-recaps')
|
||||||
|
} catch (err) { setError(err instanceof Error ? err.message : 'Could not update your preference.') }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = state === 'enabled' || state === 'off'
|
||||||
|
return <main className="recap-link-page"><a className="recap-brand" href="/login"><BrandingLogo className="brand-logo" /><span>Magent</span></a><section className="account-panel">
|
||||||
|
<span className="recap-eyebrow">Personal monthly recaps</span>
|
||||||
|
<h1>{state === 'enabled' ? 'You’re on the list.' : state === 'off' ? 'Recaps are turned off.' : state === 'loading' ? 'Checking your email link' : state === 'error' ? 'This link needs another look' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps?' : 'Your month, delivered.'}</h1>
|
||||||
|
<p>{state === 'enabled' ? 'Your email is confirmed. You’ll receive your personal viewing recap when the monthly schedule runs.' : state === 'off' ? 'You won’t receive further monthly recaps. You can turn them back on in Profile.' : state === 'ready' && link?.action === 'unsubscribe' ? 'This turns off your monthly viewing emails. You can still explore all your reports in Magent.' : state === 'ready' ? 'Confirm to receive your minutes, movies, episodes, longest run and requests each month.' : ''}</p>
|
||||||
|
{error && <p className="account-notice is-error" role="alert">{error}</p>}
|
||||||
|
{state === 'ready' && <button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>{busy ? 'Updating…' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps' : 'Confirm email recaps'}</button>}
|
||||||
|
{(done || state === 'error') && <a className="recap-text-link" href="/profile#monthly-recaps">Manage email preferences ↗</a>}
|
||||||
|
{state === 'loading' && <p role="status">One moment…</p>}
|
||||||
|
</section></main>
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
.recap-admin { display: grid; gap: 24px; }
|
||||||
|
.recap-admin-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 24px; }
|
||||||
|
.recap-panel.admin-panel { margin: 0; padding: 28px; min-width: 0; border: 1px solid var(--ops-line); border-radius: 14px; background: var(--ops-panel); }
|
||||||
|
.recap-panel h2, .recap-preference h2 { margin: 8px 0 12px; font-size: 22px; }
|
||||||
|
.recap-panel h3 { font-size: 16px; }
|
||||||
|
.recap-panel p, .recap-preference p { color: var(--ops-muted); font-size: 13px; line-height: 1.7; }
|
||||||
|
.recap-panel a, .recap-preference a, .recap-text-link { color: #c7bdff; text-decoration: none; }
|
||||||
|
.recap-panel a:hover, .recap-preference a:hover, .recap-text-link:hover { text-decoration: underline; }
|
||||||
|
.recap-eyebrow { display: block; color: #bcb3eb; font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; }
|
||||||
|
.recap-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||||
|
.recap-section-heading > div { min-width: 0; }
|
||||||
|
.recap-pill { display: inline-flex; align-items: center; padding: 6px 10px; border: 1px solid var(--ops-line); border-radius: 99px; font-size: 11px; line-height: 1.4; color: var(--ops-muted); white-space: nowrap; }
|
||||||
|
.recap-pill.is-enabled { color: #cfc7fc; background: #c7bdff12; border-color: #c7bdff40; }
|
||||||
|
.recap-pill.is-attention { color: #eab9a6; border-color: #eab9a650; }
|
||||||
|
.recap-overview-strip { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px 28px; border: 1px solid var(--ops-line); border-radius: 14px; background: linear-gradient(110deg, #c7bdff0c, transparent 65%), var(--ops-panel); }
|
||||||
|
.recap-overview-strip p { color: var(--ops-muted); font-size: 13px; margin: 12px 0 0; line-height: 1.6; }
|
||||||
|
.recap-subscriber-count { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.recap-subscriber-count strong { color: #d8d0ff; font-size: 36px; font-weight: 500; }
|
||||||
|
.recap-subscriber-count span { max-width: 100px; color: var(--ops-muted); font-size: 12px; line-height: 1.5; }
|
||||||
|
.recap-schedule-form { display: grid; gap: 18px; margin-top: 24px; }
|
||||||
|
.recap-schedule-form label, .recap-month-label { display: grid; gap: 9px; padding: 0; margin: 0; color: var(--ops-text); font-size: 13px; text-transform: none; border: 0; background: none; }
|
||||||
|
.recap-schedule-form input:not([type=checkbox]), .recap-schedule-form select, .recap-month-label select { min-width: 0; width: 100%; min-height: 44px; border: 1px solid var(--ops-line); border-radius: 8px; padding: 10px 12px; font: 13px Inter, sans-serif; }
|
||||||
|
.recap-schedule-form small { color: var(--ops-faint); font-size: 12px; line-height: 1.5; }
|
||||||
|
.recap-schedule-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||||
|
.recap-schedule-form .recap-checkbox { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
|
||||||
|
.recap-checkbox input { width: 18px; height: 18px; accent-color: #c7bdff; }
|
||||||
|
.recap-schedule-form button { justify-self: start; }
|
||||||
|
.recap-schedule-form .recap-muted { margin: -6px 0 0; }
|
||||||
|
.recap-panel .recap-muted, .recap-preference .recap-muted { font-size: 12px; color: var(--ops-faint); }
|
||||||
|
.recap-setup-note { padding: 14px 16px; border: 1px solid var(--ops-line); border-radius: 8px; margin: 20px 0 0; }
|
||||||
|
.recap-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
|
||||||
|
.recap-month-label { margin-top: 24px; }
|
||||||
|
.recap-preview-guidance { border-top: 1px solid var(--ops-line); margin-top: 28px; padding-top: 16px; }
|
||||||
|
.recap-preview-guidance ul { padding-left: 18px; margin: 18px 0; color: var(--ops-muted); font-size: 13px; line-height: 2; }
|
||||||
|
.recap-preview-guidance a { font-size: 13px; }
|
||||||
|
.recap-mode-buttons { display: flex; flex-shrink: 0; gap: 6px; }
|
||||||
|
.recap-mode-buttons button { background: transparent !important; color: var(--ops-muted); border: 1px solid var(--ops-line); padding: 10px 12px; font-size: 12px; text-transform: none; }
|
||||||
|
.recap-mode-buttons button[aria-pressed=true] { border-color: #c7bdff70; color: #d5cdff; background: #c7bdff10 !important; }
|
||||||
|
.recap-preview iframe { display: block; width: 100%; height: 1050px; margin-top: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: #131315; }
|
||||||
|
.recap-plain-preview { white-space: pre-wrap; overflow-wrap: anywhere; padding: 24px; background: #131315; border: 1px solid var(--ops-line); border-radius: 10px; color: var(--ops-muted); font-size: 13px; line-height: 1.8; }
|
||||||
|
.recap-history-scroll { overflow-x: auto; margin-top: 18px; }
|
||||||
|
.recap-history { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.recap-history th { color: var(--ops-faint); font-size: 11px; font-weight: 500; text-align: left; }
|
||||||
|
.recap-history th, .recap-history td { padding: 16px 12px; border-bottom: 1px solid var(--ops-line-soft); vertical-align: top; }
|
||||||
|
.recap-history td { min-width: 135px; line-height: 1.7; }
|
||||||
|
.recap-history td:first-child { min-width: 170px; }
|
||||||
|
.recap-history td:nth-child(3) { min-width: 245px; max-width: 400px; }
|
||||||
|
.recap-history strong { display: block; font-weight: 500; }
|
||||||
|
.recap-history small { display: block; margin-top: 6px; font-size: 11px; color: var(--ops-muted); overflow-wrap: anywhere; }
|
||||||
|
.recap-pagination { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: var(--ops-muted); font-size: 12px; margin-top: 16px; }
|
||||||
|
.recap-pagination .recap-actions { margin: 0; }
|
||||||
|
.recap-empty { text-align: center; padding: 36px 20px 24px; }
|
||||||
|
.recap-empty > span { color: #bcb3eb; font-size: 28px; }
|
||||||
|
.recap-empty p { max-width: 430px; margin: 12px auto; }
|
||||||
|
.recap-preference { border-top: 1px solid var(--ops-line); margin-top: 28px; padding-top: 28px; scroll-margin-top: 24px; }
|
||||||
|
.recap-preference p { max-width: 620px; }
|
||||||
|
.recap-delivery-address { overflow-wrap: anywhere; }
|
||||||
|
.page > main.recap-link-page { max-width: 600px; margin: 70px auto; }
|
||||||
|
.recap-brand { display: flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 32px; text-decoration: none; color: var(--ops-text); font: 500 25px "DM Sans", sans-serif; }
|
||||||
|
.recap-brand img { width: 42px; height: 42px; object-fit: contain; }
|
||||||
|
.recap-brand .brand-logo { width: 42px; height: 42px; flex: 0 0 42px; }
|
||||||
|
.recap-link-page h1 { font-size: clamp(25px, 5vw, 36px); margin: 16px 0; }
|
||||||
|
.recap-link-page p { font-size: 14px; line-height: 1.8; color: var(--ops-muted); }
|
||||||
|
.recap-link-page button, .recap-link-page .recap-text-link { margin-top: 16px; }
|
||||||
|
.recap-link-page .recap-text-link { display: inline-block; font-size: 14px; }
|
||||||
|
@media (max-width: 1000px) { .recap-admin-grid { grid-template-columns: 1fr; } }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.recap-panel.admin-panel { padding: 20px 16px; }
|
||||||
|
.recap-overview-strip, .recap-section-heading { flex-direction: column; gap: 16px; }
|
||||||
|
.recap-overview-strip { padding: 20px; }
|
||||||
|
.recap-subscriber-count span { max-width: none; }
|
||||||
|
.recap-schedule-fields { gap: 12px; }
|
||||||
|
.recap-panel h2, .recap-preference h2 { font-size: 20px; }
|
||||||
|
.recap-preview iframe { height: 1200px; }
|
||||||
|
.page > main.recap-link-page { margin: 36px auto; }
|
||||||
|
.recap-link-page .account-panel { padding: 26px 22px; }
|
||||||
|
.recap-pagination { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ function ChangeLabel({ change, unit = '' }: { change: Change; unit?: string }) {
|
|||||||
export default function MonthlyReportsPage() {
|
export default function MonthlyReportsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [month, setMonth] = useState('')
|
const [month, setMonth] = useState('')
|
||||||
|
const [monthReady, setMonthReady] = useState(false)
|
||||||
const [months, setMonths] = useState<string[]>([])
|
const [months, setMonths] = useState<string[]>([])
|
||||||
const [data, setData] = useState<MonthlyReport | null>(null)
|
const [data, setData] = useState<MonthlyReport | null>(null)
|
||||||
const [busy, setBusy] = useState(true)
|
const [busy, setBusy] = useState(true)
|
||||||
@@ -39,6 +40,13 @@ export default function MonthlyReportsPage() {
|
|||||||
const [downloadError, setDownloadError] = useState('')
|
const [downloadError, setDownloadError] = useState('')
|
||||||
const downloadController = useRef<AbortController | null>(null)
|
const downloadController = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMonth(new URLSearchParams(window.location.search).get('month') || '')
|
||||||
|
setMonthReady(true)
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
if (monthReady) window.history.replaceState(null, '', `/insights/reports${month ? `?month=${encodeURIComponent(month)}` : ''}`)
|
||||||
|
}, [month, monthReady])
|
||||||
useEffect(() => () => downloadController.current?.abort(), [])
|
useEffect(() => () => downloadController.current?.abort(), [])
|
||||||
const load = useCallback(async (signal: AbortSignal) => {
|
const load = useCallback(async (signal: AbortSignal) => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -48,7 +56,7 @@ export default function MonthlyReportsPage() {
|
|||||||
try {
|
try {
|
||||||
const query = month ? `?month=${encodeURIComponent(month)}` : ''
|
const query = month ? `?month=${encodeURIComponent(month)}` : ''
|
||||||
const response = await authFetch(`${getApiBase()}/insights/reports/monthly${query}`, { signal })
|
const response = await authFetch(`${getApiBase()}/insights/reports/monthly${query}`, { signal })
|
||||||
if (response.status === 401) { router.replace('/login?next=%2Finsights%2Freports'); return }
|
if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports${query}`)}`); return }
|
||||||
if (response.status === 403) throw new Error('Your account cannot access viewing reports. Please contact an administrator.')
|
if (response.status === 403) throw new Error('Your account cannot access viewing reports. Please contact an administrator.')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const result = await response.json().catch(() => ({}))
|
const result = await response.json().catch(() => ({}))
|
||||||
@@ -63,10 +71,11 @@ export default function MonthlyReportsPage() {
|
|||||||
}
|
}
|
||||||
}, [month, router])
|
}, [month, router])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!monthReady) return
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
void load(controller.signal)
|
void load(controller.signal)
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [load, revision])
|
}, [load, revision, monthReady])
|
||||||
|
|
||||||
const download = async () => {
|
const download = async () => {
|
||||||
if (data?.state !== 'ready' || downloading) return
|
if (data?.state !== 'ready' || downloading) return
|
||||||
@@ -77,7 +86,7 @@ export default function MonthlyReportsPage() {
|
|||||||
setDownloadError('')
|
setDownloadError('')
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${getApiBase()}/insights/reports/monthly.csv?month=${selected}`, { signal: controller.signal })
|
const response = await authFetch(`${getApiBase()}/insights/reports/monthly.csv?month=${selected}`, { signal: controller.signal })
|
||||||
if (response.status === 401) { router.replace('/login?next=%2Finsights%2Freports'); return }
|
if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports?month=${selected}`)}`); return }
|
||||||
if (!response.ok) throw new Error('The report could not be downloaded. Please try again.')
|
if (!response.ok) throw new Error('The report could not be downloaded. Please try again.')
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
@@ -116,7 +125,7 @@ export default function MonthlyReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{downloadError && <p className="stats-notice" role="alert">{downloadError}</p>}
|
{downloadError && <p className="stats-notice" role="alert">{downloadError}</p>}
|
||||||
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true">◷</span><h2>Putting your month together</h2><p>Gathering your viewing history and the previous month’s comparison.</p></div>}
|
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true">◷</span><h2>Putting your month together</h2><p>Gathering your viewing history and the previous month’s comparison.</p></div>}
|
||||||
{error && <div className="stats-state" role="alert"><h2>Report couldn’t load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button></div>}
|
{error && <div className="stats-state" role="alert"><h2>Report couldn’t load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button>{month && <button type="button" className="ghost-button" onClick={() => setMonth('')}>Latest complete month</button>}</div>}
|
||||||
{data?.state === 'not_configured' && <section className="stats-state"><h2>Your monthly story starts here</h2><p>{data.is_admin ? 'Connect Jellystat to bring your monthly viewing reports into Magent.' : 'Monthly reports will appear once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>}
|
{data?.state === 'not_configured' && <section className="stats-state"><h2>Your monthly story starts here</h2><p>{data.is_admin ? 'Connect Jellystat to bring your monthly viewing reports into Magent.' : 'Monthly reports will appear once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>}
|
||||||
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review your user identities.</p>{data.is_admin && <a className="stats-action" href="/admin/identities">Review user identities</a>}</section>}
|
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review your user identities.</p>{data.is_admin && <a className="stats-action" href="/admin/identities">Review user identities</a>}</section>}
|
||||||
{data && summary && changes && <>
|
{data && summary && changes && <>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { authFetch, getApiBase } from '../lib/auth'
|
||||||
|
import '../email-recaps/recaps.css'
|
||||||
|
|
||||||
|
type Preference = { state: 'off' | 'pending' | 'expired' | 'enabled'; email: string | null; can_subscribe: boolean; detail: string; schedule_enabled: boolean; next_send_at: number | null; day: number; hour: number; resend_after: number | null }
|
||||||
|
const scheduled = (value: number) => `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' })} UTC`
|
||||||
|
|
||||||
|
export default function MonthlyRecapPreference() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [data, setData] = useState<Preference | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [revision, setRevision] = useState(0)
|
||||||
|
const [now, setNow] = useState(Date.now())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController()
|
||||||
|
setError('')
|
||||||
|
void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal }).then(async (response) => {
|
||||||
|
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
|
||||||
|
if (!response.ok) throw new Error('Could not load your email preference. Please try again.')
|
||||||
|
const result = await response.json() as Preference
|
||||||
|
if (!abort.signal.aborted) setData(result)
|
||||||
|
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
|
||||||
|
return () => abort.abort()
|
||||||
|
}, [revision, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.resend_after || data.state === 'enabled' || data.resend_after * 1000 <= Date.now()) return
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [data?.resend_after, data?.state])
|
||||||
|
|
||||||
|
const save = async (enabled: boolean) => {
|
||||||
|
if (busy) return
|
||||||
|
setBusy(true); setError(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${getApiBase()}/profile/email-recaps`, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }),
|
||||||
|
})
|
||||||
|
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
|
||||||
|
const result = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your email preference.')
|
||||||
|
setData(result); setNow(Date.now())
|
||||||
|
setNotice(result.message || (enabled ? 'Monthly recaps are on.' : 'Monthly recaps are off.'))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not update your email preference.')
|
||||||
|
// A confirmation may be pending even if SMTP could not confirm delivery.
|
||||||
|
const response = await authFetch(`${getApiBase()}/profile/email-recaps`).catch(() => null)
|
||||||
|
if (response?.ok) { setData(await response.json()); setNow(Date.now()) }
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0
|
||||||
|
return <section className="recap-preference" id="monthly-recaps" aria-labelledby="recap-preference-title">
|
||||||
|
<div className="recap-section-heading"><div><span className="recap-eyebrow">A little look back</span><h2 id="recap-preference-title">Your month, delivered.</h2></div>{data && <span className={`recap-pill ${data.state === 'enabled' ? 'is-enabled' : ''}`}>{({ off: 'Off', pending: 'Check your inbox', expired: 'Confirmation expired', enabled: 'Subscribed' })[data.state]}</span>}</div>
|
||||||
|
<p>Your minutes, movies, episodes, longest run and requests, in one personal monthly email. <a href="/insights/reports">Explore your latest report ↗</a></p>
|
||||||
|
{!data && !error && <p role="status">Loading your email preference…</p>}
|
||||||
|
{data && <>
|
||||||
|
{data.state === 'enabled' ? <p className="recap-delivery-address">Recaps will go to <strong>{data.email}</strong>. {data.schedule_enabled && data.next_send_at ? `Next scheduled send: ${scheduled(data.next_send_at)}.` : 'The administrator has paused scheduled delivery.'}</p> : <p>{data.state === 'pending' ? `Check ${data.email} for a confirmation link. It expires after 24 hours. No viewing history is emailed until you confirm.` : data.state === 'expired' ? 'Request a new confirmation link to turn on your recaps.' : 'Confirm your profile email to turn this on. You can unsubscribe from any recap or here in Profile.'}</p>}
|
||||||
|
{!data.can_subscribe && data.state !== 'enabled' && <p className="recap-muted">{data.detail}</p>}
|
||||||
|
{data.state !== 'enabled' && data.can_subscribe && !data.schedule_enabled && <p className="recap-muted">You can subscribe now. Monthly sends will begin when your administrator starts the schedule.</p>}
|
||||||
|
<div className="recap-actions">
|
||||||
|
{data.state !== 'enabled' && <button type="button" className="account-primary" disabled={busy || !data.can_subscribe || cooldown > 0} onClick={() => void save(true)}>{busy ? 'Sending confirmation…' : data.state === 'off' ? 'Email me my monthly recap' : 'Send a new confirmation'}</button>}
|
||||||
|
{data.state !== 'off' && <button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>{busy ? 'Updating…' : data.state === 'enabled' ? 'Turn off recaps' : 'Cancel subscription'}</button>}
|
||||||
|
<button type="button" className="account-secondary" disabled={busy} onClick={() => { setNotice(''); setRevision((value) => value + 1) }}>Refresh preference</button>
|
||||||
|
</div>
|
||||||
|
{cooldown > 0 && data.state !== 'enabled' && <p className="recap-muted">Another confirmation can be requested in {Math.ceil(cooldown / 60)} {Math.ceil(cooldown / 60) === 1 ? 'minute' : 'minutes'}.</p>}
|
||||||
|
</>}
|
||||||
|
{error && <p className="account-notice is-error" role="alert">{error}{!data && <button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>Try again</button>}</p>}
|
||||||
|
{notice && <p className="account-notice is-status" role="status">{notice}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import PageHeading from '../ui/PageHeading'
|
import PageHeading from '../ui/PageHeading'
|
||||||
|
import MonthlyRecapPreference from './MonthlyRecapPreference'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
|
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@@ -197,6 +198,7 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div className="account-connected"><span className="account-connection-dot" aria-hidden="true" /><span>{user.auth_provider === 'jellyfin' ? 'Connected with your Grizzlyflix account' : user.auth_provider === 'local' ? 'Signed in with a Magent account' : 'Signed in with your media account'}</span></div>
|
<div className="account-connected"><span className="account-connection-dot" aria-hidden="true" /><span>{user.auth_provider === 'jellyfin' ? 'Connected with your Grizzlyflix account' : user.auth_provider === 'local' ? 'Signed in with a Magent account' : 'Signed in with your media account'}</span></div>
|
||||||
|
<MonthlyRecapPreference key={user.email || 'no-email'} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="account-panel" id="profile-panel-security" role="tabpanel" aria-labelledby="profile-tab-security" hidden={activeTab !== 'security'}>
|
<section className="account-panel" id="profile-panel-security" role="tabpanel" aria-labelledby="profile-tab-security" hidden={activeTab !== 'security'}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation'
|
|||||||
|
|
||||||
export default function ApplicationChrome() {
|
export default function ApplicationChrome() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
if (['/welcome', '/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null
|
if (['/welcome', '/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup', '/email-recaps'].includes(pathname)) return null
|
||||||
return <>
|
return <>
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
|
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Fixture-only browser review. All API calls are intercepted; no real messages are sent.
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
|
||||||
|
const base = process.env.REVIEW_BASE || 'http://localhost:3114'
|
||||||
|
const output = process.env.REVIEW_DIR
|
||||||
|
const preview = JSON.parse(fs.readFileSync(process.env.REVIEW_EMAIL_FIXTURE, 'utf8'))
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true })
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext()
|
||||||
|
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
|
||||||
|
const calls = []
|
||||||
|
let role = 'admin'
|
||||||
|
let failPreview = false
|
||||||
|
let email = 'viewer@example.test'
|
||||||
|
let settings = { enabled: false, day: 2, hour: 9, public_url: 'https://beta.example.test', next_send_at: null }
|
||||||
|
let preference = { state: 'off', email, can_subscribe: true, detail: 'Ready', schedule_enabled: false, day: 2, hour: 9, next_send_at: null, resend_after: null }
|
||||||
|
let deliveries = []
|
||||||
|
let tokenState = 'ready'
|
||||||
|
const months = Array.from({ length: 23 }, (_, index) => new Date(Date.UTC(2026, 7 - index, 1)).toISOString().slice(0, 7))
|
||||||
|
await context.route('**/api/**', async (route) => {
|
||||||
|
const request = route.request()
|
||||||
|
const url = new URL(request.url())
|
||||||
|
const payload = request.postDataJSON()
|
||||||
|
calls.push({ method: request.method(), path: url.pathname, payload })
|
||||||
|
if (url.pathname === '/api/auth/me') return route.fulfill({ json: { username: 'Fixture viewer', role, email } })
|
||||||
|
if (url.pathname === '/api/auth/profile') return route.fulfill({ json: { user: { username: 'Fixture viewer', role, email, auth_provider: 'jellyfin', password_change_supported: true, password_provider: 'jellyfin' }, activity: { recent: [] } } })
|
||||||
|
if (url.pathname === '/api/auth/profile/email') { email = payload.email; preference = { ...preference, email, state: 'off' }; return route.fulfill({ json: { email } }) }
|
||||||
|
if (url.pathname === '/api/profile/email-recaps') {
|
||||||
|
if (request.method() === 'PUT') preference = { ...preference, state: payload.enabled ? 'pending' : 'off', resend_after: payload.enabled ? Date.now() / 1000 + 300 : null }
|
||||||
|
return route.fulfill({ json: preference })
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith('/api/admin/email-recaps')) {
|
||||||
|
if (role !== 'admin') return route.fulfill({ status: role === 'unauthorized' ? 401 : 403, json: { detail: 'Administrator access is required.' } })
|
||||||
|
if (url.pathname.endsWith('/preview')) {
|
||||||
|
if (failPreview) return route.fulfill({ status: 502, json: { detail: 'Your report is temporarily unavailable. Please try again shortly.' } })
|
||||||
|
return route.fulfill({ json: { ...preview, month: url.searchParams.get('month') } })
|
||||||
|
}
|
||||||
|
if (url.pathname.endsWith('/test')) {
|
||||||
|
assert.deepEqual(Object.keys(payload).sort(), ['month', 'request_id'])
|
||||||
|
deliveries = [{ id: payload.request_id, month: payload.month, kind: 'test', email, username: 'Fixture viewer', state: 'queued', attempts: 0, created_at: Date.now() / 1000, updated_at: Date.now() / 1000, next_attempt_at: Date.now() / 1000, detail: '' }]
|
||||||
|
return route.fulfill({ status: 202, json: { id: payload.request_id, message: 'Test queued for your confirmed email. Check delivery history for the result.' } })
|
||||||
|
}
|
||||||
|
if (request.method() === 'PUT') { settings = { ...payload, next_send_at: payload.enabled ? Date.UTC(2026, 9, payload.day, payload.hour) / 1000 : null }; return route.fulfill({ json: settings }) }
|
||||||
|
return route.fulfill({ json: { settings, months, ready: true, detail: 'Ready', deliveries, total: deliveries.length, subscribers: 12, worker_enabled: true } })
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith('/api/email-recaps/')) {
|
||||||
|
if (url.pathname.endsWith('/confirm')) tokenState = payload.action === 'confirm' ? 'enabled' : 'off'
|
||||||
|
return route.fulfill({ json: { action: payload.action, state: tokenState } })
|
||||||
|
}
|
||||||
|
if (url.pathname.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
|
||||||
|
return route.fulfill({ json: {} })
|
||||||
|
})
|
||||||
|
const page = await context.newPage()
|
||||||
|
const errors = []
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message))
|
||||||
|
if (output) fs.mkdirSync(output, { recursive: true })
|
||||||
|
for (const width of [1440, 980, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 })
|
||||||
|
await page.goto(`${base}/admin/recaps`)
|
||||||
|
await page.getByRole('heading', { name: 'Monthly schedule', exact: true }).waitFor()
|
||||||
|
assert.equal(await page.getByLabel('Enable scheduled monthly recaps').isChecked(), false)
|
||||||
|
assert.equal(await page.getByRole('button', { name: 'Send test to me', exact: true }).isDisabled(), true)
|
||||||
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Admin overflow at ${width}`)
|
||||||
|
if (output) await page.screenshot({ path: path.join(output, `recaps-admin-${width}.png`), fullPage: true })
|
||||||
|
await page.getByRole('button', { name: 'Preview my recap', exact: true }).click()
|
||||||
|
const frame = page.frameLocator('iframe[title="Monthly recap email preview"]')
|
||||||
|
await frame.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
|
||||||
|
assert.equal(await page.locator('iframe').getAttribute('sandbox'), '')
|
||||||
|
assert.equal(await frame.getByRole('link', { name: /Explore your full report/ }).getAttribute('href'), 'https://beta.example.test/insights/reports?month=2026-08')
|
||||||
|
assert(await frame.locator('body').evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Email overflow at ${width}`)
|
||||||
|
if (output) await page.locator('.recap-preview').screenshot({ path: path.join(output, `recap-preview-${width}.png`) })
|
||||||
|
await page.getByRole('button', { name: 'Plain text', exact: true }).click()
|
||||||
|
assert.match(await page.locator('.recap-plain-preview').innerText(), /Minutes watched: 1,500/)
|
||||||
|
await page.goto(`${base}/profile`)
|
||||||
|
await page.getByRole('heading', { name: 'Your month, delivered.', exact: true }).waitFor()
|
||||||
|
await page.getByRole('button', { name: 'Email me my monthly recap', exact: true }).waitFor()
|
||||||
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Profile overflow at ${width}`)
|
||||||
|
if (output) await page.screenshot({ path: path.join(output, `recap-profile-${width}.png`), fullPage: true })
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: 'Email me my monthly recap', exact: true }).click()
|
||||||
|
await page.getByText('Check your inbox', { exact: true }).waitFor()
|
||||||
|
assert.equal(await page.getByRole('button', { name: 'Send a new confirmation', exact: true }).isDisabled(), true)
|
||||||
|
await page.getByRole('button', { name: 'Cancel subscription', exact: true }).click()
|
||||||
|
await page.getByText('Off', { exact: true }).waitFor()
|
||||||
|
preference = { ...preference, state: 'enabled' }
|
||||||
|
await page.getByRole('button', { name: 'Refresh preference', exact: true }).click()
|
||||||
|
await page.getByText('Subscribed', { exact: true }).waitFor()
|
||||||
|
await page.getByRole('textbox', { name: 'Email address', exact: true }).fill('changed@example.test')
|
||||||
|
await page.getByRole('button', { name: 'Save email', exact: true }).click()
|
||||||
|
await page.getByText('Off', { exact: true }).waitFor()
|
||||||
|
await page.goto(`${base}/admin/recaps`)
|
||||||
|
await page.getByLabel('Day of the month').selectOption('3')
|
||||||
|
await page.getByLabel('Enable scheduled monthly recaps').check()
|
||||||
|
assert.equal(await page.getByRole('button', { name: 'Preview my recap', exact: true }).isDisabled(), true)
|
||||||
|
await page.getByRole('button', { name: 'Save schedule', exact: true }).click()
|
||||||
|
await page.getByText('Schedule running', { exact: true }).waitFor()
|
||||||
|
assert.equal(settings.day, 3)
|
||||||
|
assert.equal(settings.enabled, true)
|
||||||
|
await page.getByRole('button', { name: 'Preview my recap', exact: true }).click()
|
||||||
|
await page.locator('iframe').waitFor()
|
||||||
|
await page.getByRole('button', { name: 'Send test to me', exact: true }).click()
|
||||||
|
await page.getByText('Queued', { exact: true }).waitFor()
|
||||||
|
deliveries = ['sent', 'retry', 'unknown', 'failed', 'cancelled'].map((state, index) => ({ ...deliveries[0], id: `fixture-${index}`, state, attempts: index + 1, detail: state === 'unknown' ? 'Check the mail server.' : 'Fixture delivery result.' }))
|
||||||
|
await page.getByRole('button', { name: 'Refresh history', exact: true }).click()
|
||||||
|
await page.getByText('Needs review', { exact: true }).waitFor()
|
||||||
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'History overflows mobile viewport')
|
||||||
|
assert.match(await page.locator('.recap-history').innerText(), /Automatic retries are stopped/)
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||||
|
if (output) await page.screenshot({ path: path.join(output, 'recap-delivery-history.png'), fullPage: true })
|
||||||
|
failPreview = true
|
||||||
|
await page.getByRole('button', { name: 'Preview my recap', exact: true }).click()
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'temporarily unavailable' }).waitFor()
|
||||||
|
await context.clearCookies()
|
||||||
|
const token = 'fixture'.repeat(7)
|
||||||
|
tokenState = 'ready'
|
||||||
|
await page.goto(`${base}/email-recaps#action=confirm&token=${token}`)
|
||||||
|
await page.getByRole('button', { name: 'Confirm email recaps', exact: true }).waitFor()
|
||||||
|
assert.equal(calls.filter((call) => call.path === '/api/email-recaps/confirm').length, 0)
|
||||||
|
assert.equal(await page.locator('.header').count(), 0)
|
||||||
|
await page.getByRole('button', { name: 'Confirm email recaps', exact: true }).click()
|
||||||
|
await page.getByRole('heading', { name: 'You’re on the list.', exact: true }).waitFor()
|
||||||
|
assert.equal(new URL(page.url()).hash, '')
|
||||||
|
tokenState = 'ready'
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 })
|
||||||
|
await page.goto(`${base}/email-recaps#action=unsubscribe&token=${token}`)
|
||||||
|
await page.getByRole('button', { name: 'Unsubscribe from recaps', exact: true }).waitFor()
|
||||||
|
if (output) await page.screenshot({ path: path.join(output, 'recap-unsubscribe-mobile.png'), fullPage: true })
|
||||||
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Unsubscribe overflow')
|
||||||
|
await page.getByRole('button', { name: 'Unsubscribe from recaps', exact: true }).click()
|
||||||
|
await page.getByRole('heading', { name: 'Recaps are turned off.', exact: true }).waitFor()
|
||||||
|
await page.goto(`${base}/email-recaps`)
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'incomplete' }).waitFor()
|
||||||
|
role = 'user'
|
||||||
|
await page.goto(`${base}/admin/recaps`)
|
||||||
|
await page.waitForURL(`${base}/`)
|
||||||
|
role = 'unauthorized'
|
||||||
|
await page.goto(`${base}/admin/recaps`)
|
||||||
|
await page.waitForURL(/\/login\?next=/)
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
console.log(`Email recap UI passed: desktop/mobile layout, preview isolation, consent, schedule, test queue, history, public links and access control; ${calls.length} intercepted API calls.`)
|
||||||
|
} finally { await browser.close() }
|
||||||
|
})().catch((error) => { console.error(error); process.exitCode = 1 })
|
||||||
@@ -92,6 +92,15 @@ const output = process.env.REVIEW_DIR
|
|||||||
await page.screenshot({ path: path.join(output, `monthly-report-${width}.png`), fullPage: true })
|
await page.screenshot({ path: path.join(output, `monthly-report-${width}.png`), fullPage: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const beforeLink = calls.length
|
||||||
|
await page.goto(base + '/insights/reports?month=2026-07')
|
||||||
|
await page.getByRole('heading', { name: 'July 2026', exact: true }).waitFor()
|
||||||
|
const linkedCalls = calls.slice(beforeLink).filter((call) => call.path === '/api/insights/reports/monthly')
|
||||||
|
assert.deepEqual(linkedCalls.map((call) => call.month), ['2026-07'])
|
||||||
|
await page.reload()
|
||||||
|
await page.getByRole('heading', { name: 'July 2026', exact: true }).waitFor()
|
||||||
|
await page.goto(base + '/insights/reports')
|
||||||
|
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
|
||||||
await page.getByRole('button', { name: 'Previous month', exact: true }).click()
|
await page.getByRole('button', { name: 'Previous month', exact: true }).click()
|
||||||
await page.getByRole('heading', { name: 'July 2026', exact: true }).waitFor()
|
await page.getByRole('heading', { name: 'July 2026', exact: true }).waitFor()
|
||||||
await page.getByRole('button', { name: 'Next month', exact: true }).click()
|
await page.getByRole('button', { name: 'Next month', exact: true }).click()
|
||||||
@@ -126,7 +135,8 @@ const output = process.env.REVIEW_DIR
|
|||||||
assert.equal(await page.getByRole('link', { name: 'Connect Jellystat' }).count(), 0)
|
assert.equal(await page.getByRole('link', { name: 'Connect Jellystat' }).count(), 0)
|
||||||
mode = 'unauthorized'
|
mode = 'unauthorized'
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await page.waitForURL('**/login?next=%2Finsights%2Freports')
|
await page.waitForURL(/\/login\?next=/)
|
||||||
|
assert.equal(new URL(page.url()).searchParams.get('next'), `/insights/reports?month=${months.at(-1)}`)
|
||||||
mode = 'ready'
|
mode = 'ready'
|
||||||
await page.goto(base + '/insights/reports')
|
await page.goto(base + '/insights/reports')
|
||||||
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
|
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
|
||||||
|
|||||||
Reference in New Issue
Block a user