Add opt-in monthly email recaps with scheduling and delivery history
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user