Files
silo-server/internal/api/handlers/email.go
T
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

73 lines
2.1 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"net/mail"
"time"
silomail "github.com/Silo-Server/silo-server/internal/mail"
)
// EmailHandler exposes admin operations for the shared outbound email
// facility (internal/mail). Feature-specific email content lives with the
// features; this handler only owns configuration verification.
type EmailHandler struct {
sender silomail.Sender
}
// NewEmailHandler creates an EmailHandler.
func NewEmailHandler(sender silomail.Sender) *EmailHandler {
return &EmailHandler{sender: sender}
}
type emailTestRequest struct {
To string `json:"to"`
}
type emailTestResponse struct {
OK bool `json:"ok"`
DurationMS int64 `json:"duration_ms"`
Message string `json:"message,omitempty"`
}
// HandleTest handles POST /admin/email/test: synchronously sends a test
// message so admins can verify SMTP settings before any feature depends on
// them.
func (h *EmailHandler) HandleTest(w http.ResponseWriter, r *http.Request) {
if h == nil || h.sender == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Email is not available")
return
}
var req emailTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if _, err := mail.ParseAddress(req.To); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "A valid recipient address is required")
return
}
started := time.Now()
err := h.sender.Send(r.Context(), silomail.Message{
To: []string{req.To},
Subject: "Silo test email",
TextBody: "This is a test email from your Silo server.\n\n" +
"If you received it, outbound email is configured correctly.",
})
response := emailTestResponse{
OK: err == nil,
DurationMS: time.Since(started).Milliseconds(),
}
switch {
case err == nil:
case errors.Is(err, silomail.ErrNotConfigured):
response.Message = "Email is not configured. Set the SMTP host, from address, and enable email first."
default:
response.Message = err.Error()
}
writeJSON(w, http.StatusOK, response)
}