The weekly email digest (#11) #1358

Merged
claude-bot merged 11 commits from feat/11-weekly-digest into main 2026-11-06 22:56:56 +00:00
Collaborator

Implements #11, the weekly email digest. Defaults below are the implementer's choices for the owner to confirm; each is a single constant in webapp/backend/app/services/digest_service.py.

What it does

  • users.digest_email_opt_in (boolean, not null, default false) and users.digest_sent_at (timestamptz, null). Migration b0011d1ae570, revises a1257b0c1d2e, real downgrade (checked upgrade, downgrade -1, upgrade).
  • A "Weekly digest" row in the notification grid's Email column (email's alone, like calendar invites), shown only where the instance can send mail, saved through PUT /api/me/notification-settings (digest: {email}; read back as digest in the GET, null without mail).
  • Beat task poll-weekly-digests, hourly. For each opted-in, non-system user with an address whose local time (users.timezone, else UTC) is Monday 08:00-08:59 and who has not been queued one in the last 6 days, it builds one email across all their live campaigns and queues send_weekly_digest. digest_sent_at is stamped and committed right after the enqueue succeeds; a failed enqueue leaves the person unstamped so the next hourly poll retries. Nothing is stamped or sent when all three lists are empty, or when the instance has no mail.
  • The email lists: sessions in the next 7 days (title, campaign, time in the reader's zone, their RSVP), sessions completed in the last 7 days, and vote-mode sessions still waiting on this person's vote. Titles and links only: never a summary, transcript or description (D96). Plain-text and HTML parts, built with the recap email's template; footer links to Settings -> Notifications.
  • Archived and trashed (soft-deleted) campaigns are skipped.

Defaults chosen (constants)

  • DIGEST_WEEKDAY = 0 (Monday), DIGEST_HOUR = 8
  • DIGEST_MIN_GAP = 6 days
  • DIGEST_LOOKAHEAD = DIGEST_LOOKBACK = 7 days
  • DIGEST_FALLBACK_ZONE = "UTC"
  • Beat schedule 3600.0 seconds

For the owner to look at

  • Muted campaigns are left out of the digest (D48: a mute is "only the bell"). Not in the brief; one outerjoin in collect() to drop.
  • Not a grid cell. The catalogue's IS-169 said the digest should not be "its own opt-in column", but the brief asked for a column; it sits beside the grid the way calendar invites do (IS-538 records this; IS-169 is annotated).
  • No one-click stop link in the digest (no List-Unsubscribe header): the footer links to the notification settings page as asked. A stop token for it would be a small follow-up (email_tokens, /email/stop).
  • "Active user" is taken as: opted in, has an email, not the system tombstone (the users table has no is_active column).
  • "Played this week" uses confirmed_time of completed sessions as the date.
  • The send task is queued with the rendered subject/HTML/text as arguments (titles in the broker, no content).
  • No bot API change; BOT_CONTRACT_VERSION untouched.

Wording for review

Setting (notification grid, Email column):

  • Row label: Weekly digest
  • Row hint: on Monday morning: the week ahead, what was played, and votes waiting for you

Email:

  • Subject: [Quest Board] Your week ahead
  • Heading: Your week ahead
  • Section headings: Coming up, Played this week, Waiting for your vote
  • RSVP words after the time: you are in, you said maybe, you can't make it, you haven't answered
  • Line shape: <title> (<campaign>) — Tue 10 Nov, 8:00 PM EST, you are in
  • Button: Open Quest Board
  • Footer: Sent by Quest Board because you asked for a weekly digest. then Turn the weekly digest off in Settings → Notifications
  • Empty state: none; the email is not sent when there is nothing to list.

Verification

  • Backend (docker-test): tests/test_weekly_digest.py tests/test_notifications.py tests/test_notifications_out.py tests/test_email_routes.py tests/test_email_tokens.py tests/test_recap_email.py tests/test_calendar_invites.py tests/test_asr_hotwords.py -> 215 passed.
  • Frontend (docker-test): eslint 0 errors; vitest run src/ui/NotifyGrid src/pages/Notifications.test.jsx src/pages/Profile.test.jsx -> 47 passed (first run had 3 timeouts at a host load average of 58; rerun with --testTimeout=90000 all green).
  • ruff format / ruff check (0.4.4, the CI paths), changelog.py check, check_version_sync.py: clean.
  • Migration: upgrade head, downgrade -1, upgrade head on Postgres 18: clean.
  • CATALOGUE: EX-056 (the email) and IS-538 (the setting) added; IS-169 annotated.

🤖 Generated with Claude Code

Implements #11, the weekly email digest. Defaults below are the implementer's choices for the owner to confirm; each is a single constant in `webapp/backend/app/services/digest_service.py`. ## What it does - `users.digest_email_opt_in` (boolean, not null, default false) and `users.digest_sent_at` (timestamptz, null). Migration `b0011d1ae570`, revises `a1257b0c1d2e`, real downgrade (checked upgrade, downgrade -1, upgrade). - A "Weekly digest" row in the notification grid's Email column (email's alone, like calendar invites), shown only where the instance can send mail, saved through `PUT /api/me/notification-settings` (`digest: {email}`; read back as `digest` in the GET, null without mail). - Beat task `poll-weekly-digests`, hourly. For each opted-in, non-system user with an address whose local time (`users.timezone`, else UTC) is Monday 08:00-08:59 and who has not been queued one in the last 6 days, it builds one email across all their live campaigns and queues `send_weekly_digest`. `digest_sent_at` is stamped and committed right after the enqueue succeeds; a failed enqueue leaves the person unstamped so the next hourly poll retries. Nothing is stamped or sent when all three lists are empty, or when the instance has no mail. - The email lists: sessions in the next 7 days (title, campaign, time in the reader's zone, their RSVP), sessions completed in the last 7 days, and vote-mode sessions still waiting on this person's vote. Titles and links only: never a summary, transcript or description (D96). Plain-text and HTML parts, built with the recap email's template; footer links to Settings -> Notifications. - Archived and trashed (soft-deleted) campaigns are skipped. ## Defaults chosen (constants) - `DIGEST_WEEKDAY = 0` (Monday), `DIGEST_HOUR = 8` - `DIGEST_MIN_GAP = 6 days` - `DIGEST_LOOKAHEAD = DIGEST_LOOKBACK = 7 days` - `DIGEST_FALLBACK_ZONE = "UTC"` - Beat schedule `3600.0` seconds ## For the owner to look at - **Muted campaigns are left out of the digest** (D48: a mute is "only the bell"). Not in the brief; one `outerjoin` in `collect()` to drop. - **Not a grid cell.** The catalogue's IS-169 said the digest should not be "its own opt-in column", but the brief asked for a column; it sits beside the grid the way calendar invites do (IS-538 records this; IS-169 is annotated). - **No one-click stop link** in the digest (no `List-Unsubscribe` header): the footer links to the notification settings page as asked. A stop token for it would be a small follow-up (`email_tokens`, `/email/stop`). - "Active user" is taken as: opted in, has an email, not the system tombstone (the `users` table has no is_active column). - "Played this week" uses `confirmed_time` of completed sessions as the date. - The send task is queued with the rendered subject/HTML/text as arguments (titles in the broker, no content). - No bot API change; `BOT_CONTRACT_VERSION` untouched. ## Wording for review Setting (notification grid, Email column): - Row label: **Weekly digest** - Row hint: **on Monday morning: the week ahead, what was played, and votes waiting for you** Email: - Subject: **[Quest Board] Your week ahead** - Heading: **Your week ahead** - Section headings: **Coming up**, **Played this week**, **Waiting for your vote** - RSVP words after the time: **you are in**, **you said maybe**, **you can't make it**, **you haven't answered** - Line shape: `<title> (<campaign>) — Tue 10 Nov, 8:00 PM EST, you are in` - Button: **Open Quest Board** - Footer: **Sent by Quest Board because you asked for a weekly digest.** then **Turn the weekly digest off in Settings → Notifications** - Empty state: none; the email is not sent when there is nothing to list. ## Verification - Backend (docker-test): `tests/test_weekly_digest.py tests/test_notifications.py tests/test_notifications_out.py tests/test_email_routes.py tests/test_email_tokens.py tests/test_recap_email.py tests/test_calendar_invites.py tests/test_asr_hotwords.py` -> 215 passed. - Frontend (docker-test): `eslint` 0 errors; `vitest run src/ui/NotifyGrid src/pages/Notifications.test.jsx src/pages/Profile.test.jsx` -> 47 passed (first run had 3 timeouts at a host load average of 58; rerun with `--testTimeout=90000` all green). - `ruff format` / `ruff check` (0.4.4, the CI paths), `changelog.py check`, `check_version_sync.py`: clean. - Migration: upgrade head, downgrade -1, upgrade head on Postgres 18: clean. - CATALOGUE: EX-056 (the email) and IS-538 (the setting) added; IS-169 annotated. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
users.digest_email_opt_in (off by default) and digest_sent_at; an hourly Beat
poll queues one email per opted-in person on their Monday 08:00 hour, across
live, unarchived, unmuted campaigns: sessions coming up, sessions played, votes
waiting. Titles and links only. A Weekly digest row in the notification grid's
Email column, shown only where mail works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test(webapp): the grid's page test counts the digest row (#11)
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m4s
CI / Feedback site tests (pull_request) Successful in 46s
CI / Backend lint (ruff) (pull_request) Successful in 1m7s
CI / Bot/backend version sync (pull_request) Successful in 27s
CI / Does this change touch the recording path? (pull_request) Successful in 23s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Which journeys does this change need? (pull_request) Successful in 23s
CI / Bot tests and audit (pull_request) Successful in 2m7s
CI / Frontend (shard 2/2) (pull_request) Successful in 10m3s
CI / Backend migration, tests, and audit (pull_request) Failing after 10m6s
CI / Docker image build (pull_request) Successful in 8m22s
CI / Frontend (shard 1/2) (pull_request) Successful in 20m42s
CI / End-to-end journeys (desktop) (pull_request) Successful in 14m18s
1175dcb04d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test: the one head is now the digest migration (#11)
Some checks failed
CI / Does this change touch the recording path? (pull_request) Successful in 18s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Backend lint (ruff) (pull_request) Successful in 41s
CI / Which journeys does this change need? (pull_request) Successful in 22s
CI / Bot/backend version sync (pull_request) Successful in 49s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m2s
CI / Feedback site tests (pull_request) Successful in 1m38s
CI / Bot tests and audit (pull_request) Successful in 2m18s
CI / Docker image build (pull_request) Successful in 5m25s
CI / Frontend (shard 2/2) (pull_request) Successful in 10m31s
CI / Frontend (shard 1/2) (pull_request) Successful in 12m27s
CI / Backend migration, tests, and audit (pull_request) Failing after 20m36s
CI / End-to-end journeys (desktop) (pull_request) Successful in 15m58s
6e7d395c11
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The static off-loop check matches publishing helpers by bare name, and the
digest task's nested sync helper was called enqueue(), so every
await enqueue(...) in the app looked like a blocking publish. Renamed it;
queue_due_digests now runs the helper through off_loop (#841).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test(webapp): digest tests follow the five-argument queue callback (#11)
Some checks failed
CI / Bot/backend version sync (pull_request) Successful in 46s
CI / Backend lint (ruff) (pull_request) Successful in 57s
CI / Does this change touch the recording path? (pull_request) Successful in 26s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Which journeys does this change need? (pull_request) Successful in 33s
CI / Feedback site tests (pull_request) Successful in 1m53s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 2m14s
CI / Bot tests and audit (pull_request) Successful in 3m48s
CI / Docker image build (pull_request) Successful in 5m42s
CI / Frontend (shard 2/2) (pull_request) Successful in 8m52s
CI / Frontend (shard 1/2) (pull_request) Successful in 10m8s
CI / Backend migration, tests, and audit (pull_request) Failing after 18m52s
CI / End-to-end journeys (desktop) (pull_request) Successful in 13m41s
00928fec76
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Collaborator

Pushed 3 commits (afcfd620..00928fec).

CI fix. test_enqueue_off_loop matches publishing helpers by bare name. digest_tasks had a nested sync helper called enqueue() that calls .delay(), so every await enqueue(...) in the app (the real app.enqueue.enqueue) looked like a blocking publish: 31 hits on the branch, 0 on main. Renamed the helper publish_digest, and queue_due_digests now runs it via off_loop (#841). The check is unchanged.

DG1. users.digest_weekday (smallint, default 0, CHECK 0-6) added to the same migration b0011d1ae570 (upgrade/downgrade/upgrade verified). GET/PUT /api/me/notification-settings carry digest_weekday (422 outside 0-6). Settings → Notifications: a day select under the Weekly digest email choice, shown only while it is on. Hour stays 08:00 local.

DG3. Reused the existing signed stop-token machinery (what=digest) and the existing /email/stop page, /api/email/stop/one-click POST (rate-limited, no session/CSRF) and List-Unsubscribe + List-Unsubscribe-Post headers. Footer gains "Stop these emails". Bad or foreign tokens stay 404 {"detail": ...} as for every other stop token.

Tests: backend 156 passed (digest, off-loop, tokens, email routes, notifications, one-head); frontend 53 passed; ruff format/check, version sync, changelog check clean.

Pushed 3 commits (afcfd620..00928fec). **CI fix.** `test_enqueue_off_loop` matches publishing helpers by bare name. `digest_tasks` had a nested sync helper called `enqueue()` that calls `.delay()`, so every `await enqueue(...)` in the app (the real `app.enqueue.enqueue`) looked like a blocking publish: 31 hits on the branch, 0 on main. Renamed the helper `publish_digest`, and `queue_due_digests` now runs it via `off_loop` (#841). The check is unchanged. **DG1.** `users.digest_weekday` (smallint, default 0, CHECK 0-6) added to the same migration `b0011d1ae570` (upgrade/downgrade/upgrade verified). `GET/PUT /api/me/notification-settings` carry `digest_weekday` (422 outside 0-6). Settings → Notifications: a day select under the Weekly digest email choice, shown only while it is on. Hour stays 08:00 local. **DG3.** Reused the existing signed stop-token machinery (`what=digest`) and the existing `/email/stop` page, `/api/email/stop/one-click` POST (rate-limited, no session/CSRF) and `List-Unsubscribe` + `List-Unsubscribe-Post` headers. Footer gains "Stop these emails". Bad or foreign tokens stay 404 `{"detail": ...}` as for every other stop token. Tests: backend 156 passed (digest, off-loop, tokens, email routes, notifications, one-head); frontend 53 passed; ruff format/check, version sync, changelog check clean.
fix(webapp): the digest's day choice gets its own line, and neither select clips (#11)
Some checks failed
CI / Does this change touch the recording path? (pull_request) Successful in 29s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot/backend version sync (pull_request) Successful in 47s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 57s
CI / Backend lint (ruff) (pull_request) Successful in 59s
CI / Which journeys does this change need? (pull_request) Successful in 35s
CI / Feedback site tests (pull_request) Successful in 1m41s
CI / Bot tests and audit (pull_request) Successful in 1m57s
CI / Docker image build (pull_request) Successful in 6m37s
CI / Frontend (shard 2/2) (pull_request) Successful in 8m0s
CI / Frontend (shard 1/2) (pull_request) Successful in 9m28s
CI / Backend migration, tests, and audit (pull_request) Failing after 18m2s
CI / End-to-end journeys (desktop) (pull_request) Successful in 12m53s
5bc722ae16
The render check found the two selects sharing one line at 390 px
("Eac", "Frid") and the stacked email select squeezed to "Each c" at
1280 px. The day now wraps onto its own line on narrow screens, and on a
wide screen both keep room for their longest word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Collaborator

Render checks (orchestrator, journeys stack, Priya, /profile/notifications):

  • First check (head 00928fec, merged with #1359): at 390 px the digest's two selects shared one line and clipped to "Eac" and "Frid". At 1280 px the email select clipped to "Each c".
  • Fixed in 5bc722ae: at phone width the day takes its own line, and on a wide screen both selects keep room for their longest word.
  • Re-check of 5bc722ae with "Each one" and Wednesday:
    • Both fit at 390 px (light and dark) and at 1280 px, with at least 30 px to spare.
    • There's no sideways overflow (scrollWidth equals clientWidth).
    • The selects overlap neither each other nor the Discord DM cell, and there are 0 console errors.
  • Axe: one heading-order (moderate), which is also on main ("Per campaign" is an h3 under the h1). Filed as #1360, not part of this PR.
  • Cosmetic, left as is: on a phone the day select starts at the row's left edge, 40 px left of the email select, which sits after its "Email" label.
**Render checks (orchestrator, journeys stack, Priya, `/profile/notifications`):** - **First check (head `00928fec`, merged with #1359):** at 390 px the digest's two selects shared one line and clipped to "Eac" and "Frid". At 1280 px the email select clipped to "Each c". - **Fixed in `5bc722ae`:** at phone width the day takes its own line, and on a wide screen both selects keep room for their longest word. - **Re-check of `5bc722ae` with "Each one" and Wednesday:** - Both fit at 390 px (light and dark) and at 1280 px, with at least 30 px to spare. - There's no sideways overflow (scrollWidth equals clientWidth). - The selects overlap neither each other nor the Discord DM cell, and there are 0 console errors. - **Axe:** one `heading-order` (moderate), which is also on main ("Per campaign" is an h3 under the h1). Filed as #1360, not part of this PR. - **Cosmetic, left as is:** on a phone the day select starts at the row's left edge, 40 px left of the email select, which sits after its "Email" label.
test: the name-offers migration no longer pins itself as the head (#11)
Some checks failed
CI / Bot/backend version sync (pull_request) Successful in 30s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 56s
CI / Does this change touch the recording path? (pull_request) Successful in 24s
CI / Backend lint (ruff) (pull_request) Successful in 1m17s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Which journeys does this change need? (pull_request) Successful in 24s
CI / Bot tests and audit (pull_request) Successful in 2m11s
CI / Feedback site tests (pull_request) Successful in 3m25s
CI / Docker image build (pull_request) Successful in 5m49s
CI / Frontend (shard 1/2) (pull_request) Successful in 10m51s
CI / Frontend (shard 2/2) (pull_request) Successful in 16m31s
CI / End-to-end journeys (desktop) (pull_request) Successful in 11m46s
CI / Backend migration, tests, and audit (pull_request) Failing after 1h3m49s
9ce8b5f7a3
The one-head pin lives in test_import_jobs_migration.py and moves with
each new migration; this test now checks only that it follows
f1280a0b1c2d and that there is a single head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test: the digest's line reads a session's title, not a page's (#11)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 48s
CI / Backend lint (ruff) (pull_request) Successful in 55s
CI / Which journeys does this change need? (pull_request) Successful in 17s
CI / Does this change touch the recording path? (pull_request) Successful in 18s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m32s
CI / Feedback site tests (pull_request) Successful in 2m14s
CI / Bot tests and audit (pull_request) Successful in 3m0s
CI / Docker image build (pull_request) Successful in 5m2s
CI / Frontend (shard 2/2) (pull_request) Successful in 8m18s
CI / Frontend (shard 1/2) (pull_request) Successful in 10m21s
CI / End-to-end journeys (desktop) (pull_request) Successful in 11m41s
CI / Backend migration, tests, and audit (pull_request) Successful in 37m35s
aa7c9c363d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.