E-mail notification digests #95

Closed
opened 2026-07-04 14:52:52 +02:00 by fable-5 · 1 comment
Collaborator

Context

Users who are not logged in daily still learn about changes — batched, never mail-per-edit.

Scope

Digest job (scheduler): per user with unread notifications older than the batch window (default 1 h, user setting: immediate-ish hourly | daily | off), one localized mail summarizing grouped notifications (per pond → per page, actor and counts) via the outbox; unsubscribe link (signed, no login required) landing on the notification settings; delivery marks notifications as mailed (not read).

Acceptance criteria

  • two edits + one comment within the window produce exactly one mail (grouping test)
  • user setting 'off' suppresses mails; 'daily' batches across the day (time-travel test)
  • unsubscribe link disables mails without login and is single-purpose (no session created)
  • mail content passes permission re-check at send time

Technical notes

  • data-model.md (mail_outbox), ADR 0007 (signed tokens), ADR 0012 (mail i18n).

Dependencies

Depends on #12, #94.

Size: ~1.5 days


Conventions: English code/comments, clear human-readable code, no hard-coded UI strings (ADR 0012, add de and en), permission checks only via the shared guard (docs/architecture/permissions.md). Read the referenced ADRs before starting.

## Context Users who are not logged in daily still learn about changes — batched, never mail-per-edit. ## Scope Digest job (scheduler): per user with unread notifications older than the batch window (default 1 h, user setting: immediate-ish hourly | daily | off), one localized mail summarizing grouped notifications (per pond → per page, actor and counts) via the outbox; unsubscribe link (signed, no login required) landing on the notification settings; delivery marks notifications as mailed (not read). ## Acceptance criteria - [ ] two edits + one comment within the window produce exactly one mail (grouping test) - [ ] user setting 'off' suppresses mails; 'daily' batches across the day (time-travel test) - [ ] unsubscribe link disables mails without login and is single-purpose (no session created) - [ ] mail content passes permission re-check at send time ## Technical notes - data-model.md (`mail_outbox`), ADR 0007 (signed tokens), ADR 0012 (mail i18n). ## Dependencies Depends on #12, #94. **Size**: ~1.5 days --- *Conventions: English code/comments, clear human-readable code, no hard-coded UI strings (ADR 0012, add `de` **and** `en`), permission checks only via the shared guard (docs/architecture/permissions.md). Read the referenced ADRs before starting.*
fable-5 added this to the M9 — Comments & notifications milestone 2026-07-04 14:52:52 +02:00
fable-5 added the
backend
label 2026-07-04 14:52:52 +02:00
Author
Collaborator

Implemented in 9674c0b + 9ee9bbe (pipeline green, 8/8; migration 20260712010000_digest_frequency auto-applied on Test + Int).

User settingdigestFrequency (hourly default | daily | off) on the profile PATCH, in /auth/me, and as a select next to the watch preferences in the account settings.

Digest job — a new notification-digest maintenance job (15 min cadence, visible and manually triggerable in the #86 system panel) runs DigestService.runOnce(now): for every user with unread, not-yet-mailed notifications whose oldest entry exceeds their cadence window, it sends exactly one localized mail (user's locale, de/en, texts in the shared mails catalog) via the mail outbox — grouped per pond → per page with change/comment counts and actor names, never bodies. Sending stamps the whole batch mailed_at — never read_at.

Permission re-check at send time — every entry is re-checked against page read permission (one lookup per distinct page); entries the user can no longer read are dropped from the mail but still marked handled, so revoked content cannot queue forever. If nothing readable remains, no mail goes out at all.

Unsubscribe — every digest carries a signed, single-purpose link (HMAC(purpose ∥ payload), 90 d expiry, ADR 0007 pattern): GET /notifications/unsubscribe?token=… verifies in constant time, flips the setting to off, and renders a session-free, localized confirmation page through the shared HTML shell — no cookie is ever set, and the token grants nothing else. Tampered/garbage/absent tokens → 400.

Acceptance criteria (in digest.e2e.db.test.ts, 4 tests):

  • two edits + one comment within the window produce exactly one mail — grouped body asserts pond, page, "2 Änderungen", "1 Kommentar", actor name, and the unsubscribe link; a second run sends nothing (all mailed).
  • 'off' suppresses; 'daily' batches across the day — off → no mail; daily with a 3 h-old batch → nothing, with runOnce(now + 25 h) (time travel via the injectable clock) → one mail.
  • unsubscribe without login, single-purpose — no cookie on the request, no set-cookie on the response, setting flips to off; tampered and garbage tokens rejected.
  • permission re-check at send time — membership revoked via the API → no mail, batch still marked handled.

Follow-up commit 9ee9bbe: the system-panel pack's hard-coded job count now includes the fifth job.

Implemented in `9674c0b` + `9ee9bbe` (pipeline green, 8/8; migration `20260712010000_digest_frequency` auto-applied on Test + Int). **User setting** — `digestFrequency` (`hourly` default | `daily` | `off`) on the profile PATCH, in `/auth/me`, and as a select next to the watch preferences in the account settings. **Digest job** — a new `notification-digest` maintenance job (15 min cadence, visible and manually triggerable in the #86 system panel) runs `DigestService.runOnce(now)`: for every user with unread, not-yet-mailed notifications whose **oldest** entry exceeds their cadence window, it sends **exactly one localized mail** (user's locale, de/en, texts in the shared `mails` catalog) via the mail outbox — grouped **per pond → per page** with change/comment counts and actor names, never bodies. Sending stamps the whole batch `mailed_at` — never `read_at`. **Permission re-check at send time** — every entry is re-checked against page read permission (one lookup per distinct page); entries the user can no longer read are dropped from the mail **but still marked handled**, so revoked content cannot queue forever. If nothing readable remains, no mail goes out at all. **Unsubscribe** — every digest carries a signed, single-purpose link (`HMAC(purpose ∥ payload)`, 90 d expiry, ADR 0007 pattern): `GET /notifications/unsubscribe?token=…` verifies in constant time, flips the setting to `off`, and renders a session-free, localized confirmation page through the shared HTML shell — **no cookie is ever set**, and the token grants nothing else. Tampered/garbage/absent tokens → 400. **Acceptance criteria** (in `digest.e2e.db.test.ts`, 4 tests): - *two edits + one comment within the window produce exactly one mail* — grouped body asserts pond, page, "2 Änderungen", "1 Kommentar", actor name, and the unsubscribe link; a second run sends nothing (all `mailed`). - *'off' suppresses; 'daily' batches across the day* — off → no mail; daily with a 3 h-old batch → nothing, with `runOnce(now + 25 h)` (time travel via the injectable clock) → one mail. - *unsubscribe without login, single-purpose* — no cookie on the request, no `set-cookie` on the response, setting flips to off; tampered and garbage tokens rejected. - *permission re-check at send time* — membership revoked via the API → no mail, batch still marked handled. Follow-up commit `9ee9bbe`: the system-panel pack's hard-coded job count now includes the fifth job.
Sign in to join this conversation.
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: stwaidele/dorfteich#95
No description provided.