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
+120
View File
@@ -0,0 +1,120 @@
package notifications
import (
"bytes"
"embed"
"encoding/json"
"fmt"
htmltemplate "html/template"
"strings"
texttemplate "text/template"
)
//go:embed templates/digest.txt templates/digest.html
var digestFS embed.FS
var (
digestText = texttemplate.Must(texttemplate.ParseFS(digestFS, "templates/digest.txt"))
digestHTML = htmltemplate.Must(htmltemplate.ParseFS(digestFS, "templates/digest.html"))
)
// digestEmail is one rendered email.
type digestEmail struct {
Subject string
Text string
HTML string
}
type digestLine struct {
Title string
Body string
URL string
}
type digestArtist struct {
Name string
URL string
Titles []digestLine
}
type digestVars struct {
Name string
Intro string
Items []digestLine
Artists []digestArtist
SettingsURL string
}
// renderDigest renders one group's email. base is the public address; with
// none set, the email still goes out, without links.
func renderDigest(group EmailGroup, name string, items []pendingItem, base string) (digestEmail, error) {
vars := digestVars{Name: name, SettingsURL: joinURL(base, "/settings#notifications")}
var subject string
if group == EmailSummary {
vars.Artists = summaryArtists(items, base)
vars.Intro = "New in your library since the last summary:"
subject = "Minstrel: new music in your library"
} else {
for _, it := range items {
r := Render(it.Kind, it.Payload)
vars.Items = append(vars.Items, digestLine{Title: r.Title, Body: r.Body, URL: joinURL(base, r.Link)})
}
vars.Intro = "Here's what happened on Minstrel:"
subject = fmt.Sprintf("Minstrel: %d updates", len(items))
if len(items) == 1 {
subject = "Minstrel: " + vars.Items[0].Title
}
}
var text, html bytes.Buffer
if err := digestText.Execute(&text, vars); err != nil {
return digestEmail{}, fmt.Errorf("render text: %w", err)
}
if err := digestHTML.Execute(&html, vars); err != nil {
return digestEmail{}, fmt.Errorf("render html: %w", err)
}
return digestEmail{Subject: subject, Text: text.String(), HTML: html.String()}, nil
}
// summaryArtists groups new-music arrivals by artist, in the order each
// artist first arrived, with each artist's albums and tracks under them. An
// artist request has no title of its own; the artist links to its page.
func summaryArtists(items []pendingItem, base string) []digestArtist {
var out []digestArtist
index := map[string]int{}
for _, it := range items {
var p Payload
_ = json.Unmarshal(it.Payload, &p)
artist, title := p.Artist, p.Title
if artist == "" {
// Rows from before Artist was recorded carry only "Artist – Title".
artist, title, _ = strings.Cut(p.Name, " – ")
}
if artist == "" {
artist = "Something you asked for"
}
i, ok := index[artist]
if !ok {
i = len(out)
index[artist] = i
out = append(out, digestArtist{Name: artist})
}
link := Render(it.Kind, it.Payload).Link
if title == "" {
if p.ArtistID != "" {
out[i].URL = joinURL(base, link)
}
continue
}
out[i].Titles = append(out[i].Titles, digestLine{Title: title, URL: joinURL(base, link)})
}
return out
}
// joinURL is base followed by path, or "" when no public address is set.
func joinURL(base, path string) string {
if base == "" || path == "" {
return ""
}
return strings.TrimRight(base, "/") + path
}