Add opt-in monthly email recaps with scheduling and delivery history
This commit is contained in:
@@ -54,11 +54,34 @@ Open **My Stats → Monthly reports** (`/insights/reports`). The default is the
|
||||
- Periods include their start and exclude their end. Midnight activity belongs to exactly one month; leap years and December/January boundaries use calendar arithmetic. Missing prior activity has no percentage increase, rather than an infinite or invented percentage.
|
||||
- Reports use the same stored Jellyfin identity resolution as My Stats, including administrator-confirmed links. They accept only a month, never a browser-supplied user ID or server scope. Requests use the authenticated account's Seerr ID under the existing ownership rules.
|
||||
- `GET /insights/reports/monthly` returns the report; `GET /insights/reports/monthly.csv` downloads its summary, comparisons, daily totals, leading titles, players, streaming methods, transcoding and request counts. Both require authentication and return `Cache-Control: no-store`. CSV text cells are escaped and formula-like values are prefixed to prevent spreadsheet execution. Exports omit account IDs, artwork tokens and upstream credentials.
|
||||
- Reports are generated on demand from retained Jellystat history and Magent's available request cache. They are not immutable historical snapshots. Request statuses are current, and historical totals can change with retention or library metadata. No report database, email delivery or scheduler is added.
|
||||
- Reports are generated on demand from retained Jellystat history and Magent's available request cache. They are not immutable historical snapshots. Request statuses are current, and historical totals can change with retention or library metadata.
|
||||
- One bounded history read covers the selected and comparison months. Playback summaries are cached for 60 seconds in at most 128 entries, separated by identity, connection and month. Request totals are refreshed independently. Upstream errors or history limits fail the report without presenting a partial result.
|
||||
|
||||
`backend/tests/test_monthly_reports.py` covers calendar boundaries, matched partial periods, ownership, cache isolation, comparisons and safe CSV exports. `scripts/review_monthly_reports_ui.cjs` checks the report controls and layouts with fixtures only.
|
||||
|
||||
## Personal monthly email recaps
|
||||
|
||||
**Settings → Monthly email recaps** (`/admin/recaps`) controls the public Magent address, monthly schedule, personal preview, test emails and delivery history. The dark email design matches My Stats and includes viewing/request totals, changes against the previous month, the longest run and top three titles. The full-report link preserves its month through sign-in. A plain-text alternative is included; private artwork tokens and service credentials are never embedded in an email.
|
||||
|
||||
New installations start with scheduled delivery paused and no subscriptions. Set this environment's public Magent origin (for Beta, `https://beta.grizzlyflix.co.nz`), check **Email & notifications**, preview your own report and confirm your email in **Profile → Monthly recaps** before sending yourself a test. Test emails use the same queue and are allowed while the monthly schedule is paused. They can only go to the signed-in administrator's confirmed profile email. Previewing never sends email, and the preview's preference links do not contain a live unsubscribe token.
|
||||
|
||||
Users choose **Email me my monthly recap** in Profile and confirm ownership of their profile email through a link that expires in 24 hours. The confirmation email contains no viewing data. Opening a confirmation or unsubscribe link only checks it; the user must press the action button. Unsubscribe works without signing in and is also available in Profile. Link tokens travel in URL fragments, then in a redacted JSON `token` field. Confirmation tokens are stored as hashes and consumed on use. Unsubscribe tokens are random, scoped to the current subscription and rotated on a new opt-in.
|
||||
|
||||
Subscriptions are bound to the Magent account, confirmed email and stored Jellyfin source/user ID. The background worker does not infer links from emails or playback names. Email changes (even if later changed back), blocked accounts, deleted/replaced identity links and changed Jellyfin sources invalidate consent. Expired and deleted accounts are excluded. The worker checks the current binding again immediately before handing a message to SMTP; unsubscribing cancels queued/preparing messages. Email already handed to the mail server cannot be recalled.
|
||||
|
||||
The schedule uses a selected day from 1–28 and an hour in **UTC**, defaulting to day 2 at 09:00. Starting, resuming or changing a schedule begins at its next future occurrence; it does not immediately email an old report. Each occurrence covers the preceding complete UTC calendar month and includes subscribers confirmed by that scheduled time. After an outage, only the latest due occurrence is caught up. Earlier missed months and late subscribers are not backfilled. Pausing cancels queued scheduled deliveries. `BACKGROUND_TASKS_ENABLED=false` also disables recap automation. The worker checks the durable queue every 30 seconds.
|
||||
|
||||
SQLite stores the schedule, consent and delivery metadata in `email_recap_settings`, `email_recap_subscriptions` and `email_recap_deliveries`. Report bodies are generated at delivery and are not stored in the queue. Keep the existing Magent database persistent across deployments and back it up with the application's other data. The migration is additive; no existing user is opted in and no identity is merged.
|
||||
|
||||
- A unique account/month key prevents duplicate scheduled recaps across workers, refreshes and restarts. Test requests carry an idempotency key and have a five-minute account cooldown. Confirmation requests also have a five-minute account cooldown.
|
||||
- Queue claims are transactional. Report preparation has a three-minute timeout. Known temporary SMTP rejections and temporary history failures retry after five minutes, then thirty minutes, with at most three attempts. Permanent failures and history limits stop without sending a partial report.
|
||||
- The worker records SMTP acceptance separately from connection teardown. A failed QUIT after acceptance does not cause a retry. A disconnect while submitting DATA, or an interrupted worker that had begun sending, is marked **Needs review** and is not automatically resent. Inspect the mail server for the stable `magent-recap-<delivery ID>` Message-ID before deciding whether any follow-up is needed. A stable Message-ID helps investigation; SMTP does not promise deduplication. See [RFC 5321 §4.5.3.2.6](https://www.rfc-editor.org/rfc/rfc5321#section-4.5.3.2.6) and the [Python SMTP exception definitions](https://docs.python.org/3/library/smtplib.html).
|
||||
- Delivery history contains recipient, month, type, attempts, timestamps and a sanitized outcome. It reports mail-server acceptance, not inbox placement or read receipts. No automatic retry button is offered for uncertain deliveries.
|
||||
|
||||
The APIs are `/profile/email-recaps`, `/admin/email-recaps`, `/admin/email-recaps/preview`, `/admin/email-recaps/test`, and the public token-only `/email-recaps/check` and `/email-recaps/confirm` actions. Admin APIs enforce the administrator role; personal APIs use the signed-in account. Payloads reject recipient/user overrides.
|
||||
|
||||
`backend/tests/test_email_recaps.py` covers consent, identity changes, scheduling boundaries, concurrent claims, duplicate suppression, retries, interruption recovery, SMTP acceptance, access control and escaping. Its SMTP capture listens only on localhost and never delivers external mail. `scripts/review_email_recaps_ui.cjs` intercepts every API request and checks desktop/mobile layouts, preview isolation, preferences, scheduling, delivery history and public links. Set `REVIEW_EMAIL_FIXTURE` to a JSON file returned by `recap_email.render_recap`, with `month` and `email` added, plus the usual `REVIEW_BASE`, `REVIEW_PLAYWRIGHT` and optional `REVIEW_DIR`.
|
||||
|
||||
## Artwork and transcoding
|
||||
|
||||
Recently watched uses Jellystat's `NowPlayingItemId`, which identifies the movie or series, to load a Jellyfin primary poster. Magent proxies the image through an authenticated endpoint using a short-lived signature bound to the viewer, item and Jellyfin connection. Jellyfin credentials stay on the backend. Missing or deleted artwork falls back to a media tile. Thumbnail responses are privately cached.
|
||||
|
||||
Reference in New Issue
Block a user