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,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
)
|
||||
|
||||
// notificationEmailBody is the wire shape for GET and PUT
|
||||
// /api/admin/notification-email (M489 #5346): when the grouped emails go out.
|
||||
type notificationEmailBody struct {
|
||||
SummaryHour int32 `json:"summary_hour"`
|
||||
BatchWindowMinutes int32 `json:"batch_window_minutes"`
|
||||
}
|
||||
|
||||
func notificationEmailBodyOf(s notifications.EmailSettings) notificationEmailBody {
|
||||
return notificationEmailBody{SummaryHour: s.SummaryHour, BatchWindowMinutes: s.BatchWindowMinutes}
|
||||
}
|
||||
|
||||
// handleGetNotificationEmail implements GET /api/admin/notification-email.
|
||||
func (h *handlers) handleGetNotificationEmail(w http.ResponseWriter, r *http.Request) {
|
||||
s, err := notifications.LoadEmailSettings(r.Context(), dbq.New(h.pool))
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin notification email: load failed", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, notificationEmailBodyOf(s))
|
||||
}
|
||||
|
||||
// handleUpdateNotificationEmail implements PUT /api/admin/notification-email.
|
||||
// A whole-row write; the digest reads it on its next tick.
|
||||
func (h *handlers) handleUpdateNotificationEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req notificationEmailBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||
return
|
||||
}
|
||||
saved, err := notifications.SaveEmailSettings(r.Context(), dbq.New(h.pool), notifications.EmailSettings{
|
||||
SummaryHour: req.SummaryHour,
|
||||
BatchWindowMinutes: req.BatchWindowMinutes,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, notifications.ErrEmailSettingOutOfRange) {
|
||||
writeErr(w, apierror.BadRequest("invalid_setting", err.Error()))
|
||||
return
|
||||
}
|
||||
writeErrWithLog(w, h.logger, "admin notification email: update failed", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, notificationEmailBodyOf(saved))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateNotificationEmail_Rejects(t *testing.T) {
|
||||
// No pool: every case is refused before the database is reached.
|
||||
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
for name, tc := range map[string]struct {
|
||||
body string
|
||||
code string
|
||||
mentions string
|
||||
}{
|
||||
"an hour past 23": {body: `{"summary_hour":24,"batch_window_minutes":60}`, code: "invalid_setting", mentions: "summary_hour"},
|
||||
"a window under 15m": {body: `{"summary_hour":9,"batch_window_minutes":5}`, code: "invalid_setting", mentions: "batch_window_minutes"},
|
||||
"a body missing both": {body: `{}`, code: "invalid_setting", mentions: "batch_window_minutes"},
|
||||
"malformed JSON": {body: `{"summary_hour":`, code: "invalid_body"},
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleUpdateNotificationEmail(rec, httptest.NewRequest(
|
||||
http.MethodPut, "/api/admin/notification-email", strings.NewReader(tc.body)))
|
||||
body := rec.Body.String()
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(body, `"`+tc.code+`"`) || !strings.Contains(body, tc.mentions) {
|
||||
t.Errorf("%s: status %d body %s; want 400 %s mentioning %q", name, rec.Code, body, tc.code, tc.mentions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,6 +300,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
||||
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
||||
admin.Get("/notification-email", h.handleGetNotificationEmail)
|
||||
admin.Put("/notification-email", h.handleUpdateNotificationEmail)
|
||||
|
||||
// Recommendation tuning lab (#1250): scoring-weight
|
||||
// profiles + taste-build knobs, DB-backed, live effect.
|
||||
|
||||
Reference in New Issue
Block a user