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>
73 lines
2.1 KiB
Go
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)
|
|
}
|