Files
silo-server/internal/notifications/dispatcher.go
QuickandClaude Fable 5 b091f0c6c1 feat(notifications): in-app inbox, realtime, webhooks, web push + shared SMTP core
Implements the notification system foundation and all v1 delivery channels
that need no external infrastructure (specs 00/01/04/05 in
docs/superpowers/plans/notifications/):

Foundation (spec 01):
- episode_availability seeding + per-library seed markers: "newly available"
  means newly released to this server, so back-catalog imports and first
  scans never flood (verified on dev: 1.13M episodes seeded silently)
- release_events -> profile_series_interest fanout worker with settling
  delay, per-series burst caps, FOR UPDATE SKIP LOCKED multi-node claims,
  and a guarded last-notified cursor
- interest index maintained via a userstore provider decorator so every
  favorites/watchlist/progress mutation path (REST, jellycompat, imports,
  playback) feeds it; progress writes only recompute on state transitions
- durable per-profile inbox + read state, forward-sync cursor API,
  websocket channel with short-lived single-use handshake tickets
- web UI: sidebar badge, inbox page, toasts, per-profile preferences
- startup/daily tasks: availability seeding, interest rebuild, retention

Outbound webhooks (spec 04):
- Discord embeds (text-only per the v1 privacy contract) and generic
  JSON signed Stripe-style with per-webhook secrets
- HTTPS-only + private-destination guard enforced at registration and at
  connect time (DNS-rebinding mitigation); URLs/secrets encrypted at rest
- durable per-target outbox enqueued in the fanout transaction, lease-based
  claims, 24h exponential retry, 3x-consecutive-4xx auto-disable with an
  in-app notice (loop-guarded)

Web push (spec 05):
- VAPID keypair self-provisioned at startup (single atomic JSON setting,
  private half encrypted at rest) — no third-party accounts needed
- payloads E2E-encrypted (RFC 8291); 404/410 treated as unsubscribe
- service worker + subscribe flow in Settings -> Notifications

Shared SMTP core (internal/mail):
- feature-agnostic mail.Sender over live email.* settings, STARTTLS or
  implicit TLS, encrypted password, admin Email settings page with
  synchronous test send; no consumer yet by design (digest is v1.5)

APNs/FCM (specs 02/03) are deferred to v2; the capability endpoint reports
them unavailable so clients render truthfully.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:55:46 -04:00

137 lines
4.8 KiB
Go

package notifications
import (
"context"
"encoding/json"
"time"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// EventNotificationCreated is published on ChannelNotifications when a new
// delivery is created; EventNotificationRead when one is marked read
// (multi-tab coherence).
const (
EventNotificationCreated = "notification.created"
EventNotificationRead = "notification.read"
)
// Dispatcher fans one committed notification delivery out to a channel.
// Called once per notification_deliveries row, AFTER the row commits. The
// websocket dispatcher is idempotent by delivery_id alone (re-publishing the
// same delivery is a no-op for connected clients); per-target channels (push,
// webhooks — see specs 02-04) claim durable `pending` outbox attempt rows
// enqueued in the fanout transaction instead of deciding their own work.
type Dispatcher interface {
Dispatch(ctx context.Context, delivery DeliveryRow) error
}
// DeliveryRowPayload is the JSON shape shared by the inbox API, the websocket
// snapshot, and notification.created events. Keep these in lockstep.
type DeliveryRowPayload struct {
ID string `json:"id"`
Type string `json:"type"`
ProfileID string `json:"profile_id"`
LibraryID *int `json:"library_id,omitempty"`
SeriesID *string `json:"series_id,omitempty"`
EpisodeID *string `json:"episode_id,omitempty"`
SeriesTitle string `json:"series_title,omitempty"`
EpisodeTitle string `json:"episode_title,omitempty"`
SeasonNumber *int `json:"season_number,omitempty"`
EpisodeNumber *int `json:"episode_number,omitempty"`
PosterPath string `json:"poster_path,omitempty"`
PosterURL string `json:"poster_url,omitempty"`
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
ReasonFlags json.RawMessage `json:"reason_flags"`
CreatedAt time.Time `json:"created_at"`
ReadAt *time.Time `json:"read_at"`
}
// PayloadForRow converts a DeliveryRow into its wire shape.
func PayloadForRow(row DeliveryRow) DeliveryRowPayload {
reasonFlags := json.RawMessage(row.ReasonFlags)
if len(reasonFlags) == 0 {
reasonFlags = json.RawMessage("{}")
}
return DeliveryRowPayload{
ID: row.ID,
Type: row.Type,
ProfileID: row.ProfileID,
LibraryID: row.LibraryID,
SeriesID: row.SeriesID,
EpisodeID: row.EpisodeID,
SeriesTitle: row.SeriesTitle,
EpisodeTitle: row.EpisodeTitle,
SeasonNumber: row.SeasonNumber,
EpisodeNumber: row.EpisodeNumber,
PosterPath: row.PosterPath,
PosterThumbhash: row.PosterThumbhash,
ReasonFlags: reasonFlags,
CreatedAt: row.CreatedAt,
ReadAt: row.ReadAt,
}
}
// WebsocketDispatcher publishes notification.created on ChannelNotifications,
// scoped to the delivery's (user_id, profile_id). Best-effort: the durable
// inbox row is the source of truth and covers reconnect.
type WebsocketDispatcher struct {
hub *evt.Hub
// payload overrides the default PayloadForRow conversion (e.g. to attach
// presigned poster URLs). Optional.
payload func(ctx context.Context, row DeliveryRow) DeliveryRowPayload
}
// NewWebsocketDispatcher creates a WebsocketDispatcher.
func NewWebsocketDispatcher(hub *evt.Hub) *WebsocketDispatcher {
return &WebsocketDispatcher{hub: hub}
}
// Dispatch publishes the delivery to connected clients.
func (d *WebsocketDispatcher) Dispatch(ctx context.Context, delivery DeliveryRow) error {
if d == nil || d.hub == nil {
return nil
}
payload := PayloadForRow(delivery)
if d.payload != nil {
payload = d.payload(ctx, delivery)
}
return d.hub.PublishJSON(ctx, evt.ChannelNotifications, EventNotificationCreated,
payload, evt.PublishOptions{
UserID: delivery.UserID,
ProfileID: delivery.ProfileID,
})
}
// MultiDispatcher runs all configured dispatchers; channel failures are
// isolated so a downed channel never blocks the others.
type MultiDispatcher struct {
dispatchers []Dispatcher
}
// NewMultiDispatcher creates a MultiDispatcher over the given channels.
func NewMultiDispatcher(dispatchers ...Dispatcher) *MultiDispatcher {
out := make([]Dispatcher, 0, len(dispatchers))
for _, d := range dispatchers {
if d != nil {
out = append(out, d)
}
}
return &MultiDispatcher{dispatchers: out}
}
// Dispatch fans the delivery to every channel, returning the first error
// (after attempting all channels).
func (m *MultiDispatcher) Dispatch(ctx context.Context, delivery DeliveryRow) error {
if m == nil {
return nil
}
var firstErr error
for _, d := range m.dispatchers {
if err := d.Dispatch(ctx, delivery); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}