Let users email personal reports on demand
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m40s

This commit is contained in:
2026-09-10 16:15:39 +12:00
parent 6e473fd0a7
commit b286ca3c42
13 changed files with 261 additions and 36 deletions
+10 -1
View File
@@ -23,6 +23,7 @@ class StrictPayload(BaseModel):
class Preference(StrictPayload):
enabled: bool
automatic_monthly: bool | None = Field(default=None, strict=True)
class RecapSettings(StrictPayload):
@@ -75,7 +76,7 @@ def preferences(user: dict = Depends(get_current_user)) -> dict:
async def preference(payload: Preference, user: dict = Depends(get_current_user)) -> dict:
try:
if payload.enabled:
return await recaps.subscribe(user)
return await recaps.subscribe(user, payload.automatic_monthly)
store.disable(recaps.current_account(user)["id"])
return recaps.preferences(user)
except recaps.RecapError as exc:
@@ -131,3 +132,11 @@ def test_email(payload: TestEmail, user: dict = Depends(require_admin)) -> dict:
return recaps.queue_test(user, payload.month, str(payload.request_id))
except recaps.RecapError as exc:
error(exc)
@router.post('/profile/email-recaps/send', status_code=202)
def email_personal_report(payload: TestEmail, user: dict = Depends(get_current_user)) -> dict:
try:
return recaps.queue_personal(user, payload.month, str(payload.request_id))
except recaps.RecapError as exc:
error(exc)
+30 -7
View File
@@ -82,23 +82,27 @@ def preferences(user: dict) -> dict:
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) -> dict:
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":
return preference
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())
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})
@@ -108,7 +112,7 @@ async def subscribe(user: dict) -> dict:
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."}
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:
@@ -180,7 +184,7 @@ def eligible_delivery(delivery: dict) -> tuple[dict, dict]:
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 (delivery["kind"] == "scheduled" and (not config["enabled"] or not sub["automatic_monthly"]))):
raise mail.DeliveryCancelled()
return account, sub
@@ -190,10 +194,10 @@ async def process_delivery(delivery: dict) -> None:
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"]:
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 = mail.render_recap(report, account["username"], delivery["public_url"], unsubscribe, test=delivery["kind"] == "test")
rendered = mail.render_recap(report, account["username"], delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
def before_data():
eligible_delivery(delivery)
@@ -241,3 +245,22 @@ async def run_email_recap_loop() -> None:
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.'}
+8 -3
View File
@@ -56,7 +56,7 @@ def document(*, title: str, intro: str, content: str, action: str, url: str, foo
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."
intro = f"Hi {username}, confirm this email address to receive personal viewing reports from Magent. You choose whether to request them yourself or also receive automatic monthly emails."
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>',
@@ -65,10 +65,13 @@ def render_confirmation(username: str, url: str) -> dict:
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:
def render_recap(report: dict, username: str, public_url: str, unsubscribe_url: str, *, test: bool = False, requested: bool = False) -> dict:
esc = html.escape
month = month_label(report["month"])
previous = month_label(report["comparison_month"])
if report.get('is_partial'):
month += ' so far'
previous += ' (same elapsed period, capped at month end)' if report.get('comparison_capped') else ' (same elapsed period)'
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"]))
@@ -94,7 +97,9 @@ def render_recap(report: dict, username: str, public_url: str, unsubscribe_url:
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>'
footer = f'You enabled personal report emails 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 requested:
intro = 'You requested this report. ' + intro
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)
+31 -9
View File
@@ -51,6 +51,9 @@ def init_schema(conn: sqlite3.Connection) -> None:
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
):
conn.execute(statement)
columns = {row[1] for row in conn.execute('PRAGMA table_info(email_recap_subscriptions)')}
if 'automatic_monthly' not in columns:
conn.execute('ALTER TABLE email_recap_subscriptions ADD COLUMN automatic_monthly INTEGER NOT NULL DEFAULT 1')
@contextmanager
@@ -106,7 +109,7 @@ def disable(user_id: int) -> None:
WHERE user_id=? AND state IN ('queued', 'retry', 'preparing')""", (user_id,))
def request_confirmation(user: dict, source: str, identity: str, now: float) -> str:
def request_confirmation(user: dict, source: str, identity: str, now: float, automatic_monthly: bool = True) -> str:
token = secrets.token_urlsafe(32)
with transaction() as conn:
old = conn.execute("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user["id"],)).fetchone()
@@ -122,6 +125,7 @@ def request_confirmation(user: dict, source: str, identity: str, now: float) ->
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)))
conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (automatic_monthly, user['id']))
return token
@@ -152,17 +156,19 @@ def _enqueue(conn, sub: dict, month: str, kind: str, key: str, public_url: str,
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}"
def enqueue_test(sub: dict, month: str, request_id: str, public_url: str, now: float, kind: str = "test") -> str:
key = f"{kind}:{sub['user_id']}:{request_id}"
with transaction() as conn:
existing = conn.execute("SELECT id FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()
existing = conn.execute("SELECT id,month,subscription_version FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()
if existing:
if existing['month'] != month or existing['subscription_version'] != sub['version']:
raise ValueError('This send request was already used. Refresh before requesting another report.')
return existing[0]
recent = conn.execute("SELECT 1 FROM email_recap_deliveries WHERE user_id=? AND kind='test' AND created_at>?",
recent = conn.execute("SELECT 1 FROM email_recap_deliveries WHERE user_id=? AND kind IN ('test','on_demand') 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)
raise ValueError("Please wait five minutes between report emails.")
return _enqueue(conn, sub, month, kind, key, public_url, now)
def enqueue_due(now: datetime) -> int:
@@ -175,7 +181,7 @@ def enqueue_due(now: datetime) -> int:
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()
subs = conn.execute("SELECT * FROM email_recap_subscriptions WHERE state='enabled' AND automatic_monthly=1 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())
@@ -199,9 +205,10 @@ def begin_sending(delivery: dict, now: float) -> bool:
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 (email_recap_deliveries.kind!='scheduled' OR s.automatic_monthly=1)
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"]))
AND (email_recap_deliveries.kind IN ('test','on_demand') OR c.enabled=1))""", (now, now + 1800, delivery["id"], delivery["claim"]))
return result.rowcount == 1
@@ -219,3 +226,18 @@ def history(limit: int = 50, offset: int = 0) -> dict:
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}
def set_automatic(user_id: int, enabled: bool):
with transaction() as conn:
conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (enabled, user_id))
if not enabled:
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled',detail='Automatic monthly emails turned off.'
WHERE user_id=? AND kind='scheduled' AND state IN ('queued','retry','preparing')""", (user_id,))
def personal_history(user_id: int) -> list[dict]:
with closing(db._connect()) as conn:
conn.row_factory = sqlite3.Row
return [dict(row) for row in conn.execute("""SELECT id,month,kind,state,created_at,detail
FROM email_recap_deliveries WHERE user_id=? ORDER BY created_at DESC,id DESC LIMIT 5""", (user_id,))]
+68
View File
@@ -491,3 +491,71 @@ class RecapEmailTests(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
class OnDemandReportTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
async def test_new_confirmation_defaults_to_manual_without_changing_schedule(self):
with patch.object(mail, 'send_email'):
result = await recaps.subscribe(self.user)
self.assertFalse(result['automatic_monthly'])
self.assertFalse(store.settings()['enabled'])
self.assertEqual(result['state'], 'pending')
with self.assertRaises(recaps.RecapError):
recaps.queue_personal(self.user, None, 'pending')
async def test_manual_current_month_delivers_with_monthly_schedule_off(self):
sub, _ = self.subscribe()
store.set_automatic(self.user['id'], False)
month = datetime.now(timezone.utc).strftime('%Y-%m')
queued = recaps.queue_personal(self.user, month, 'manual-1')
self.assertEqual(recaps.queue_personal(self.user, month, 'manual-1')['id'], queued['id'])
report = {**self.report, **month_periods(month, datetime.now(timezone.utc))}
def send(recipient, rendered, message_id, before_data):
before_data()
self.assertEqual(recipient, self.user['email'])
self.assertIn('so far', rendered['subject'])
self.assertNotIn('[Test]', rendered['subject'])
with patch.object(recaps, 'get_monthly_report', new_callable=AsyncMock, return_value=report), patch.object(mail, 'send_email', side_effect=send):
await recaps.process_delivery(store.claim_delivery(time.time()))
self.assertEqual(self.delivery(queued['id'])['state'], 'sent')
self.assertFalse(store.settings()['enabled'])
self.assertFalse(store.subscription(self.user['id'])['automatic_monthly'])
with self.assertRaises(recaps.RecapError) as error:
recaps.queue_personal(self.user, month, 'manual-2')
self.assertEqual(error.exception.status, 429)
async def test_automatic_opt_out_cancels_scheduled_but_keeps_manual(self):
sub, _ = self.subscribe()
with store.transaction() as conn:
scheduled = store._enqueue(conn, sub, self.report['month'], 'scheduled', 'scheduled-fixture', self.config['public_url'], time.time())
manual = recaps.queue_personal(self.user, None, 'manual')
store.set_automatic(self.user['id'], False)
self.assertEqual(self.delivery(scheduled)['state'], 'cancelled')
self.assertEqual(self.delivery(manual['id'])['state'], 'queued')
self.assertEqual(store.subscription(self.user['id'])['state'], 'enabled')
now = datetime.now(timezone.utc)
store.save_settings({**self.config, 'enabled': True}, now)
self.assertEqual(store.enqueue_due(now + timedelta(days=40)), 0)
async def test_changed_identity_cancels_manual_delivery(self):
self.subscribe()
queued = recaps.queue_personal(self.user, None, 'manual')
delivery = store.claim_delivery(time.time())
db.set_user_email('viewer', 'changed@example.test')
with patch.object(mail, 'send_email') as send:
await recaps.process_delivery(delivery)
send.assert_not_called()
self.assertEqual(self.delivery(queued['id'])['state'], 'cancelled')
async def test_regular_user_can_only_send_to_self(self):
self.subscribe()
app = FastAPI(); app.include_router(router.router)
app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user'}
client = TestClient(app)
body = {'month': self.report['month'], 'request_id': '11111111-1111-4111-8111-111111111111'}
for extra in [{'email': 'other@example.test'}, {'user_id': 42}, {'kind': 'scheduled'}]:
self.assertEqual(client.post('/profile/email-recaps/send', json={**body, **extra}).status_code, 422)
self.assertEqual(client.post('/profile/email-recaps/send', json=body).status_code, 202)
response = client.get('/profile/email-recaps')
self.assertEqual(response.headers['cache-control'], 'no-store')
self.assertEqual(len(response.json()['deliveries']), 1)