From b286ca3c42b7e0989d76998cb57f2eb846565e2e Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Thu, 10 Sep 2026 16:15:39 +1200 Subject: [PATCH] Let users email personal reports on demand --- backend/app/routers/recaps.py | 11 ++- backend/app/services/email_recaps.py | 37 ++++++++-- backend/app/services/recap_email.py | 11 ++- backend/app/services/recap_store.py | 40 ++++++++--- backend/tests/test_email_recaps.py | 68 +++++++++++++++++++ docs/email-recaps.md | 22 ++++++ frontend/app/admin/recaps/page.tsx | 2 +- frontend/app/email-recaps/page.tsx | 4 +- frontend/app/email-recaps/recaps.css | 3 + .../insights/reports/EmailReportControl.tsx | 60 ++++++++++++++++ frontend/app/insights/reports/page.tsx | 3 + frontend/app/insights/reports/reports.css | 4 ++ .../app/profile/MonthlyRecapPreference.tsx | 32 +++++---- 13 files changed, 261 insertions(+), 36 deletions(-) create mode 100644 docs/email-recaps.md create mode 100644 frontend/app/insights/reports/EmailReportControl.tsx diff --git a/backend/app/routers/recaps.py b/backend/app/routers/recaps.py index b6fbf56..a47735b 100644 --- a/backend/app/routers/recaps.py +++ b/backend/app/routers/recaps.py @@ -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) diff --git a/backend/app/services/email_recaps.py b/backend/app/services/email_recaps.py index 6aeb35b..f5484a3 100644 --- a/backend/app/services/email_recaps.py +++ b/backend/app/services/email_recaps.py @@ -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.'} diff --git a/backend/app/services/recap_email.py b/backend/app/services/recap_email.py index d94af35..3756230 100644 --- a/backend/app/services/recap_email.py +++ b/backend/app/services/recap_email.py @@ -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='

Minutes watched, movies, episodes, your longest run and requests — with a link to your full monthly report.

', @@ -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 += '

No viewing was recorded this month. Your requests are still included.

' 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.
Based on retained Jellystat history. Calendar months use UTC; request statuses are current.
Unsubscribe from recaps · Email preferences' + footer = f'You enabled personal report emails from Magent.
Based on retained Jellystat history. Calendar months use UTC; request statuses are current.
Unsubscribe from recaps · Email preferences' + 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) diff --git a/backend/app/services/recap_store.py b/backend/app/services/recap_store.py index 87f547d..04577d2 100644 --- a/backend/app/services/recap_store.py +++ b/backend/app/services/recap_store.py @@ -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,))] diff --git a/backend/tests/test_email_recaps.py b/backend/tests/test_email_recaps.py index 0bdab22..09f91af 100644 --- a/backend/tests/test_email_recaps.py +++ b/backend/tests/test_email_recaps.py @@ -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) diff --git a/docs/email-recaps.md b/docs/email-recaps.md new file mode 100644 index 0000000..333a3e6 --- /dev/null +++ b/docs/email-recaps.md @@ -0,0 +1,22 @@ +# Personal report emails + + +## On-demand personal reports + +Users can email the month shown on **My stats > Monthly report**, including the +current month to date or any available previous month. Delivery uses the confirmed +profile email and the existing private report generator. The API does not accept +another user, recipient, or delivery kind. The request UUID is idempotent, and +manual/test report requests share a five-minute per-user cooldown. + +**Profile > Your reports, your choice** offers on-demand-only delivery or on-demand +plus automatic monthly emails. New confirmations default to on-demand-only; +existing confirmed subscribers retain their previous automatic monthly preference. +Turning automatic delivery off cancels pending scheduled emails, but preserves +verified-email consent and explicitly requested emails. A full unsubscribe, email +change, identity change or blocked account prevents pending personal delivery. + +On-demand requests work while the monthly schedule is paused, provided SMTP, +Jellystat and the delivery worker are available. Delivery status is available to +the requesting user and in the admin recap history. New-arrivals newsletters are +unchanged and keep their independent subscriptions and schedule. diff --git a/frontend/app/admin/recaps/page.tsx b/frontend/app/admin/recaps/page.tsx index a01ce10..f35aad5 100644 --- a/frontend/app/admin/recaps/page.tsx +++ b/frontend/app/admin/recaps/page.tsx @@ -123,7 +123,7 @@ export default function EmailRecapsAdminPage() { {preview &&
Email preview

{preview.subject}

For {preview.email || 'your profile email'} · Preview links use your saved public address.

{previewMode === 'html' ?