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.
setPreviewMode('html')}>Email design setPreviewMode('text')}>Plain text
{previewMode === 'html' ? : {preview.body_text} } }
From queue to inbox Delivery history Server acceptance is recorded here. Inbox placement depends on your mail provider.
{ setError(''); setRevision((value) => value + 1) }}>Refresh history
- {data.deliveries.length ? <>Recipient Report Delivery Updated {data.deliveries.map((delivery) => {delivery.username || 'Removed account'} {delivery.email} {monthLabel(delivery.month)}{delivery.kind === 'test' ? 'Test email' : 'Scheduled recap'} {stateLabels[delivery.state] || delivery.state} {delivery.attempts} {delivery.attempts === 1 ? 'attempt' : 'attempts'} · {delivery.detail || 'Waiting for the next worker check.'} {delivery.state === 'retry' && Next attempt {dateLabel(delivery.next_attempt_at)} }{delivery.state === 'unknown' && Automatic retries are stopped to avoid a duplicate email. }{dateLabel(delivery.updated_at)} )}
{offset + 1}–{Math.min(offset + 50, data.total)} of {data.total} setOffset(Math.max(0, offset - 50))}>Previous = data.total} onClick={() => setOffset(offset + 50)}>Next
> : ✉ Your first recap starts here Preview your email, send yourself a test, then start the monthly schedule. Delivery results will appear here.
}
+ {data.deliveries.length ? <>Recipient Report Delivery Updated {data.deliveries.map((delivery) => {delivery.username || 'Removed account'} {delivery.email} {monthLabel(delivery.month)}{delivery.kind === 'test' ? 'Test email' : delivery.kind === 'on_demand' ? 'Requested by user' : 'Scheduled recap'} {stateLabels[delivery.state] || delivery.state} {delivery.attempts} {delivery.attempts === 1 ? 'attempt' : 'attempts'} · {delivery.detail || 'Waiting for the next worker check.'} {delivery.state === 'retry' && Next attempt {dateLabel(delivery.next_attempt_at)} }{delivery.state === 'unknown' && Automatic retries are stopped to avoid a duplicate email. }{dateLabel(delivery.updated_at)} )}
{offset + 1}–{Math.min(offset + 50, data.total)} of {data.total} setOffset(Math.max(0, offset - 50))}>Previous = data.total} onClick={() => setOffset(offset + 50)}>Next
> : ✉ Your first recap starts here Preview your email, send yourself a test, then start the monthly schedule. Delivery results will appear here.
}
>}
diff --git a/frontend/app/email-recaps/page.tsx b/frontend/app/email-recaps/page.tsx
index eb63392..98690e0 100644
--- a/frontend/app/email-recaps/page.tsx
+++ b/frontend/app/email-recaps/page.tsx
@@ -55,9 +55,9 @@ export default function EmailRecapLinkPage() {
const done = state === 'enabled' || state === 'off'
return Magent
- Personal monthly recaps
+ Personal viewing reports
{state === 'enabled' ? 'You’re on the list.' : state === 'off' ? 'Recaps are turned off.' : state === 'loading' ? 'Checking your email link' : state === 'error' ? 'This link needs another look' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps?' : 'Your month, delivered.'}
- {state === 'enabled' ? 'Your email is confirmed. You’ll receive your personal viewing recap when the monthly schedule runs.' : state === 'off' ? 'You won’t receive further monthly recaps. You can turn them back on in Profile.' : state === 'ready' && link?.action === 'unsubscribe' ? 'This turns off your monthly viewing emails. You can still explore all your reports in Magent.' : state === 'ready' ? 'Confirm to receive your minutes, movies, episodes, longest run and requests each month.' : ''}
+ {state === 'enabled' ? 'Your email is confirmed. You’ll receive your personal viewing recap when the monthly schedule runs.' : state === 'off' ? 'You won’t receive further monthly recaps. You can turn them back on in Profile.' : state === 'ready' && link?.action === 'unsubscribe' ? 'This turns off all personal viewing report emails. You can still explore all your reports in Magent.' : state === 'ready' ? 'Confirm to email yourself your minutes, movies, episodes, longest run and requests. Manage automatic monthly delivery separately in Profile.' : ''}
{error && {error}
}
{state === 'ready' && void apply()}>{busy ? 'Updating…' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps' : 'Confirm email recaps'} }
{(done || state === 'error') && Manage email preferences ↗ }
diff --git a/frontend/app/email-recaps/recaps.css b/frontend/app/email-recaps/recaps.css
index 21c997c..557bf5f 100644
--- a/frontend/app/email-recaps/recaps.css
+++ b/frontend/app/email-recaps/recaps.css
@@ -76,3 +76,6 @@
.recap-link-page .account-panel { padding: 26px 22px; }
.recap-pagination { flex-wrap: wrap; }
}
+
+.recap-delivery-choice { display: grid; gap: 8px; margin-block: 16px; }
+.recap-delivery-choice select { width: 100%; min-width: 0; }
diff --git a/frontend/app/insights/reports/EmailReportControl.tsx b/frontend/app/insights/reports/EmailReportControl.tsx
new file mode 100644
index 0000000..b08050d
--- /dev/null
+++ b/frontend/app/insights/reports/EmailReportControl.tsx
@@ -0,0 +1,60 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { authFetch, getApiBase } from '../../lib/auth'
+
+type Delivery = { id: string; month: string; state: string; detail: string }
+type Preference = { email: string | null; can_send: boolean; state: string; detail: string; deliveries: Delivery[] }
+
+export default function EmailReportControl({ month }: { month: string }) {
+ const [data, setData] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [notice, setNotice] = useState('')
+ const [error, setError] = useState('')
+ const [revision, setRevision] = useState(0)
+ const request = useRef<{ month: string; id: string } | null>(null)
+ const pending = data?.deliveries.some((item) => ['queued', 'preparing', 'sending', 'retry'].includes(item.state)) ?? false
+
+ useEffect(() => {
+ const abort = new AbortController()
+ void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal }).then(async (response) => {
+ if (!response.ok) throw new Error('Could not load your report email preferences. Refresh to try again.')
+ const result = await response.json()
+ if (!abort.signal.aborted) setData(result)
+ }).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
+ return () => abort.abort()
+ }, [revision])
+
+ useEffect(() => {
+ if (!pending) return
+ const timer = window.setInterval(() => setRevision((value) => value + 1), 10000)
+ return () => window.clearInterval(timer)
+ }, [pending])
+
+ const send = async () => {
+ if (busy || !data?.can_send) return
+ setBusy(true); setError(''); setNotice('')
+ if (request.current?.month !== month) request.current = { month, id: crypto.randomUUID() }
+ try {
+ const response = await authFetch(`${getApiBase()}/profile/email-recaps/send`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ month, request_id: request.current.id }),
+ })
+ const result = await response.json().catch(() => ({}))
+ if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not queue your report. Try again.')
+ setNotice(result.message); request.current = null
+ setRevision((value) => value + 1)
+ } catch (err) { setError(err instanceof Error ? err.message : 'Could not queue your report.') }
+ finally { setBusy(false) }
+ }
+
+ return
+
+ Choose a month above, including the current month so far, then send its viewing and request summary to your confirmed profile email.
+ {data?.can_send ? {data.email} · One report email every five minutes.
: data && {data.state === 'enabled' ? data.detail : 'Confirm your profile email in Email preferences first. You can choose on-demand delivery without automatic monthly emails.'}
}
+ void send()}>{busy ? 'Queueing report…' : 'Email this report'}
+ {notice && {notice}
}
+ {error && {error}
}
+ {!!data?.deliveries.length && Recent report emails {data.deliveries.map((item) => {item.month} · {item.state === 'sent' ? 'Accepted by mail server' : item.state} — {item.detail || 'Waiting for delivery'} )} }
+
+}
diff --git a/frontend/app/insights/reports/page.tsx b/frontend/app/insights/reports/page.tsx
index 259b506..478897f 100644
--- a/frontend/app/insights/reports/page.tsx
+++ b/frontend/app/insights/reports/page.tsx
@@ -1,5 +1,7 @@
'use client'
+import EmailReportControl from './EmailReportControl'
+
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../../lib/auth'
@@ -124,6 +126,7 @@ export default function MonthlyReportsPage() {
From Jellystat · UTC
{downloadError && {downloadError}
}
+ {data?.state === 'ready' && !busy && }
{busy && ◷ Putting your month together Gathering your viewing history and the previous month’s comparison.
}
{error && Report couldn’t load {error}
setRevision((value) => value + 1)}>Try again {month &&
setMonth('')}>Latest complete month }
}
{data?.state === 'not_configured' && Your monthly story starts here {data.is_admin ? 'Connect Jellystat to bring your monthly viewing reports into Magent.' : 'Monthly reports will appear once your administrator connects Jellystat.'}
{data.is_admin && Connect Jellystat } }
diff --git a/frontend/app/insights/reports/reports.css b/frontend/app/insights/reports/reports.css
index 579c7e8..a069aec 100644
--- a/frontend/app/insights/reports/reports.css
+++ b/frontend/app/insights/reports/reports.css
@@ -24,3 +24,7 @@
.report-month-picker { width: 100%; }
.report-month-picker label { flex: 1; }
}
+
+.report-email-panel { display: grid; gap: 12px; }
+.report-email-panel > button { justify-self: start; }
+.report-email-panel p, .report-email-panel li { overflow-wrap: anywhere; }
diff --git a/frontend/app/profile/MonthlyRecapPreference.tsx b/frontend/app/profile/MonthlyRecapPreference.tsx
index b2da8b8..2d0413a 100644
--- a/frontend/app/profile/MonthlyRecapPreference.tsx
+++ b/frontend/app/profile/MonthlyRecapPreference.tsx
@@ -5,12 +5,13 @@ import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../lib/auth'
import '../email-recaps/recaps.css'
-type Preference = { state: 'off' | 'pending' | 'expired' | 'enabled'; email: string | null; can_subscribe: boolean; detail: string; schedule_enabled: boolean; next_send_at: number | null; day: number; hour: number; resend_after: number | null }
+type Preference = { automatic_monthly: boolean; state: 'off' | 'pending' | 'expired' | 'enabled'; email: string | null; can_subscribe: boolean; detail: string; schedule_enabled: boolean; next_send_at: number | null; day: number; hour: number; resend_after: number | null }
const scheduled = (value: number) => `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' })} UTC`
export default function MonthlyRecapPreference() {
const router = useRouter()
const [data, setData] = useState(null)
+ const [automatic, setAutomatic] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
@@ -24,7 +25,7 @@ export default function MonthlyRecapPreference() {
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
if (!response.ok) throw new Error('Could not load your email preference. Please try again.')
const result = await response.json() as Preference
- if (!abort.signal.aborted) setData(result)
+ if (!abort.signal.aborted) { setData(result); setAutomatic(result.automatic_monthly) }
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
return () => abort.abort()
}, [revision, router])
@@ -35,38 +36,43 @@ export default function MonthlyRecapPreference() {
return () => window.clearInterval(timer)
}, [data?.resend_after, data?.state])
- const save = async (enabled: boolean) => {
+ const save = async (enabled: boolean, monthly = automatic) => {
if (busy) return
setBusy(true); setError(''); setNotice('')
try {
const response = await authFetch(`${getApiBase()}/profile/email-recaps`, {
- method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }),
+ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, automatic_monthly: monthly }),
})
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
const result = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your email preference.')
- setData(result); setNow(Date.now())
- setNotice(result.message || (enabled ? 'Monthly recaps are on.' : 'Monthly recaps are off.'))
+ setData(result); setAutomatic(result.automatic_monthly); setNow(Date.now())
+ setNotice(result.message || (enabled ? 'Personal report emails are enabled.' : 'Personal report emails are off.'))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update your email preference.')
// A confirmation may be pending even if SMTP could not confirm delivery.
const response = await authFetch(`${getApiBase()}/profile/email-recaps`).catch(() => null)
- if (response?.ok) { setData(await response.json()); setNow(Date.now()) }
+ if (response?.ok) { const fresh = await response.json(); setData(fresh); setAutomatic(fresh.automatic_monthly); setNow(Date.now()) }
} finally { setBusy(false) }
}
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0
return
- A little look back
Your month, delivered. {data &&
{({ off: 'Off', pending: 'Check your inbox', expired: 'Confirmation expired', enabled: 'Subscribed' })[data.state]} }
- Your minutes, movies, episodes, longest run and requests, in one personal monthly email. Explore your latest report ↗
+ A little look back
Your reports, your choice. {data &&
{({ off: 'Off', pending: 'Check your inbox', expired: 'Confirmation expired', enabled: 'Email confirmed' })[data.state]} }
+ Email yourself your viewing report whenever you want. Choose a previous month or the current month so far, and decide whether you also want automatic monthly emails. Explore your latest report ↗
{!data && !error && Loading your email preference…
}
{data && <>
- {data.state === 'enabled' ? Recaps will go to {data.email} . {data.schedule_enabled && data.next_send_at ? `Next scheduled send: ${scheduled(data.next_send_at)}.` : 'The administrator has paused scheduled delivery.'}
: {data.state === 'pending' ? `Check ${data.email} for a confirmation link. It expires after 24 hours. No viewing history is emailed until you confirm.` : data.state === 'expired' ? 'Request a new confirmation link to turn on your recaps.' : 'Confirm your profile email to turn this on. You can unsubscribe from any recap or here in Profile.'}
}
+ {data.state === 'enabled' ? Recaps will go to {data.email} . {!data.automatic_monthly ? 'On demand only: choose a month in Reports and email it whenever you want.' : data.schedule_enabled && data.next_send_at ? `Next scheduled send: ${scheduled(data.next_send_at)}.` : 'The administrator has paused scheduled delivery.'}
: {data.state === 'pending' ? `Check ${data.email} for a confirmation link. It expires after 24 hours. No viewing history is emailed until you confirm.` : data.state === 'expired' ? 'Request a new confirmation link to turn on your recaps.' : 'Confirm your profile email to turn this on. You can unsubscribe from any recap or here in Profile.'}
}
{!data.can_subscribe && data.state !== 'enabled' && {data.detail}
}
- {data.state !== 'enabled' && data.can_subscribe && !data.schedule_enabled && You can subscribe now. Monthly sends will begin when your administrator starts the schedule.
}
+ {data.state !== 'enabled' && data.can_subscribe && automatic && !data.schedule_enabled && You can subscribe now. Monthly sends will begin when your administrator starts the schedule.
}
+ Delivery preference {
+ const monthly = event.target.value === 'monthly'
+ setAutomatic(monthly)
+ if (data.state === 'enabled') void save(true, monthly)
+ }}>On demand only On demand + automatic monthly emails
- {data.state !== 'enabled' && 0} onClick={() => void save(true)}>{busy ? 'Sending confirmation…' : data.state === 'off' ? 'Email me my monthly recap' : 'Send a new confirmation'} }
- {data.state !== 'off' && void save(false)}>{busy ? 'Updating…' : data.state === 'enabled' ? 'Turn off recaps' : 'Cancel subscription'} }
+ {data.state !== 'enabled' && 0} onClick={() => void save(true)}>{busy ? 'Sending confirmation…' : data.state === 'off' ? 'Confirm my email for reports' : 'Send a new confirmation'} }
+ {data.state !== 'off' && void save(false)}>{busy ? 'Updating…' : data.state === 'enabled' ? 'Turn off report emails' : 'Cancel subscription'} }
{ setNotice(''); setRevision((value) => value + 1) }}>Refresh preference
{cooldown > 0 && data.state !== 'enabled' && Another confirmation can be requested in {Math.ceil(cooldown / 60)} {Math.ceil(cooldown / 60) === 1 ? 'minute' : 'minutes'}.
}