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
+7 -3
View File
@@ -92,7 +92,7 @@ func (n *Notifier) Notify(ctx context.Context, kind Kind, to Recipients, payload
if !channels[id].Inbox {
continue
}
if err := write(ctx, q, kind, id, body); err != nil {
if err := write(ctx, q, kind, id, body, channels[id].Email); err != nil {
return fmt.Errorf("notifications: write %s: %w", kind, err)
}
delivered = append(delivered, id)
@@ -166,11 +166,14 @@ func channelsFor(ctx context.Context, q *dbq.Queries, kind Kind, ids []pgtype.UU
return out, nil
}
func write(ctx context.Context, q *dbq.Queries, kind Kind, userID pgtype.UUID, body []byte) error {
// write stores one row. emailWanted is the recipient's email channel for the
// kind: a row nobody wants emailed is stamped emailed_at at once, so the
// digest never sends it, even if email is switched on later.
func write(ctx context.Context, q *dbq.Queries, kind Kind, userID pgtype.UUID, body []byte, emailWanted bool) error {
key := kind.coalesceKey()
if key == "" {
_, err := q.InsertNotification(ctx, dbq.InsertNotificationParams{
UserID: userID, Kind: string(kind), Payload: body,
UserID: userID, Kind: string(kind), Payload: body, EmailWanted: emailWanted,
})
return err
}
@@ -179,6 +182,7 @@ func write(ctx context.Context, q *dbq.Queries, kind Kind, userID pgtype.UUID, b
Kind: string(kind),
Payload: body,
CoalesceKey: &key,
EmailWanted: emailWanted,
SumCount: specs[kind].sumCount,
})
return err