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,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
|
||||
}
|
||||
Reference in New Issue
Block a user