244 lines
11 KiB
Python
244 lines
11 KiB
Python
"""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)
|