feat(notifications): grouped email digest, new music as a daily summary (#5346)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped

Nothing is emailed per event. New music (request_completed) goes out at
most once a day, at the summary hour in each user's own timezone, grouped
by artist. Everything else is batched: one email a window after the first
un-emailed item, holding whatever accumulated.

- Migration 0074: notification_email_settings (summary hour, batch window,
  admin-configurable) and user_notification_email_state (batch start, last
  sent, failures and retry_after per user and group). Existing rows are
  stamped emailed so the upgrade sends no backlog.
- The Notifier stamps emailed_at at write time when the recipient's email
  channel is off, so turning email on later doesn't send old items.
- Read rows are never selected. A row is stamped only after the mailer
  accepts, in one transaction with the state, against the read's clock, so
  a coalesced row updated mid-send stays pending.
- A failed send backs off 5m doubling to 6h; SMTP not configured just waits.
- Links come from the public address; without one the email has none.
- The mailer now RFC 2047-encodes subjects and strips line breaks from them.
- Admin → Integrations gains a Notification emails card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:48:22 -04:00
co-authored by Claude Opus 5.5
parent 62f76290fb
commit 63709a433d
28 changed files with 1764 additions and 22 deletions
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS user_notifications_email_pending_idx;
DROP TABLE IF EXISTS user_notification_email_state;
DROP TABLE IF EXISTS notification_email_settings;
@@ -0,0 +1,54 @@
-- M489 #5346: notifications by email, grouped. Nothing is emailed per event.
--
-- New music (request_completed) goes out as at most one summary a day, at a set
-- hour in each user's own timezone. Everything else is batched: a batch opens
-- at the first un-emailed item and one email goes out a window later, holding
-- whatever accumulated. internal/notifications/digest.go is the one sender.
-- The two knobs, in admin Settings (rule 25). Singleton in the style of
-- fingerprint_settings (0061).
CREATE TABLE notification_email_settings (
id boolean PRIMARY KEY DEFAULT true,
-- The local hour (0-23, in each user's timezone) the daily new-music
-- summary goes out.
summary_hour integer NOT NULL DEFAULT 9,
-- How long a batch stays open after its first item before it is sent.
batch_window_minutes integer NOT NULL DEFAULT 60,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT notification_email_settings_singleton CHECK (id = true),
CONSTRAINT notification_email_settings_hour_range
CHECK (summary_hour >= 0 AND summary_hour <= 23),
CONSTRAINT notification_email_settings_window_range
CHECK (batch_window_minutes >= 15 AND batch_window_minutes <= 1440)
);
INSERT INTO notification_email_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
-- Per user, per email group: where the digest stands. A missing row is a user
-- who has never had a batch open or a summary sent.
CREATE TABLE user_notification_email_state (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email_group text NOT NULL CHECK (email_group IN ('batch', 'summary')),
-- When the open batch started. Held here rather than read off the items,
-- because a coalesced item moves its created_at forward on every update
-- and would otherwise keep a batch from ever coming due.
batch_opened_at timestamptz,
-- The last email of this group the mailer accepted. A summary is due once
-- per local day, after the summary hour, if this is before it.
last_sent_at timestamptz,
-- A failed send backs off: nothing is tried for this group before
-- retry_after, and the gap doubles with each failure in a row.
failures integer NOT NULL DEFAULT 0,
retry_after timestamptz,
PRIMARY KEY (user_id, email_group)
);
-- The digest's selection: unread rows not yet emailed.
CREATE INDEX user_notifications_email_pending_idx
ON user_notifications (user_id, created_at)
WHERE read_at IS NULL AND emailed_at IS NULL;
-- Rows written before this migration were never judged for email. Treat them
-- as already handled, so the first digest after the upgrade doesn't send a
-- backlog nobody asked for.
UPDATE user_notifications SET emailed_at = now() WHERE emailed_at IS NULL;