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>
122 lines
3.3 KiB
Go
122 lines
3.3 KiB
Go
package mail
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type stubSettings map[string]string
|
|
|
|
func (s stubSettings) Get(_ context.Context, key string) (string, error) {
|
|
return s[key], nil
|
|
}
|
|
|
|
func configuredSettings() stubSettings {
|
|
return stubSettings{
|
|
SettingEnabled: "true",
|
|
SettingSMTPHost: "smtp.example.com",
|
|
SettingFromAddress: "silo@example.com",
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigGates(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("disabled by default", func(t *testing.T) {
|
|
sender := NewSMTPSender(stubSettings{})
|
|
if sender.Enabled(ctx) {
|
|
t.Fatal("email must be disabled with no settings")
|
|
}
|
|
err := sender.Send(ctx, Message{To: []string{"a@b.c"}, Subject: "x", TextBody: "y"})
|
|
if !errors.Is(err, ErrNotConfigured) {
|
|
t.Fatalf("Send = %v, want ErrNotConfigured", err)
|
|
}
|
|
})
|
|
|
|
t.Run("requires host and from address", func(t *testing.T) {
|
|
settings := configuredSettings()
|
|
settings[SettingSMTPHost] = ""
|
|
if NewSMTPSender(settings).Enabled(ctx) {
|
|
t.Fatal("missing host must disable email")
|
|
}
|
|
settings = configuredSettings()
|
|
settings[SettingFromAddress] = ""
|
|
if NewSMTPSender(settings).Enabled(ctx) {
|
|
t.Fatal("missing from address must disable email")
|
|
}
|
|
})
|
|
|
|
t.Run("complete config enables", func(t *testing.T) {
|
|
if !NewSMTPSender(configuredSettings()).Enabled(ctx) {
|
|
t.Fatal("complete config must enable email")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestLoadConfigValidation(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("defaults", func(t *testing.T) {
|
|
cfg, err := NewSMTPSender(configuredSettings()).loadConfig(ctx)
|
|
if err != nil {
|
|
t.Fatalf("loadConfig: %v", err)
|
|
}
|
|
if cfg.port != 587 || cfg.security != securityStartTLS || cfg.fromName != "Silo" {
|
|
t.Fatalf("unexpected defaults: %+v", cfg)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid port", func(t *testing.T) {
|
|
settings := configuredSettings()
|
|
settings[SettingSMTPPort] = "99999"
|
|
if _, err := NewSMTPSender(settings).loadConfig(ctx); err == nil {
|
|
t.Fatal("out-of-range port must be rejected")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid security mode", func(t *testing.T) {
|
|
settings := configuredSettings()
|
|
settings[SettingSMTPSecurity] = "plz-hack-me"
|
|
if _, err := NewSMTPSender(settings).loadConfig(ctx); err == nil {
|
|
t.Fatal("unknown security mode must be rejected")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSendInputValidation(t *testing.T) {
|
|
ctx := context.Background()
|
|
sender := NewSMTPSender(configuredSettings())
|
|
|
|
if err := sender.Send(ctx, Message{Subject: "x", TextBody: "y"}); err == nil {
|
|
t.Fatal("a message without recipients must be rejected")
|
|
}
|
|
if err := sender.Send(ctx, Message{To: []string{"a@b.c"}, Subject: "x"}); err == nil {
|
|
t.Fatal("a message without a body must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestBuildMessageMultipart(t *testing.T) {
|
|
cfg := &smtpConfig{fromAddress: "silo@example.com", fromName: "Silo"}
|
|
message, err := buildMessage(cfg, Message{
|
|
To: []string{"user@example.com"},
|
|
Subject: "Hello",
|
|
TextBody: "plain",
|
|
HTMLBody: "<b>rich</b>",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("buildMessage: %v", err)
|
|
}
|
|
var rendered strings.Builder
|
|
if _, err := message.WriteTo(&rendered); err != nil {
|
|
t.Fatalf("render message: %v", err)
|
|
}
|
|
output := rendered.String()
|
|
for _, want := range []string{"multipart/alternative", "plain", "rich", "Silo", "user@example.com"} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("rendered message missing %q:\n%s", want, output)
|
|
}
|
|
}
|
|
}
|