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>
121 lines
3.3 KiB
Go
121 lines
3.3 KiB
Go
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
|
||
}
|