Files
silo-server/internal/mail/mail_test.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

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