Add opt-in monthly email recaps with scheduling and delivery history
Magent CI/CD / verify (push) Canceled after 3m55s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-09 22:39:22 +12:00
parent 333a799e21
commit 1979e02cde
18 changed files with 1832 additions and 7 deletions
+2
View File
@@ -733,6 +733,8 @@ def init_db() -> None:
conn.execute("PRAGMA optimize")
except sqlite3.OperationalError:
pass
from .services.recap_store import init_schema as init_recap_schema
init_recap_schema(conn)
_backfill_auth_providers()
ensure_admin_user()
_backfill_request_repairs()
+4
View File
@@ -29,8 +29,10 @@ from .routers.portal import router as portal_router
from .routers.operations import router as operations_router
from .routers.insights import router as insights_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.issue_resolution import run_issue_confirmation_loop
from .services.email_recaps import run_email_recap_loop
from .services.operation_progress import (
begin_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("db-cleanup", run_daily_db_cleanup)
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
_launch_background_task("email-recaps", run_email_recap_loop)
logger.info("startup complete")
@@ -284,3 +287,4 @@ app.include_router(portal_router)
app.include_router(operations_router)
app.include_router(insights_router)
app.include_router(identities_router)
app.include_router(recaps_router)
+133
View File
@@ -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)
+243
View File
@@ -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)
+172
View File
@@ -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)} &#8599;</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}, heres 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}>"
+235
View File
@@ -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}