feat/email-digest #80
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/email-digest"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Phase 22 task 4. The previous commits supplied the building blocks (WS-receive tracking, SMTP transport, content primitives, view structs). This commit closes the loop: an hourly background task that finds offline users with unread mentions or DMs, builds a multipart email, and dispatches it. Adds a `users.email` column via migration 0011. Recipient address is otherwise the missing piece - without it the digest cannot send anywhere. Nullable, no UNIQUE constraint: it is a notification destination, not an identity field. The eligibility query filters out NULL/empty so users without an address are silently no-ops until task 5 ships the /settings UI to let them set one. Adds the operator-supplied site URL as a key in the `settings` table (`public_base_url`). The admin SMTP page gains an input for it; on save a trailing slash is stripped so the digest can `format!("{base}/room/...")` without worrying about doubles. The templates degrade gracefully when empty: items still render but anchor tags are omitted (no `<a href="">` malformed-link emails). This was the half-built risk the user flagged before approving the task: addressed by making the URL optional rather than mandatory, with a clear path to enable. `db::auth::find_digest_candidates` runs the eligibility predicate from the design doc: `notify_email_digest_enabled = 1` AND non-empty email AND `MAX(last_active_at, COALESCE(last_ws_seen_at, '')) < now - quiet_period` AND `last_digest_sent_at < the same MAX OR NULL`. The SQLite `MAX(a, COALESCE(b, ''))` shape works because timestamps are ISO 8601, which sorts lexicographically the same as chronologically. One digest per offline session falls out automatically: the moment the user comes back online and any activity column bumps, the self-comparison reopens eligibility for the next offline stretch. `email::digest::run_tick` orchestrates the whole pass: 1. Short-circuit when `state.email_client` is None. 2. Load `public_base_url` and SMTP from-address once per tick; abort if from is empty. 3. For each candidate, fetch missed mentions and missed DMs from the chat pool with the `MAX(...)` activity floor as the lower bound on message `created_at`, capped at a 7-day window, with `room_notification_settings.mute_mode <> 'all'` excluding muted rooms (DM mute writes 'all' to the same shared table per `db::notifications::set_dm_mute`). 4. Bulk-resolve all author/peer ids to usernames in one auth-pool round trip. 5. Group rows into `DigestDmSection`/`DigestRoomSection` while applying a 50-item budget across both sections; spill into the overflow counter. 6. Render both Askama templates, build the `EmailMessage`, send through the trait, mark `last_digest_sent_at` on success. 7. Per-user failures log warn and the loop continues - one bad recipient does not stop the rest of the tick. `main.rs` gains `spawn_digest_sender`, modeled on `spawn_idle_scanner`: `tokio::time::interval(3600s)` plus a leading-tick skip so the first run lands an hour after boot rather than at startup. The dispatch tests in `email_digest_dispatch.rs` cover seven scenarios: happy-path send with both DMs and mentions, second-tick self-skipping, missing email skipping, opt-out skipping, room-mute exclusion, base-URL-empty graceful degradation, and SMTP-from-empty whole-tick abort. The test discovered that the chat migrations auto-seed a default "general" room at id 1, so the harness pins its own created room id rather than assuming `WHERE room_type = 'public' LIMIT 1` returns the right one. 24 existing test files updated to apply auth/0011. The dispatch test is `#![cfg(feature = "standalone")]` because it constructs a full AppState through routes::build_router, which only mounts the admin/settings paths under standalone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Phase 22 task 5. The dispatch tick has been functionally complete since task 4 but had no UI for end users to opt in or supply an email address; this commit adds both, plus the admin toggle that lets operators flip the default for new registrations. /settings gains an email input pre-populated from `users.email` and an "Email me a digest of missed mentions and DMs" checkbox. The checkbox is disabled when `state.email_available()` is false, with help text explaining that SMTP is unconfigured. POST handler defends against client tampering by AND'ing the form value with the server gate (same shape as the existing push checkbox). Empty email field stores NULL so the eligibility query filters cleanly. Light validation requires exactly one '@' on a non-empty input; full RFC validation is the upstream MTA's job. `db::auth::set_notification_prefs` now takes a fourth `email_digest: bool` argument. The one existing test caller (`push_dispatch`) updated to pass `false`. Two new helpers carry the digest-specific paths: `set_email` (Option<&str>; None stores NULL) and `set_notify_email_digest_enabled` (target toggle the register flow uses). `UserSettingsPage` gains `email_available: bool` and `current_email: String`. The public `User` projection still does NOT carry `email` - it is recipient metadata, not identity, and shouldn't flow through handler/template contexts where the in-app rendering layer could leak it. Admin SMTP page gets a small "Defaults for new users" form below the test-email controls. Stored in the existing `settings` key-value table under `default_notify_email_digest` ("0"/"1"). Documented clearly that it only affects FUTURE registrations: existing users are not retroactively changed. POST handler at /admin/settings/email-digest-default flips the row. Register flow (standalone-only; saas users come from the parent app) reads `default_notify_email_digest` after `create_user` and calls the helper when "1". Failure is logged warn but not fatal: the worst case is the user has to opt in manually. `db_email_digest_default.rs` pins three properties: absent key gives the column default (0), flag flipped to "1" propagates via the helper to a new user, and toggling the flag off later does NOT touch users created under the previous regime. Fourth test rounds out the file with a `set_email` NULL-on-empty round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>