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,66 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// EmailSettings are the digest's two knobs, in admin Settings (rule 25).
|
||||
type EmailSettings struct {
|
||||
// SummaryHour is the local hour (0-23, in each user's own timezone) the
|
||||
// daily new-music summary goes out.
|
||||
SummaryHour int32
|
||||
// BatchWindowMinutes is how long a batch stays open after its first item.
|
||||
BatchWindowMinutes int32
|
||||
}
|
||||
|
||||
const (
|
||||
minBatchWindowMinutes = 15
|
||||
maxBatchWindowMinutes = 1440
|
||||
)
|
||||
|
||||
// DefaultEmailSettings mirrors migration 0074's column defaults.
|
||||
var DefaultEmailSettings = EmailSettings{SummaryHour: 9, BatchWindowMinutes: 60}
|
||||
|
||||
// ErrEmailSettingOutOfRange is a value migration 0074's CHECKs would reject,
|
||||
// so the API answers 400 naming the field rather than a constraint violation.
|
||||
var ErrEmailSettingOutOfRange = errors.New("notification email setting out of range")
|
||||
|
||||
// BatchWindow is BatchWindowMinutes as a duration.
|
||||
func (s EmailSettings) BatchWindow() time.Duration {
|
||||
return time.Duration(s.BatchWindowMinutes) * time.Minute
|
||||
}
|
||||
|
||||
// LoadEmailSettings reads the settings. A failed read returns the defaults
|
||||
// with the error, so the digest keeps its shipped cadence rather than stopping.
|
||||
func LoadEmailSettings(ctx context.Context, q *dbq.Queries) (EmailSettings, error) {
|
||||
row, err := q.GetNotificationEmailSettings(ctx)
|
||||
if err != nil {
|
||||
return DefaultEmailSettings, fmt.Errorf("notification email settings: load: %w", err)
|
||||
}
|
||||
return EmailSettings{SummaryHour: row.SummaryHour, BatchWindowMinutes: row.BatchWindowMinutes}, nil
|
||||
}
|
||||
|
||||
// SaveEmailSettings validates and writes the settings, returning them as saved.
|
||||
// The digest reads them on its next tick; nothing is cached.
|
||||
func SaveEmailSettings(ctx context.Context, q *dbq.Queries, in EmailSettings) (EmailSettings, error) {
|
||||
switch {
|
||||
case in.SummaryHour < 0 || in.SummaryHour > 23:
|
||||
return EmailSettings{}, fmt.Errorf("%w: summary_hour must be 0-23", ErrEmailSettingOutOfRange)
|
||||
case in.BatchWindowMinutes < minBatchWindowMinutes || in.BatchWindowMinutes > maxBatchWindowMinutes:
|
||||
return EmailSettings{}, fmt.Errorf("%w: batch_window_minutes must be %d-%d",
|
||||
ErrEmailSettingOutOfRange, minBatchWindowMinutes, maxBatchWindowMinutes)
|
||||
}
|
||||
row, err := q.UpdateNotificationEmailSettings(ctx, dbq.UpdateNotificationEmailSettingsParams{
|
||||
SummaryHour: in.SummaryHour,
|
||||
BatchWindowMinutes: in.BatchWindowMinutes,
|
||||
})
|
||||
if err != nil {
|
||||
return EmailSettings{}, fmt.Errorf("notification email settings: save: %w", err)
|
||||
}
|
||||
return EmailSettings{SummaryHour: row.SummaryHour, BatchWindowMinutes: row.BatchWindowMinutes}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user