Let users email personal reports on demand
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
@@ -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}, 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>'
|
||||
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)
|
||||
|
||||
@@ -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,))]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -123,7 +123,7 @@ export default function EmailRecapsAdminPage() {
|
||||
</div>
|
||||
{preview && <section className="admin-panel recap-panel recap-preview"><div className="recap-section-heading"><div><span className="recap-eyebrow">Email preview</span><h2>{preview.subject}</h2><p>For {preview.email || 'your profile email'} · Preview links use your saved public address.</p></div><div className="recap-mode-buttons"><button type="button" aria-pressed={previewMode === 'html'} onClick={() => setPreviewMode('html')}>Email design</button><button type="button" aria-pressed={previewMode === 'text'} onClick={() => setPreviewMode('text')}>Plain text</button></div></div>{previewMode === 'html' ? <iframe title="Monthly recap email preview" sandbox="" referrerPolicy="no-referrer" srcDoc={preview.body_html} /> : <pre className="recap-plain-preview">{preview.body_text}</pre>}</section>}
|
||||
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">From queue to inbox</span><h2>Delivery history</h2><p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p></div><button type="button" className="ghost-button" disabled={!!busy} onClick={() => { setError(''); setRevision((value) => value + 1) }}>Refresh history</button></div>
|
||||
{data.deliveries.length ? <><div className="recap-history-scroll"><table className="recap-history"><thead><tr><th scope="col">Recipient</th><th scope="col">Report</th><th scope="col">Delivery</th><th scope="col">Updated</th></tr></thead><tbody>{data.deliveries.map((delivery) => <tr key={delivery.id}><td><strong>{delivery.username || 'Removed account'}</strong><small>{delivery.email}</small></td><td>{monthLabel(delivery.month)}<small>{delivery.kind === 'test' ? 'Test email' : 'Scheduled recap'}</small></td><td><span className={`recap-pill ${delivery.state === 'sent' ? 'is-enabled' : ['failed', 'unknown'].includes(delivery.state) ? 'is-attention' : ''}`}>{stateLabels[delivery.state] || delivery.state}</span><small>{delivery.attempts} {delivery.attempts === 1 ? 'attempt' : 'attempts'} · {delivery.detail || 'Waiting for the next worker check.'}</small>{delivery.state === 'retry' && <small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>}{delivery.state === 'unknown' && <small>Automatic retries are stopped to avoid a duplicate email.</small>}</td><td>{dateLabel(delivery.updated_at)}</td></tr>)}</tbody></table></div><div className="recap-pagination"><span>{offset + 1}–{Math.min(offset + 50, data.total)} of {data.total}</span><div className="recap-actions"><button className="ghost-button" type="button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>Previous</button><button className="ghost-button" type="button" disabled={offset + 50 >= data.total} onClick={() => setOffset(offset + 50)}>Next</button></div></div></> : <div className="recap-empty"><span aria-hidden="true">✉</span><h3>Your first recap starts here</h3><p>Preview your email, send yourself a test, then start the monthly schedule. Delivery results will appear here.</p></div>}
|
||||
{data.deliveries.length ? <><div className="recap-history-scroll"><table className="recap-history"><thead><tr><th scope="col">Recipient</th><th scope="col">Report</th><th scope="col">Delivery</th><th scope="col">Updated</th></tr></thead><tbody>{data.deliveries.map((delivery) => <tr key={delivery.id}><td><strong>{delivery.username || 'Removed account'}</strong><small>{delivery.email}</small></td><td>{monthLabel(delivery.month)}<small>{delivery.kind === 'test' ? 'Test email' : delivery.kind === 'on_demand' ? 'Requested by user' : 'Scheduled recap'}</small></td><td><span className={`recap-pill ${delivery.state === 'sent' ? 'is-enabled' : ['failed', 'unknown'].includes(delivery.state) ? 'is-attention' : ''}`}>{stateLabels[delivery.state] || delivery.state}</span><small>{delivery.attempts} {delivery.attempts === 1 ? 'attempt' : 'attempts'} · {delivery.detail || 'Waiting for the next worker check.'}</small>{delivery.state === 'retry' && <small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>}{delivery.state === 'unknown' && <small>Automatic retries are stopped to avoid a duplicate email.</small>}</td><td>{dateLabel(delivery.updated_at)}</td></tr>)}</tbody></table></div><div className="recap-pagination"><span>{offset + 1}–{Math.min(offset + 50, data.total)} of {data.total}</span><div className="recap-actions"><button className="ghost-button" type="button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>Previous</button><button className="ghost-button" type="button" disabled={offset + 50 >= data.total} onClick={() => setOffset(offset + 50)}>Next</button></div></div></> : <div className="recap-empty"><span aria-hidden="true">✉</span><h3>Your first recap starts here</h3><p>Preview your email, send yourself a test, then start the monthly schedule. Delivery results will appear here.</p></div>}
|
||||
</section>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
@@ -55,9 +55,9 @@ export default function EmailRecapLinkPage() {
|
||||
|
||||
const done = state === 'enabled' || state === 'off'
|
||||
return <main className="recap-link-page"><a className="recap-brand" href="/login"><BrandingLogo className="brand-logo" /><span>Magent</span></a><section className="account-panel">
|
||||
<span className="recap-eyebrow">Personal monthly recaps</span>
|
||||
<span className="recap-eyebrow">Personal viewing reports</span>
|
||||
<h1>{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.'}</h1>
|
||||
<p>{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.' : ''}</p>
|
||||
<p>{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.' : ''}</p>
|
||||
{error && <p className="account-notice is-error" role="alert">{error}</p>}
|
||||
{state === 'ready' && <button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>{busy ? 'Updating…' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps' : 'Confirm email recaps'}</button>}
|
||||
{(done || state === 'error') && <a className="recap-text-link" href="/profile#monthly-recaps">Manage email preferences ↗</a>}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<Preference | null>(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 <section className="stats-panel report-email-panel" aria-label="Email your report">
|
||||
<div className="stats-panel-heading"><h2>Email yourself this report</h2><a href="/profile#monthly-recaps">Email preferences</a></div>
|
||||
<p>Choose a month above, including the current month so far, then send its viewing and request summary to your confirmed profile email.</p>
|
||||
{data?.can_send ? <p><strong>{data.email}</strong> · One report email every five minutes.</p> : data && <p>{data.state === 'enabled' ? data.detail : 'Confirm your profile email in Email preferences first. You can choose on-demand delivery without automatic monthly emails.'}</p>}
|
||||
<button type="button" disabled={busy || !data?.can_send} onClick={() => void send()}>{busy ? 'Queueing report…' : 'Email this report'}</button>
|
||||
{notice && <p role="status">{notice}</p>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{!!data?.deliveries.length && <details><summary>Recent report emails</summary><ul>{data.deliveries.map((item) => <li key={item.id}><strong>{item.month}</strong> · {item.state === 'sent' ? 'Accepted by mail server' : item.state} — {item.detail || 'Waiting for delivery'}</li>)}</ul></details>}
|
||||
</section>
|
||||
}
|
||||
@@ -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() {
|
||||
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat · UTC</p>
|
||||
</div>
|
||||
{downloadError && <p className="stats-notice" role="alert">{downloadError}</p>}
|
||||
{data?.state === 'ready' && !busy && <EmailReportControl month={data.month} />}
|
||||
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true">◷</span><h2>Putting your month together</h2><p>Gathering your viewing history and the previous month’s comparison.</p></div>}
|
||||
{error && <div className="stats-state" role="alert"><h2>Report couldn’t load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button>{month && <button type="button" className="ghost-button" onClick={() => setMonth('')}>Latest complete month</button>}</div>}
|
||||
{data?.state === 'not_configured' && <section className="stats-state"><h2>Your monthly story starts here</h2><p>{data.is_admin ? 'Connect Jellystat to bring your monthly viewing reports into Magent.' : 'Monthly reports will appear once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<Preference | null>(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 <section className="recap-preference" id="monthly-recaps" aria-labelledby="recap-preference-title">
|
||||
<div className="recap-section-heading"><div><span className="recap-eyebrow">A little look back</span><h2 id="recap-preference-title">Your month, delivered.</h2></div>{data && <span className={`recap-pill ${data.state === 'enabled' ? 'is-enabled' : ''}`}>{({ off: 'Off', pending: 'Check your inbox', expired: 'Confirmation expired', enabled: 'Subscribed' })[data.state]}</span>}</div>
|
||||
<p>Your minutes, movies, episodes, longest run and requests, in one personal monthly email. <a href="/insights/reports">Explore your latest report ↗</a></p>
|
||||
<div className="recap-section-heading"><div><span className="recap-eyebrow">A little look back</span><h2 id="recap-preference-title">Your reports, your choice.</h2></div>{data && <span className={`recap-pill ${data.state === 'enabled' ? 'is-enabled' : ''}`}>{({ off: 'Off', pending: 'Check your inbox', expired: 'Confirmation expired', enabled: 'Email confirmed' })[data.state]}</span>}</div>
|
||||
<p>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. <a href="/insights/reports">Explore your latest report ↗</a></p>
|
||||
{!data && !error && <p role="status">Loading your email preference…</p>}
|
||||
{data && <>
|
||||
{data.state === 'enabled' ? <p className="recap-delivery-address">Recaps will go to <strong>{data.email}</strong>. {data.schedule_enabled && data.next_send_at ? `Next scheduled send: ${scheduled(data.next_send_at)}.` : 'The administrator has paused scheduled delivery.'}</p> : <p>{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.'}</p>}
|
||||
{data.state === 'enabled' ? <p className="recap-delivery-address">Recaps will go to <strong>{data.email}</strong>. {!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.'}</p> : <p>{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.'}</p>}
|
||||
{!data.can_subscribe && data.state !== 'enabled' && <p className="recap-muted">{data.detail}</p>}
|
||||
{data.state !== 'enabled' && data.can_subscribe && !data.schedule_enabled && <p className="recap-muted">You can subscribe now. Monthly sends will begin when your administrator starts the schedule.</p>}
|
||||
{data.state !== 'enabled' && data.can_subscribe && automatic && !data.schedule_enabled && <p className="recap-muted">You can subscribe now. Monthly sends will begin when your administrator starts the schedule.</p>}
|
||||
<label className="recap-delivery-choice">Delivery preference<select value={automatic ? 'monthly' : 'manual'} disabled={busy || data.state === 'pending'} onChange={(event) => {
|
||||
const monthly = event.target.value === 'monthly'
|
||||
setAutomatic(monthly)
|
||||
if (data.state === 'enabled') void save(true, monthly)
|
||||
}}><option value="manual">On demand only</option><option value="monthly">On demand + automatic monthly emails</option></select></label>
|
||||
<div className="recap-actions">
|
||||
{data.state !== 'enabled' && <button type="button" className="account-primary" disabled={busy || !data.can_subscribe || cooldown > 0} onClick={() => void save(true)}>{busy ? 'Sending confirmation…' : data.state === 'off' ? 'Email me my monthly recap' : 'Send a new confirmation'}</button>}
|
||||
{data.state !== 'off' && <button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>{busy ? 'Updating…' : data.state === 'enabled' ? 'Turn off recaps' : 'Cancel subscription'}</button>}
|
||||
{data.state !== 'enabled' && <button type="button" className="account-primary" disabled={busy || !data.can_subscribe || cooldown > 0} onClick={() => void save(true)}>{busy ? 'Sending confirmation…' : data.state === 'off' ? 'Confirm my email for reports' : 'Send a new confirmation'}</button>}
|
||||
{data.state !== 'off' && <button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>{busy ? 'Updating…' : data.state === 'enabled' ? 'Turn off report emails' : 'Cancel subscription'}</button>}
|
||||
<button type="button" className="account-secondary" disabled={busy} onClick={() => { setNotice(''); setRevision((value) => value + 1) }}>Refresh preference</button>
|
||||
</div>
|
||||
{cooldown > 0 && data.state !== 'enabled' && <p className="recap-muted">Another confirmation can be requested in {Math.ceil(cooldown / 60)} {Math.ceil(cooldown / 60) === 1 ? 'minute' : 'minutes'}.</p>}
|
||||
|
||||
Reference in New Issue
Block a user