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>
140 lines
4.5 KiB
Go
140 lines
4.5 KiB
Go
package notifications
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
)
|
||
|
||
// Rendered is a notification as people read it. The server renders it once,
|
||
// so the web inbox, the Android inbox, the phone's shade and the email digest
|
||
// all say the same thing, and a wording change is one edit here rather than
|
||
// a phrase table per client.
|
||
type Rendered struct {
|
||
Title string `json:"title"`
|
||
Body string `json:"body"`
|
||
// Link is the web path the notification opens. Android maps the same
|
||
// paths onto its screens.
|
||
Link string `json:"link"`
|
||
}
|
||
|
||
// Payload is what producers put in a notification. Every field is optional;
|
||
// each kind documents the ones it uses in Render.
|
||
type Payload struct {
|
||
RequestID string `json:"request_id,omitempty"`
|
||
RequestKind string `json:"request_kind,omitempty"` // artist | album | track
|
||
// Name is what was requested or flagged, as the user would say it:
|
||
// "WWW", "Moe Shop", "Moe Shop – WWW".
|
||
Name string `json:"name,omitempty"`
|
||
// Artist and Title are Name's parts, for request_completed: the new-music
|
||
// summary email groups arrivals by artist. Title is empty for an artist
|
||
// request.
|
||
Artist string `json:"artist,omitempty"`
|
||
Title string `json:"title,omitempty"`
|
||
ArtistID string `json:"artist_id,omitempty"`
|
||
AlbumID string `json:"album_id,omitempty"`
|
||
// Actor is the other person involved: who asked, who flagged.
|
||
Actor string `json:"actor,omitempty"`
|
||
Reason string `json:"reason,omitempty"`
|
||
// Count is the coalesced kinds' running total.
|
||
Count int64 `json:"count,omitempty"`
|
||
// Detail is free text for scan_failed (the error message).
|
||
Detail string `json:"detail,omitempty"`
|
||
}
|
||
|
||
// Map turns a Payload into the map Notify takes, dropping empty fields.
|
||
func (p Payload) Map() map[string]any {
|
||
b, _ := json.Marshal(p)
|
||
var m map[string]any
|
||
_ = json.Unmarshal(b, &m)
|
||
return m
|
||
}
|
||
|
||
// Render turns a stored row into what people read. An unreadable payload
|
||
// still renders, from the kind alone, rather than failing the whole inbox.
|
||
func Render(kind Kind, payload []byte) Rendered {
|
||
var p Payload
|
||
_ = json.Unmarshal(payload, &p)
|
||
name := p.Name
|
||
if name == "" {
|
||
name = "Your request"
|
||
}
|
||
|
||
switch kind {
|
||
case KindRequestApproved:
|
||
return Rendered{Title: "Request approved", Body: name + " is on its way.", Link: "/requests"}
|
||
case KindRequestRejected:
|
||
body := name + " was declined."
|
||
if p.Reason != "" {
|
||
body = name + " was declined: " + p.Reason
|
||
}
|
||
return Rendered{Title: "Request declined", Body: body, Link: "/requests"}
|
||
case KindRequestCompleted:
|
||
link := "/requests"
|
||
switch {
|
||
case p.AlbumID != "":
|
||
link = "/albums/" + p.AlbumID
|
||
case p.ArtistID != "":
|
||
link = "/artists/" + p.ArtistID
|
||
}
|
||
return Rendered{Title: "Now in your library", Body: name + " has arrived.", Link: link}
|
||
case KindRequestPending:
|
||
who := p.Actor
|
||
if who == "" {
|
||
who = "Someone"
|
||
}
|
||
return Rendered{Title: "Request to review", Body: who + " asked for " + name + ".", Link: "/admin/requests"}
|
||
case KindQuarantineFlagged:
|
||
who := p.Actor
|
||
if who == "" {
|
||
who = "Someone"
|
||
}
|
||
body := who + " flagged " + name + "."
|
||
if p.Reason != "" {
|
||
body = who + " flagged " + name + ": " + p.Reason
|
||
}
|
||
return Rendered{Title: "Track flagged", Body: body, Link: "/admin/quarantine"}
|
||
case KindScanFailed:
|
||
title := "Library scan failed"
|
||
if p.Count > 1 {
|
||
title = fmt.Sprintf("Library scan failed %d times", p.Count)
|
||
}
|
||
body := p.Detail
|
||
if body == "" {
|
||
body = "The last scan stopped with an error."
|
||
}
|
||
return Rendered{Title: title, Body: body, Link: "/admin"}
|
||
case KindTracksMissing:
|
||
return Rendered{
|
||
Title: plural(p.Count, "track went missing", "tracks went missing"),
|
||
Body: "Their files are no longer where the library expects them.",
|
||
Link: "/admin/missing-files",
|
||
}
|
||
case KindDuplicatesFound:
|
||
return Rendered{
|
||
Title: plural(p.Count, "duplicate group to review", "duplicate groups to review"),
|
||
Body: "The duplicate sweep found tracks holding the same recording.",
|
||
Link: "/admin/duplicates",
|
||
}
|
||
case KindPlaybackErrors:
|
||
return Rendered{
|
||
Title: plural(p.Count, "playback error reported", "playback errors reported"),
|
||
Body: "A track failed to play for someone.",
|
||
Link: "/admin/playback-errors",
|
||
}
|
||
default:
|
||
return Rendered{Title: "Notification", Link: "/"}
|
||
}
|
||
}
|
||
|
||
func plural(n int64, one, many string) string {
|
||
if n == 1 {
|
||
return "1 " + one
|
||
}
|
||
if n < 1 {
|
||
// A coalesced kind always carries a count; this only guards a
|
||
// malformed row from reading "0 tracks went missing".
|
||
return "Some " + many
|
||
}
|
||
return fmt.Sprintf("%d %s", n, many)
|
||
}
|