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
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:
@@ -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;
|
||||
Reference in New Issue
Block a user