"""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 application URL in Hosting & proxy 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.", "automatic_monthly": bool(sub["automatic_monthly"]) if sub else False, "can_send": ready and state == "enabled", "deliveries": store.personal_history(account["id"]), "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, automatic_monthly: bool | None = None) -> dict: account = current_account(user) preference = preferences(user) automatic = preference['automatic_monthly'] if automatic_monthly is None else automatic_monthly if preference["state"] == "enabled": store.set_automatic(account['id'], automatic) return preferences(user) 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(), automatic) 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 enable personal report emails."} 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 illustrated_recap(report, account, public_url, unsubscribe_url, *, preview=False, **kwargs): """Embed only signed artwork from this account's report; missing art is optional.""" import base64 import re from .insights_artwork import get_artwork runtime = get_runtime_settings() images = [] report = {**report, "top_titles": [dict(row) for row in report.get("top_titles", [])]} async def picture(index, row): match = re.fullmatch(r"/insights/artwork/([a-f0-9]{32})\?token=([0-9]+\.[a-f0-9]{64})", row.get("artwork_url") or "") if not match: return try: data, mime = await get_artwork(account, runtime, *match.groups()) cid = f"recap-title-{index}@magent" row["email_artwork"] = f"data:{mime};base64,{base64.b64encode(data).decode()}" if preview else f"cid:{cid}" images.append({"cid": cid, "data": data, "subtype": mime.split("/")[1]}) except Exception: pass # An unavailable poster must never prevent a personal report. await asyncio.gather(*(picture(i, row) for i, row in enumerate(report["top_titles"][:3]))) rendered = mail.render_recap(report, account["username"], public_url, unsubscribe_url, **kwargs) if not preview: rendered["inline_images"] = images return rendered 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("Set the application URL in Hosting & proxy 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"), **await illustrated_recap( report, account, config["public_url"], config["public_url"] + "/profile#monthly-recaps", preview=True)} 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"]) from ..feature_access import permissions if not account or not permissions(account)["stats"]: raise mail.DeliveryCancelled() 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"] or not sub["automatic_monthly"]))): 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"] and delivery["kind"] != "on_demand"): 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 = await illustrated_recap(report, account, delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand") 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) def queue_personal(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('Confirm your profile email in email preferences before emailing a report.') try: selected = month_periods(month, datetime.now(timezone.utc))['month'] except ValueError as exc: raise RecapError(str(exc), 422) from exc try: delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()['public_url'], time.time(), 'on_demand') except ValueError as exc: raise RecapError(str(exc), 429) from exc return {'id': delivery_id, 'message': 'Your report is queued for your confirmed profile email. Delivery status appears below.'}