Files
minstrel/internal/api/admin_notification_email.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

56 lines
2.0 KiB
Go

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))
}