Files
minstrel/internal/notifications/digest_render.go
T
bvandeusenandClaude Opus 5.5 63709a433d
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
feat(notifications): grouped email digest, new music as a daily summary (#5346)
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>
2026-10-08 07:48:22 -04:00

121 lines
3.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}