Let users email personal reports on demand
This commit is contained in:
@@ -23,6 +23,7 @@ class StrictPayload(BaseModel):
|
|||||||
|
|
||||||
class Preference(StrictPayload):
|
class Preference(StrictPayload):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
automatic_monthly: bool | None = Field(default=None, strict=True)
|
||||||
|
|
||||||
|
|
||||||
class RecapSettings(StrictPayload):
|
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:
|
async def preference(payload: Preference, user: dict = Depends(get_current_user)) -> dict:
|
||||||
try:
|
try:
|
||||||
if payload.enabled:
|
if payload.enabled:
|
||||||
return await recaps.subscribe(user)
|
return await recaps.subscribe(user, payload.automatic_monthly)
|
||||||
store.disable(recaps.current_account(user)["id"])
|
store.disable(recaps.current_account(user)["id"])
|
||||||
return recaps.preferences(user)
|
return recaps.preferences(user)
|
||||||
except recaps.RecapError as exc:
|
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))
|
return recaps.queue_test(user, payload.month, str(payload.request_id))
|
||||||
except recaps.RecapError as exc:
|
except recaps.RecapError as exc:
|
||||||
error(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),
|
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
|
"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.",
|
"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"],
|
"schedule_enabled": config["enabled"], "next_send_at": config["next_send_at"],
|
||||||
"day": config["day"], "hour": config["hour"], "timezone": "UTC",
|
"day": config["day"], "hour": config["hour"], "timezone": "UTC",
|
||||||
"resend_after": (sub["requested_at"] + 300) if sub else None}
|
"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)
|
account = current_account(user)
|
||||||
preference = preferences(user)
|
preference = preferences(user)
|
||||||
|
automatic = preference['automatic_monthly'] if automatic_monthly is None else automatic_monthly
|
||||||
if preference["state"] == "enabled":
|
if preference["state"] == "enabled":
|
||||||
return preference
|
store.set_automatic(account['id'], automatic)
|
||||||
|
return preferences(user)
|
||||||
if not preference["can_subscribe"]:
|
if not preference["can_subscribe"]:
|
||||||
raise RecapError(preference["detail"])
|
raise RecapError(preference["detail"])
|
||||||
config = store.settings()
|
config = store.settings()
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
try:
|
try:
|
||||||
token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
|
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:
|
except ValueError as exc:
|
||||||
raise RecapError(str(exc), 429) from exc
|
raise RecapError(str(exc), 429) from exc
|
||||||
url = f"{config['public_url']}/email-recaps#" + urlencode({"action": "confirm", "token": token})
|
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"]))
|
mail.message_id(uuid.uuid4().hex, config["public_url"]))
|
||||||
except mail.DeliveryError as exc:
|
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
|
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:
|
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"]
|
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 sub["email"] != delivery["email"] or not binding_matches(sub, account)
|
||||||
or config["public_url"] != delivery["public_url"]
|
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()
|
raise mail.DeliveryCancelled()
|
||||||
return account, sub
|
return account, sub
|
||||||
|
|
||||||
@@ -190,10 +194,10 @@ async def process_delivery(delivery: dict) -> None:
|
|||||||
try:
|
try:
|
||||||
account, sub = eligible_delivery(delivery)
|
account, sub = eligible_delivery(delivery)
|
||||||
report = await asyncio.wait_for(get_monthly_report(account, delivery["month"]), timeout=180)
|
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.")
|
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"]})
|
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():
|
def before_data():
|
||||||
eligible_delivery(delivery)
|
eligible_delivery(delivery)
|
||||||
@@ -241,3 +245,22 @@ async def run_email_recap_loop() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("email recap worker failed type=%s", type(exc).__name__)
|
logger.error("email recap worker failed type=%s", type(exc).__name__)
|
||||||
await asyncio.sleep(30)
|
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:
|
def render_confirmation(username: str, url: str) -> dict:
|
||||||
title = "Your month, delivered."
|
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."
|
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,
|
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>',
|
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}
|
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
|
esc = html.escape
|
||||||
month = month_label(report["month"])
|
month = month_label(report["month"])
|
||||||
previous = month_label(report["comparison_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"]
|
summary = report["summary"]
|
||||||
metrics = (("Minutes watched", "minutes", summary["minutes"]), ("Movies played", "movies", summary["movies"]),
|
metrics = (("Minutes watched", "minutes", summary["minutes"]), ("Movies played", "movies", summary["movies"]),
|
||||||
("Episodes played", "episodes", summary["episodes"]), ("Requests made", "requests", report["requests"]["total"]))
|
("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>'
|
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']}"
|
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."
|
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:
|
if test:
|
||||||
intro = "This is your test recap. " + intro
|
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)
|
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""",
|
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
|
||||||
):
|
):
|
||||||
conn.execute(statement)
|
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
|
@contextmanager
|
||||||
@@ -106,7 +109,7 @@ def disable(user_id: int) -> None:
|
|||||||
WHERE user_id=? AND state IN ('queued', 'retry', 'preparing')""", (user_id,))
|
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)
|
token = secrets.token_urlsafe(32)
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
old = conn.execute("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user["id"],)).fetchone()
|
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""",
|
requested_at=excluded.requested_at, confirmed_at=NULL, unsubscribe_token=excluded.unsubscribe_token""",
|
||||||
(user["id"], user["email"].strip(), source, identity, uuid.uuid4().hex,
|
(user["id"], user["email"].strip(), source, identity, uuid.uuid4().hex,
|
||||||
hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
|
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
|
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]
|
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:
|
def enqueue_test(sub: dict, month: str, request_id: str, public_url: str, now: float, kind: str = "test") -> str:
|
||||||
key = f"test:{sub['user_id']}:{request_id}"
|
key = f"{kind}:{sub['user_id']}:{request_id}"
|
||||||
with transaction() as conn:
|
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:
|
||||||
|
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]
|
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()
|
(sub["user_id"], now - 300)).fetchone()
|
||||||
if recent:
|
if recent:
|
||||||
raise ValueError("Please wait five minutes between test emails.")
|
raise ValueError("Please wait five minutes between report emails.")
|
||||||
return _enqueue(conn, sub, month, "test", key, public_url, now)
|
return _enqueue(conn, sub, month, kind, key, public_url, now)
|
||||||
|
|
||||||
|
|
||||||
def enqueue_due(now: datetime) -> int:
|
def enqueue_due(now: datetime) -> int:
|
||||||
@@ -175,7 +181,7 @@ def enqueue_due(now: datetime) -> int:
|
|||||||
if due > now:
|
if due > now:
|
||||||
due = shift_month(now, -1).replace(day=config["day"], hour=config["hour"])
|
due = shift_month(now, -1).replace(day=config["day"], hour=config["hour"])
|
||||||
month = shift_month(due, -1).strftime("%Y-%m")
|
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
|
before = conn.total_changes
|
||||||
for sub in subs:
|
for sub in subs:
|
||||||
_enqueue(conn, dict(sub), month, "scheduled", f"scheduled:{sub['user_id']}:{month}", config["public_url"], now.timestamp())
|
_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
|
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'
|
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 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 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 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
|
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]
|
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]
|
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}
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.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>
|
</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>}
|
{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>
|
<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>
|
</section>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ export default function EmailRecapLinkPage() {
|
|||||||
|
|
||||||
const done = state === 'enabled' || state === 'off'
|
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">
|
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>
|
<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>}
|
{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>}
|
{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>}
|
{(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-link-page .account-panel { padding: 26px 22px; }
|
||||||
.recap-pagination { flex-wrap: wrap; }
|
.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'
|
'use client'
|
||||||
|
|
||||||
|
import EmailReportControl from './EmailReportControl'
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, getApiBase } from '../../lib/auth'
|
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>
|
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat · UTC</p>
|
||||||
</div>
|
</div>
|
||||||
{downloadError && <p className="stats-notice" role="alert">{downloadError}</p>}
|
{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>}
|
{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>}
|
{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>}
|
{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 { width: 100%; }
|
||||||
.report-month-picker label { flex: 1; }
|
.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 { authFetch, getApiBase } from '../lib/auth'
|
||||||
import '../email-recaps/recaps.css'
|
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`
|
const scheduled = (value: number) => `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' })} UTC`
|
||||||
|
|
||||||
export default function MonthlyRecapPreference() {
|
export default function MonthlyRecapPreference() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [data, setData] = useState<Preference | null>(null)
|
const [data, setData] = useState<Preference | null>(null)
|
||||||
|
const [automatic, setAutomatic] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [notice, setNotice] = 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.status === 401) { router.replace('/login?next=%2Fprofile'); return }
|
||||||
if (!response.ok) throw new Error('Could not load your email preference. Please try again.')
|
if (!response.ok) throw new Error('Could not load your email preference. Please try again.')
|
||||||
const result = await response.json() as Preference
|
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) })
|
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
|
||||||
return () => abort.abort()
|
return () => abort.abort()
|
||||||
}, [revision, router])
|
}, [revision, router])
|
||||||
@@ -35,38 +36,43 @@ export default function MonthlyRecapPreference() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [data?.resend_after, data?.state])
|
}, [data?.resend_after, data?.state])
|
||||||
|
|
||||||
const save = async (enabled: boolean) => {
|
const save = async (enabled: boolean, monthly = automatic) => {
|
||||||
if (busy) return
|
if (busy) return
|
||||||
setBusy(true); setError(''); setNotice('')
|
setBusy(true); setError(''); setNotice('')
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${getApiBase()}/profile/email-recaps`, {
|
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 }
|
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
|
||||||
const result = await response.json().catch(() => ({}))
|
const result = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your email preference.')
|
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your email preference.')
|
||||||
setData(result); setNow(Date.now())
|
setData(result); setAutomatic(result.automatic_monthly); setNow(Date.now())
|
||||||
setNotice(result.message || (enabled ? 'Monthly recaps are on.' : 'Monthly recaps are off.'))
|
setNotice(result.message || (enabled ? 'Personal report emails are enabled.' : 'Personal report emails are off.'))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Could not update your email preference.')
|
setError(err instanceof Error ? err.message : 'Could not update your email preference.')
|
||||||
// A confirmation may be pending even if SMTP could not confirm delivery.
|
// A confirmation may be pending even if SMTP could not confirm delivery.
|
||||||
const response = await authFetch(`${getApiBase()}/profile/email-recaps`).catch(() => null)
|
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) }
|
} finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0
|
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">
|
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>
|
<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>Your minutes, movies, episodes, longest run and requests, in one personal monthly email. <a href="/insights/reports">Explore your latest report ↗</a></p>
|
<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 && !error && <p role="status">Loading your email preference…</p>}
|
||||||
{data && <>
|
{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.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">
|
<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 !== '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 recaps' : 'Cancel subscription'}</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>
|
<button type="button" className="account-secondary" disabled={busy} onClick={() => { setNotice(''); setRevision((value) => value + 1) }}>Refresh preference</button>
|
||||||
</div>
|
</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>}
|
{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