diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f77c29f1d..f8ec168dc 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -130,7 +130,7 @@ graph TB
| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming |
| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption |
| **Metrics** | Prometheus Client | Latest | Application metrics |
-| **Notifications** | Notify (Discord-first) | Current | Discord notifications now; additional services in phased rollout |
+| **Notifications** | github.com/Wikid82/go_notify_yourself | Current | External delivery-engine module (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, and email) consumed via Charon-supplied SSRF/SMTP/template adapters — see Service Layer below |
| **Docker Client** | Docker SDK | Latest | Container discovery |
| **Logging** | Logrus + Lumberjack | Latest | Structured logging with rotation |
| **Backup Archive Encryption** | filippo.io/age | Latest | Passphrase (scrypt) encryption of backup archives; audited, pure Go, streaming AEAD — avoids buffering whole archives in RAM or hand-rolling chunked AES-GCM |
@@ -336,7 +336,8 @@ graph TB
- **ProxyService:** CRUD operations for proxy hosts, validation logic
- **CertificateService:** ACME certificate provisioning and renewal
- **DockerService:** Container discovery and monitoring
-- **MailService:** Email notifications for certificate expiry
+- **MailService:** SMTP transport and branded HTML templates for certificate-expiry and other system emails
+- **NotificationService:** GORM CRUD for providers/templates, event-type-to-provider routing, and feature-flag gating (`internal/services/notification_service.go`); outbound dispatch for all seven provider types (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook) plus email is delegated to the external `github.com/Wikid82/go_notify_yourself` module (`v0.2.0+`) through three Charon-supplied adapters — `notify_client_adapter.go` (SSRF-safe HTTP client/URL validation, wired to `internal/network`/`internal/security`), `notify_provider_adapter.go`, and `notify_email_adapter.go` (wraps `MailService` behind the module's `Mailer`/`TemplateRenderer` interfaces). `notify_provider_adapter.go`'s `buildNotifySender` maps a `NotificationProvider` row into a `map[string]any` config (`providerConfigMap`) and constructs the `Sender` by calling the module's self-registering provider factory registry (`notify.New(provider.Type, config)`) rather than a hardcoded per-provider switch/constructor call — `notify_providers_import.go` hand-picks the blank imports (`providers/discord`, `providers/slack`, `providers/gotify`, `providers/pushover`, `providers/ntfy`, `providers/telegram`, `providers/webhook`, `providers/email`) that register those factories at `init()` time, deliberately not importing `providers/all`. Charon's own supported-provider allowlist (`isSupportedNotificationProviderType`, `notification_service.go`) remains independently hardcoded and is not derived from the registry; a unit test asserts it stays a subset of `notify.RegisteredTypes()`. The formerly in-repo delivery engine (`internal/notifications/`) has been removed.
- **SettingsService:** Application settings management
- **BackupService:** Format-v2 archive creation (manifest + SHA-256 checksums), configurable cron scheduling, the safe-restore pipeline (validate → pre-restore safety backup → apply → reconcile), and optional age/scrypt archive encryption — see "Backup & Restore Subsystem" below
diff --git a/backend/go.mod b/backend/go.mod
index 19a9d28be..dadd570e0 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -4,6 +4,7 @@ go 1.26.6
require (
filippo.io/age v1.3.1
+ github.com/Wikid82/go_notify_yourself v0.2.0
github.com/gin-contrib/gzip v1.2.6
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
@@ -11,7 +12,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/yamux v0.1.2
- github.com/minio/minio-go/v7 v7.2.1
+ github.com/minio/minio-go/v7 v7.3.0
github.com/moby/moby/client v0.5.1
github.com/oschwald/geoip2-golang/v2 v2.3.0
github.com/pkg/sftp v1.13.11
diff --git a/backend/go.sum b/backend/go.sum
index 7cd563f9a..09cc3b467 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -6,6 +6,8 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/Wikid82/go_notify_yourself v0.2.0 h1:fyzzzfO7ASHibGDhg+IPVnkG4miNJsMeW4OtCk9apvI=
+github.com/Wikid82/go_notify_yourself v0.2.0/go.mod h1:Y2CIgAEYdsOwgEQDOXHO1nYYKvEDdGt08R3QD/6fG+w=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
@@ -110,8 +112,8 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
-github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw=
-github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
+github.com/minio/minio-go/v7 v7.3.0 h1:HM4pFCSQq/TK+j0/zmorSh5ddh81iDgRgU0BG0Vz/YU=
+github.com/minio/minio-go/v7 v7.3.0/go.mod h1:KUPWdecEO1LWyUz+sTGXAuf2jZHrPh5fCsRH86QbPfk=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
diff --git a/backend/integration/notification_http_wrapper_integration_test.go b/backend/integration/notification_http_wrapper_integration_test.go
deleted file mode 100644
index 2b228a0e2..000000000
--- a/backend/integration/notification_http_wrapper_integration_test.go
+++ /dev/null
@@ -1,124 +0,0 @@
-//go:build integration
-// +build integration
-
-package integration
-
-import (
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
-
- "github.com/Wikid82/charon/backend/internal/notifications"
-)
-
-func TestNotificationHTTPWrapperIntegration_RetriesOn429AndSucceeds(t *testing.T) {
- t.Parallel()
-
- var calls int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- current := atomic.AddInt32(&calls, 1)
- if current == 1 {
- w.WriteHeader(http.StatusTooManyRequests)
- return
- }
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"ok":true}`))
- }))
- defer server.Close()
-
- wrapper := notifications.NewNotifyHTTPWrapper()
- result, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
- URL: server.URL,
- Body: []byte(`{"message":"hello"}`),
- })
- if err != nil {
- t.Fatalf("expected retry success, got error: %v", err)
- }
- if result.Attempts != 2 {
- t.Fatalf("expected 2 attempts, got %d", result.Attempts)
- }
-}
-
-func TestNotificationHTTPWrapperIntegration_DoesNotRetryOn400(t *testing.T) {
- t.Parallel()
-
- var calls int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- atomic.AddInt32(&calls, 1)
- w.WriteHeader(http.StatusBadRequest)
- }))
- defer server.Close()
-
- wrapper := notifications.NewNotifyHTTPWrapper()
- _, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
- URL: server.URL,
- Body: []byte(`{"message":"hello"}`),
- })
- if err == nil {
- t.Fatalf("expected non-retryable 400 error")
- }
- if atomic.LoadInt32(&calls) != 1 {
- t.Fatalf("expected one request attempt, got %d", calls)
- }
-}
-
-func TestNotificationHTTPWrapperIntegration_RejectsTokenizedQueryWithoutEcho(t *testing.T) {
- t.Parallel()
-
- wrapper := notifications.NewNotifyHTTPWrapper()
- secret := "pr1-secret-token-value"
- _, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
- URL: "http://example.com/hook?token=" + secret,
- Body: []byte(`{"message":"hello"}`),
- })
- if err == nil {
- t.Fatalf("expected tokenized query rejection")
- }
- if !strings.Contains(err.Error(), "query authentication is not allowed") {
- t.Fatalf("expected sanitized query-auth rejection, got: %v", err)
- }
- if strings.Contains(err.Error(), secret) {
- t.Fatalf("error must not echo secret token")
- }
-}
-
-func TestNotificationHTTPWrapperIntegration_HeaderAllowlistSafety(t *testing.T) {
- t.Parallel()
-
- var seenAuthHeader string
- var seenCookieHeader string
- var seenGotifyKey string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- seenAuthHeader = r.Header.Get("Authorization")
- seenCookieHeader = r.Header.Get("Cookie")
- seenGotifyKey = r.Header.Get("X-Gotify-Key")
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- wrapper := notifications.NewNotifyHTTPWrapper()
- _, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
- URL: server.URL,
- Headers: map[string]string{
- "Authorization": "Bearer should-not-leak",
- "Cookie": "session=should-not-leak",
- "X-Gotify-Key": "allowed-token",
- },
- Body: []byte(`{"message":"hello"}`),
- })
- if err != nil {
- t.Fatalf("expected success, got error: %v", err)
- }
- if seenAuthHeader != "" {
- t.Fatalf("authorization header must be stripped")
- }
- if seenCookieHeader != "" {
- t.Fatalf("cookie header must be stripped")
- }
- if seenGotifyKey != "allowed-token" {
- t.Fatalf("expected X-Gotify-Key to pass through")
- }
-}
diff --git a/backend/internal/notifications/engine.go b/backend/internal/notifications/engine.go
deleted file mode 100644
index b94f6fd84..000000000
--- a/backend/internal/notifications/engine.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package notifications
-
-import "context"
-
-const (
- EngineNotifyV1 = "notify_v1"
-)
-
-type DispatchRequest struct {
- ProviderID string
- Type string
- URL string
- Title string
- Message string
- Data map[string]any
-}
-
-type DeliveryEngine interface {
- Name() string
- Send(ctx context.Context, req DispatchRequest) error
- Test(ctx context.Context, req DispatchRequest) error
-}
diff --git a/backend/internal/notifications/http_client_executor.go b/backend/internal/notifications/http_client_executor.go
deleted file mode 100644
index 250419511..000000000
--- a/backend/internal/notifications/http_client_executor.go
+++ /dev/null
@@ -1,7 +0,0 @@
-package notifications
-
-import "net/http"
-
-func executeNotifyRequest(client *http.Client, req *http.Request) (*http.Response, error) {
- return client.Do(req)
-}
diff --git a/backend/internal/notifications/http_wrapper.go b/backend/internal/notifications/http_wrapper.go
deleted file mode 100644
index e9831e2c2..000000000
--- a/backend/internal/notifications/http_wrapper.go
+++ /dev/null
@@ -1,540 +0,0 @@
-package notifications
-
-import (
- "bytes"
- "context"
- crand "crypto/rand"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "math/big"
- "net"
- "net/http"
- neturl "net/url"
- "os"
- "strconv"
- "strings"
- "time"
-
- "github.com/Wikid82/charon/backend/internal/network"
- "github.com/Wikid82/charon/backend/internal/security"
-)
-
-const (
- MaxNotifyRequestBodyBytes = 256 * 1024
- MaxNotifyResponseBodyBytes = 1024 * 1024
-)
-
-type RetryPolicy struct {
- MaxAttempts int
- BaseDelay time.Duration
- MaxDelay time.Duration
-}
-
-type HTTPWrapperRequest struct {
- URL string
- Headers map[string]string
- Body []byte
-}
-
-type HTTPWrapperResult struct {
- StatusCode int
- ResponseBody []byte
- Attempts int
-}
-
-type HTTPWrapper struct {
- retryPolicy RetryPolicy
- allowHTTP bool
- maxRedirects int
- httpClientFactory func(allowHTTP bool, maxRedirects int) *http.Client
- sleep func(time.Duration)
- jitterNanos func(int64) int64
-}
-
-func NewNotifyHTTPWrapper() *HTTPWrapper {
- return &HTTPWrapper{
- retryPolicy: RetryPolicy{
- MaxAttempts: 3,
- BaseDelay: 200 * time.Millisecond,
- MaxDelay: 2 * time.Second,
- },
- allowHTTP: allowNotifyHTTPOverride(),
- maxRedirects: notifyMaxRedirects(),
- httpClientFactory: func(allowHTTP bool, maxRedirects int) *http.Client {
- opts := []network.Option{network.WithTimeout(10 * time.Second), network.WithMaxRedirects(maxRedirects)}
- if allowHTTP {
- opts = append(opts, network.WithAllowLocalhost())
- }
- return network.NewSafeHTTPClient(opts...)
- },
- sleep: time.Sleep,
- }
-}
-
-func (w *HTTPWrapper) Send(ctx context.Context, request HTTPWrapperRequest) (*HTTPWrapperResult, error) {
- if len(request.Body) > MaxNotifyRequestBodyBytes {
- return nil, fmt.Errorf("request payload exceeds maximum size")
- }
-
- validatedURL, err := w.validateURL(request.URL)
- if err != nil {
- return nil, err
- }
-
- parsedValidatedURL, err := neturl.Parse(validatedURL)
- if err != nil {
- return nil, fmt.Errorf("destination URL validation failed")
- }
-
- validationOptions := []security.ValidationOption{}
- if w.allowHTTP {
- validationOptions = append(validationOptions, security.WithAllowHTTP(), security.WithAllowLocalhost())
- }
-
- safeURL, safeURLErr := security.ValidateExternalURL(parsedValidatedURL.String(), validationOptions...)
- if safeURLErr != nil {
- return nil, fmt.Errorf("destination URL validation failed")
- }
-
- safeParsedURL, safeParseErr := neturl.Parse(safeURL)
- if safeParseErr != nil {
- return nil, fmt.Errorf("destination URL validation failed")
- }
-
- if err := w.guardDestination(safeParsedURL); err != nil {
- return nil, err
- }
-
- safeRequestURL, hostHeader, safeRequestErr := w.buildSafeRequestURL(safeParsedURL)
- if safeRequestErr != nil {
- return nil, safeRequestErr
- }
-
- headers := sanitizeOutboundHeaders(request.Headers)
- client := w.httpClientFactory(w.allowHTTP, w.maxRedirects)
- w.applyRedirectGuard(client)
-
- var lastErr error
- for attempt := 1; attempt <= w.retryPolicy.MaxAttempts; attempt++ {
- httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, safeRequestURL.String(), bytes.NewReader(request.Body))
- if reqErr != nil {
- return nil, fmt.Errorf("create outbound request: %w", reqErr)
- }
-
- httpReq.Host = hostHeader
-
- for key, value := range headers {
- httpReq.Header.Set(key, value)
- }
-
- if httpReq.Header.Get("Content-Type") == "" {
- httpReq.Header.Set("Content-Type", "application/json")
- }
-
- resp, doErr := executeNotifyRequest(client, httpReq)
- if doErr != nil {
- lastErr = doErr
- if attempt < w.retryPolicy.MaxAttempts && shouldRetry(nil, doErr) {
- w.waitBeforeRetry(attempt)
- continue
- }
- return nil, fmt.Errorf("outbound request failed: %s", sanitizeTransportErrorReason(doErr))
- }
-
- body, bodyErr := readCappedResponseBody(resp.Body)
- closeErr := resp.Body.Close()
- if bodyErr != nil {
- return nil, bodyErr
- }
- if closeErr != nil {
- return nil, fmt.Errorf("close response body: %w", closeErr)
- }
-
- if shouldRetry(resp, nil) && attempt < w.retryPolicy.MaxAttempts {
- w.waitBeforeRetry(attempt)
- continue
- }
-
- if resp.StatusCode >= http.StatusBadRequest {
- if hint := extractProviderErrorHint(body); hint != "" {
- return nil, fmt.Errorf("provider returned status %d: %s", resp.StatusCode, hint)
- }
- return nil, fmt.Errorf("provider returned status %d", resp.StatusCode)
- }
-
- return &HTTPWrapperResult{
- StatusCode: resp.StatusCode,
- ResponseBody: body,
- Attempts: attempt,
- }, nil
- }
-
- if lastErr != nil {
- return nil, fmt.Errorf("provider request failed after retries: %s", sanitizeTransportErrorReason(lastErr))
- }
-
- return nil, fmt.Errorf("provider request failed")
-}
-
-func sanitizeTransportErrorReason(err error) string {
- if err == nil {
- return "connection failed"
- }
-
- errText := strings.ToLower(strings.TrimSpace(err.Error()))
-
- switch {
- case strings.Contains(errText, "no such host"):
- return "dns lookup failed"
- case strings.Contains(errText, "connection refused"):
- return "connection refused"
- case strings.Contains(errText, "no route to host") || strings.Contains(errText, "network is unreachable"):
- return "network unreachable"
- case strings.Contains(errText, "timeout") || strings.Contains(errText, "deadline exceeded"):
- return "request timed out"
- case strings.Contains(errText, "tls") || strings.Contains(errText, "certificate") || strings.Contains(errText, "x509"):
- return "tls handshake failed"
- default:
- return "connection failed"
- }
-}
-
-func (w *HTTPWrapper) applyRedirectGuard(client *http.Client) {
- if client == nil {
- return
- }
-
- originalCheckRedirect := client.CheckRedirect
- client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
- if originalCheckRedirect != nil {
- if err := originalCheckRedirect(req, via); err != nil {
- return err
- }
- }
-
- return w.guardOutboundRequestURL(req)
- }
-}
-
-func (w *HTTPWrapper) validateURL(rawURL string) (string, error) {
- parsedURL, err := neturl.Parse(rawURL)
- if err != nil {
- return "", fmt.Errorf("invalid destination URL")
- }
-
- if hasDisallowedQueryAuthKey(parsedURL.Query()) {
- return "", fmt.Errorf("destination URL query authentication is not allowed")
- }
-
- options := []security.ValidationOption{}
- if w.allowHTTP {
- options = append(options, security.WithAllowHTTP(), security.WithAllowLocalhost())
- }
-
- validatedURL, err := security.ValidateExternalURL(rawURL, options...)
- if err != nil {
- return "", fmt.Errorf("destination URL validation failed")
- }
-
- return validatedURL, nil
-}
-
-func hasDisallowedQueryAuthKey(query neturl.Values) bool {
- for key := range query {
- normalizedKey := strings.ToLower(strings.TrimSpace(key))
- switch normalizedKey {
- case "token", "auth", "apikey", "api_key":
- return true
- }
- }
-
- return false
-}
-
-func (w *HTTPWrapper) guardOutboundRequestURL(httpReq *http.Request) error {
- if httpReq == nil || httpReq.URL == nil {
- return fmt.Errorf("destination URL validation failed")
- }
-
- reqURL := httpReq.URL.String()
- validatedURL, err := w.validateURL(reqURL)
- if err != nil {
- return err
- }
-
- parsedValidatedURL, err := neturl.Parse(validatedURL)
- if err != nil {
- return fmt.Errorf("destination URL validation failed")
- }
-
- return w.guardDestination(parsedValidatedURL)
-}
-
-func (w *HTTPWrapper) guardDestination(destinationURL *neturl.URL) error {
- if destinationURL == nil {
- return fmt.Errorf("destination URL validation failed")
- }
-
- if destinationURL.User != nil || destinationURL.Fragment != "" {
- return fmt.Errorf("destination URL validation failed")
- }
-
- hostname := strings.TrimSpace(destinationURL.Hostname())
- if hostname == "" {
- return fmt.Errorf("destination URL validation failed")
- }
-
- if parsedIP := net.ParseIP(hostname); parsedIP != nil {
- if !w.isAllowedDestinationIP(hostname, parsedIP) {
- return fmt.Errorf("destination URL validation failed")
- }
- return nil
- }
-
- resolvedIPs, err := net.LookupIP(hostname)
- if err != nil || len(resolvedIPs) == 0 {
- return fmt.Errorf("destination URL validation failed")
- }
-
- for _, resolvedIP := range resolvedIPs {
- if !w.isAllowedDestinationIP(hostname, resolvedIP) {
- return fmt.Errorf("destination URL validation failed")
- }
- }
-
- return nil
-}
-
-func (w *HTTPWrapper) isAllowedDestinationIP(hostname string, ip net.IP) bool {
- if ip == nil {
- return false
- }
-
- if ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
- return false
- }
-
- if ip.IsLoopback() {
- return w.allowHTTP && isLocalDestinationHost(hostname)
- }
-
- if network.IsPrivateIP(ip) {
- return false
- }
-
- return true
-}
-
-func (w *HTTPWrapper) buildSafeRequestURL(destinationURL *neturl.URL) (*neturl.URL, string, error) {
- if destinationURL == nil {
- return nil, "", fmt.Errorf("destination URL validation failed")
- }
-
- hostname := strings.TrimSpace(destinationURL.Hostname())
- if hostname == "" {
- return nil, "", fmt.Errorf("destination URL validation failed")
- }
-
- // Validate destination IPs are allowed (defense-in-depth alongside safeDialer).
- _, err := w.resolveAllowedDestinationIP(hostname)
- if err != nil {
- return nil, "", err
- }
-
- // Preserve the original hostname in the URL so Go's TLS layer derives the
- // correct ServerName for SNI and certificate verification. The safeDialer
- // resolves DNS, validates IPs against SSRF rules, and connects to a
- // validated IP at dial time, so protection is maintained without
- // IP-pinning in the URL.
- safeRequestURL := &neturl.URL{
- Scheme: destinationURL.Scheme,
- Host: destinationURL.Host,
- Path: destinationURL.EscapedPath(),
- RawQuery: destinationURL.RawQuery,
- }
-
- if safeRequestURL.Path == "" {
- safeRequestURL.Path = "/"
- }
-
- return safeRequestURL, destinationURL.Host, nil
-}
-
-func (w *HTTPWrapper) resolveAllowedDestinationIP(hostname string) (net.IP, error) {
- if parsedIP := net.ParseIP(hostname); parsedIP != nil {
- if !w.isAllowedDestinationIP(hostname, parsedIP) {
- return nil, fmt.Errorf("destination URL validation failed")
- }
- return parsedIP, nil
- }
-
- resolvedIPs, err := net.LookupIP(hostname)
- if err != nil || len(resolvedIPs) == 0 {
- return nil, fmt.Errorf("destination URL validation failed")
- }
-
- for _, resolvedIP := range resolvedIPs {
- if w.isAllowedDestinationIP(hostname, resolvedIP) {
- return resolvedIP, nil
- }
- }
-
- return nil, fmt.Errorf("destination URL validation failed")
-}
-
-func isLocalDestinationHost(host string) bool {
- trimmedHost := strings.TrimSpace(host)
- if strings.EqualFold(trimmedHost, "localhost") {
- return true
- }
-
- parsedIP := net.ParseIP(trimmedHost)
- return parsedIP != nil && parsedIP.IsLoopback()
-}
-
-func shouldRetry(resp *http.Response, err error) bool {
- if err != nil {
- var netErr net.Error
- if isNetErr := strings.Contains(strings.ToLower(err.Error()), "timeout") || strings.Contains(strings.ToLower(err.Error()), "connection"); isNetErr {
- return true
- }
- return errors.As(err, &netErr)
- }
-
- if resp == nil {
- return false
- }
-
- if resp.StatusCode == http.StatusTooManyRequests {
- return true
- }
-
- return resp.StatusCode >= http.StatusInternalServerError
-}
-
-// extractProviderErrorHint attempts to extract a short, human-readable error description
-// from a JSON error response body. Only well-known fields are extracted to avoid
-// accidentally surfacing sensitive or overlong content from arbitrary providers.
-func extractProviderErrorHint(body []byte) string {
- if len(body) == 0 {
- return ""
- }
- var errResp map[string]any
- if err := json.Unmarshal(body, &errResp); err != nil {
- return ""
- }
- for _, key := range []string{"description", "message", "error", "error_description"} {
- v, ok := errResp[key]
- if !ok {
- continue
- }
- s, ok := v.(string)
- if !ok || strings.TrimSpace(s) == "" {
- continue
- }
- if len(s) > 100 {
- s = s[:100] + "..."
- }
- return strings.TrimSpace(s)
- }
- return ""
-}
-
-func readCappedResponseBody(body io.Reader) ([]byte, error) {
- limited := io.LimitReader(body, MaxNotifyResponseBodyBytes+1)
- content, err := io.ReadAll(limited)
- if err != nil {
- return nil, fmt.Errorf("read response body: %w", err)
- }
-
- if len(content) > MaxNotifyResponseBodyBytes {
- return nil, fmt.Errorf("response payload exceeds maximum size")
- }
-
- return content, nil
-}
-
-func sanitizeOutboundHeaders(headers map[string]string) map[string]string {
- allowed := map[string]struct{}{
- "content-type": {},
- "user-agent": {},
- "x-request-id": {},
- "x-gotify-key": {},
- "authorization": {},
- }
-
- sanitized := make(map[string]string)
- for key, value := range headers {
- normalizedKey := strings.ToLower(strings.TrimSpace(key))
- if _, ok := allowed[normalizedKey]; !ok {
- continue
- }
- sanitized[http.CanonicalHeaderKey(normalizedKey)] = strings.TrimSpace(value)
- }
-
- return sanitized
-}
-
-func (w *HTTPWrapper) waitBeforeRetry(attempt int) {
- delay := w.retryPolicy.BaseDelay << (attempt - 1)
- if delay > w.retryPolicy.MaxDelay {
- delay = w.retryPolicy.MaxDelay
- }
-
- jitterFn := w.jitterNanos
- if jitterFn == nil {
- jitterFn = func(max int64) int64 {
- if max <= 0 {
- return 0
- }
- n, err := crand.Int(crand.Reader, big.NewInt(max))
- if err != nil {
- return 0
- }
- return n.Int64()
- }
- }
-
- jitter := time.Duration(jitterFn(int64(delay) / 2))
- sleepFn := w.sleep
- if sleepFn == nil {
- sleepFn = time.Sleep
- }
- sleepFn(delay + jitter)
-}
-
-func allowNotifyHTTPOverride() bool {
- if strings.HasSuffix(os.Args[0], ".test") {
- return true
- }
-
- allowHTTP := strings.EqualFold(strings.TrimSpace(os.Getenv("CHARON_NOTIFY_ALLOW_HTTP")), "true")
- if !allowHTTP {
- return false
- }
-
- environment := strings.ToLower(strings.TrimSpace(os.Getenv("CHARON_ENV")))
- return environment == "development" || environment == "test"
-}
-
-func notifyMaxRedirects() int {
- raw := strings.TrimSpace(os.Getenv("CHARON_NOTIFY_MAX_REDIRECTS"))
- if raw == "" {
- return 0
- }
-
- value, err := strconv.Atoi(raw)
- if err != nil {
- return 0
- }
-
- if value < 0 {
- return 0
- }
- if value > 5 {
- return 5
- }
- return value
-}
diff --git a/backend/internal/notifications/http_wrapper_test.go b/backend/internal/notifications/http_wrapper_test.go
deleted file mode 100644
index 2097e0917..000000000
--- a/backend/internal/notifications/http_wrapper_test.go
+++ /dev/null
@@ -1,1001 +0,0 @@
-package notifications
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/http/httptest"
- neturl "net/url"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-)
-
-func TestHTTPWrapperRejectsOversizedRequestBody(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- payload := make([]byte, MaxNotifyRequestBodyBytes+1)
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: "http://example.com/hook",
- Body: payload,
- })
- if err == nil || !strings.Contains(err.Error(), "request payload exceeds") {
- t.Fatalf("expected oversized request body error, got: %v", err)
- }
-}
-
-func TestHTTPWrapperRejectsTokenizedQueryURL(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: "http://example.com/hook?token=secret",
- Body: []byte(`{"message":"hello"}`),
- })
- if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
- t.Fatalf("expected query token rejection, got: %v", err)
- }
-}
-
-func TestHTTPWrapperRejectsQueryAuthCaseVariants(t *testing.T) {
- testCases := []string{
- "http://example.com/hook?Token=secret",
- "http://example.com/hook?AUTH=secret",
- "http://example.com/hook?apiKey=secret",
- }
-
- for _, testURL := range testCases {
- t.Run(testURL, func(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: testURL,
- Body: []byte(`{"message":"hello"}`),
- })
- if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
- t.Fatalf("expected query auth rejection for %q, got: %v", testURL, err)
- }
- })
- }
-}
-
-func TestHTTPWrapperSendRejectsRedirectTargetWithDisallowedScheme(t *testing.T) {
- var attempts int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- atomic.AddInt32(&attempts, 1)
- http.Redirect(w, r, "ftp://example.com/redirected", http.StatusFound)
- }))
- defer server.Close()
-
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
- wrapper.maxRedirects = 3
- wrapper.retryPolicy.MaxAttempts = 1
-
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: server.URL,
- Body: []byte(`{"message":"hello"}`),
- })
- if err == nil || !strings.Contains(err.Error(), "outbound request failed") {
- t.Fatalf("expected outbound failure due to redirect target validation, got: %v", err)
- }
- if got := atomic.LoadInt32(&attempts); got != 1 {
- t.Fatalf("expected only initial request due to blocked redirect, got %d attempts", got)
- }
-}
-
-func TestHTTPWrapperSendRejectsRedirectTargetWithMixedCaseQueryAuth(t *testing.T) {
- var attempts int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- atomic.AddInt32(&attempts, 1)
- http.Redirect(w, r, "https://example.com/redirected?Token=secret", http.StatusFound)
- }))
- defer server.Close()
-
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
- wrapper.maxRedirects = 3
- wrapper.retryPolicy.MaxAttempts = 1
-
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: server.URL,
- Body: []byte(`{"message":"hello"}`),
- })
- if err == nil || !strings.Contains(err.Error(), "outbound request failed") {
- t.Fatalf("expected outbound failure due to redirect query auth validation, got: %v", err)
- }
- if got := atomic.LoadInt32(&attempts); got != 1 {
- t.Fatalf("expected only initial request due to blocked redirect, got %d attempts", got)
- }
-}
-
-func TestHTTPWrapperRetriesOn429ThenSucceeds(t *testing.T) {
- var calls int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- current := atomic.AddInt32(&calls, 1)
- if current == 1 {
- w.WriteHeader(http.StatusTooManyRequests)
- return
- }
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte("ok"))
- }))
- defer server.Close()
-
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
- wrapper.sleep = func(time.Duration) {}
- wrapper.jitterNanos = func(int64) int64 { return 0 }
-
- result, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: server.URL,
- Body: []byte(`{"message":"hello"}`),
- })
- if err != nil {
- t.Fatalf("expected success after retry, got error: %v", err)
- }
- if result.Attempts != 2 {
- t.Fatalf("expected 2 attempts, got %d", result.Attempts)
- }
-}
-
-func TestHTTPWrapperSendSuccessWithValidatedDestination(t *testing.T) {
- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if got := r.Header.Get("Content-Type"); got != "application/json" {
- t.Fatalf("expected default content-type, got %q", got)
- }
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte("ok"))
- }))
- defer server.Close()
-
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
- wrapper.retryPolicy.MaxAttempts = 1
- wrapper.httpClientFactory = func(bool, int) *http.Client {
- return server.Client()
- }
-
- result, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: server.URL,
- Body: []byte(`{"message":"hello"}`),
- })
- if err != nil {
- t.Fatalf("expected successful send, got error: %v", err)
- }
- if result.Attempts != 1 {
- t.Fatalf("expected 1 attempt, got %d", result.Attempts)
- }
- if result.StatusCode != http.StatusOK {
- t.Fatalf("expected status %d, got %d", http.StatusOK, result.StatusCode)
- }
-}
-
-func TestHTTPWrapperSendRejectsUserInfoInDestinationURL(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
-
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ //nolint:gosec // test verifies rejection of credentials in URL
- URL: "https://user:pass@example.com/hook",
- Body: []byte(`{"message":"hello"}`),
- })
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected destination validation failure, got: %v", err)
- }
-}
-
-func TestHTTPWrapperSendRejectsFragmentInDestinationURL(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
-
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: "https://example.com/hook#fragment",
- Body: []byte(`{"message":"hello"}`),
- })
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected destination validation failure, got: %v", err)
- }
-}
-
-func TestHTTPWrapperDoesNotRetryOn400(t *testing.T) {
- var calls int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- atomic.AddInt32(&calls, 1)
- w.WriteHeader(http.StatusBadRequest)
- }))
- defer server.Close()
-
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
- wrapper.sleep = func(time.Duration) {}
- wrapper.jitterNanos = func(int64) int64 { return 0 }
-
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: server.URL,
- Body: []byte(`{"message":"hello"}`),
- })
- if err == nil || !strings.Contains(err.Error(), "status 400") {
- t.Fatalf("expected non-retryable 400 error, got: %v", err)
- }
- if atomic.LoadInt32(&calls) != 1 {
- t.Fatalf("expected exactly one request attempt, got %d", calls)
- }
-}
-
-func TestHTTPWrapperResponseBodyCap(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- _, _ = io.WriteString(w, strings.Repeat("x", MaxNotifyResponseBodyBytes+8))
- }))
- defer server.Close()
-
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: server.URL,
- Body: []byte(`{"message":"hello"}`),
- })
- if err == nil || !strings.Contains(err.Error(), "response payload exceeds") {
- t.Fatalf("expected capped response body error, got: %v", err)
- }
-}
-
-func TestSanitizeOutboundHeadersAllowlist(t *testing.T) {
- headers := sanitizeOutboundHeaders(map[string]string{
- "Content-Type": "application/json",
- "User-Agent": "Charon",
- "X-Request-ID": "abc",
- "X-Gotify-Key": "secret",
- "Authorization": "Bearer token",
- "Cookie": "sid=1",
- })
-
- if len(headers) != 5 {
- t.Fatalf("expected 5 allowed headers, got %d", len(headers))
- }
- if _, ok := headers["Authorization"]; !ok {
- t.Fatalf("authorization header must be allowed for ntfy Bearer auth")
- }
- if _, ok := headers["Cookie"]; ok {
- t.Fatalf("cookie header must be stripped")
- }
-}
-
-func TestHTTPWrapperGuardOutboundRequestURLRejectsNilRequest(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
-
- err := wrapper.guardOutboundRequestURL(nil)
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected validation failure for nil request, got: %v", err)
- }
-}
-
-func TestHTTPWrapperGuardOutboundRequestURLRejectsQueryAuth(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook", RawQuery: "token=secret"}}
- err := wrapper.guardOutboundRequestURL(httpReq)
- if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
- t.Fatalf("expected query auth rejection, got: %v", err)
- }
-}
-
-func TestHTTPWrapperGuardOutboundRequestURLRejectsMixedCaseQueryAuth(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook", RawQuery: "apiKey=secret"}}
- err := wrapper.guardOutboundRequestURL(httpReq)
- if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
- t.Fatalf("expected query auth rejection, got: %v", err)
- }
-}
-
-func TestHTTPWrapperApplyRedirectGuardPreservesOriginalBehavior(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- baseErr := fmt.Errorf("base redirect policy")
- client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
- return baseErr
- }}
-
- wrapper.applyRedirectGuard(client)
- err := client.CheckRedirect(&http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com"}}, nil)
- if !errors.Is(err, baseErr) {
- t.Fatalf("expected original redirect policy error, got: %v", err)
- }
-}
-
-func TestHTTPWrapperGuardOutboundRequestURLRejectsUnsafeDestination(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = false
-
- httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook"}}
- err := wrapper.guardOutboundRequestURL(httpReq)
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected destination validation failure, got: %v", err)
- }
-}
-
-func TestHTTPWrapperGuardOutboundRequestURLAllowsValidatedDestination(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
-
- httpReq := &http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com", Path: "/hook"}}
- err := wrapper.guardOutboundRequestURL(httpReq)
- if err != nil {
- t.Fatalf("expected validated destination to pass guard, got: %v", err)
- }
-}
-
-func TestHTTPWrapperGuardOutboundRequestURLRejectsUserInfo(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "127.0.0.1", User: neturl.UserPassword("user", "pass"), Path: "/hook"}}
- err := wrapper.guardOutboundRequestURL(httpReq)
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected userinfo rejection, got: %v", err)
- }
-}
-
-func TestHTTPWrapperGuardOutboundRequestURLRejectsFragment(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
-
- httpReq := &http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com", Path: "/hook", Fragment: "frag"}}
- err := wrapper.guardOutboundRequestURL(httpReq)
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected fragment rejection, got: %v", err)
- }
-}
-
-func TestSanitizeTransportErrorReason(t *testing.T) {
- tests := []struct {
- name string
- err error
- expected string
- }{
- {name: "nil error", err: nil, expected: "connection failed"},
- {name: "dns error", err: errors.New("dial tcp: lookup gotify.example: no such host"), expected: "dns lookup failed"},
- {name: "connection refused", err: errors.New("connect: connection refused"), expected: "connection refused"},
- {name: "network unreachable", err: errors.New("connect: no route to host"), expected: "network unreachable"},
- {name: "timeout", err: errors.New("context deadline exceeded"), expected: "request timed out"},
- {name: "tls failure", err: errors.New("tls: handshake failure"), expected: "tls handshake failed"},
- {name: "fallback", err: errors.New("some unexpected transport error"), expected: "connection failed"},
- }
-
- for _, testCase := range tests {
- t.Run(testCase.name, func(t *testing.T) {
- actual := sanitizeTransportErrorReason(testCase.err)
- if actual != testCase.expected {
- t.Fatalf("expected %q, got %q", testCase.expected, actual)
- }
- })
- }
-}
-
-func TestBuildSafeRequestURLPreservesHostnameForTLS(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- destinationURL := &neturl.URL{
- Scheme: "https",
- Host: "example.com",
- Path: "/webhook",
- }
-
- safeURL, hostHeader, err := wrapper.buildSafeRequestURL(destinationURL)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if safeURL.Hostname() != "example.com" {
- t.Fatalf("expected hostname 'example.com' preserved in URL for TLS SNI, got %q", safeURL.Hostname())
- }
-
- if hostHeader != "example.com" {
- t.Fatalf("expected host header 'example.com', got %q", hostHeader)
- }
-
- if safeURL.Scheme != "https" {
- t.Fatalf("expected scheme 'https', got %q", safeURL.Scheme)
- }
-
- if safeURL.Path != "/webhook" {
- t.Fatalf("expected path '/webhook', got %q", safeURL.Path)
- }
-}
-
-func TestBuildSafeRequestURLDefaultsEmptyPathToSlash(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- destinationURL := &neturl.URL{
- Scheme: "http",
- Host: "localhost",
- }
-
- safeURL, _, err := wrapper.buildSafeRequestURL(destinationURL)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if safeURL.Path != "/" {
- t.Fatalf("expected default path '/', got %q", safeURL.Path)
- }
-}
-
-func TestBuildSafeRequestURLPreservesQueryString(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- destinationURL := &neturl.URL{
- Scheme: "https",
- Host: "example.com",
- Path: "/hook",
- RawQuery: "key=value",
- }
-
- safeURL, _, err := wrapper.buildSafeRequestURL(destinationURL)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if safeURL.RawQuery != "key=value" {
- t.Fatalf("expected query 'key=value', got %q", safeURL.RawQuery)
- }
-}
-
-func TestBuildSafeRequestURLRejectsNilDestination(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
-
- _, _, err := wrapper.buildSafeRequestURL(nil)
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected validation failure for nil URL, got: %v", err)
- }
-}
-
-func TestBuildSafeRequestURLRejectsEmptyHostname(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
-
- destinationURL := &neturl.URL{
- Scheme: "https",
- Host: "",
- Path: "/hook",
- }
-
- _, _, err := wrapper.buildSafeRequestURL(destinationURL)
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected validation failure for empty hostname, got: %v", err)
- }
-}
-
-func TestBuildSafeRequestURLWithTLSServer(t *testing.T) {
- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- serverURL, _ := neturl.Parse(server.URL)
-
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- safeURL, hostHeader, err := wrapper.buildSafeRequestURL(serverURL)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if safeURL.Host != serverURL.Host {
- t.Fatalf("expected host %q preserved for TLS, got %q", serverURL.Host, safeURL.Host)
- }
-
- if hostHeader != serverURL.Host {
- t.Fatalf("expected host header %q, got %q", serverURL.Host, hostHeader)
- }
-}
-
-// ===== Additional coverage for uncovered paths =====
-
-type errReader struct{}
-
-func (errReader) Read([]byte) (int, error) {
- return 0, errors.New("simulated read error")
-}
-
-type roundTripFunc func(*http.Request) (*http.Response, error)
-
-func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
- return f(req)
-}
-
-func TestApplyRedirectGuardNilClient(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.applyRedirectGuard(nil)
-}
-
-func TestGuardDestinationNilURL(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- err := wrapper.guardDestination(nil)
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected validation failure for nil URL, got: %v", err)
- }
-}
-
-func TestGuardDestinationEmptyHostname(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- err := wrapper.guardDestination(&neturl.URL{Scheme: "https", Host: ""})
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected validation failure for empty hostname, got: %v", err)
- }
-}
-
-func TestGuardDestinationUserInfoRejection(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- u := &neturl.URL{Scheme: "https", Host: "example.com", User: neturl.User("admin")}
- err := wrapper.guardDestination(u)
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected userinfo rejection, got: %v", err)
- }
-}
-
-func TestGuardDestinationFragmentRejection(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- u := &neturl.URL{Scheme: "https", Host: "example.com", Fragment: "section"}
- err := wrapper.guardDestination(u)
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected fragment rejection, got: %v", err)
- }
-}
-
-func TestGuardDestinationPrivateIPRejection(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = false
- err := wrapper.guardDestination(&neturl.URL{Scheme: "https", Host: "192.168.1.1"})
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected private IP rejection, got: %v", err)
- }
-}
-
-func TestIsAllowedDestinationIPEdgeCases(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = false
-
- tests := []struct {
- name string
- hostname string
- ip net.IP
- expected bool
- }{
- {"nil IP", "", nil, false},
- {"unspecified", "0.0.0.0", net.IPv4zero, false},
- {"multicast", "224.0.0.1", net.ParseIP("224.0.0.1"), false},
- {"link-local unicast", "169.254.1.1", net.ParseIP("169.254.1.1"), false},
- {"loopback without allowHTTP", "127.0.0.1", net.ParseIP("127.0.0.1"), false},
- {"private 10.x", "10.0.0.1", net.ParseIP("10.0.0.1"), false},
- {"private 172.16.x", "172.16.0.1", net.ParseIP("172.16.0.1"), false},
- {"private 192.168.x", "192.168.1.1", net.ParseIP("192.168.1.1"), false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := wrapper.isAllowedDestinationIP(tt.hostname, tt.ip)
- if result != tt.expected {
- t.Fatalf("isAllowedDestinationIP(%q, %v) = %v, want %v", tt.hostname, tt.ip, result, tt.expected)
- }
- })
- }
-}
-
-func TestIsAllowedDestinationIPLoopbackAllowHTTP(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
-
- if !wrapper.isAllowedDestinationIP("localhost", net.ParseIP("127.0.0.1")) {
- t.Fatal("expected loopback allowed for localhost with allowHTTP")
- }
-
- if wrapper.isAllowedDestinationIP("not-localhost", net.ParseIP("127.0.0.1")) {
- t.Fatal("expected loopback rejected for non-localhost hostname")
- }
-}
-
-func TestIsLocalDestinationHost(t *testing.T) {
- tests := []struct {
- host string
- expected bool
- }{
- {"localhost", true},
- {"LOCALHOST", true},
- {"127.0.0.1", true},
- {"::1", true},
- {"example.com", false},
- {"", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.host, func(t *testing.T) {
- if got := isLocalDestinationHost(tt.host); got != tt.expected {
- t.Fatalf("isLocalDestinationHost(%q) = %v, want %v", tt.host, got, tt.expected)
- }
- })
- }
-}
-
-func TestShouldRetryComprehensive(t *testing.T) {
- tests := []struct {
- name string
- resp *http.Response
- err error
- expected bool
- }{
- {"nil resp nil err", nil, nil, false},
- {"timeout error string", nil, errors.New("operation timeout"), true},
- {"connection error string", nil, errors.New("connection reset"), true},
- {"unrelated error", nil, errors.New("json parse error"), false},
- {"500 response", &http.Response{StatusCode: 500}, nil, true},
- {"502 response", &http.Response{StatusCode: 502}, nil, true},
- {"503 response", &http.Response{StatusCode: 503}, nil, true},
- {"429 response", &http.Response{StatusCode: 429}, nil, true},
- {"200 response", &http.Response{StatusCode: 200}, nil, false},
- {"400 response", &http.Response{StatusCode: 400}, nil, false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := shouldRetry(tt.resp, tt.err); got != tt.expected {
- t.Fatalf("shouldRetry = %v, want %v", got, tt.expected)
- }
- })
- }
-}
-
-func TestShouldRetryNetError(t *testing.T) {
- netErr := &net.DNSError{Err: "no such host", Name: "example.invalid"}
- if !shouldRetry(nil, netErr) {
- t.Fatal("expected net.Error to trigger retry via errors.As fallback")
- }
-}
-
-func TestReadCappedResponseBodyReadError(t *testing.T) {
- _, err := readCappedResponseBody(errReader{})
- if err == nil || !strings.Contains(err.Error(), "read response body") {
- t.Fatalf("expected read body error, got: %v", err)
- }
-}
-
-func TestReadCappedResponseBodyOversize(t *testing.T) {
- oversized := strings.NewReader(strings.Repeat("x", MaxNotifyResponseBodyBytes+10))
- _, err := readCappedResponseBody(oversized)
- if err == nil || !strings.Contains(err.Error(), "response payload exceeds") {
- t.Fatalf("expected oversize error, got: %v", err)
- }
-}
-
-func TestReadCappedResponseBodySuccess(t *testing.T) {
- content, err := readCappedResponseBody(strings.NewReader("hello"))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if string(content) != "hello" {
- t.Fatalf("expected 'hello', got %q", string(content))
- }
-}
-
-func TestHasDisallowedQueryAuthKeyAllVariants(t *testing.T) {
- tests := []struct {
- name string
- key string
- expected bool
- }{
- {"token", "token", true},
- {"auth", "auth", true},
- {"apikey", "apikey", true},
- {"api_key", "api_key", true},
- {"TOKEN uppercase", "TOKEN", true},
- {"Api_Key mixed", "Api_Key", true},
- {"safe key", "callback", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- query := neturl.Values{}
- query.Set(tt.key, "secret")
- if got := hasDisallowedQueryAuthKey(query); got != tt.expected {
- t.Fatalf("hasDisallowedQueryAuthKey with key %q = %v, want %v", tt.key, got, tt.expected)
- }
- })
- }
-}
-
-func TestHasDisallowedQueryAuthKeyEmptyQuery(t *testing.T) {
- if hasDisallowedQueryAuthKey(neturl.Values{}) {
- t.Fatal("expected empty query to be safe")
- }
-}
-
-func TestNotifyMaxRedirects(t *testing.T) {
- tests := []struct {
- name string
- envValue string
- expected int
- }{
- {"empty", "", 0},
- {"valid 3", "3", 3},
- {"zero", "0", 0},
- {"negative", "-1", 0},
- {"above max", "10", 5},
- {"exactly 5", "5", 5},
- {"invalid", "abc", 0},
- {"whitespace", " 2 ", 2},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Setenv("CHARON_NOTIFY_MAX_REDIRECTS", tt.envValue)
- if got := notifyMaxRedirects(); got != tt.expected {
- t.Fatalf("notifyMaxRedirects() = %d, want %d", got, tt.expected)
- }
- })
- }
-}
-
-func TestResolveAllowedDestinationIPRejectsPrivateIP(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = false
- _, err := wrapper.resolveAllowedDestinationIP("192.168.1.1")
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected private IP rejection, got: %v", err)
- }
-}
-
-func TestResolveAllowedDestinationIPRejectsLoopback(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = false
- _, err := wrapper.resolveAllowedDestinationIP("127.0.0.1")
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected loopback rejection, got: %v", err)
- }
-}
-
-func TestResolveAllowedDestinationIPAllowsPublic(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- ip, err := wrapper.resolveAllowedDestinationIP("1.1.1.1")
- if err != nil {
- t.Fatalf("expected public IP to be allowed, got: %v", err)
- }
- if !ip.Equal(net.ParseIP("1.1.1.1")) {
- t.Fatalf("expected 1.1.1.1, got %v", ip)
- }
-}
-
-func TestBuildSafeRequestURLRejectsPrivateHostname(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = false
- u := &neturl.URL{Scheme: "https", Host: "192.168.1.1", Path: "/hook"}
- _, _, err := wrapper.buildSafeRequestURL(u)
- if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
- t.Fatalf("expected private host rejection, got: %v", err)
- }
-}
-
-func TestWaitBeforeRetryBasic(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- var sleptDuration time.Duration
- wrapper.sleep = func(d time.Duration) { sleptDuration = d }
- wrapper.jitterNanos = func(int64) int64 { return 0 }
- wrapper.retryPolicy.BaseDelay = 100 * time.Millisecond
- wrapper.retryPolicy.MaxDelay = 1 * time.Second
-
- wrapper.waitBeforeRetry(1)
- if sleptDuration != 100*time.Millisecond {
- t.Fatalf("expected 100ms delay for attempt 1, got %v", sleptDuration)
- }
-
- wrapper.waitBeforeRetry(2)
- if sleptDuration != 200*time.Millisecond {
- t.Fatalf("expected 200ms delay for attempt 2, got %v", sleptDuration)
- }
-}
-
-func TestWaitBeforeRetryClampedToMax(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- var sleptDuration time.Duration
- wrapper.sleep = func(d time.Duration) { sleptDuration = d }
- wrapper.jitterNanos = func(int64) int64 { return 0 }
- wrapper.retryPolicy.BaseDelay = 1 * time.Second
- wrapper.retryPolicy.MaxDelay = 2 * time.Second
-
- wrapper.waitBeforeRetry(5)
- if sleptDuration != 2*time.Second {
- t.Fatalf("expected clamped delay of 2s, got %v", sleptDuration)
- }
-}
-
-func TestWaitBeforeRetryDefaultJitter(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.jitterNanos = nil
- wrapper.sleep = func(time.Duration) {}
- wrapper.retryPolicy.BaseDelay = 100 * time.Millisecond
- wrapper.retryPolicy.MaxDelay = 1 * time.Second
- wrapper.waitBeforeRetry(1)
-}
-
-func TestHTTPWrapperSendExhaustsRetriesOnTransportError(t *testing.T) {
- var calls int32
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
- wrapper.sleep = func(time.Duration) {}
- wrapper.jitterNanos = func(int64) int64 { return 0 }
- wrapper.httpClientFactory = func(bool, int) *http.Client {
- return &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- atomic.AddInt32(&calls, 1)
- return nil, errors.New("connection timeout failure")
- }),
- }
- }
-
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: "http://localhost:19999/hook",
- Body: []byte(`{"msg":"test"}`),
- })
- if err == nil {
- t.Fatal("expected error after transport failures")
- }
- if !strings.Contains(err.Error(), "outbound request failed") {
- t.Fatalf("expected outbound request failed message, got: %v", err)
- }
- if got := atomic.LoadInt32(&calls); got != 3 {
- t.Fatalf("expected 3 attempts, got %d", got)
- }
-}
-
-func TestHTTPWrapperSendExhaustsRetriesOn500(t *testing.T) {
- var calls int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- atomic.AddInt32(&calls, 1)
- w.WriteHeader(http.StatusInternalServerError)
- }))
- defer server.Close()
-
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
- wrapper.sleep = func(time.Duration) {}
- wrapper.jitterNanos = func(int64) int64 { return 0 }
-
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: server.URL,
- Body: []byte(`{"msg":"test"}`),
- })
- if err == nil || !strings.Contains(err.Error(), "status 500") {
- t.Fatalf("expected 500 status error, got: %v", err)
- }
- if got := atomic.LoadInt32(&calls); got != 3 {
- t.Fatalf("expected 3 attempts for 500 retries, got %d", got)
- }
-}
-
-func TestHTTPWrapperSendTransportErrorNoRetry(t *testing.T) {
- wrapper := NewNotifyHTTPWrapper()
- wrapper.allowHTTP = true
- wrapper.retryPolicy.MaxAttempts = 1
- wrapper.httpClientFactory = func(bool, int) *http.Client {
- return &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- return nil, errors.New("some unretryable error")
- }),
- }
- }
-
- _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
- URL: "http://localhost:19999/hook",
- Body: []byte(`{"msg":"test"}`),
- })
- if err == nil || !strings.Contains(err.Error(), "outbound request failed") {
- t.Fatalf("expected outbound request failed, got: %v", err)
- }
-}
-
-func TestSanitizeTransportErrorReasonNetworkUnreachable(t *testing.T) {
- result := sanitizeTransportErrorReason(errors.New("connect: network is unreachable"))
- if result != "network unreachable" {
- t.Fatalf("expected 'network unreachable', got %q", result)
- }
-}
-
-func TestSanitizeTransportErrorReasonCertificate(t *testing.T) {
- result := sanitizeTransportErrorReason(errors.New("x509: certificate signed by unknown authority"))
- if result != "tls handshake failed" {
- t.Fatalf("expected 'tls handshake failed', got %q", result)
- }
-}
-
-func TestAllowNotifyHTTPOverride(t *testing.T) {
- result := allowNotifyHTTPOverride()
- if !result {
- t.Fatal("expected allowHTTP to be true in test binary")
- }
-}
-
-func TestExtractProviderErrorHint(t *testing.T) {
- tests := []struct {
- name string
- body []byte
- expected string
- }{
- {
- name: "description field",
- body: []byte(`{"description":"Not Found: chat not found"}`),
- expected: "Not Found: chat not found",
- },
- {
- name: "message field",
- body: []byte(`{"message":"Unauthorized"}`),
- expected: "Unauthorized",
- },
- {
- name: "error field",
- body: []byte(`{"error":"rate limited"}`),
- expected: "rate limited",
- },
- {
- name: "error_description field",
- body: []byte(`{"error_description":"invalid token"}`),
- expected: "invalid token",
- },
- {
- name: "empty body",
- body: []byte{},
- expected: "",
- },
- {
- name: "non-JSON body",
- body: []byte(`Server Error`),
- expected: "",
- },
- {
- name: "string over 100 chars truncated",
- body: []byte(`{"description":"` + strings.Repeat("x", 120) + `"}`),
- expected: strings.Repeat("x", 100) + "...",
- },
- {
- name: "empty string value ignored",
- body: []byte(`{"description":"","message":"fallback hint"}`),
- expected: "fallback hint",
- },
- {
- name: "whitespace-only value ignored",
- body: []byte(`{"description":" ","message":"real hint"}`),
- expected: "real hint",
- },
- {
- name: "non-string value ignored",
- body: []byte(`{"description":42,"message":"string hint"}`),
- expected: "string hint",
- },
- {
- name: "priority order: description before message",
- body: []byte(`{"message":"second","description":"first"}`),
- expected: "first",
- },
- {
- name: "no recognized fields",
- body: []byte(`{"status":"error","code":500}`),
- expected: "",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := extractProviderErrorHint(tt.body)
- if result != tt.expected {
- t.Errorf("extractProviderErrorHint(%q) = %q, want %q", string(tt.body), result, tt.expected)
- }
- })
- }
-}
diff --git a/backend/internal/notifications/router.go b/backend/internal/notifications/router.go
deleted file mode 100644
index 5aa780765..000000000
--- a/backend/internal/notifications/router.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package notifications
-
-import "strings"
-
-// NOTE: used only in tests
-type Router struct{}
-
-func NewRouter() *Router {
- return &Router{}
-}
-
-func (r *Router) ShouldUseNotify(providerType string, flags map[string]bool) bool {
- if !flags[FlagNotifyEngineEnabled] {
- return false
- }
-
- switch strings.ToLower(providerType) {
- case "discord":
- return flags[FlagDiscordServiceEnabled]
- case "email":
- return flags[FlagEmailServiceEnabled]
- case "gotify":
- return flags[FlagGotifyServiceEnabled]
- case "webhook":
- return flags[FlagWebhookServiceEnabled]
- case "telegram":
- return flags[FlagTelegramServiceEnabled]
- case "slack":
- return flags[FlagSlackServiceEnabled]
- case "pushover":
- return flags[FlagPushoverServiceEnabled]
- case "ntfy":
- return flags[FlagNtfyServiceEnabled]
- default:
- return false
- }
-}
diff --git a/backend/internal/notifications/router_test.go b/backend/internal/notifications/router_test.go
deleted file mode 100644
index 25395dba1..000000000
--- a/backend/internal/notifications/router_test.go
+++ /dev/null
@@ -1,142 +0,0 @@
-package notifications
-
-import "testing"
-
-func TestRouter_ShouldUseNotify(t *testing.T) {
- router := NewRouter()
-
- flags := map[string]bool{
- FlagNotifyEngineEnabled: true,
- FlagDiscordServiceEnabled: true,
- }
-
- if !router.ShouldUseNotify("discord", flags) {
- t.Fatalf("expected notify routing for discord when enabled")
- }
-
- if router.ShouldUseNotify("telegram", flags) {
- t.Fatalf("expected unsupported service to remain legacy")
- }
-}
-
-// TestRouter_ShouldUseNotify_EngineDisabled covers lines 13-14
-func TestRouter_ShouldUseNotify_EngineDisabled(t *testing.T) {
- router := NewRouter()
-
- flags := map[string]bool{
- FlagNotifyEngineEnabled: false,
- FlagDiscordServiceEnabled: true,
- }
-
- if router.ShouldUseNotify("discord", flags) {
- t.Fatalf("expected notify routing disabled when FlagNotifyEngineEnabled is false")
- }
-}
-
-// TestRouter_ShouldUseNotify_DiscordServiceFlag covers lines 23-24
-func TestRouter_ShouldUseNotify_DiscordServiceFlag(t *testing.T) {
- router := NewRouter()
-
- flags := map[string]bool{
- FlagNotifyEngineEnabled: true,
- FlagDiscordServiceEnabled: false,
- }
-
- if router.ShouldUseNotify("discord", flags) {
- t.Fatalf("expected notify routing disabled for discord when FlagDiscordServiceEnabled is false")
- }
-}
-
-// TestRouter_ShouldUseNotify_GotifyServiceFlag covers lines 23-24 (gotify case)
-func TestRouter_ShouldUseNotify_GotifyServiceFlag(t *testing.T) {
- router := NewRouter()
-
- // Test with gotify enabled
- flags := map[string]bool{
- FlagNotifyEngineEnabled: true,
- FlagGotifyServiceEnabled: true,
- }
-
- if !router.ShouldUseNotify("gotify", flags) {
- t.Fatalf("expected notify routing enabled for gotify when FlagGotifyServiceEnabled is true")
- }
-
- // Test with gotify disabled
- flags[FlagGotifyServiceEnabled] = false
-
- if router.ShouldUseNotify("gotify", flags) {
- t.Fatalf("expected notify routing disabled for gotify when FlagGotifyServiceEnabled is false")
- }
-}
-
-func TestRouter_ShouldUseNotify_WebhookServiceFlag(t *testing.T) {
- router := NewRouter()
-
- flags := map[string]bool{
- FlagNotifyEngineEnabled: true,
- FlagWebhookServiceEnabled: true,
- }
-
- if !router.ShouldUseNotify("webhook", flags) {
- t.Fatalf("expected notify routing enabled for webhook when FlagWebhookServiceEnabled is true")
- }
-
- flags[FlagWebhookServiceEnabled] = false
- if router.ShouldUseNotify("webhook", flags) {
- t.Fatalf("expected notify routing disabled for webhook when FlagWebhookServiceEnabled is false")
- }
-}
-
-func TestRouter_ShouldUseNotify_SlackServiceFlag(t *testing.T) {
- router := NewRouter()
-
- flags := map[string]bool{
- FlagNotifyEngineEnabled: true,
- FlagSlackServiceEnabled: true,
- }
-
- if !router.ShouldUseNotify("slack", flags) {
- t.Fatalf("expected notify routing enabled for slack when FlagSlackServiceEnabled is true")
- }
-
- flags[FlagSlackServiceEnabled] = false
- if router.ShouldUseNotify("slack", flags) {
- t.Fatalf("expected notify routing disabled for slack when FlagSlackServiceEnabled is false")
- }
-}
-
-func TestRouter_ShouldUseNotify_PushoverServiceFlag(t *testing.T) {
- router := NewRouter()
-
- flags := map[string]bool{
- FlagNotifyEngineEnabled: true,
- FlagPushoverServiceEnabled: true,
- }
-
- if !router.ShouldUseNotify("pushover", flags) {
- t.Fatalf("expected notify routing enabled for pushover when FlagPushoverServiceEnabled is true")
- }
-
- flags[FlagPushoverServiceEnabled] = false
- if router.ShouldUseNotify("pushover", flags) {
- t.Fatalf("expected notify routing disabled for pushover when FlagPushoverServiceEnabled is false")
- }
-}
-
-func TestRouter_ShouldUseNotify_NtfyServiceFlag(t *testing.T) {
- router := NewRouter()
-
- flags := map[string]bool{
- FlagNotifyEngineEnabled: true,
- FlagNtfyServiceEnabled: true,
- }
-
- if !router.ShouldUseNotify("ntfy", flags) {
- t.Fatalf("expected notify routing enabled for ntfy when FlagNtfyServiceEnabled is true")
- }
-
- flags[FlagNtfyServiceEnabled] = false
- if router.ShouldUseNotify("ntfy", flags) {
- t.Fatalf("expected notify routing disabled for ntfy when FlagNtfyServiceEnabled is false")
- }
-}
diff --git a/backend/internal/services/coverage_boost_test.go b/backend/internal/services/coverage_boost_test.go
index cb4e0029b..9e2e7f15d 100644
--- a/backend/internal/services/coverage_boost_test.go
+++ b/backend/internal/services/coverage_boost_test.go
@@ -2,7 +2,6 @@ package services
import (
"context"
- "net"
"testing"
"github.com/Wikid82/charon/backend/internal/models"
@@ -301,18 +300,6 @@ func TestCoverageBoost_HelperFunctions(t *testing.T) {
headers := map[string][]string{}
assert.False(t, hasHeader(headers, "Any-Header"))
})
-
- t.Run("isPrivateIP_PrivateRanges", func(t *testing.T) {
- assert.True(t, isPrivateIP(net.ParseIP("192.168.1.1")))
- assert.True(t, isPrivateIP(net.ParseIP("10.0.0.1")))
- assert.True(t, isPrivateIP(net.ParseIP("172.16.0.1")))
- assert.True(t, isPrivateIP(net.ParseIP("127.0.0.1")))
- })
-
- t.Run("isPrivateIP_PublicIP", func(t *testing.T) {
- assert.False(t, isPrivateIP(net.ParseIP("8.8.8.8")))
- assert.False(t, isPrivateIP(net.ParseIP("1.1.1.1")))
- })
}
// TestCoverageBoost_ProxyHostService_DB tests DB accessor
diff --git a/backend/internal/notifications/feature_flags.go b/backend/internal/services/notification_feature_flags.go
similarity index 63%
rename from backend/internal/notifications/feature_flags.go
rename to backend/internal/services/notification_feature_flags.go
index 846a78cb2..c1b0db08f 100644
--- a/backend/internal/notifications/feature_flags.go
+++ b/backend/internal/services/notification_feature_flags.go
@@ -1,5 +1,12 @@
-package notifications
+package services
+// Notification feature-flag keys (models.Setting table). These gate
+// per-provider dispatch via NotificationService.isDispatchEnabled /
+// getFeatureFlagValue. Moved here from the now-removed internal/notifications
+// package (docs/plans/notifications_extraction_spec.md §3.6 step 2) — this is
+// Charon policy (which provider types are enabled), not delivery-engine
+// logic, so it stays in Charon rather than moving to the extracted
+// go_notify_yourself module.
const (
FlagNotifyEngineEnabled = "feature.notifications.engine.notify_v1.enabled"
FlagDiscordServiceEnabled = "feature.notifications.service.discord.enabled"
diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go
index c48ed6d6f..5f480c4ef 100644
--- a/backend/internal/services/notification_service.go
+++ b/backend/internal/services/notification_service.go
@@ -5,20 +5,19 @@ import (
"context"
"encoding/json"
"fmt"
- "html"
"net"
- "net/http"
neturl "net/url"
"regexp"
"strings"
"text/template"
"time"
+ notify "github.com/Wikid82/go_notify_yourself"
+ "github.com/Wikid82/go_notify_yourself/providers/email"
+ "github.com/Wikid82/go_notify_yourself/providers/webhook"
+ "github.com/Wikid82/go_notify_yourself/transport"
+
"github.com/Wikid82/charon/backend/internal/logger"
- "github.com/Wikid82/charon/backend/internal/network"
- "github.com/Wikid82/charon/backend/internal/notifications"
- "github.com/Wikid82/charon/backend/internal/security"
- "github.com/Wikid82/charon/backend/internal/trace"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/util"
@@ -27,7 +26,7 @@ import (
type NotificationService struct {
DB *gorm.DB
- httpWrapper *notifications.HTTPWrapper
+ notifyWrapper *transport.Wrapper
mailService MailServiceInterface
telegramAPIBaseURL string
pushoverAPIBaseURL string
@@ -45,10 +44,22 @@ func WithSlackURLValidator(fn func(string) error) NotificationServiceOption {
}
}
+// WithNotifyTransportWrapper overrides the *transport.Wrapper used to
+// dispatch notifications through the extracted notify module's provider
+// packages (buildNotifySender, notify_provider_adapter.go). Intended for
+// tests that need to intercept outbound requests (e.g. a fake
+// http.RoundTripper) without hitting a real network destination —
+// production code always uses the wrapper built by NewNotifyTransportWrapper.
+func WithNotifyTransportWrapper(w *transport.Wrapper) NotificationServiceOption {
+ return func(s *NotificationService) {
+ s.notifyWrapper = w
+ }
+}
+
func NewNotificationService(db *gorm.DB, mailService MailServiceInterface, opts ...NotificationServiceOption) *NotificationService {
s := &NotificationService{
DB: db,
- httpWrapper: notifications.NewNotifyHTTPWrapper(),
+ notifyWrapper: NewNotifyTransportWrapper(),
mailService: mailService,
telegramAPIBaseURL: "https://api.telegram.org",
pushoverAPIBaseURL: "https://api.pushover.net",
@@ -60,8 +71,6 @@ func NewNotificationService(db *gorm.DB, mailService MailServiceInterface, opts
return s
}
-var discordWebhookRegex = regexp.MustCompile(`^https://discord(?:app)?\.com/api/webhooks/(\d+)/([a-zA-Z0-9_-]+)`)
-
var allowedDiscordWebhookHosts = map[string]struct{}{
"discord.com": {},
"canary.discord.com": {},
@@ -76,18 +85,6 @@ func validateSlackWebhookURL(rawURL string) error {
return nil
}
-func normalizeURL(serviceType, rawURL string) string {
- if serviceType == "discord" {
- matches := discordWebhookRegex.FindStringSubmatch(rawURL)
- if len(matches) == 3 {
- id := matches[1]
- token := matches[2]
- return fmt.Sprintf("discord://%s@%s", token, id)
- }
- }
- return rawURL
-}
-
func validateDiscordWebhookURL(rawURL string) error {
parsedURL, err := neturl.Parse(rawURL)
if err != nil {
@@ -150,19 +147,19 @@ func (s *NotificationService) isDispatchEnabled(providerType string) bool {
case "discord":
return true
case "email":
- return s.getFeatureFlagValue(notifications.FlagEmailServiceEnabled, false)
+ return s.getFeatureFlagValue(FlagEmailServiceEnabled, false)
case "gotify":
- return s.getFeatureFlagValue(notifications.FlagGotifyServiceEnabled, true)
+ return s.getFeatureFlagValue(FlagGotifyServiceEnabled, true)
case "webhook":
- return s.getFeatureFlagValue(notifications.FlagWebhookServiceEnabled, true)
+ return s.getFeatureFlagValue(FlagWebhookServiceEnabled, true)
case "telegram":
- return s.getFeatureFlagValue(notifications.FlagTelegramServiceEnabled, true)
+ return s.getFeatureFlagValue(FlagTelegramServiceEnabled, true)
case "slack":
- return s.getFeatureFlagValue(notifications.FlagSlackServiceEnabled, true)
+ return s.getFeatureFlagValue(FlagSlackServiceEnabled, true)
case "pushover":
- return s.getFeatureFlagValue(notifications.FlagPushoverServiceEnabled, true)
+ return s.getFeatureFlagValue(FlagPushoverServiceEnabled, true)
case "ntfy":
- return s.getFeatureFlagValue(notifications.FlagNtfyServiceEnabled, true)
+ return s.getFeatureFlagValue(FlagNtfyServiceEnabled, true)
default:
return false
}
@@ -267,7 +264,7 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title
continue
}
if strings.ToLower(strings.TrimSpace(provider.Type)) == "email" {
- go s.dispatchEmail(ctx, provider, eventType, title, message)
+ go s.dispatchEmailViaNotify(ctx, provider, eventType, title, message)
continue
}
go func(p models.NotificationProvider) {
@@ -275,88 +272,111 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title
logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).WithField("type", p.Type).Warn("Provider type is not supported by notify-only runtime")
return
}
-
- if err := s.sendJSONPayload(ctx, p, data); err != nil {
- logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send JSON notification")
- }
+ s.dispatchViaNotify(ctx, p, eventType, title, message, data)
}(provider)
}
}
-// sanitizeForEmail strips ASCII control characters (0x00–0x1F and 0x7F DEL)
-// and trims leading/trailing whitespace from untrusted strings before they
-// enter the email pipeline. The result is a normalized, single-line string.
-// This provides defense-in-depth alongside rejectCRLF() validation in
-// SendEmail/buildEmail.
-func sanitizeForEmail(s string) string {
- stripped := strings.Map(func(r rune) rune {
- if r < 0x20 || r == 0x7F {
- return -1
- }
- return r
- }, s)
- return strings.TrimSpace(stripped)
+// notifyMessageDataFromLegacyFlatMap extracts the host/service extras that
+// legacyDetailedTemplate (notify_provider_adapter.go) reads via
+// {{index .Data "HostName"}}/{{index .Data "HostIP"}}/
+// {{index .Data "ServiceCount"}}/{{index .Data "Services"}} from the flat
+// data map SendExternal callers (e.g. uptime_service.go's
+// sendHostDownNotification) pass in, so a provider configured with the old
+// "detailed" template keeps rendering the same host/IP/service-count/
+// services values after cutover to the extracted notify module. Reading a
+// missing key from a nil or incomplete map yields nil (renders as JSON
+// null), matching the old flat-map template's behavior for callers that
+// don't supply these optional fields (e.g. proxy_host/domain/cert/
+// remote_server events).
+func notifyMessageDataFromLegacyFlatMap(data map[string]any) map[string]any {
+ return map[string]any{
+ "HostName": data["HostName"],
+ "HostIP": data["HostIP"],
+ "ServiceCount": data["ServiceCount"],
+ "Services": data["Services"],
+ }
+}
+
+// dispatchViaNotify sends a notification through the extracted notify
+// module (buildNotifySender, notify_provider_adapter.go). It builds
+// a notify.Message from the caller-supplied source data
+// (title/message/eventType plus the HostName/HostIP/ServiceCount/Services
+// extras a caller may have supplied), then dispatches it through the
+// provider-specific Sender, which routes through the shared
+// *transport.Wrapper (s.notifyWrapper) — gaining that wrapper's
+// retry/backoff behavior for every dispatch that goes through this path.
+func (s *NotificationService) dispatchViaNotify(ctx context.Context, p models.NotificationProvider, eventType, title, message string, data map[string]any) {
+ sender, err := buildNotifySender(p, s.notifyWrapper)
+ if err != nil {
+ logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to build notify sender")
+ return
+ }
+
+ msg := notify.Message{
+ Title: title,
+ Body: message,
+ EventType: eventType,
+ Data: notifyMessageDataFromLegacyFlatMap(data),
+ }
+
+ if err := sender.Send(ctx, msg); err != nil {
+ logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send notification via notify module")
+ }
}
-// dispatchEmail sends an email notification for the given provider.
+// dispatchEmailViaNotify sends an email notification through the extracted
+// notify module's email package (NewNotifyEmailConfig, notify_email_adapter.go).
// It runs in a goroutine; all errors are logged rather than returned.
-func (s *NotificationService) dispatchEmail(ctx context.Context, p models.NotificationProvider, eventType, title, message string) {
+//
+// Behavior note: a template-rendering failure still
+// results in the notification being sent, using a manually built plain
+// HTML body — see mailServiceTemplateRendererAdapter.Render's doc comment
+// (notify_email_adapter.go) for where that fallback now lives. Only a real
+// Mailer/SMTP transport failure (mailServiceMailerAdapter.Send) causes this
+// function's error branch to fire.
+func (s *NotificationService) dispatchEmailViaNotify(ctx context.Context, p models.NotificationProvider, eventType, title, message string) {
if s.mailService == nil || !s.mailService.IsConfigured() {
logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).Warn("Email provider is not configured, skipping dispatch")
return
}
- rawRecipients := strings.Split(p.URL, ",")
- recipients := make([]string, 0, len(rawRecipients))
- for _, r := range rawRecipients {
- if trimmed := strings.TrimSpace(r); trimmed != "" {
- recipients = append(recipients, trimmed)
- }
- }
-
+ recipients := parseEmailRecipients(p.URL)
if len(recipients) == 0 {
logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).Warn("Email provider has no recipients configured")
return
}
- safeTitle := sanitizeForEmail(title)
- safeMessage := sanitizeForEmail(message)
- subject := fmt.Sprintf("[Charon Alert] %s", safeTitle)
+ client := email.New(NewNotifyEmailConfig(s.mailService, recipients))
- templateName := emailTemplateForEventType(eventType)
- data := EmailTemplateData{
+ msg := notify.Message{
+ Title: title,
+ Body: message,
EventType: eventType,
- Title: safeTitle,
- Message: safeMessage,
- Timestamp: time.Now().Format(time.RFC3339),
- }
-
- htmlBody, renderErr := s.mailService.RenderNotificationEmail(templateName, data)
- if renderErr != nil {
- logger.Log().WithError(renderErr).WithField("template", templateName).Warn("Email template rendering failed, using fallback")
- var bodyBuilder strings.Builder
- if safeTitle != "" {
- bodyBuilder.WriteString("")
- bodyBuilder.WriteString(html.EscapeString(safeTitle))
- bodyBuilder.WriteString("")
- }
- if safeMessage != "" {
- if bodyBuilder.Len() > 0 {
- bodyBuilder.WriteString("
")
- }
- bodyBuilder.WriteString(html.EscapeString(safeMessage))
- }
- htmlBody = bodyBuilder.String()
}
timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
- if err := s.mailService.SendEmail(timeoutCtx, recipients, subject, htmlBody); err != nil {
+ if err := client.Send(timeoutCtx, msg); err != nil {
logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send email notification")
}
}
+// parseEmailRecipients splits a NotificationProvider's comma-separated URL
+// field into a trimmed, non-empty recipient list. Shared by
+// dispatchEmailViaNotify and TestEmailProvider's notify-path counterpart.
+func parseEmailRecipients(rawURL string) []string {
+ rawRecipients := strings.Split(rawURL, ",")
+ recipients := make([]string, 0, len(rawRecipients))
+ for _, r := range rawRecipients {
+ if trimmed := strings.TrimSpace(r); trimmed != "" {
+ recipients = append(recipients, trimmed)
+ }
+ }
+ return recipients
+}
+
func emailTemplateForEventType(eventType string) string {
switch strings.ToLower(strings.TrimSpace(eventType)) {
case "security_waf", "security_acl", "security_rate_limit", "security_crowdsec":
@@ -370,332 +390,10 @@ func emailTemplateForEventType(eventType string) string {
}
}
-// webhookDoRequestFunc is a test hook for outbound JSON webhook requests.
-// In production it defaults to (*http.Client).Do.
-var webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- return client.Do(req)
-}
-
// validateDiscordProviderURLFunc is a test hook for Discord webhook URL validation.
// In tests, you can override this to bypass strict hostname checks for localhost testing.
var validateDiscordProviderURLFunc = validateDiscordProviderURL
-func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.NotificationProvider, data map[string]any) error {
- // Built-in templates
- const minimalTemplate = `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}}`
- const detailedTemplate = `{"title": {{toJSON .Title}}, "message": {{toJSON .Message}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}, "host": {{toJSON .HostName}}, "host_ip": {{toJSON .HostIP}}, "service_count": {{toJSON .ServiceCount}}, "services": {{toJSON .Services}}, "data": {{toJSON .}}}`
-
- // Select template based on provider.Template; if 'custom' use Config; else builtin.
- tmplStr := p.Config
- switch strings.ToLower(strings.TrimSpace(p.Template)) {
- case "detailed":
- tmplStr = detailedTemplate
- case "minimal":
- tmplStr = minimalTemplate
- case "custom":
- if tmplStr == "" {
- tmplStr = minimalTemplate
- }
- default:
- if tmplStr == "" {
- tmplStr = minimalTemplate
- }
- }
-
- // Template size limit validation (10KB max)
- const maxTemplateSize = 10 * 1024
- if len(tmplStr) > maxTemplateSize {
- return fmt.Errorf("template size exceeds maximum limit of %d bytes", maxTemplateSize)
- }
-
- providerType := strings.ToLower(strings.TrimSpace(p.Type))
- if providerType == "discord" {
- if err := validateDiscordProviderURLFunc(p.Type, p.URL); err != nil {
- return err
- }
-
- if !isValidRedirectURL(p.URL) {
- return fmt.Errorf("invalid webhook url")
- }
- }
-
- // Parse template and add helper funcs
- tmpl, err := template.New("webhook").Funcs(template.FuncMap{
- "toJSON": func(v any) string {
- b, _ := json.Marshal(v)
- return string(b)
- },
- }).Parse(tmplStr)
- if err != nil {
- return fmt.Errorf("failed to parse webhook template: %w", err)
- }
-
- // Template execution with timeout (5 seconds)
- var body bytes.Buffer
- execDone := make(chan error, 1)
- go func() {
- execDone <- tmpl.Execute(&body, data)
- }()
-
- select {
- case execErr := <-execDone:
- if execErr != nil {
- return fmt.Errorf("failed to execute webhook template: %w", execErr)
- }
- case <-time.After(5 * time.Second):
- return fmt.Errorf("template execution timeout after 5 seconds")
- }
-
- // Service-specific JSON validation
- var jsonPayload map[string]any
- if unmarshalErr := json.Unmarshal(body.Bytes(), &jsonPayload); unmarshalErr != nil {
- return fmt.Errorf("invalid JSON payload: %w", unmarshalErr)
- }
-
- // Validate service-specific requirements
- switch strings.ToLower(p.Type) {
- case "discord":
- // Discord requires either 'content' or 'embeds'
- if _, hasContent := jsonPayload["content"]; !hasContent {
- if _, hasEmbeds := jsonPayload["embeds"]; !hasEmbeds {
- if messageValue, hasMessage := jsonPayload["message"]; hasMessage {
- jsonPayload["content"] = messageValue
- normalizedBody, marshalErr := json.Marshal(jsonPayload)
- if marshalErr != nil {
- return fmt.Errorf("failed to normalize discord payload: %w", marshalErr)
- }
- body.Reset()
- if _, writeErr := body.Write(normalizedBody); writeErr != nil {
- return fmt.Errorf("failed to write normalized discord payload: %w", writeErr)
- }
- } else {
- return fmt.Errorf("discord payload requires 'content' or 'embeds' field")
- }
- }
- }
- case "slack":
- if _, hasText := jsonPayload["text"]; !hasText {
- if _, hasBlocks := jsonPayload["blocks"]; !hasBlocks {
- if messageValue, hasMessage := jsonPayload["message"]; hasMessage {
- jsonPayload["text"] = messageValue
- normalizedBody, marshalErr := json.Marshal(jsonPayload)
- if marshalErr != nil {
- return fmt.Errorf("failed to normalize slack payload: %w", marshalErr)
- }
- body.Reset()
- if _, writeErr := body.Write(normalizedBody); writeErr != nil {
- return fmt.Errorf("failed to write normalized slack payload: %w", writeErr)
- }
- } else {
- return fmt.Errorf("slack payload requires 'text' or 'blocks' field")
- }
- }
- }
- case "gotify":
- // Gotify requires 'message' field
- if _, hasMessage := jsonPayload["message"]; !hasMessage {
- return fmt.Errorf("gotify payload requires 'message' field")
- }
- case "telegram":
- // Telegram requires 'text' field for the message body
- if _, hasText := jsonPayload["text"]; !hasText {
- if messageValue, hasMessage := jsonPayload["message"]; hasMessage {
- jsonPayload["text"] = messageValue
- normalizedBody, marshalErr := json.Marshal(jsonPayload)
- if marshalErr != nil {
- return fmt.Errorf("failed to normalize telegram payload: %w", marshalErr)
- }
- body.Reset()
- if _, writeErr := body.Write(normalizedBody); writeErr != nil {
- return fmt.Errorf("failed to write normalized telegram payload: %w", writeErr)
- }
- } else {
- return fmt.Errorf("telegram payload requires 'text' field")
- }
- }
- case "pushover":
- if _, hasMessage := jsonPayload["message"]; !hasMessage {
- return fmt.Errorf("pushover payload requires 'message' field")
- }
- if priority, ok := jsonPayload["priority"]; ok {
- if p, isFloat := priority.(float64); isFloat && p == 2 {
- return fmt.Errorf("pushover emergency priority (2) requires retry and expire parameters; not yet supported")
- }
- }
- case "ntfy":
- if _, hasMessage := jsonPayload["message"]; !hasMessage {
- return fmt.Errorf("ntfy payload must include a 'message' field")
- }
- }
-
- if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy" {
- headers := map[string]string{
- "Content-Type": "application/json",
- "User-Agent": "Charon-Notify/1.0",
- }
- if rid := ctx.Value(trace.RequestIDKey); rid != nil {
- if ridStr, ok := rid.(string); ok {
- headers["X-Request-ID"] = ridStr
- }
- }
-
- dispatchURL := p.URL
-
- if providerType == "gotify" {
- if strings.TrimSpace(p.Token) != "" {
- headers["X-Gotify-Key"] = strings.TrimSpace(p.Token)
- }
- }
-
- if providerType == "telegram" {
- decryptedToken := p.Token
- telegramBase := s.telegramAPIBaseURL
- if telegramBase == "" {
- telegramBase = "https://api.telegram.org"
- }
- dispatchURL = telegramBase + "/bot" + decryptedToken + "/sendMessage"
-
- parsedURL, parseErr := neturl.Parse(dispatchURL)
- expectedHost := "api.telegram.org"
- if parsedURL != nil && parsedURL.Hostname() != "" && telegramBase != "https://api.telegram.org" {
- // In test overrides, skip the hostname pin check.
- expectedHost = parsedURL.Hostname()
- }
- if parseErr != nil || parsedURL.Hostname() != expectedHost {
- return fmt.Errorf("telegram dispatch URL validation failed: invalid hostname")
- }
-
- jsonPayload["chat_id"] = p.URL
- updatedBody, marshalErr := json.Marshal(jsonPayload)
- if marshalErr != nil {
- return fmt.Errorf("failed to marshal telegram payload with chat_id: %w", marshalErr)
- }
- body.Reset()
- body.Write(updatedBody)
- }
-
- if providerType == "slack" {
- decryptedWebhookURL := p.Token
- if strings.TrimSpace(decryptedWebhookURL) == "" {
- return fmt.Errorf("slack webhook URL is not configured")
- }
- if validateErr := s.validateSlackURL(decryptedWebhookURL); validateErr != nil {
- return validateErr
- }
- dispatchURL = decryptedWebhookURL
- }
-
- if providerType == "ntfy" {
- if strings.TrimSpace(p.Token) != "" {
- headers["Authorization"] = "Bearer " + strings.TrimSpace(p.Token)
- }
- }
-
- if providerType == "pushover" {
- decryptedToken := p.Token
- if strings.TrimSpace(decryptedToken) == "" {
- return fmt.Errorf("pushover API token is not configured")
- }
- if strings.TrimSpace(p.URL) == "" {
- return fmt.Errorf("pushover user key is not configured")
- }
-
- pushoverBase := s.pushoverAPIBaseURL
- if pushoverBase == "" {
- pushoverBase = "https://api.pushover.net"
- }
- dispatchURL = pushoverBase + "/1/messages.json"
-
- parsedURL, parseErr := neturl.Parse(dispatchURL)
- expectedHost := "api.pushover.net"
- if parsedURL != nil && parsedURL.Hostname() != "" && pushoverBase != "https://api.pushover.net" {
- expectedHost = parsedURL.Hostname()
- }
- if parseErr != nil || parsedURL.Hostname() != expectedHost {
- return fmt.Errorf("pushover dispatch URL validation failed: invalid hostname")
- }
-
- jsonPayload["token"] = decryptedToken
- jsonPayload["user"] = p.URL
-
- updatedBody, marshalErr := json.Marshal(jsonPayload)
- if marshalErr != nil {
- return fmt.Errorf("failed to marshal pushover payload: %w", marshalErr)
- }
- body.Reset()
- body.Write(updatedBody)
- }
-
- if _, sendErr := s.httpWrapper.Send(ctx, notifications.HTTPWrapperRequest{
- URL: dispatchURL,
- Headers: headers,
- Body: body.Bytes(),
- }); sendErr != nil {
- return fmt.Errorf("failed to send webhook: %w", sendErr)
- }
- return nil
- }
-
- validatedURLStr, err := security.ValidateExternalURL(p.URL,
- security.WithAllowHTTP(),
- security.WithAllowLocalhost(),
- )
- if err != nil {
- return fmt.Errorf("invalid webhook url: %w", err)
- }
-
- client := network.NewSafeHTTPClient(
- network.WithTimeout(10*time.Second),
- network.WithAllowLocalhost(),
- )
-
- req, err := http.NewRequestWithContext(ctx, "POST", validatedURLStr, &body)
- if err != nil {
- return fmt.Errorf("failed to create webhook request: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
- if rid := ctx.Value(trace.RequestIDKey); rid != nil {
- if ridStr, ok := rid.(string); ok {
- req.Header.Set("X-Request-ID", ridStr)
- }
- }
-
- resp, err := webhookDoRequestFunc(client, req)
- if err != nil {
- return fmt.Errorf("failed to send webhook: %w", err)
- }
- defer func() {
- if err := resp.Body.Close(); err != nil {
- logger.Log().WithError(err).Warn("failed to close webhook response body")
- }
- }()
-
- if resp.StatusCode >= 400 {
- return fmt.Errorf("webhook returned status: %d", resp.StatusCode)
- }
- return nil
-}
-
-// isPrivateIP returns true for RFC1918, loopback and link-local addresses.
-// This wraps network.IsPrivateIP for backward compatibility and local use.
-func isPrivateIP(ip net.IP) bool {
- return network.IsPrivateIP(ip)
-}
-
-func isValidRedirectURL(rawURL string) bool {
- u, err := neturl.Parse(rawURL)
- if err != nil {
- return false
- }
- if u.Scheme != "http" && u.Scheme != "https" {
- return false
- }
- if u.Hostname() == "" {
- return false
- }
- return true
-}
-
func (s *NotificationService) TestProvider(provider models.NotificationProvider) error {
providerType := strings.ToLower(strings.TrimSpace(provider.Type))
if !isSupportedNotificationProviderType(providerType) {
@@ -710,46 +408,73 @@ func (s *NotificationService) TestProvider(provider models.NotificationProvider)
return fmt.Errorf("provider type %q does not support JSON templates", providerType)
}
- data := map[string]any{
- "Title": "Test Notification",
- "Message": "This is a test notification from Charon",
- "Status": "TEST",
- "Name": "Test Monitor",
- "Latency": 123,
- "Time": time.Now().Format(time.RFC3339),
+ return s.testProviderViaNotify(provider)
+}
+
+// testProviderViaNotify sends a test notification through the extracted
+// notify module (buildNotifySender, notify_provider_adapter.go).
+func (s *NotificationService) testProviderViaNotify(provider models.NotificationProvider) error {
+ sender, err := buildNotifySender(provider, s.notifyWrapper)
+ if err != nil {
+ return fmt.Errorf("build notify sender: %w", err)
}
- return s.sendJSONPayload(context.Background(), provider, data)
+
+ msg := notify.Message{
+ Title: "Test Notification",
+ Body: "This is a test notification from Charon",
+ EventType: "test",
+ }
+ return sender.Send(context.Background(), msg)
}
-// TestEmailProvider sends a test email to the recipients configured in provider.URL.
-// It bypasses the JSON-template path used by TestProvider and uses the SMTP mail service directly.
+// TestEmailProvider sends a test email to the recipients configured in
+// provider.URL, dispatched through the extracted notify module's email
+// package (providers/email) the same way TestEmailProvider's real-dispatch
+// counterpart (dispatchEmailViaNotify) is. It bypasses the JSON-template
+// path used by TestProvider.
+//
+// This uses its own inline email.Config, rather than NewNotifyEmailConfig
+// (notify_email_adapter.go), because the test-send subject prefix
+// ("[Charon Test] ") and forced "email_system_event.html" template differ
+// from NewNotifyEmailConfig's production values ("[Charon Alert] " and
+// emailTemplateForEventType's event-type-based mapping) — matching the old
+// TestEmailProvider's hardcoded subject/template exactly.
+//
+// Behavior note: see dispatchEmailViaNotify's comment — like that path,
+// this still falls back to a manually built plain HTML body (via
+// mailServiceTemplateRendererAdapter.Render) when template rendering fails,
+// and still sends/succeeds. Only a real Mailer/SMTP transport failure
+// causes this function to return an error.
func (s *NotificationService) TestEmailProvider(provider models.NotificationProvider) error {
if s.mailService == nil || !s.mailService.IsConfigured() {
return fmt.Errorf("email service is not configured; configure SMTP settings before testing email providers")
}
- rawRecipients := strings.Split(provider.URL, ",")
- recipients := make([]string, 0, len(rawRecipients))
- for _, r := range rawRecipients {
- if trimmed := strings.TrimSpace(r); trimmed != "" {
- recipients = append(recipients, trimmed)
- }
- }
+
+ recipients := parseEmailRecipients(provider.URL)
if len(recipients) == 0 {
return fmt.Errorf("no recipients configured; add at least one recipient email address")
}
- data := EmailTemplateData{
- EventType: "test",
- Title: "Test Notification",
- Message: "This is a test notification from Charon. If you received this email, your email notification provider is configured correctly.",
- Timestamp: time.Now().Format(time.RFC3339),
+
+ cfg := email.Config{
+ Recipients: recipients,
+ SubjectPrefix: "[Charon Test] ",
+ TemplateName: func(notify.Message) string {
+ return "email_system_event.html"
+ },
+ Renderer: &mailServiceTemplateRendererAdapter{mailService: s.mailService},
+ Mailer: &mailServiceMailerAdapter{mailService: s.mailService},
}
- htmlBody, renderErr := s.mailService.RenderNotificationEmail("email_system_event.html", data)
- if renderErr != nil {
- htmlBody = "Test Notification
This is a test notification from Charon. If you received this email, your email notification provider is configured correctly."
+ client := email.New(cfg)
+
+ msg := notify.Message{
+ Title: "Test Notification",
+ Body: "This is a test notification from Charon. If you received this email, your email notification provider is configured correctly.",
+ EventType: "test",
}
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
- return s.mailService.SendEmail(ctx, recipients, "[Charon Test] Test Notification", htmlBody)
+ return client.Send(ctx, msg)
}
// ListTemplates returns all external notification templates stored in the database.
@@ -863,11 +588,15 @@ func (s *NotificationService) CreateProvider(provider *models.NotificationProvid
provider.Token = ""
}
- // Validate custom template before creating
+ // Validate custom template before creating. Uses providers/webhook.RenderPreview
+ // (the extracted notify module's template-preview function) rather than the old
+ // RenderTemplate, so preview validation exercises the same TemplateData shape
+ // (Title/Message/Time/EventType/Data) that dispatchViaNotify's actual dispatch
+ // uses — a custom template referencing {{index .Data "..."}} now validates
+ // correctly instead of failing preview with a flat map that had no Data field.
if strings.ToLower(strings.TrimSpace(provider.Template)) == "custom" && strings.TrimSpace(provider.Config) != "" {
- // Provide a minimal preview payload
- payload := map[string]any{"Title": "Preview", "Message": "Preview", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"}
- if _, _, err := s.RenderTemplate(*provider, payload); err != nil {
+ previewMsg := notify.Message{Title: "Preview", Body: "Preview", EventType: "preview"}
+ if _, _, err := webhook.RenderPreview(provider.Config, previewMsg); err != nil {
return fmt.Errorf("invalid custom template: %w", err)
}
}
@@ -909,10 +638,11 @@ func (s *NotificationService) UpdateProvider(provider *models.NotificationProvid
}
}
- // Validate custom template before saving
+ // Validate custom template before saving — see the matching comment in
+ // CreateProvider for why this uses providers/webhook.RenderPreview.
if strings.ToLower(strings.TrimSpace(provider.Template)) == "custom" && strings.TrimSpace(provider.Config) != "" {
- payload := map[string]any{"Title": "Preview", "Message": "Preview", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"}
- if _, _, err := s.RenderTemplate(*provider, payload); err != nil {
+ previewMsg := notify.Message{Title: "Preview", Body: "Preview", EventType: "preview"}
+ if _, _, err := webhook.RenderPreview(provider.Config, previewMsg); err != nil {
return fmt.Errorf("invalid custom template: %w", err)
}
}
diff --git a/backend/internal/services/notification_service_discord_only_test.go b/backend/internal/services/notification_service_discord_only_test.go
index 8ca4b9ff0..be6b9e561 100644
--- a/backend/internal/services/notification_service_discord_only_test.go
+++ b/backend/internal/services/notification_service_discord_only_test.go
@@ -181,6 +181,12 @@ func TestDiscordOnly_UpdateProviderAllowsWebhookUpdates(t *testing.T) {
// TestDiscordOnly_TestProviderAllowsWebhookWithoutFeatureFlag tests that webhook TestProvider
// works without explicit feature flag (bypasses dispatch gate).
func TestDiscordOnly_TestProviderAllowsWebhookWithoutFeatureFlag(t *testing.T) {
+ // Webhook is cut over to the extracted notify module, whose transport
+ // wrapper gates plain-HTTP/localhost dispatch on CHARON_ENV=test
+ // explicitly (resolveNotifyAllowHTTP in notify_client_adapter.go)
+ // rather than the old implicit os.Args[0]-".test"-suffix detection.
+ t.Setenv("CHARON_ENV", "test")
+
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Setting{}))
diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go
index 3403b5595..8f44924d6 100644
--- a/backend/internal/services/notification_service_json_test.go
+++ b/backend/internal/services/notification_service_json_test.go
@@ -3,11 +3,6 @@ package services
import (
"context"
"encoding/json"
- "net/http"
- "net/http/httptest"
- "net/url"
- "strings"
- "sync/atomic"
"testing"
"time"
@@ -43,32 +38,6 @@ func TestSupportsJSONTemplates(t *testing.T) {
}
}
-func TestSendJSONPayload_DiscordIPHostRejected(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
- require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
-
- svc := NewNotificationService(db, nil)
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "https://203.0.113.10/api/webhooks/123456/token_abc",
- Template: "custom",
- Config: `{"content": {{toJSON .Message}}, "username": "Charon"}`,
- }
-
- data := map[string]any{
- "Message": "Test notification",
- "Title": "Test",
- "Time": time.Now().Format(time.RFC3339),
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "invalid Discord webhook URL")
- assert.Contains(t, err.Error(), "IP address hosts are not allowed")
-}
-
func TestValidateDiscordWebhookURL_AcceptsDiscordHostname(t *testing.T) {
err := validateDiscordWebhookURL("https://discord.com/api/webhooks/123456/token_abc?wait=true")
assert.NoError(t, err)
@@ -84,371 +53,24 @@ func TestValidateDiscordProviderURL_NonDiscordUnchanged(t *testing.T) {
assert.NoError(t, err)
}
-func TestSendJSONPayload_UsesStoredHostnameURLWithoutHostMutation(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- // Mock Discord validation to allow test server URLs
- origValidateDiscordFunc := validateDiscordProviderURLFunc
- defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
-
- var observedURLHost string
- var observedRequestHost string
- originalDo := webhookDoRequestFunc
- defer func() { webhookDoRequestFunc = originalDo }()
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- observedURLHost = req.URL.Host
- observedRequestHost = req.Host
- return client.Do(req) //nolint:gosec // G704: test uses a controlled mock server URL
- }
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- parsedServerURL, err := url.Parse(server.URL)
- require.NoError(t, err)
- parsedServerURL.Host = "localhost:" + parsedServerURL.Port()
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: parsedServerURL.String(),
- Template: "minimal",
- }
-
- data := map[string]any{
- "Message": "Test notification",
- "Title": "Test",
- "Time": time.Now().Format(time.RFC3339),
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- require.NoError(t, err)
-
- assert.Equal(t, "localhost:"+parsedServerURL.Port(), observedURLHost)
- assert.Equal(t, observedURLHost, observedRequestHost)
-}
-
-func TestSendJSONPayload_Discord(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, "POST", r.Method)
- assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
-
- var payload map[string]any
- err := json.NewDecoder(r.Body).Decode(&payload)
- require.NoError(t, err)
-
- // Discord webhook should have 'content' or 'embeds'
- assert.True(t, payload["content"] != nil || payload["embeds"] != nil, "Discord payload should have content or embeds")
-
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- // Mock Discord validation to allow test server URL
- origValidateDiscordFunc := validateDiscordProviderURLFunc
- defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
-
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
- require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
-
- svc := NewNotificationService(db, nil)
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: server.URL,
- Template: "custom",
- Config: `{"content": {{toJSON .Message}}, "username": "Charon"}`,
- }
-
- data := map[string]any{
- "Message": "Test notification",
- "Title": "Test",
- "Time": time.Now().Format(time.RFC3339),
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.NoError(t, err)
-}
-
-func TestSendJSONPayload_Slack(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var payload map[string]any
- err := json.NewDecoder(r.Body).Decode(&payload)
- require.NoError(t, err)
-
- // Slack webhook should have 'text' or 'blocks'
- assert.True(t, payload["text"] != nil || payload["blocks"] != nil, "Slack payload should have text or blocks")
-
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil }))
-
- provider := models.NotificationProvider{
- Type: "slack",
- URL: "#test",
- Token: server.URL,
- Template: "custom",
- Config: `{"text": {{toJSON .Message}}}`,
- }
-
- data := map[string]any{
- "Message": "Test notification",
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.NoError(t, err)
-}
-
-func TestSendJSONPayload_Gotify(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var payload map[string]any
- err := json.NewDecoder(r.Body).Decode(&payload)
- require.NoError(t, err)
-
- // Gotify webhook should have 'message'
- assert.NotNil(t, payload["message"], "Gotify payload should have message field")
-
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- provider := models.NotificationProvider{
- Type: "gotify",
- URL: server.URL,
- Token: "test-token",
- Template: "custom",
- Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`,
- }
-
- data := map[string]any{
- "Message": "Test notification",
- "Title": "Test",
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.NoError(t, err)
-}
-
-func TestSendJSONPayload_TemplateTimeout(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- // Mock Discord validation to allow private IP check to run
- origValidateDiscordFunc := validateDiscordProviderURLFunc
- defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
-
- // Create a template that would take too long to execute
- // This is simulated by having a large number of iterations
- // Use a private IP (10.x) which is blocked by SSRF protection to trigger an error
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "http://10.0.0.1:9999",
- Template: "custom",
- Config: `{"content": {{toJSON .Message}}, "data": {{toJSON .}}}`,
- }
-
- // Create data that will be processed
- data := map[string]any{
- "Message": "Test",
- }
-
- // This should complete quickly, but test the timeout mechanism exists
- ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
- defer cancel()
-
- err = svc.sendJSONPayload(ctx, provider, data)
- // The private IP is blocked by SSRF protection
- // We're mainly testing that the validation and timeout mechanisms are in place
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "private ip addresses is blocked")
-}
-
-func TestSendJSONPayload_TemplateSizeLimit(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- // Create a template larger than 10KB
- largeTemplate := strings.Repeat("x", 11*1024)
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "http://localhost:9999",
- Template: "custom",
- Config: largeTemplate,
- }
-
- data := map[string]any{
- "Message": "Test",
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "template size exceeds maximum limit")
-}
-
-func TestSendJSONPayload_DiscordValidation(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "https://203.0.113.10/api/webhooks/123456/token_abc",
- Template: "custom",
- Config: `{"username": "Charon", "message": {{toJSON .Message}}}`,
- }
-
- data := map[string]any{
- "Message": "Test",
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "invalid Discord webhook URL")
- assert.Contains(t, err.Error(), "IP address hosts are not allowed")
-}
-
-func TestSendJSONPayload_DiscordValidation_MissingMessage(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "https://discord.com/api/webhooks/123456/token_abc",
- Template: "custom",
- Config: `{"username": "Charon"}`,
- }
-
- data := map[string]any{}
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "discord payload requires 'content' or 'embeds'")
-}
-
-func TestSendJSONPayload_SlackValidation(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- // Slack payload without text or blocks should fail
- provider := models.NotificationProvider{
- Type: "slack",
- URL: "http://localhost:9999",
- Template: "custom",
- Config: `{"username": "Charon"}`,
- }
-
- data := map[string]any{
- "Message": "Test",
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "slack payload requires 'text' or 'blocks'")
-}
-
-func TestSendJSONPayload_GotifyValidation(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- // Gotify payload without message should fail
- provider := models.NotificationProvider{
- Type: "gotify",
- URL: "http://localhost:9999",
- Template: "custom",
- Config: `{"title": "Test"}`,
- }
-
- data := map[string]any{
- "Message": "Test",
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "gotify payload requires 'message'")
-}
-
-func TestSendJSONPayload_InvalidJSON(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "http://localhost:9999",
- Template: "custom",
- Config: `{invalid json}`,
- }
-
- data := map[string]any{
- "Message": "Test",
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
-}
-
-func TestNormalizeURL_DiscordWebhook_ConvertsToDiscordScheme(t *testing.T) {
- got := normalizeURL("discord", "https://discord.com/api/webhooks/123/abcDEF_123")
- assert.Equal(t, "discord://abcDEF_123@123", got)
-
- got2 := normalizeURL("discord", "https://discordapp.com/api/webhooks/456/xyz")
- assert.Equal(t, "discord://xyz@456", got2)
-}
-
+// TestSendExternal_UsesJSONForSupportedServices exercises Discord dispatch
+// after its cutover to the extracted notify module (buildNotifySender).
+// Discord's own webhook validation (providers/discord.ValidateWebhookURL)
+// only accepts discord.com/canary.discord.com hosts, so an httptest.Server
+// URL (as used before cutover) can no longer stand in for a Discord
+// webhook — the test instead injects a capturing fake RoundTripper via
+// WithNotifyTransportWrapper, matching the pattern
+// notify_provider_adapter_test.go uses to test buildNotifySender directly.
func TestSendExternal_UsesJSONForSupportedServices(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
- var called atomic.Bool
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- called.Store(true)
- var payload map[string]any
- _ = json.NewDecoder(r.Body).Decode(&payload)
- assert.NotNil(t, payload["content"])
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- // Mock Discord validation to allow test server URL
- origValidateDiscordFunc := validateDiscordProviderURLFunc
- defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
+ wrapper, rt := newCapturingWrapper()
provider := models.NotificationProvider{
Type: "discord",
- URL: server.URL,
+ URL: "https://discord.com/api/webhooks/123456789/notify-json-token",
Template: "custom",
Config: `{"content": {{toJSON .Message}}}`,
Enabled: true,
@@ -456,301 +78,45 @@ func TestSendExternal_UsesJSONForSupportedServices(t *testing.T) {
}
db.Create(&provider)
- svc := NewNotificationService(db, nil)
+ svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper))
svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil)
- // Give goroutine time to execute
- time.Sleep(100 * time.Millisecond)
- assert.True(t, called.Load(), "notification should have been sent via JSON")
+ require.Eventually(t, func() bool {
+ _, body := rt.last()
+ return body != nil
+ }, time.Second, 10*time.Millisecond, "notification should have been sent via JSON")
+
+ _, body := rt.last()
+ var payload map[string]any
+ require.NoError(t, json.Unmarshal(body, &payload))
+ assert.NotNil(t, payload["content"])
}
+// TestTestProvider_UsesJSONForSupportedServices is the TestProvider
+// (test-send) counterpart of TestSendExternal_UsesJSONForSupportedServices
+// — see its comment for why a capturing fake RoundTripper replaces the old
+// httptest.Server + validateDiscordProviderURLFunc override.
func TestTestProvider_UsesJSONForSupportedServices(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var payload map[string]any
- err := json.NewDecoder(r.Body).Decode(&payload)
- require.NoError(t, err)
- assert.NotNil(t, payload["content"])
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- // Mock Discord validation to allow test server URL
- origValidateDiscordFunc := validateDiscordProviderURLFunc
- origWebhookDoReq := webhookDoRequestFunc
- defer func() {
- validateDiscordProviderURLFunc = origValidateDiscordFunc
- webhookDoRequestFunc = origWebhookDoReq
- }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- return client.Do(req) //nolint:gosec // G704: test-controlled httptest server, not user input
- }
+ wrapper, rt := newCapturingWrapper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
- svc := NewNotificationService(db, nil)
+ svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper))
provider := models.NotificationProvider{
Type: "discord",
- URL: server.URL,
+ URL: "https://discord.com/api/webhooks/123456789/notify-json-test-token",
Template: "custom",
Config: `{"content": {{toJSON .Message}}}`,
}
err = svc.TestProvider(provider)
assert.NoError(t, err)
-}
-
-func TestSendJSONPayload_Telegram_ValidPayload(t *testing.T) {
- var capturedPayload map[string]any
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- err := json.NewDecoder(r.Body).Decode(&capturedPayload)
- require.NoError(t, err)
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"ok":true}`))
- }))
- defer server.Close()
-
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
- svc.telegramAPIBaseURL = server.URL
-
- provider := models.NotificationProvider{ //nolint:gosec // G101: test credential
- Type: "telegram",
- URL: "123456789",
- Token: "bot-test-token",
- Template: "custom",
- Config: `{"text": {{toJSON .Message}}, "title": {{toJSON .Title}}}`,
- }
-
- data := map[string]any{
- "Message": "Test notification",
- "Title": "Test",
- }
-
- sendErr := svc.sendJSONPayload(context.Background(), provider, data)
- require.NoError(t, sendErr)
- assert.NotNil(t, capturedPayload["text"], "Telegram payload should have text field")
- assert.NotNil(t, capturedPayload["chat_id"], "Telegram payload should have chat_id field")
-}
-
-func TestSendJSONPayload_Telegram_AutoMapMessageToText(t *testing.T) {
- var capturedPayload map[string]any
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _ = json.NewDecoder(r.Body).Decode(&capturedPayload)
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"ok":true}`))
- }))
- defer server.Close()
-
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
- svc.telegramAPIBaseURL = server.URL
-
- provider := models.NotificationProvider{ //nolint:gosec // G101: test credential
- Type: "telegram",
- URL: "123456789",
- Token: "bot-test-token",
- Template: "custom",
- Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`,
- }
-
- data := map[string]any{
- "Message": "Test notification",
- "Title": "Test",
- }
-
- sendErr := svc.sendJSONPayload(context.Background(), provider, data)
- // 'message' must be auto-mapped to 'text' — dispatch must succeed.
- require.NoError(t, sendErr)
- assert.Equal(t, "Test notification", capturedPayload["text"], "'message' should be auto-mapped to 'text'")
-}
-
-func TestSendJSONPayload_Telegram_MissingTextAndMessage(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- provider := models.NotificationProvider{ //nolint:gosec // G101: test credential
- Type: "telegram",
- URL: "123456789",
- Token: "bot-test-token",
- Template: "custom",
- Config: `{"title": {{toJSON .Title}}}`,
- }
-
- data := map[string]any{
- "Title": "Test",
- }
-
- sendErr := svc.sendJSONPayload(context.Background(), provider, data)
- require.Error(t, sendErr)
- assert.Contains(t, sendErr.Error(), "telegram payload requires 'text' field")
-}
-
-func TestSendJSONPayload_Telegram_SSRFValidation(t *testing.T) {
- var capturedPath string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- capturedPath = r.URL.Path
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"ok":true}`))
- }))
- defer server.Close()
-
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
- svc.telegramAPIBaseURL = server.URL
-
- // Path traversal in token: Go's net/http transport cleans the URL path,
- // so "/../../../evil.com/x" does not escape the server host.
- provider := models.NotificationProvider{ //nolint:gosec // G101: test credential
- Type: "telegram",
- URL: "123456789",
- Token: "test-token/../../../evil.com/x",
- Template: "custom",
- Config: `{"text": {{toJSON .Message}}}`,
- }
-
- data := map[string]any{
- "Message": "Test",
- }
-
- sendErr := svc.sendJSONPayload(context.Background(), provider, data)
- // Dispatch must succeed (no validation error) — the path traversal in the
- // token cannot redirect the request to a different host. The request was
- // received by our local server, not by evil.com.
- require.NoError(t, sendErr)
- // capturedPath is non-empty only if our server handled the request.
- assert.NotEmpty(t, capturedPath, "request must have been served by the local test server, not redirected to evil.com")
-}
-
-func TestSendJSONPayload_Telegram_401ErrorMessage(t *testing.T) {
- // Use a webhook provider with a mock server returning 401 to verify
- // that the dispatch path surfaces "provider returned status 401" in the error.
- // Telegram cannot be tested this way because its SSRF check requires api.telegram.org.
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- }))
- defer server.Close()
-
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- provider := models.NotificationProvider{
- Type: "webhook",
- URL: server.URL,
- Template: "custom",
- Config: `{"message": {{toJSON .Message}}}`,
- }
-
- data := map[string]any{
- "Message": "Test notification",
- }
-
- sendErr := svc.sendJSONPayload(context.Background(), provider, data)
- require.Error(t, sendErr)
- assert.Contains(t, sendErr.Error(), "provider returned status 401")
-}
-
-func TestSendJSONPayload_Ntfy_Valid(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, "POST", r.Method)
- assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
- assert.Empty(t, r.Header.Get("Authorization"), "no auth header when token is empty")
-
- var payload map[string]any
- err := json.NewDecoder(r.Body).Decode(&payload)
- require.NoError(t, err)
- assert.NotNil(t, payload["message"], "ntfy payload should have message field")
-
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- provider := models.NotificationProvider{
- Type: "ntfy",
- URL: server.URL,
- Template: "custom",
- Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`,
- }
-
- data := map[string]any{
- "Message": "Test notification",
- "Title": "Test",
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.NoError(t, err)
-}
-
-func TestSendJSONPayload_Ntfy_WithToken(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, "Bearer tk_test123", r.Header.Get("Authorization"))
-
- var payload map[string]any
- err := json.NewDecoder(r.Body).Decode(&payload)
- require.NoError(t, err)
- assert.NotNil(t, payload["message"])
-
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- provider := models.NotificationProvider{
- Type: "ntfy",
- URL: server.URL,
- Token: "tk_test123",
- Template: "custom",
- Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`,
- }
-
- data := map[string]any{
- "Message": "Test notification",
- "Title": "Test",
- }
-
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.NoError(t, err)
-}
-
-func TestSendJSONPayload_Ntfy_MissingMessage(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
- require.NoError(t, err)
-
- svc := NewNotificationService(db, nil)
-
- provider := models.NotificationProvider{
- Type: "ntfy",
- URL: "http://localhost:9999",
- Template: "custom",
- Config: `{"title": "Test"}`,
- }
-
- data := map[string]any{
- "Message": "Test",
- }
- err = svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "ntfy payload must include a 'message' field")
+ _, body := rt.last()
+ require.NotNil(t, body)
+ var payload map[string]any
+ require.NoError(t, json.Unmarshal(body, &payload))
+ assert.NotNil(t, payload["content"])
}
diff --git a/backend/internal/services/notification_service_registry_consistency_test.go b/backend/internal/services/notification_service_registry_consistency_test.go
new file mode 100644
index 000000000..3ba76d8fd
--- /dev/null
+++ b/backend/internal/services/notification_service_registry_consistency_test.go
@@ -0,0 +1,47 @@
+package services
+
+import (
+ "testing"
+
+ notify "github.com/Wikid82/go_notify_yourself"
+)
+
+// TestSupportedProviderAllowlistIsSubsetOfRegisteredTypes is a
+// build-configuration drift guard, not a runtime assertion (per
+// docs/plans/notify_provider_registry_spec.md §3.6.2/§3.10): it asserts
+// that every provider type isSupportedNotificationProviderType claims to
+// support is actually registered in the go_notify_yourself registry given
+// Charon's hand-picked blank imports (notify_providers_import.go).
+//
+// This deliberately does NOT run in the other direction — the registry may
+// (and, per notify_providers_import.go's doc comment, currently does not)
+// contain types Charon's allowlist doesn't yet expose; that's the expected,
+// intentional state per §3.6.2 Option A (Charon curates its own supported
+// surface independently of what's merely constructible). If this test ever
+// fails, it means Charon's allowlist claims to support a provider type
+// whose package was never blank-imported for registration — a drift bug to
+// fix by adding the missing import, not by touching
+// isSupportedNotificationProviderType itself.
+func TestSupportedProviderAllowlistIsSubsetOfRegisteredTypes(t *testing.T) {
+ registered := make(map[string]struct{}, len(notify.RegisteredTypes()))
+ for _, name := range notify.RegisteredTypes() {
+ registered[name] = struct{}{}
+ }
+
+ // Mirrors isSupportedNotificationProviderType's exact case list
+ // (notification_service.go) — kept as a literal list here rather than
+ // derived from the function itself, since that switch has no
+ // enumerable form to introspect.
+ supportedTypes := []string{"discord", "email", "gotify", "webhook", "telegram", "slack", "pushover", "ntfy"}
+
+ for _, providerType := range supportedTypes {
+ if !isSupportedNotificationProviderType(providerType) {
+ t.Fatalf("test bug: %q is not actually in isSupportedNotificationProviderType's allowlist", providerType)
+ }
+ if _, ok := registered[providerType]; !ok {
+ t.Errorf("provider type %q is in isSupportedNotificationProviderType's allowlist but is not registered "+
+ "in the notify registry (registered types: %v) — a package that registers it under notify.Register "+
+ "is missing a blank import in notify_providers_import.go", providerType, notify.RegisteredTypes())
+ }
+ }
+}
diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go
index 89a085e7e..6ce15513e 100644
--- a/backend/internal/services/notification_service_test.go
+++ b/backend/internal/services/notification_service_test.go
@@ -5,21 +5,19 @@ import (
"encoding/json"
"fmt"
"io"
- "net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
- "strings"
"sync"
"sync/atomic"
"testing"
"time"
+ "github.com/Wikid82/go_notify_yourself/transport"
+
"github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/notifications"
"github.com/Wikid82/charon/backend/internal/security"
- "github.com/Wikid82/charon/backend/internal/trace"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
@@ -128,65 +126,50 @@ func TestNotificationService_Providers(t *testing.T) {
assert.Len(t, list, 0)
}
+// TestNotificationService_TestProvider_Webhook (despite its name, this
+// exercises a Discord provider) verifies TestProvider dispatch after
+// Discord's cutover to the extracted notify module (buildNotifySender).
+// Discord's own webhook validation only accepts discord.com/
+// canary.discord.com hosts, so it can no longer be pointed at an
+// httptest.Server the way pre-cutover tests could — a capturing fake
+// RoundTripper (via WithNotifyTransportWrapper) stands in instead, mirroring
+// notify_provider_adapter_test.go's pattern for testing buildNotifySender.
func TestNotificationService_TestProvider_Webhook(t *testing.T) {
db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- // Mock validation and webhook request for testing
- origValidateDiscordFunc := validateDiscordProviderURLFunc
- origWebhookDoReq := webhookDoRequestFunc
- defer func() {
- validateDiscordProviderURLFunc = origValidateDiscordFunc
- webhookDoRequestFunc = origWebhookDoReq
- }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil
- }
-
- // Start a test server
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var body map[string]any
- _ = json.NewDecoder(r.Body).Decode(&body)
- // Minimal template uses lowercase keys: title, message
- assert.Equal(t, "Test Notification", body["title"])
- w.WriteHeader(http.StatusOK)
- }))
- defer ts.Close()
+ wrapper, rt := newCapturingWrapper()
+ svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper))
provider := models.NotificationProvider{
Name: "Test Discord",
Type: "discord",
- URL: ts.URL,
+ URL: "https://discord.com/api/webhooks/123456789/webhook-test-token",
Template: "minimal",
- Config: `{"Header": "{{.Title}}"}`,
}
err := svc.TestProvider(provider)
require.NoError(t, err)
+
+ _, body := rt.last()
+ require.NotNil(t, body)
+ var payload map[string]any
+ require.NoError(t, json.Unmarshal(body, &payload))
+ // Minimal template uses lowercase keys: title, message
+ assert.Equal(t, "Test Notification", payload["title"])
}
+// TestNotificationService_SendExternal exercises SendExternal's async
+// Discord dispatch after cutover — see
+// TestNotificationService_TestProvider_Webhook's comment for why a
+// capturing fake RoundTripper replaces the old httptest.Server.
func TestNotificationService_SendExternal(t *testing.T) {
db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- received := make(chan struct{})
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- close(received)
- w.WriteHeader(http.StatusOK)
- }))
- defer ts.Close()
-
- // Mock discord webhook validation to allow test server URLs
- // Do NOT mock webhookDoRequestFunc - we want real HTTP call to test server
- origValidateDiscordFunc := validateDiscordProviderURLFunc
- defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
+ wrapper, rt := newCapturingWrapper()
+ svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper))
provider := models.NotificationProvider{
Name: "Test Discord",
Type: "discord",
- URL: ts.URL,
+ URL: "https://discord.com/api/webhooks/123456789/send-external-token",
Enabled: true,
NotifyProxyHosts: true,
Template: "minimal",
@@ -195,94 +178,81 @@ func TestNotificationService_SendExternal(t *testing.T) {
svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil)
- select {
- case <-received:
- // Success
- case <-time.After(1 * time.Second):
- t.Fatal("Timed out waiting for webhook")
- }
+ require.Eventually(t, func() bool {
+ _, body := rt.last()
+ return body != nil
+ }, time.Second, 10*time.Millisecond, "Timed out waiting for webhook")
}
+// TestNotificationService_SendExternal_MinimalVsDetailedTemplates verifies
+// both built-in templates render correctly for a cut-over Discord provider.
+// Each phase uses its own capturing wrapper/service instance, and the
+// minimal-template provider is deleted before the detailed phase runs, so
+// SendExternal's per-provider fan-out never dispatches both providers to
+// the same capturing wrapper at once (which would race the "last request"
+// assertions below).
func TestNotificationService_SendExternal_MinimalVsDetailedTemplates(t *testing.T) {
db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- // Mock validation only - allow real HTTP calls to test servers
- origValidateDiscordFunc := validateDiscordProviderURLFunc
- defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
- // Minimal template
- rcvMinimal := make(chan map[string]any, 1)
- tsMin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var body map[string]any
- _ = json.NewDecoder(r.Body).Decode(&body)
- rcvMinimal <- body
- w.WriteHeader(http.StatusOK)
- }))
- defer tsMin.Close()
+ // Minimal template phase
+ wrapperMin, rtMin := newCapturingWrapper()
+ svcMin := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapperMin))
providerMin := models.NotificationProvider{
Name: "Minimal Discord",
Type: "discord",
- URL: tsMin.URL,
+ URL: "https://discord.com/api/webhooks/1/minimal-token",
Enabled: true,
NotifyUptime: true,
Template: "minimal",
}
- _ = svc.CreateProvider(&providerMin)
+ require.NoError(t, svcMin.CreateProvider(&providerMin))
data := map[string]any{"Title": "Min Title", "Message": "Min Message", "Time": time.Now().Format(time.RFC3339), "EventType": "uptime"}
- svc.SendExternal(context.Background(), "uptime", "Min Title", "Min Message", data)
+ svcMin.SendExternal(context.Background(), "uptime", "Min Title", "Min Message", data)
- select {
- case body := <-rcvMinimal:
- // minimal template should contain 'title' and 'message' keys
- if title, ok := body["title"].(string); ok {
- assert.Equal(t, "Min Title", title)
- } else {
- t.Fatalf("expected title in minimal body")
- }
- case <-time.After(500 * time.Millisecond):
- t.Fatal("Timeout waiting for minimal webhook")
- }
+ require.Eventually(t, func() bool {
+ _, body := rtMin.last()
+ return body != nil
+ }, 500*time.Millisecond, 10*time.Millisecond, "Timeout waiting for minimal webhook")
- // Detailed template
- rcvDetailed := make(chan map[string]any, 1)
- tsDet := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var body map[string]any
- _ = json.NewDecoder(r.Body).Decode(&body)
- rcvDetailed <- body
- w.WriteHeader(http.StatusOK)
- }))
- defer tsDet.Close()
+ _, minBody := rtMin.last()
+ var minPayload map[string]any
+ require.NoError(t, json.Unmarshal(minBody, &minPayload))
+ // minimal template should contain 'title' and 'message' keys
+ assert.Equal(t, "Min Title", minPayload["title"])
+
+ require.NoError(t, svcMin.DeleteProvider(providerMin.ID))
+
+ // Detailed template phase
+ wrapperDet, rtDet := newCapturingWrapper()
+ svcDet := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapperDet))
providerDet := models.NotificationProvider{
Name: "Detailed Discord",
Type: "discord",
- URL: tsDet.URL,
+ URL: "https://discord.com/api/webhooks/2/detailed-token",
Enabled: true,
NotifyUptime: true,
Template: "detailed",
}
- _ = svc.CreateProvider(&providerDet)
+ require.NoError(t, svcDet.CreateProvider(&providerDet))
dataDet := map[string]any{"Title": "Det Title", "Message": "Det Message", "Time": time.Now().Format(time.RFC3339), "EventType": "uptime", "HostName": "example-host", "HostIP": "1.2.3.4", "ServiceCount": 1, "Services": []map[string]any{{"Name": "svc1"}}}
- svc.SendExternal(context.Background(), "uptime", "Det Title", "Det Message", dataDet)
+ svcDet.SendExternal(context.Background(), "uptime", "Det Title", "Det Message", dataDet)
- select {
- case body := <-rcvDetailed:
- // detailed template should contain 'host' and 'services'
- if host, ok := body["host"].(string); ok {
- assert.Equal(t, "example-host", host)
- } else {
- t.Fatalf("expected host in detailed body")
- }
- if _, ok := body["services"]; !ok {
- t.Fatalf("expected services in detailed body")
- }
- case <-time.After(500 * time.Millisecond):
- t.Fatal("Timeout waiting for detailed webhook")
+ require.Eventually(t, func() bool {
+ _, body := rtDet.last()
+ return body != nil
+ }, 500*time.Millisecond, 10*time.Millisecond, "Timeout waiting for detailed webhook")
+
+ _, detBody := rtDet.last()
+ var detPayload map[string]any
+ require.NoError(t, json.Unmarshal(detBody, &detPayload))
+ // detailed template should contain 'host' and 'services'
+ assert.Equal(t, "example-host", detPayload["host"])
+ if _, ok := detPayload["services"]; !ok {
+ t.Fatalf("expected services in detailed body")
}
}
@@ -318,181 +288,6 @@ func TestNotificationService_SendExternal_Filtered(t *testing.T) {
}
}
-func TestNormalizeURL(t *testing.T) {
- tests := []struct {
- name string
- serviceType string
- rawURL string
- expected string
- }{
- {
- name: "Discord HTTPS",
- serviceType: "discord",
- rawURL: "https://discord.com/api/webhooks/123456789/abcdefg",
- expected: "discord://abcdefg@123456789",
- },
- {
- name: "Discord HTTPS with app",
- serviceType: "discord",
- rawURL: "https://discordapp.com/api/webhooks/123456789/abcdefg",
- expected: "discord://abcdefg@123456789",
- },
- {
- name: "Discord Generic",
- serviceType: "discord",
- rawURL: "discord://token@id",
- expected: "discord://token@id",
- },
- {
- name: "Other Service",
- serviceType: "slack",
- rawURL: "https://hooks.slack.com/services/...",
- expected: "https://hooks.slack.com/services/...",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := normalizeURL(tt.serviceType, tt.rawURL)
- assert.Equal(t, tt.expected, result)
- })
- }
-}
-
-func TestNotificationService_SendCustomWebhook_Errors(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- t.Run("invalid URL", func(t *testing.T) {
- provider := models.NotificationProvider{
- Type: "webhook",
- URL: "://invalid-url",
- }
- data := map[string]any{"Title": "Test", "Message": "Test Message"}
- err := svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
- })
-
- t.Run("unreachable host", func(t *testing.T) {
- provider := models.NotificationProvider{
- Type: "webhook",
- URL: "http://192.0.2.1:9999", // TEST-NET-1, unreachable
- }
- data := map[string]any{"Title": "Test", "Message": "Test Message"}
- // Set short timeout for client if possible, but here we just expect error
- // Note: http.Client default timeout is 0 (no timeout), but OS might timeout
- // We can't easily change client timeout here without modifying service
- // So we might skip this or just check if it returns error eventually
- // But for unit test speed, we should probably mock or use a closed port on localhost
- // Using a closed port on localhost is faster
- provider.URL = "http://127.0.0.1:54321" // Assuming this port is closed
- err := svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
- })
-
- t.Run("server returns error", func(t *testing.T) {
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- }))
- defer ts.Close()
-
- provider := models.NotificationProvider{
- Type: "webhook",
- URL: ts.URL,
- }
- data := map[string]any{"Title": "Test", "Message": "Test Message"}
- err := svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "500")
- })
-
- t.Run("valid custom payload template", func(t *testing.T) {
- receivedBody := ""
- received := make(chan struct{})
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var body map[string]any
- _ = json.NewDecoder(r.Body).Decode(&body)
- if custom, ok := body["custom"]; ok {
- receivedBody = custom.(string)
- }
- w.WriteHeader(http.StatusOK)
- close(received)
- }))
- defer ts.Close()
-
- provider := models.NotificationProvider{
- Type: "webhook",
- URL: ts.URL,
- Config: `{"custom": "Test: {{.Title}}"}`,
- }
- data := map[string]any{"Title": "My Title", "Message": "Test Message"}
- _ = svc.sendJSONPayload(context.Background(), provider, data)
-
- select {
- case <-received:
- assert.Equal(t, "Test: My Title", receivedBody)
- case <-time.After(500 * time.Millisecond):
- t.Fatal("Timeout waiting for webhook")
- }
- })
-
- t.Run("default payload without template", func(t *testing.T) {
- receivedContent := ""
- received := make(chan struct{})
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var body map[string]any
- _ = json.NewDecoder(r.Body).Decode(&body)
- if title, ok := body["title"]; ok {
- receivedContent = title.(string)
- }
- w.WriteHeader(http.StatusOK)
- close(received)
- }))
- defer ts.Close()
-
- provider := models.NotificationProvider{
- Type: "webhook",
- URL: ts.URL,
- // Config is empty, so default template is used: minimal
- }
- data := map[string]any{"Title": "Default Title", "Message": "Test Message"}
- _ = svc.sendJSONPayload(context.Background(), provider, data)
-
- select {
- case <-received:
- assert.Equal(t, "Default Title", receivedContent)
- case <-time.After(500 * time.Millisecond):
- t.Fatal("Timeout waiting for webhook")
- }
- })
-}
-
-func TestNotificationService_SendCustomWebhook_PropagatesRequestID(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- received := make(chan string, 1)
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- received <- r.Header.Get("X-Request-ID")
- w.WriteHeader(http.StatusOK)
- }))
- defer ts.Close()
-
- provider := models.NotificationProvider{Type: "webhook", URL: ts.URL}
- data := map[string]any{"Title": "Test", "Message": "Test"}
- // Build context with requestID value
- ctx := context.WithValue(context.Background(), trace.RequestIDKey, "my-rid")
- err := svc.sendJSONPayload(ctx, provider, data)
- require.NoError(t, err)
-
- select {
- case rid := <-received:
- assert.Equal(t, "my-rid", rid)
- case <-time.After(500 * time.Millisecond):
- t.Fatal("Timed out waiting for webhook request")
- }
-}
-
func TestNotificationService_TestProvider_Errors(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db, nil)
@@ -529,27 +324,17 @@ func TestNotificationService_TestProvider_Errors(t *testing.T) {
})
t.Run("webhook success", func(t *testing.T) {
- // Mock validation and webhook request for testing
- origValidateDiscordFunc := validateDiscordProviderURLFunc
- origWebhookDoReq := webhookDoRequestFunc
- defer func() {
- validateDiscordProviderURLFunc = origValidateDiscordFunc
- webhookDoRequestFunc = origWebhookDoReq
- }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil
- }
-
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer ts.Close()
+ // Discord's own webhook validation only accepts discord.com/
+ // canary.discord.com hosts (see TestNotificationService_SendExternal's
+ // comment), so a capturing fake RoundTripper stands in for the old
+ // httptest.Server here.
+ wrapper, _ := newCapturingWrapper()
+ svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper))
provider := models.NotificationProvider{
Type: "discord",
- URL: ts.URL,
- Template: "minimal", // Use JSON template path which supports HTTP/HTTPS
+ URL: "https://discord.com/api/webhooks/1/webhook-success-token",
+ Template: "minimal",
}
err := svc.TestProvider(provider)
assert.NoError(t, err)
@@ -619,48 +404,6 @@ func TestSSRF_URLValidation_ComprehensiveBlocking(t *testing.T) {
}
}
-func TestSSRF_WebhookIntegration(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- t.Run("blocks private IP webhook", func(t *testing.T) {
- provider := models.NotificationProvider{
- Type: "webhook",
- URL: "http://10.0.0.1/webhook",
- }
- data := map[string]any{"Title": "Test", "Message": "Test Message"}
- err := svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "destination URL validation failed")
- })
-
- t.Run("blocks cloud metadata endpoint", func(t *testing.T) {
- provider := models.NotificationProvider{
- Type: "webhook",
- URL: "http://169.254.169.254/latest/meta-data/",
- }
- data := map[string]any{"Title": "Test", "Message": "Test Message"}
- err := svc.sendJSONPayload(context.Background(), provider, data)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "destination URL validation failed")
- })
-
- t.Run("allows localhost for testing", func(t *testing.T) {
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer ts.Close()
-
- provider := models.NotificationProvider{
- Type: "webhook",
- URL: ts.URL,
- }
- data := map[string]any{"Title": "Test", "Message": "Test Message"}
- err := svc.sendJSONPayload(context.Background(), provider, data)
- assert.NoError(t, err)
- })
-}
-
func TestNotificationService_SendExternal_EdgeCases(t *testing.T) {
t.Run("no enabled providers", func(t *testing.T) {
db := setupNotificationTestDB(t)
@@ -724,45 +467,52 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) {
time.Sleep(50 * time.Millisecond)
})
+ // TestNotificationService_SendExternal_EdgeCases/custom_data_passed_to_webhook
+ // covers a cut-over Discord provider configured with the "detailed"
+ // template, verifying that SendExternal's HostName extra (passed in the
+ // `data` map, same as before cutover) still reaches the rendered
+ // payload via legacyDetailedTemplate's backward-compat translation
+ // (notify_provider_adapter.go). Note a scope change from the
+ // pre-cutover version of this test: sendJSONPayload's old flat data map
+ // exposed ANY caller-supplied key (e.g. an arbitrary "CustomField") to
+ // a *custom* template at its top level. The extracted notify module's
+ // render.TemplateData only exposes Title/Message/Time/EventType/Data,
+ // and dispatchViaNotify (notification_service.go) only populates Data
+ // with the four documented keys — a "custom" template referencing
+ // {{index .Data "HostName"}} would additionally fail CreateProvider's
+ // preview-validation step until the webhook commit (§6 commit 9)
+ // updates RenderTemplate's call sites to the new preview payload shape
+ // — so this test exercises the documented Data contract via the
+ // "detailed" template instead, which bypasses that preview validation.
t.Run("custom data passed to webhook", func(t *testing.T) {
db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- // Mock validation only - allow real HTTP calls to test server
- origValidateDiscordFunc := validateDiscordProviderURLFunc
- defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
-
- var receivedCustom atomic.Value
- receivedCustom.Store("")
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var body map[string]any
- _ = json.NewDecoder(r.Body).Decode(&body)
- if custom, ok := body["custom"]; ok {
- receivedCustom.Store(custom.(string))
- }
- w.WriteHeader(http.StatusOK)
- }))
- defer ts.Close()
+ wrapper, rt := newCapturingWrapper()
+ svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper))
provider := models.NotificationProvider{
Name: "Custom Data Discord",
Type: "discord",
- URL: ts.URL,
+ URL: "https://discord.com/api/webhooks/1/custom-data-token",
Enabled: true,
NotifyProxyHosts: true,
- Config: `{"content": {{toJSON .Message}}, "custom": "{{.CustomField}}"}`,
- Template: "custom", // Use custom template to enable Config
+ Template: "detailed",
}
- _ = svc.CreateProvider(&provider)
+ require.NoError(t, svc.CreateProvider(&provider))
customData := map[string]any{
- "CustomField": "test-value",
+ "HostName": "test-value",
}
svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", customData)
- time.Sleep(100 * time.Millisecond)
- assert.Equal(t, "test-value", receivedCustom.Load().(string))
+ require.Eventually(t, func() bool {
+ _, body := rt.last()
+ return body != nil
+ }, time.Second, 10*time.Millisecond, "expected webhook to be sent")
+
+ _, body := rt.last()
+ var payload map[string]any
+ require.NoError(t, json.Unmarshal(body, &payload))
+ assert.Equal(t, "test-value", payload["host"])
})
}
@@ -828,40 +578,6 @@ func TestNotificationService_CreateProvider_Validation(t *testing.T) {
})
}
-func TestNotificationService_IsPrivateIP(t *testing.T) {
- tests := []struct {
- name string
- ipStr string
- isPrivate bool
- }{
- {"loopback ipv4", "127.0.0.1", true},
- {"loopback ipv6", "::1", true},
- {"private 10.x", "10.0.0.1", true},
- {"private 10.x high", "10.255.255.254", true},
- {"private 172.16-31", "172.16.0.1", true},
- {"private 172.31", "172.31.255.254", true},
- {"private 192.168", "192.168.1.1", true},
- {"public 172.32", "172.32.0.1", false},
- {"public 172.15", "172.15.0.1", false},
- {"public ip", "8.8.8.8", false},
- {"public ipv6", "2001:4860:4860::8888", false},
- {"link local ipv4", "169.254.1.1", true},
- {"link local ipv6", "fe80::1", true},
- {"unique local ipv6 fc", "fc00::1", true},
- {"unique local ipv6 fc high", "fc12:3456::1", true},
- {"unique local ipv6 fd", "fd00::1", true},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- ip := net.ParseIP(tt.ipStr)
- require.NotNil(t, ip, "failed to parse IP: %s", tt.ipStr)
- got := isPrivateIP(ip)
- assert.Equal(t, tt.isPrivate, got, "IP %s private check mismatch", tt.ipStr)
- })
- }
-}
-
func TestNotificationService_CreateProvider_InvalidCustomTemplate(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db, nil)
@@ -968,174 +684,6 @@ func TestRenderTemplate_InvalidJSONOutput(t *testing.T) {
assert.Nil(t, parsed)
}
-func TestSendCustomWebhook_HTTPStatusCodeErrors(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- errorCodes := []int{400, 404, 500, 502, 503}
-
- for _, statusCode := range errorCodes {
- t.Run(fmt.Sprintf("status_%d", statusCode), func(t *testing.T) {
- // Mock webhook HTTP client to return error status
- originalDo := webhookDoRequestFunc
- defer func() { webhookDoRequestFunc = originalDo }()
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- return &http.Response{
- StatusCode: statusCode,
- Body: http.NoBody,
- Header: make(http.Header),
- }, nil
- }
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "https://discord.com/api/webhooks/123456/test_token",
- Template: "minimal",
- }
-
- data := map[string]any{
- "Title": "Test",
- "Message": "Test",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := svc.sendJSONPayload(context.Background(), provider, data)
- require.Error(t, err)
- assert.Contains(t, err.Error(), fmt.Sprintf("%d", statusCode))
- })
- }
-}
-
-func TestSendCustomWebhook_TemplateSelection(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- tests := []struct {
- name string
- template string
- config string
- expectedKeys []string
- unexpectedKeys []string
- }{
- {
- name: "minimal template",
- template: "minimal",
- expectedKeys: []string{"title", "message", "time", "event"},
- },
- {
- name: "detailed template",
- template: "detailed",
- expectedKeys: []string{"title", "message", "time", "event", "host", "host_ip", "service_count", "services"},
- },
- {
- name: "custom template",
- template: "custom",
- config: `{"custom_key": "custom_value", "content": {{toJSON .Title}}}`,
- expectedKeys: []string{"custom_key", "content"},
- },
- {
- name: "empty template defaults to minimal",
- template: "",
- expectedKeys: []string{"title", "message", "time", "event"},
- },
- {
- name: "unknown template defaults to minimal",
- template: "unknown",
- expectedKeys: []string{"title", "message", "time", "event"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- var receivedBody map[string]any
-
- // Mock webhook HTTP client to capture request
- originalDo := webhookDoRequestFunc
- defer func() { webhookDoRequestFunc = originalDo }()
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- body, _ := io.ReadAll(req.Body)
- _ = json.Unmarshal(body, &receivedBody)
- return &http.Response{
- StatusCode: http.StatusOK,
- Body: http.NoBody,
- Header: make(http.Header),
- }, nil
- }
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "https://discord.com/api/webhooks/123456/test_token",
- Template: tt.template,
- Config: tt.config,
- }
-
- data := map[string]any{
- "Title": "Test Title",
- "Message": "Test Message",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- "HostName": "testhost",
- "HostIP": "192.168.1.1",
- "ServiceCount": 3,
- "Services": []string{"svc1", "svc2"},
- }
-
- err := svc.sendJSONPayload(context.Background(), provider, data)
- require.NoError(t, err)
-
- for _, key := range tt.expectedKeys {
- assert.Contains(t, receivedBody, key, "Expected key %s in response", key)
- }
-
- for _, key := range tt.unexpectedKeys {
- assert.NotContains(t, receivedBody, key, "Unexpected key %s in response", key)
- }
- })
- }
-}
-
-func TestSendCustomWebhook_EmptyCustomTemplateDefaultsToMinimal(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- var receivedBody map[string]any
-
- // Mock webhook HTTP client
- originalDo := webhookDoRequestFunc
- defer func() { webhookDoRequestFunc = originalDo }()
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- body, _ := io.ReadAll(req.Body)
- _ = json.Unmarshal(body, &receivedBody)
- return &http.Response{
- StatusCode: http.StatusOK,
- Body: http.NoBody,
- Header: make(http.Header),
- }, nil
- }
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "https://discord.com/api/webhooks/123456/test_token",
- Template: "custom",
- Config: "", // Empty config should default to minimal
- }
-
- data := map[string]any{
- "Title": "Test",
- "Message": "Message",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := svc.sendJSONPayload(context.Background(), provider, data)
- require.NoError(t, err)
-
- // Should use minimal template
- assert.Equal(t, "Test", receivedBody["title"])
- assert.Equal(t, "Message", receivedBody["message"])
-}
-
func TestCreateProvider_EmptyCustomTemplateAllowed(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db, nil)
@@ -1173,76 +721,9 @@ func TestUpdateProvider_NonCustomTemplateSkipsValidation(t *testing.T) {
require.NoError(t, err) // Should succeed because detailed template doesn't use Config
}
-func TestIsPrivateIP_EdgeCases(t *testing.T) {
- tests := []struct {
- name string
- ip string
- isPrivate bool
- }{
- // Boundary testing for 172.16-31 range
- {"172.15.255.255 (just before private)", "172.15.255.255", false},
- {"172.16.0.0 (start of private)", "172.16.0.0", true},
- {"172.31.255.255 (end of private)", "172.31.255.255", true},
- {"172.32.0.0 (just after private)", "172.32.0.0", false},
-
- // IPv6 unique local address boundaries
- {"fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff (before ULA)", "fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false},
- {"fc00::0 (start of ULA)", "fc00::0", true},
- {"fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff (end of ULA)", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true},
- {"fe00::0 (after ULA)", "fe00::0", false},
-
- // IPv6 link-local boundaries
- {"fe7f:ffff:ffff:ffff:ffff:ffff:ffff:ffff (before link-local)", "fe7f:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false},
- {"fe80::0 (start of link-local)", "fe80::0", true},
- {"febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff (end of link-local)", "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true},
- {"fec0::0 (after link-local)", "fec0::0", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- ip := net.ParseIP(tt.ip)
- require.NotNil(t, ip, "Failed to parse IP: %s", tt.ip)
- result := isPrivateIP(ip)
- assert.Equal(t, tt.isPrivate, result, "IP %s: expected private=%v, got=%v", tt.ip, tt.isPrivate, result)
- })
- }
-}
-
-func TestSendCustomWebhook_ContextCancellation(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- // Create a server that delays response
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- time.Sleep(500 * time.Millisecond)
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: server.URL,
- Template: "minimal",
- }
-
- data := map[string]any{
- "Title": "Test",
- "Message": "Test",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- // Create context with immediate cancellation
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
-
- err := svc.sendJSONPayload(ctx, provider, data)
- require.Error(t, err)
-}
-
-func TestSendExternal_UnknownEventTypeSendsToAll(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
+func TestSendExternal_UnknownEventTypeSendsToAll(t *testing.T) {
+ db := setupNotificationTestDB(t)
+ svc := NewNotificationService(db, nil)
var callCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -1371,212 +852,6 @@ func TestRenderTemplate_MinimalAndDetailedTemplates(t *testing.T) {
// Phase 3: Service-Specific Validation Tests
// ============================================
-func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- t.Run("discord_message_is_normalized_to_content", func(t *testing.T) {
- originalDo := webhookDoRequestFunc
- defer func() { webhookDoRequestFunc = originalDo }()
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- var payload map[string]any
- err := json.NewDecoder(req.Body).Decode(&payload)
- require.NoError(t, err)
- assert.Equal(t, "Test Message", payload["content"])
- return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
- }
-
- // Discord payload with message should be normalized to content
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "https://discord.com/api/webhooks/123456/token_abc",
- Template: "custom",
- Config: `{"message": {{toJSON .Message}}}`,
- }
- data := map[string]any{
- "Title": "Test",
- "Message": "Test Message",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := svc.sendJSONPayload(context.Background(), provider, data)
- require.NoError(t, err)
- })
-
- t.Run("discord_with_content_succeeds", func(t *testing.T) {
- originalDo := webhookDoRequestFunc
- defer func() { webhookDoRequestFunc = originalDo }()
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
- }
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "https://discord.com/api/webhooks/123456/token_abc",
- Template: "custom",
- Config: `{"content": {{toJSON .Message}}}`,
- }
- data := map[string]any{
- "Title": "Test",
- "Message": "Test Message",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := svc.sendJSONPayload(context.Background(), provider, data)
- require.NoError(t, err)
- })
-
- t.Run("discord_with_embeds_succeeds", func(t *testing.T) {
- originalDo := webhookDoRequestFunc
- defer func() { webhookDoRequestFunc = originalDo }()
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
- }
-
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "https://discord.com/api/webhooks/123456/token_abc",
- Template: "custom",
- Config: `{"embeds": [{"title": {{toJSON .Title}}}]}`,
- }
- data := map[string]any{
- "Title": "Test",
- "Message": "Test Message",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := svc.sendJSONPayload(context.Background(), provider, data)
- require.NoError(t, err)
- })
-
- t.Run("slack_requires_text_or_blocks", func(t *testing.T) {
- subSvc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil }))
-
- provider := models.NotificationProvider{ //nolint:gosec // G101: test credential
- Type: "slack",
- URL: "#test",
- Token: "https://hooks.slack.com/services/T00/B00/xxx",
- Template: "custom",
- Config: `{"username": "Charon"}`,
- }
- data := map[string]any{
- "Title": "Test",
- "Message": "Test Message",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := subSvc.sendJSONPayload(context.Background(), provider, data)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "slack payload requires 'text' or 'blocks' field")
- })
-
- t.Run("slack_with_text_succeeds", func(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
- subSvc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil }))
-
- provider := models.NotificationProvider{
- Type: "slack",
- URL: "#test",
- Token: server.URL,
- Template: "custom",
- Config: `{"text": {{toJSON .Message}}}`,
- }
- data := map[string]any{
- "Title": "Test",
- "Message": "Test Message",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := subSvc.sendJSONPayload(context.Background(), provider, data)
- require.NoError(t, err)
- })
-
- t.Run("slack_with_blocks_succeeds", func(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
- subSvc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil }))
-
- provider := models.NotificationProvider{
- Type: "slack",
- URL: "#test",
- Token: server.URL,
- Template: "custom",
- Config: `{"blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": {{toJSON .Message}}}}]}`,
- }
- data := map[string]any{
- "Title": "Test",
- "Message": "Test Message",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := subSvc.sendJSONPayload(context.Background(), provider, data)
- require.NoError(t, err)
- })
-
- t.Run("gotify_requires_message", func(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- // Gotify without message should fail
- provider := models.NotificationProvider{
- Type: "gotify",
- URL: server.URL,
- Template: "custom",
- Config: `{"title": {{toJSON .Title}}}`, // Missing message
- }
- data := map[string]any{
- "Title": "Test",
- "Message": "Test Message",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := svc.sendJSONPayload(context.Background(), provider, data)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "gotify payload requires 'message' field")
- })
-
- t.Run("gotify_with_message_succeeds", func(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- provider := models.NotificationProvider{
- Type: "gotify",
- URL: server.URL,
- Template: "custom",
- Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`,
- }
- data := map[string]any{
- "Title": "Test",
- "Message": "Test Message",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := svc.sendJSONPayload(context.Background(), provider, data)
- require.NoError(t, err)
- })
-}
-
-// ============================================
-// Phase 3: SendExternal Event Type Coverage
-// ============================================
-
func TestSendExternal_AllEventTypes(t *testing.T) {
eventTypes := []struct {
eventType string
@@ -1594,24 +869,13 @@ func TestSendExternal_AllEventTypes(t *testing.T) {
for _, et := range eventTypes {
t.Run(et.eventType, func(t *testing.T) {
db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- // Mock Discord validation to allow test server URL
- origValidateDiscordFunc := validateDiscordProviderURLFunc
- defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
-
- var callCount atomic.Int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- callCount.Add(1)
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
+ wrapper, rt := newCapturingWrapper()
+ svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper))
provider := models.NotificationProvider{
Name: "event-test",
Type: "discord",
- URL: server.URL,
+ URL: "https://discord.com/api/webhooks/1/event-test-token",
Enabled: true,
Template: "minimal",
NotifyProxyHosts: et.eventType == "proxy_host",
@@ -1632,50 +896,23 @@ func TestSendExternal_AllEventTypes(t *testing.T) {
}).Error)
svc.SendExternal(context.Background(), et.eventType, "Title", "Message", nil)
- time.Sleep(100 * time.Millisecond)
// test always sends; unknown defaults to false (security-first); others only when their flag is true
switch et.eventType {
- case "test":
- assert.Greater(t, callCount.Load(), int32(0), "Event type %s should trigger notification", et.eventType)
case "unknown":
- assert.Equal(t, int32(0), callCount.Load(), "Unknown event type should not trigger notification (security-first)")
+ time.Sleep(100 * time.Millisecond)
+ _, body := rt.last()
+ assert.Nil(t, body, "Unknown event type should not trigger notification (security-first)")
default:
- assert.Greater(t, callCount.Load(), int32(0), "Event type %s should trigger notification when flag is set", et.eventType)
+ require.Eventually(t, func() bool {
+ _, body := rt.last()
+ return body != nil
+ }, time.Second, 10*time.Millisecond, "Event type %s should trigger notification", et.eventType)
}
})
}
}
-// ============================================
-// Phase 3: isValidRedirectURL Coverage
-// ============================================
-
-func TestIsValidRedirectURL(t *testing.T) {
- tests := []struct {
- name string
- url string
- expected bool
- }{
- {"valid http", "https://discord.com/api/webhooks/123/abc/webhook", true},
- {"valid https", "https://example.com/webhook", true},
- {"invalid scheme ftp", "ftp://example.com", false},
- {"invalid scheme file", "file:///etc/passwd", false},
- {"no scheme", "example.com/webhook", false},
- {"empty hostname", "http:///webhook", false},
- {"invalid url", "://invalid", false},
- {"javascript scheme", "javascript:alert(1)", false},
- {"data scheme", "data:text/html,
test
", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := isValidRedirectURL(tt.url)
- assert.Equal(t, tt.expected, result, "isValidRedirectURL(%q) = %v, want %v", tt.url, result, tt.expected)
- })
- }
-}
-
func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) {
eventCases := []struct {
name string
@@ -1715,23 +952,13 @@ func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) {
for _, tc := range eventCases {
t.Run(tc.name, func(t *testing.T) {
db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- origValidate := validateDiscordProviderURLFunc
- defer func() { validateDiscordProviderURLFunc = origValidate }()
- validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
-
- received := make(chan struct{}, 1)
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- received <- struct{}{}
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
+ wrapper, rt := newCapturingWrapper()
+ svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper))
provider := models.NotificationProvider{
Name: "discord-security",
Type: "discord",
- URL: server.URL,
+ URL: "https://discord.com/api/webhooks/1/security-token",
Enabled: true,
Template: "minimal",
}
@@ -1740,11 +967,10 @@ func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) {
svc.SendExternal(context.Background(), tc.eventType, "Security Title", "Security Message", nil)
- select {
- case <-received:
- case <-time.After(1 * time.Second):
- t.Fatalf("expected dispatch for event type %s", tc.eventType)
- }
+ require.Eventually(t, func() bool {
+ _, body := rt.last()
+ return body != nil
+ }, time.Second, 10*time.Millisecond, "expected dispatch for event type %s", tc.eventType)
})
}
}
@@ -1847,61 +1073,63 @@ func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) {
}
}
-func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) {
+// TestTestProviderViaNotify_BuildSenderError covers testProviderViaNotify's
+// defense-in-depth error branch when buildNotifySender rejects a provider
+// type it doesn't recognize. TestProvider's public entry point can never
+// reach this in practice (isSupportedNotificationProviderType and
+// supportsJSONTemplates both gate to exactly the types buildNotifySender
+// supports), so this calls the unexported method directly to exercise the
+// branch.
+func TestTestProviderViaNotify_BuildSenderError(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db, nil)
- serverCalled := atomic.Bool{}
- originalDo := webhookDoRequestFunc
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- serverCalled.Store(true)
- // Verify it's using JSON payload (not legacy fallback)
- assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
- return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
- }
- defer func() { webhookDoRequestFunc = originalDo }()
+ err := svc.testProviderViaNotify(models.NotificationProvider{Type: "not-a-real-provider-type"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "build notify sender")
+}
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "https://discord.com/api/webhooks/123456789/token_abc",
- Template: "minimal",
- }
+// TestDispatchViaNotify_BuildSenderError is dispatchViaNotify's counterpart
+// to TestTestProviderViaNotify_BuildSenderError — see that test's comment
+// for why this must call the unexported method directly rather than going
+// through SendExternal.
+func TestDispatchViaNotify_BuildSenderError(t *testing.T) {
+ db := setupNotificationTestDB(t)
+ svc := NewNotificationService(db, nil)
- err := svc.TestProvider(provider)
- require.NoError(t, err)
- assert.True(t, serverCalled.Load(), "discord provider should use JSON webhook path")
+ // Must not panic; buildNotifySender's error is logged and dispatchViaNotify returns.
+ svc.dispatchViaNotify(context.Background(), models.NotificationProvider{Type: "not-a-real-provider-type"}, "test", "Title", "Message", nil)
}
func TestTestProvider_HTTPURLValidation(t *testing.T) {
db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
- t.Run("blocks private IP", func(t *testing.T) {
+ t.Run("blocks failed dispatch", func(t *testing.T) {
+ rt := &capturingRoundTripper{statusCode: http.StatusInternalServerError}
+ wrapper := transport.NewWrapper(
+ transport.WithClientFactory(func(bool, int) *http.Client {
+ return &http.Client{Transport: rt}
+ }),
+ transport.WithURLValidator(func(rawURL string, _ bool) (string, error) {
+ return rawURL, nil
+ }),
+ transport.WithRetryPolicy(transport.RetryPolicy{MaxAttempts: 1}),
+ )
+ svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper))
+
provider := models.NotificationProvider{
Type: "discord",
URL: "https://discord.com/api/webhooks/999/invalidtoken",
- Template: "",
- }
-
- // Mock the webhook request to fail on IP validation
- originalDo := webhookDoRequestFunc
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- return nil, fmt.Errorf("private IP blocked")
+ Template: "minimal",
}
- defer func() { webhookDoRequestFunc = originalDo }()
err := svc.TestProvider(provider)
require.Error(t, err)
})
t.Run("allows valid discord webhook", func(t *testing.T) {
- serverCalled := atomic.Bool{}
- originalDo := webhookDoRequestFunc
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- serverCalled.Store(true)
- return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
- }
- defer func() { webhookDoRequestFunc = originalDo }()
+ wrapper, rt := newCapturingWrapper()
+ svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper))
provider := models.NotificationProvider{
Type: "discord",
@@ -1911,7 +1139,9 @@ func TestTestProvider_HTTPURLValidation(t *testing.T) {
err := svc.TestProvider(provider)
require.NoError(t, err)
- assert.True(t, serverCalled.Load())
+
+ _, body := rt.last()
+ require.NotNil(t, body)
})
}
@@ -1919,90 +1149,6 @@ func TestTestProvider_HTTPURLValidation(t *testing.T) {
// Phase 4: Additional Edge Case Coverage
// ============================================
-func TestSendJSONPayload_TemplateExecutionError(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- // Template that calls a method on nil should cause execution error
- provider := models.NotificationProvider{
- Type: "discord",
- URL: server.URL,
- Template: "custom",
- Config: `{"result": {{call .NonExistentFunc}}}`, // This will fail during execution
- }
-
- data := map[string]any{
- "Title": "Test",
- "Message": "Test",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := svc.sendJSONPayload(context.Background(), provider, data)
- require.Error(t, err)
- // The error could be a parse error or execution error depending on Go version
-}
-
-func TestSendJSONPayload_InvalidJSONFromTemplate(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- // Template that produces invalid JSON
- provider := models.NotificationProvider{
- Type: "webhook",
- URL: server.URL,
- Template: "custom",
- Config: `{"title": {{.Title}}}`, // Missing toJSON, will produce unquoted string
- }
-
- data := map[string]any{
- "Title": "Test Value",
- "Message": "Test",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := svc.sendJSONPayload(context.Background(), provider, data)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "invalid JSON payload")
-}
-
-func TestSendJSONPayload_RequestCreationError(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- // This test verifies request creation doesn't panic on edge cases
- provider := models.NotificationProvider{
- Type: "discord",
- URL: "http://localhost:8080/webhook",
- Template: "minimal",
- }
-
- // Use canceled context to trigger early error
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
-
- data := map[string]any{
- "Title": "Test",
- "Message": "Test",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := svc.sendJSONPayload(ctx, provider, data)
- require.Error(t, err)
-}
-
func TestRenderTemplate_CustomTemplateWithWhitespace(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db, nil)
@@ -2092,48 +1238,10 @@ func TestSendExternal_JSONPayloadError(t *testing.T) {
time.Sleep(100 * time.Millisecond)
}
-func TestSendJSONPayload_HTTPScheme(t *testing.T) {
+func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db, nil)
-
- // Test both HTTP and HTTPS schemes
- schemes := []string{"http", "https"}
-
- for _, scheme := range schemes {
- t.Run(scheme, func(t *testing.T) {
- // Create server (note: httptest.Server uses http by default)
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- provider := models.NotificationProvider{
- Type: "webhook",
- URL: server.URL, // httptest always uses http
- Template: "minimal",
- }
-
- data := map[string]any{
- "Title": "Test",
- "Message": "Test",
- "Time": time.Now().Format(time.RFC3339),
- "EventType": "test",
- }
-
- err := svc.sendJSONPayload(context.Background(), provider, data)
- require.NoError(t, err)
- })
- }
-}
-
-// ============================================
-// Migration Completeness Tests
-// ============================================
-
-func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
- ctx := context.Background()
+ ctx := context.Background()
// Create test providers: discord (supported) and others (deprecated in discord-only rollout)
providers := []models.NotificationProvider{
@@ -2328,7 +1436,7 @@ func TestIsDispatchEnabled_WebhookDefaultTrue(t *testing.T) {
}
func TestFlagEmailServiceEnabled_ConstantValue(t *testing.T) {
- assert.Equal(t, "feature.notifications.service.email.enabled", notifications.FlagEmailServiceEnabled)
+ assert.Equal(t, "feature.notifications.service.email.enabled", FlagEmailServiceEnabled)
}
func TestIsSupportedNotificationProviderType_Email(t *testing.T) {
@@ -2345,7 +1453,7 @@ func TestIsDispatchEnabled_EmailDefaultFalse(t *testing.T) {
// Explicitly set flag to true — should now return true
require.NoError(t, db.Create(&models.Setting{
- Key: notifications.FlagEmailServiceEnabled,
+ Key: FlagEmailServiceEnabled,
Value: "true",
}).Error)
assert.True(t, svc.isDispatchEnabled("email"))
@@ -2354,7 +1462,8 @@ func TestIsDispatchEnabled_EmailDefaultFalse(t *testing.T) {
// TestSendExternal_EmailProvider_NilMailService_DoesNotPanic verifies that when an
// email provider is enabled but the mail service is nil, SendExternal dispatches
// the goroutine which early-returns without panicking. The type == "email" branch
-// calls dispatchEmail and continues — it never reaches supportsJSONTemplates.
+// calls dispatchEmailViaNotify directly and continues — it never reaches
+// supportsJSONTemplates, which only gates the non-email dispatch goroutine.
func TestSendExternal_EmailProvider_NilMailService_DoesNotPanic(t *testing.T) {
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
@@ -2362,7 +1471,7 @@ func TestSendExternal_EmailProvider_NilMailService_DoesNotPanic(t *testing.T) {
// Enable the email feature flag so isDispatchEnabled("email") returns true.
require.NoError(t, db.Create(&models.Setting{
- Key: notifications.FlagEmailServiceEnabled,
+ Key: FlagEmailServiceEnabled,
Value: "true",
}).Error)
@@ -2399,6 +1508,12 @@ func TestTestProvider_EmailRejectsJSONTemplateStep(t *testing.T) {
}
func TestTestProvider_GotifyWorksWithoutFeatureFlag(t *testing.T) {
+ // Gotify is cut over to the extracted notify module, whose transport
+ // wrapper gates plain-HTTP/localhost dispatch on CHARON_ENV=test
+ // explicitly (resolveNotifyAllowHTTP in notify_client_adapter.go)
+ // rather than the old implicit os.Args[0]-".test"-suffix detection.
+ t.Setenv("CHARON_ENV", "test")
+
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db, nil)
@@ -2419,6 +1534,10 @@ func TestTestProvider_GotifyWorksWithoutFeatureFlag(t *testing.T) {
}
func TestTestProvider_WebhookWorksWithoutFeatureFlag(t *testing.T) {
+ // See TestTestProvider_GotifyWorksWithoutFeatureFlag's comment: webhook
+ // is also cut over to the extracted notify module.
+ t.Setenv("CHARON_ENV", "test")
+
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db, nil)
@@ -2439,6 +1558,9 @@ func TestTestProvider_WebhookWorksWithoutFeatureFlag(t *testing.T) {
}
func TestTestProvider_GotifyWorksWhenFlagExplicitlyFalse(t *testing.T) {
+ // See TestTestProvider_GotifyWorksWithoutFeatureFlag's comment.
+ t.Setenv("CHARON_ENV", "test")
+
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db, nil)
@@ -2463,6 +1585,10 @@ func TestTestProvider_GotifyWorksWhenFlagExplicitlyFalse(t *testing.T) {
}
func TestTestProvider_WebhookWorksWhenFlagExplicitlyFalse(t *testing.T) {
+ // See TestTestProvider_GotifyWorksWithoutFeatureFlag's comment: webhook
+ // is also cut over to the extracted notify module.
+ t.Setenv("CHARON_ENV", "test")
+
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db, nil)
@@ -2562,7 +1688,7 @@ func TestGetFeatureFlagValue_FoundSetting(t *testing.T) {
}
}
-// --- mockMailService for dispatchEmail tests ---
+// --- mockMailService for email dispatch/test-provider tests ---
type mockMailService struct {
mu sync.Mutex
@@ -2607,69 +1733,15 @@ func (m *mockMailService) firstCall() mockSendEmailCall {
return m.calls[0]
}
-func TestDispatchEmail_NilMailService(t *testing.T) {
- db := setupNotificationTestDB(t)
- svc := NewNotificationService(db, nil)
-
- p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"}
- // Must not panic
- svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message")
-}
-
-func TestDispatchEmail_SMTPNotConfigured(t *testing.T) {
- db := setupNotificationTestDB(t)
- mock := &mockMailService{isConfigured: false}
- svc := NewNotificationService(db, mock)
-
- p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"}
- svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message")
-
- assert.Empty(t, mock.calls)
-}
-
-func TestDispatchEmail_EmptyRecipients(t *testing.T) {
- db := setupNotificationTestDB(t)
- mock := &mockMailService{isConfigured: true}
- svc := NewNotificationService(db, mock)
-
- p := models.NotificationProvider{Name: "test-email", URL: " , , ", Type: "email"}
- svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message")
-
- assert.Empty(t, mock.calls)
-}
-
-func TestDispatchEmail_ValidSend(t *testing.T) {
- db := setupNotificationTestDB(t)
- mock := &mockMailService{isConfigured: true}
- svc := NewNotificationService(db, mock)
-
- p := models.NotificationProvider{Name: "test-email", URL: "a@b.com, c@d.com", Type: "email"}
- svc.dispatchEmail(context.Background(), p, "alert", "My Title", "My Message")
-
- require.Len(t, mock.calls, 1)
- assert.Equal(t, []string{"a@b.com", "c@d.com"}, mock.calls[0].to)
- assert.Equal(t, "[Charon Alert] My Title", mock.calls[0].subject)
- assert.Contains(t, mock.calls[0].body, "My Title")
- assert.Contains(t, mock.calls[0].body, "My Message")
-}
-
-func TestDispatchEmail_SendError_Logged(t *testing.T) {
- db := setupNotificationTestDB(t)
- mock := &mockMailService{isConfigured: true, sendEmailErr: fmt.Errorf("smtp failure")}
- svc := NewNotificationService(db, mock)
-
- p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"}
- // Must not panic even when SendEmail returns error
- svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message")
-
- assert.Len(t, mock.calls, 1)
-}
-
func TestSendExternal_EmailProvider_Dispatches(t *testing.T) {
db := setupNotificationTestDB(t)
require.NoError(t, db.AutoMigrate(&models.Setting{}))
- mock := &mockMailService{isConfigured: true}
+ // renderResult must be set so the notify-module email path's render
+ // step succeeds and reaches Mailer.Send — see TestEmailProvider's doc
+ // comment for why a render failure now aborts dispatch instead of
+ // falling back to a generic body.
+ mock := &mockMailService{isConfigured: true, renderResult: "rendered
"}
svc := NewNotificationService(db, mock)
provider := models.NotificationProvider{
@@ -2680,7 +1752,7 @@ func TestSendExternal_EmailProvider_Dispatches(t *testing.T) {
}
require.NoError(t, db.Create(&provider).Error)
- db.Create(&models.Setting{Key: notifications.FlagEmailServiceEnabled, Value: "true"})
+ db.Create(&models.Setting{Key: FlagEmailServiceEnabled, Value: "true"})
svc.SendExternal(context.Background(), "test", "Title", "Body", nil)
@@ -2704,7 +1776,7 @@ func TestSendExternal_EmailProvider_FlagDisabled(t *testing.T) {
}
require.NoError(t, db.Create(&provider).Error)
- db.Create(&models.Setting{Key: notifications.FlagEmailServiceEnabled, Value: "false"})
+ db.Create(&models.Setting{Key: FlagEmailServiceEnabled, Value: "false"})
svc.SendExternal(context.Background(), "test", "Title", "Body", nil)
@@ -2712,184 +1784,6 @@ func TestSendExternal_EmailProvider_FlagDisabled(t *testing.T) {
assert.Zero(t, mock.callCount())
}
-func TestDispatchEmail_InvalidRecipient(t *testing.T) {
- db := setupNotificationTestDB(t)
- mock := &mockMailService{isConfigured: true, sendEmailErr: ErrInvalidRecipient}
- svc := NewNotificationService(db, mock)
-
- p := models.NotificationProvider{Name: "test-email", URL: "not-an-email", Type: "email"}
- // dispatchEmail will call SendEmail; the mock returns ErrInvalidRecipient — must not panic
- svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message")
-
- // SendEmail was called once (validation happens inside real SendEmail, mock just returns the error)
- assert.Len(t, mock.calls, 1)
-}
-
-func TestDispatchEmail_TooManyRecipients(t *testing.T) {
- db := setupNotificationTestDB(t)
- mock := &mockMailService{isConfigured: true, sendEmailErr: ErrTooManyRecipients}
- svc := NewNotificationService(db, mock)
-
- recipients := make([]string, 21)
- for i := range recipients {
- recipients[i] = fmt.Sprintf("user%d@example.com", i)
- }
- p := models.NotificationProvider{Name: "test-email", URL: strings.Join(recipients, ","), Type: "email"}
- // dispatchEmail passes all recipients to SendEmail; mock returns ErrTooManyRecipients — must not panic
- svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message")
-
- assert.Len(t, mock.calls, 1)
-}
-
-func TestDispatchEmail_HeaderInjectionRecipient(t *testing.T) {
- db := setupNotificationTestDB(t)
- mock := &mockMailService{isConfigured: true, sendEmailErr: ErrInvalidRecipient}
- svc := NewNotificationService(db, mock)
-
- p := models.NotificationProvider{Name: "test-email", URL: "bad\r\naddr@test.com", Type: "email"}
- // The recipient contains CR/LF; dispatchEmail trims + splits but passes to SendEmail which rejects — must not panic
- svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message")
-
- assert.Len(t, mock.calls, 1)
-}
-
-func TestSendExternal_EmailProviderDoesNotCallSendJSONPayload(t *testing.T) {
- db := setupNotificationTestDB(t)
- require.NoError(t, db.AutoMigrate(&models.Setting{}))
-
- mock := &mockMailService{isConfigured: true}
- svc := NewNotificationService(db, mock)
-
- // Track any JSON payload call via the webhook hook
- jsonPayloadCalled := false
- origDo := webhookDoRequestFunc
- defer func() { webhookDoRequestFunc = origDo }()
- webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
- jsonPayloadCalled = true
- return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
- }
-
- provider := models.NotificationProvider{
- Name: "email-no-http",
- Type: "email",
- URL: "notify@example.com",
- Enabled: true,
- }
- require.NoError(t, db.Create(&provider).Error)
- db.Create(&models.Setting{Key: notifications.FlagEmailServiceEnabled, Value: "true"})
-
- svc.SendExternal(context.Background(), "test", "Title", "Body", nil)
- require.Eventually(t, func() bool { return mock.callCount() > 0 }, 2*time.Second, 10*time.Millisecond)
-
- assert.False(t, jsonPayloadCalled, "email provider must not trigger HTTP JSON payload path")
-}
-
-func TestDispatchEmail_XSSPayload_BodySanitized(t *testing.T) {
- db := setupNotificationTestDB(t)
- mock := &mockMailService{isConfigured: true}
- svc := NewNotificationService(db, mock)
-
- xssTitle := ``
- xssMessage := `
`
-
- p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"}
- svc.dispatchEmail(context.Background(), p, "alert", xssTitle, xssMessage)
-
- require.Len(t, mock.calls, 1)
- body := mock.calls[0].body
- // Raw script tags must not appear — they must be escaped.
- assert.NotContains(t, body, "`, `
`)
+ if got != "<script>alert(1)</script>
<img src=x onerror=evil()>" {
+ t.Fatalf("unexpected escaped fallback body: %q", got)
+ }
+}
+
+// TestFallbackEmailBodyEmptyFields confirms fallbackEmailBody degrades
+// gracefully (no stray "
") when title or body is empty.
+func TestFallbackEmailBodyEmptyFields(t *testing.T) {
+ if got := fallbackEmailBody("", ""); got != "" {
+ t.Fatalf("expected empty fallback body, got %q", got)
+ }
+ if got := fallbackEmailBody("Only Title", ""); got != "Only Title" {
+ t.Fatalf("unexpected fallback body: %q", got)
+ }
+ if got := fallbackEmailBody("", "Only body"); got != "Only body" {
+ t.Fatalf("unexpected fallback body: %q", got)
+ }
+}
+
+func TestMailServiceTemplateRendererAdapterNilMailServiceErrors(t *testing.T) {
+ adapter := &mailServiceTemplateRendererAdapter{mailService: nil}
+
+ if _, err := adapter.Render("t.html", notify.Message{}); err == nil {
+ t.Fatal("expected an error when mail service is not configured")
+ }
+}
+
+func TestNewNotifyEmailConfigPreservesSubjectPrefixAndTemplateSelection(t *testing.T) {
+ fake := &fakeMailServiceForEmailAdapter{isConfigured: true}
+ cfg := NewNotifyEmailConfig(fake, []string{"ops@example.com"})
+
+ if cfg.SubjectPrefix != "[Charon Alert] " {
+ t.Fatalf("SubjectPrefix = %q, want %q", cfg.SubjectPrefix, "[Charon Alert] ")
+ }
+ if len(cfg.Recipients) != 1 || cfg.Recipients[0] != "ops@example.com" {
+ t.Fatalf("unexpected recipients: %v", cfg.Recipients)
+ }
+ if cfg.Mailer == nil {
+ t.Fatal("expected a non-nil Mailer")
+ }
+ if cfg.Renderer == nil {
+ t.Fatal("expected a non-nil Renderer")
+ }
+ if cfg.TemplateName == nil {
+ t.Fatal("expected a non-nil TemplateName selector")
+ }
+
+ tests := []struct {
+ eventType string
+ want string
+ }{
+ {"security_waf", "email_security_alert.html"},
+ {"security_acl", "email_security_alert.html"},
+ {"security_rate_limit", "email_security_alert.html"},
+ {"security_crowdsec", "email_security_alert.html"},
+ {"cert", "email_ssl_event.html"},
+ {"uptime", "email_uptime_event.html"},
+ {"proxy_host", "email_system_event.html"},
+ {"unknown-event", "email_system_event.html"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.eventType, func(t *testing.T) {
+ got := cfg.TemplateName(notify.Message{EventType: tt.eventType})
+ if got != tt.want {
+ t.Fatalf("TemplateName(%q) = %q, want %q", tt.eventType, got, tt.want)
+ }
+ if got != emailTemplateForEventType(tt.eventType) {
+ t.Fatalf("TemplateName(%q) diverges from emailTemplateForEventType", tt.eventType)
+ }
+ })
+ }
+}
+
+func TestNewNotifyEmailConfigEndToEndSend(t *testing.T) {
+ fake := &fakeMailServiceForEmailAdapter{isConfigured: true, renderResult: "hi
"}
+ cfg := NewNotifyEmailConfig(fake, []string{"ops@example.com"})
+
+ client := email.New(cfg)
+
+ err := client.Send(context.Background(), notify.Message{Title: "Uptime issue", Body: "host down", EventType: "uptime"})
+ if err != nil {
+ t.Fatalf("Send returned error: %v", err)
+ }
+
+ if len(fake.renderCalls) != 1 || fake.renderCalls[0].templateName != "email_uptime_event.html" {
+ t.Fatalf("unexpected render calls: %+v", fake.renderCalls)
+ }
+ if len(fake.sendCalls) != 1 {
+ t.Fatalf("expected 1 SendEmail call, got %d", len(fake.sendCalls))
+ }
+ if fake.sendCalls[0].subject != "[Charon Alert] Uptime issue" {
+ t.Fatalf("subject = %q, want %q", fake.sendCalls[0].subject, "[Charon Alert] Uptime issue")
+ }
+}
diff --git a/backend/internal/services/notify_provider_adapter.go b/backend/internal/services/notify_provider_adapter.go
new file mode 100644
index 000000000..5dcfbe82e
--- /dev/null
+++ b/backend/internal/services/notify_provider_adapter.go
@@ -0,0 +1,178 @@
+package services
+
+import (
+ "fmt"
+ "strings"
+
+ notify "github.com/Wikid82/go_notify_yourself"
+ "github.com/Wikid82/go_notify_yourself/transport"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+// legacyDetailedTemplate reproduces, verbatim in JSON key structure, the old
+// Charon `detailedTemplate` const that lived in notification_service.go's
+// sendJSONPayload (and its identical copy in the old RenderTemplate):
+//
+// {"title": {{toJSON .Title}}, "message": {{toJSON .Message}}, "time": {{toJSON .Time}},
+// "event": {{toJSON .EventType}}, "host": {{toJSON .HostName}}, "host_ip": {{toJSON .HostIP}},
+// "service_count": {{toJSON .ServiceCount}}, "services": {{toJSON .Services}}, "data": {{toJSON .}}}
+//
+// This is a deliberate backward-compatibility decision (extraction spec
+// §3.6 step 5 / §7 risk 1b, "err toward the safer option"): the extracted
+// module's own built-in "detailed" template (providers/internal/render's
+// DetailedTemplate) nests all host-specific extras under a single "data"
+// object via notify.Message.Data instead of exposing them as flat top-level
+// JSON fields. Falling through to that new built-in template for an
+// already-configured "detailed" provider would silently change the JSON
+// payload shape delivered to any existing consumer that parses the old flat
+// keys (Discord embed parsers, custom webhook receivers, etc.).
+// buildNotifySender/resolveTemplateFields below translate a stored
+// `provider.Template == "detailed"` into `Template: "custom"`,
+// `CustomTemplate: legacyDetailedTemplate`, so already-configured
+// "detailed" providers see zero payload-shape change once cutover (a later
+// phase — Commits 3-9) wires this adapter into production dispatch.
+//
+// Field-access note: the module's shared render engine
+// (providers/internal/render.TemplateData) exposes host-specific extras
+// under a single `.Data` map (notify.Message.Data), not as top-level
+// template fields the way Charon's old flat `data map[string]any` did — so
+// `.HostName` becomes `(index .Data "HostName")` here. Go's text/template
+// `index` returns the map's zero value (nil, renders as JSON `null`) for a
+// missing key, matching the old template's behavior when a caller's data
+// map lacked one of these optional fields.
+//
+// One narrow, intentionally-documented difference from the original: the
+// old template's final `"data": {{toJSON .}}` serialized the ENTIRE input
+// map — which included Title/Message/Time/EventType as well as the
+// extras — under "data", since `.` was the whole flat map passed into
+// sendJSONPayload. Here, `"data": {{toJSON .Data}}` serializes only
+// notify.Message.Data (the caller-supplied extras), because Title/Message/
+// Time/EventType are no longer part of that map — they became top-level
+// notify.Message fields. What ends up inside `msg.Data` at cutover time is
+// decided by Commits 3-9 (SendExternal's new notify.Message construction),
+// not by this file; this comment flags the difference explicitly so that
+// decision is made consciously rather than by accident, per the extraction
+// spec's instruction not to let payload-shape decisions happen implicitly.
+const legacyDetailedTemplate = `{"title": {{toJSON .Title}}, "message": {{toJSON .Message}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}, "host": {{toJSON (index .Data "HostName")}}, "host_ip": {{toJSON (index .Data "HostIP")}}, "service_count": {{toJSON (index .Data "ServiceCount")}}, "services": {{toJSON (index .Data "Services")}}, "data": {{toJSON .Data}}}`
+
+// resolveTemplateFields translates a GORM NotificationProvider row's
+// Template/Config columns into the (template, customTemplate) pair every
+// extracted provider package's Config expects, applying the
+// "detailed" -> flat-shape CustomTemplate backward-compat translation
+// documented on legacyDetailedTemplate above. "minimal" and "custom" (and
+// any other/empty selector, which the module's own render.SelectTemplate
+// treats as "custom") pass through unchanged.
+func resolveTemplateFields(provider models.NotificationProvider) (template string, customTemplate string) {
+ if strings.EqualFold(strings.TrimSpace(provider.Template), "detailed") {
+ return "custom", legacyDetailedTemplate
+ }
+ return provider.Template, provider.Config
+}
+
+// providerConfigMap maps a GORM models.NotificationProvider row's
+// type-specific fields onto the map[string]any key convention every
+// extracted-module HTTP-based provider's Factory expects (per
+// docs/plans/notify_provider_registry_spec.md §3.4: "transport" for the
+// shared *transport.Wrapper, provider-specific fields under the lowercase
+// snake_case name of their typed Config struct field). w is the single
+// shared *transport.Wrapper built by NewNotifyTransportWrapper
+// (notify_client_adapter.go), injected into every HTTP-based provider.
+//
+// Field mappings below were read directly out of the old
+// notification_service.go sendJSONPayload's provider-specific branches (not
+// guessed from the spec's design summary) and are unchanged from the
+// pre-registry switch that used to live in buildNotifySender:
+// - discord: webhook_url <- provider.URL
+// - slack: webhook_url <- provider.Token — Slack's decrypted webhook URL
+// is stored in the Token column (provider.URL is an unused placeholder
+// for Slack, matching the old code's `decryptedWebhookURL := p.Token`)
+// - gotify: url <- provider.URL, token <- provider.Token (sent as the
+// X-Gotify-Key header when non-empty)
+// - pushover: user_key <- provider.URL, api_token <- provider.Token
+// (matching the old code's `jsonPayload["user"] = p.URL` /
+// `decryptedToken := p.Token`); base_url left unset, so
+// providers/pushover defaults to the production API
+// - ntfy: url <- provider.URL, token <- provider.Token (sent as an
+// "Authorization: Bearer " header when non-empty)
+// - telegram: bot_token <- provider.Token, chat_id <- provider.URL
+// (matching the old code's `decryptedToken := p.Token` /
+// `jsonPayload["chat_id"] = p.URL`); base_url left unset, so
+// providers/telegram defaults to the production Bot API
+// - webhook / generic: url <- provider.URL, generic JSON passthrough, no
+// provider-specific payload shape or host allowlist
+//
+// This per-type field mapping is a Charon persistence-schema fact (which
+// GORM column means what for which provider type), not something the
+// registry can know — collapsing buildNotifySender's dispatch onto
+// notify.New (below) removes the "which Go constructor do I call" branch,
+// not this one (spec §3.6.1).
+func providerConfigMap(provider models.NotificationProvider, w *transport.Wrapper, template, customTemplate string) map[string]any {
+ config := map[string]any{
+ "transport": w,
+ "template": template,
+ "custom_template": customTemplate,
+ }
+
+ switch strings.ToLower(strings.TrimSpace(provider.Type)) {
+ case "discord":
+ config["webhook_url"] = provider.URL
+ case "slack":
+ config["webhook_url"] = provider.Token
+ case "gotify":
+ config["url"] = provider.URL
+ config["token"] = provider.Token
+ case "pushover":
+ config["user_key"] = provider.URL
+ config["api_token"] = provider.Token
+ case "ntfy":
+ config["url"] = provider.URL
+ config["token"] = provider.Token
+ case "telegram":
+ config["bot_token"] = provider.Token
+ config["chat_id"] = provider.URL
+ case "webhook", "generic":
+ config["url"] = provider.URL
+ }
+
+ return config
+}
+
+// registryTypeForProvider resolves a Charon provider.Type discriminator to
+// the name it is registered under in the go_notify_yourself registry.
+// "generic" is Charon's own alias for the module's "webhook" provider (the
+// pre-registry switch handled both cases identically via webhook.New); the
+// registry itself only knows the canonical "webhook" name, so the alias
+// must be resolved here before calling notify.New.
+func registryTypeForProvider(providerType string) string {
+ t := strings.ToLower(strings.TrimSpace(providerType))
+ if t == "generic" {
+ return "webhook"
+ }
+ return t
+}
+
+// buildNotifySender maps a GORM models.NotificationProvider row into the
+// map[string]any config the extracted module's provider registry expects
+// (providerConfigMap, above) and constructs the corresponding notify.Sender
+// via notify.New — replacing the per-type switch/constructor-call dispatch
+// that used to live here (docs/plans/notify_provider_registry_spec.md
+// §3.6.1). notify.New itself returns a descriptive error, never panics, for
+// an unregistered provider type or a missing/invalid required config key
+// (e.g. "transport" absent or nil) — buildNotifySender wraps that error
+// rather than reinterpreting it.
+//
+// Email is handled separately (notify_email_adapter.go / providers/email),
+// not by this function — the module's email package has a different shape
+// (Mailer/TemplateRenderer, not a Wrapper-backed Sender) and Charon's
+// dispatch code never routes an "email" provider.Type through here.
+func buildNotifySender(provider models.NotificationProvider, w *transport.Wrapper) (notify.Sender, error) {
+ tmpl, customTemplate := resolveTemplateFields(provider)
+ config := providerConfigMap(provider, w, tmpl, customTemplate)
+
+ sender, err := notify.New(registryTypeForProvider(provider.Type), config)
+ if err != nil {
+ return nil, fmt.Errorf("notify provider adapter: %w", err)
+ }
+ return sender, nil
+}
diff --git a/backend/internal/services/notify_provider_adapter_test.go b/backend/internal/services/notify_provider_adapter_test.go
new file mode 100644
index 000000000..7e38edd26
--- /dev/null
+++ b/backend/internal/services/notify_provider_adapter_test.go
@@ -0,0 +1,468 @@
+package services
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ notify "github.com/Wikid82/go_notify_yourself"
+ "github.com/Wikid82/go_notify_yourself/providers/discord"
+ "github.com/Wikid82/go_notify_yourself/providers/gotify"
+ "github.com/Wikid82/go_notify_yourself/providers/ntfy"
+ "github.com/Wikid82/go_notify_yourself/providers/pushover"
+ "github.com/Wikid82/go_notify_yourself/providers/slack"
+ "github.com/Wikid82/go_notify_yourself/providers/telegram"
+ "github.com/Wikid82/go_notify_yourself/providers/webhook"
+ "github.com/Wikid82/go_notify_yourself/transport"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+// capturingRoundTripper is a fake http.RoundTripper that records every
+// outbound request (method, URL, headers, body) and returns a canned
+// response (200 OK by default, or statusCode when set) without hitting any
+// real network destination — including providers like pushover/telegram
+// whose dispatch URL is hardcoded to a production API host.
+type capturingRoundTripper struct {
+ mu sync.Mutex
+ requests []*http.Request
+ bodies [][]byte
+ statusCode int
+}
+
+func (c *capturingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ var body []byte
+ if req.Body != nil {
+ body, _ = io.ReadAll(req.Body)
+ _ = req.Body.Close()
+ }
+ c.requests = append(c.requests, req)
+ c.bodies = append(c.bodies, body)
+
+ status := c.statusCode
+ if status == 0 {
+ status = http.StatusOK
+ }
+
+ return &http.Response{
+ StatusCode: status,
+ Body: io.NopCloser(bytes.NewReader(nil)),
+ Header: make(http.Header),
+ }, nil
+}
+
+func (c *capturingRoundTripper) last() (*http.Request, []byte) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ n := len(c.requests)
+ if n == 0 {
+ return nil, nil
+ }
+ return c.requests[n-1], c.bodies[n-1]
+}
+
+// newCapturingWrapper builds a *transport.Wrapper whose ClientFactory routes
+// every outbound request through a capturingRoundTripper (so tests can
+// inspect exactly what was dispatched) and whose URLValidator is a
+// pass-through (SSRF policy is exercised by notify_client_adapter's own
+// tests, not here).
+func newCapturingWrapper() (*transport.Wrapper, *capturingRoundTripper) {
+ rt := &capturingRoundTripper{}
+ w := transport.NewWrapper(
+ transport.WithClientFactory(func(bool, int) *http.Client {
+ return &http.Client{Transport: rt}
+ }),
+ transport.WithURLValidator(func(rawURL string, _ bool) (string, error) {
+ return rawURL, nil
+ }),
+ )
+ return w, rt
+}
+
+func TestBuildNotifySenderDiscord(t *testing.T) {
+ w, rt := newCapturingWrapper()
+ provider := models.NotificationProvider{
+ Type: "discord",
+ URL: "https://discord.com/api/webhooks/123456/abcdef",
+ Template: "minimal",
+ }
+
+ sender, err := buildNotifySender(provider, w)
+ if err != nil {
+ t.Fatalf("buildNotifySender returned error: %v", err)
+ }
+ if _, ok := sender.(*discord.Client); !ok {
+ t.Fatalf("expected *discord.Client, got %T", sender)
+ }
+
+ if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil {
+ t.Fatalf("Send failed: %v", err)
+ }
+
+ req, body := rt.last()
+ if req == nil {
+ t.Fatal("expected a request to be captured")
+ }
+ if req.URL.String() != provider.URL {
+ t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), provider.URL)
+ }
+
+ var payload map[string]any
+ if err := json.Unmarshal(body, &payload); err != nil {
+ t.Fatalf("invalid JSON payload: %v", err)
+ }
+ if payload["content"] != "hello" {
+ t.Fatalf("expected content fallback from message, got %v", payload)
+ }
+}
+
+func TestBuildNotifySenderSlackUsesTokenAsWebhookURL(t *testing.T) {
+ w, rt := newCapturingWrapper()
+ provider := models.NotificationProvider{
+ Type: "slack",
+ URL: "unused-placeholder",
+ Token: "https://hooks.slack.com/services/T000/B000/xxxxxxxxxxxxxxxxxxxxxxxx",
+ Template: "minimal",
+ }
+
+ sender, err := buildNotifySender(provider, w)
+ if err != nil {
+ t.Fatalf("buildNotifySender returned error: %v", err)
+ }
+ if _, ok := sender.(*slack.Client); !ok {
+ t.Fatalf("expected *slack.Client, got %T", sender)
+ }
+
+ if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil {
+ t.Fatalf("Send failed: %v", err)
+ }
+
+ req, body := rt.last()
+ if req.URL.String() != provider.Token {
+ t.Fatalf("dispatch URL = %q, want provider.Token %q", req.URL.String(), provider.Token)
+ }
+ var payload map[string]any
+ _ = json.Unmarshal(body, &payload)
+ if payload["text"] != "hello" {
+ t.Fatalf("expected text fallback from message, got %v", payload)
+ }
+}
+
+func TestBuildNotifySenderGotifySetsAuthHeader(t *testing.T) {
+ w, rt := newCapturingWrapper()
+ provider := models.NotificationProvider{
+ Type: "gotify",
+ URL: "https://gotify.example.com/message",
+ Token: "app-token-123",
+ Template: "minimal",
+ }
+
+ sender, err := buildNotifySender(provider, w)
+ if err != nil {
+ t.Fatalf("buildNotifySender returned error: %v", err)
+ }
+ if _, ok := sender.(*gotify.Client); !ok {
+ t.Fatalf("expected *gotify.Client, got %T", sender)
+ }
+
+ if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil {
+ t.Fatalf("Send failed: %v", err)
+ }
+
+ req, _ := rt.last()
+ if req.URL.String() != provider.URL {
+ t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), provider.URL)
+ }
+ if got := req.Header.Get("X-Gotify-Key"); got != provider.Token {
+ t.Fatalf("X-Gotify-Key header = %q, want %q", got, provider.Token)
+ }
+}
+
+func TestBuildNotifySenderPushoverBuildsProductionURLAndInjectsCredentials(t *testing.T) {
+ w, rt := newCapturingWrapper()
+ provider := models.NotificationProvider{
+ Type: "pushover",
+ URL: "user-key-abc",
+ Token: "api-token-xyz",
+ Template: "minimal",
+ }
+
+ sender, err := buildNotifySender(provider, w)
+ if err != nil {
+ t.Fatalf("buildNotifySender returned error: %v", err)
+ }
+ if _, ok := sender.(*pushover.Client); !ok {
+ t.Fatalf("expected *pushover.Client, got %T", sender)
+ }
+
+ if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil {
+ t.Fatalf("Send failed: %v", err)
+ }
+
+ req, body := rt.last()
+ wantURL := "https://api.pushover.net/1/messages.json"
+ if req.URL.String() != wantURL {
+ t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), wantURL)
+ }
+ var payload map[string]any
+ _ = json.Unmarshal(body, &payload)
+ if payload["token"] != provider.Token {
+ t.Fatalf("payload token = %v, want %q", payload["token"], provider.Token)
+ }
+ if payload["user"] != provider.URL {
+ t.Fatalf("payload user = %v, want %q", payload["user"], provider.URL)
+ }
+}
+
+func TestBuildNotifySenderNtfySetsBearerHeader(t *testing.T) {
+ w, rt := newCapturingWrapper()
+ provider := models.NotificationProvider{
+ Type: "ntfy",
+ URL: "https://ntfy.sh/my-topic",
+ Token: "ntfy-token",
+ Template: "minimal",
+ }
+
+ sender, err := buildNotifySender(provider, w)
+ if err != nil {
+ t.Fatalf("buildNotifySender returned error: %v", err)
+ }
+ if _, ok := sender.(*ntfy.Client); !ok {
+ t.Fatalf("expected *ntfy.Client, got %T", sender)
+ }
+
+ if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil {
+ t.Fatalf("Send failed: %v", err)
+ }
+
+ req, _ := rt.last()
+ if req.URL.String() != provider.URL {
+ t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), provider.URL)
+ }
+ if got := req.Header.Get("Authorization"); got != "Bearer "+provider.Token {
+ t.Fatalf("Authorization header = %q, want %q", got, "Bearer "+provider.Token)
+ }
+}
+
+func TestBuildNotifySenderTelegramBuildsProductionURLAndChatID(t *testing.T) {
+ w, rt := newCapturingWrapper()
+ provider := models.NotificationProvider{
+ Type: "telegram",
+ URL: "chat-id-456",
+ Token: "bot-token-789",
+ Template: "minimal",
+ }
+
+ sender, err := buildNotifySender(provider, w)
+ if err != nil {
+ t.Fatalf("buildNotifySender returned error: %v", err)
+ }
+ if _, ok := sender.(*telegram.Client); !ok {
+ t.Fatalf("expected *telegram.Client, got %T", sender)
+ }
+
+ if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil {
+ t.Fatalf("Send failed: %v", err)
+ }
+
+ req, body := rt.last()
+ wantURL := "https://api.telegram.org/bot" + provider.Token + "/sendMessage"
+ if req.URL.String() != wantURL {
+ t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), wantURL)
+ }
+ var payload map[string]any
+ _ = json.Unmarshal(body, &payload)
+ if payload["chat_id"] != provider.URL {
+ t.Fatalf("payload chat_id = %v, want %q", payload["chat_id"], provider.URL)
+ }
+ if payload["text"] != "hello" {
+ t.Fatalf("expected text fallback from message, got %v", payload)
+ }
+}
+
+func TestBuildNotifySenderWebhookGeneric(t *testing.T) {
+ for _, providerType := range []string{"webhook", "generic"} {
+ t.Run(providerType, func(t *testing.T) {
+ w, rt := newCapturingWrapper()
+ provider := models.NotificationProvider{
+ Type: providerType,
+ URL: "https://example.com/hook",
+ Template: "minimal",
+ }
+
+ sender, err := buildNotifySender(provider, w)
+ if err != nil {
+ t.Fatalf("buildNotifySender returned error: %v", err)
+ }
+ if _, ok := sender.(*webhook.Client); !ok {
+ t.Fatalf("expected *webhook.Client, got %T", sender)
+ }
+
+ if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil {
+ t.Fatalf("Send failed: %v", err)
+ }
+
+ req, _ := rt.last()
+ if req.URL.String() != provider.URL {
+ t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), provider.URL)
+ }
+ })
+ }
+}
+
+func TestBuildNotifySenderUnsupportedTypeErrors(t *testing.T) {
+ w, _ := newCapturingWrapper()
+ provider := models.NotificationProvider{Type: "carrier-pigeon"}
+
+ _, err := buildNotifySender(provider, w)
+ if err == nil {
+ t.Fatal("expected an error for an unsupported/unregistered provider type")
+ }
+ if !strings.Contains(err.Error(), "no provider registered") {
+ t.Fatalf("expected a registry not-found error, got: %v", err)
+ }
+}
+
+// TestBuildNotifySenderMissingTransportErrors asserts that a nil
+// *transport.Wrapper (which providerConfigMap stores under the
+// config["transport"] key notify.New's per-provider factories require)
+// produces a descriptive error, not a panic — per
+// docs/plans/notify_provider_registry_spec.md §3.10's "missing/wrong-typed
+// required config key" error-handling convention.
+func TestBuildNotifySenderMissingTransportErrors(t *testing.T) {
+ provider := models.NotificationProvider{Type: "discord", URL: "https://discord.com/api/webhooks/123/abc"}
+
+ _, err := buildNotifySender(provider, nil)
+ if err == nil {
+ t.Fatal("expected an error for a nil transport wrapper")
+ }
+ if !strings.Contains(err.Error(), "transport") {
+ t.Fatalf("expected error to mention the missing transport, got: %v", err)
+ }
+}
+
+// TestBuildNotifySenderInvalidTransportInConfigMapErrors exercises
+// providerConfigMap/notify.New's config["transport"] validation directly:
+// a config map whose "transport" key holds something other than a
+// *transport.Wrapper (or is absent) must produce an error from notify.New,
+// never a panic.
+func TestBuildNotifySenderInvalidTransportInConfigMapErrors(t *testing.T) {
+ config := map[string]any{"webhook_url": "https://discord.com/api/webhooks/123/abc"}
+
+ _, err := notify.New("discord", config)
+ if err == nil {
+ t.Fatal("expected an error for a config map missing a valid transport")
+ }
+ if !strings.Contains(err.Error(), "transport") {
+ t.Fatalf("expected error to mention the missing transport, got: %v", err)
+ }
+}
+
+// TestRegistryTypeForProviderResolvesGenericAlias asserts the "generic" ->
+// "webhook" alias translation (registryTypeForProvider) matches the
+// pre-registry switch's `case "webhook", "generic":` behavior exactly.
+func TestRegistryTypeForProviderResolvesGenericAlias(t *testing.T) {
+ if got := registryTypeForProvider("generic"); got != "webhook" {
+ t.Fatalf("registryTypeForProvider(%q) = %q, want %q", "generic", got, "webhook")
+ }
+ if got := registryTypeForProvider("Discord"); got != "discord" {
+ t.Fatalf("registryTypeForProvider(%q) = %q, want %q", "Discord", got, "discord")
+ }
+}
+
+func TestResolveTemplateFieldsPassesThroughMinimalAndCustom(t *testing.T) {
+ minimal := models.NotificationProvider{Template: "minimal", Config: ""}
+ if tmpl, custom := resolveTemplateFields(minimal); tmpl != "minimal" || custom != "" {
+ t.Fatalf("minimal: got (%q, %q)", tmpl, custom)
+ }
+
+ custom := models.NotificationProvider{Template: "custom", Config: `{"foo": {{toJSON .Title}}}`}
+ if tmpl, cfg := resolveTemplateFields(custom); tmpl != "custom" || cfg != custom.Config {
+ t.Fatalf("custom: got (%q, %q)", tmpl, cfg)
+ }
+}
+
+func TestResolveTemplateFieldsTranslatesDetailedToLegacyFlatShape(t *testing.T) {
+ provider := models.NotificationProvider{Template: "detailed", Config: ""}
+
+ tmpl, custom := resolveTemplateFields(provider)
+ if tmpl != "custom" {
+ t.Fatalf("expected template to become %q, got %q", "custom", tmpl)
+ }
+ if custom != legacyDetailedTemplate {
+ t.Fatalf("expected custom template to be legacyDetailedTemplate, got %q", custom)
+ }
+}
+
+// TestDetailedTemplateBackwardCompatMatchesOldFlatJSONShape renders the
+// backward-compat legacyDetailedTemplate via providers/webhook.RenderPreview
+// (the module's public template-preview function, replacing the old
+// RenderTemplate) and asserts the resulting JSON keys/values exactly match
+// what the OLD Charon detailedTemplate const in notification_service.go
+// would have produced for the same inputs — proving the "detailed" ->
+// flat-shape backward-compat translation (extraction spec §3.6 step 5) is
+// implemented faithfully.
+func TestDetailedTemplateBackwardCompatMatchesOldFlatJSONShape(t *testing.T) {
+ fixedTime := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)
+ msg := notify.Message{
+ Title: "Certificate Renewed",
+ Body: "Certificate renewed for example.com",
+ EventType: "cert",
+ Timestamp: fixedTime,
+ Data: map[string]any{
+ "HostName": "example.com",
+ "HostIP": "1.2.3.4",
+ "ServiceCount": float64(3),
+ "Services": []any{"web", "api", "admin"},
+ },
+ }
+
+ _, customTemplate := resolveTemplateFields(models.NotificationProvider{Template: "detailed"})
+
+ rendered, parsed, err := webhook.RenderPreview(customTemplate, msg)
+ if err != nil {
+ t.Fatalf("RenderPreview failed: %v (rendered=%s)", err, rendered)
+ }
+
+ parsedMap, ok := parsed.(map[string]any)
+ if !ok {
+ t.Fatalf("expected parsed output to be a JSON object, got %T", parsed)
+ }
+
+ want := map[string]any{
+ "title": "Certificate Renewed",
+ "message": "Certificate renewed for example.com",
+ "time": fixedTime.Format(time.RFC3339),
+ "event": "cert",
+ "host": "example.com",
+ "host_ip": "1.2.3.4",
+ "service_count": float64(3),
+ }
+ for key, wantVal := range want {
+ if got := parsedMap[key]; got != wantVal {
+ t.Fatalf("key %q = %v (%T), want %v (%T)", key, got, got, wantVal, wantVal)
+ }
+ }
+
+ services, ok := parsedMap["services"].([]any)
+ if !ok || len(services) != 3 {
+ t.Fatalf("expected services to be a 3-element array, got %v", parsedMap["services"])
+ }
+
+ dataField, ok := parsedMap["data"].(map[string]any)
+ if !ok {
+ t.Fatalf("expected data field to be a JSON object, got %v", parsedMap["data"])
+ }
+ if dataField["HostName"] != "example.com" {
+ t.Fatalf("expected data.HostName to be preserved, got %v", dataField["HostName"])
+ }
+}
diff --git a/backend/internal/services/notify_providers_import.go b/backend/internal/services/notify_providers_import.go
new file mode 100644
index 000000000..2eafbfb05
--- /dev/null
+++ b/backend/internal/services/notify_providers_import.go
@@ -0,0 +1,32 @@
+package services
+
+// This file exists solely for its side effects: each blank import below
+// runs that provider package's init(), which self-registers a
+// notify.Factory into the go_notify_yourself registry (notify.Register),
+// making the provider constructible via notify.New(provider.Type, config)
+// from buildNotifySender (notify_provider_adapter.go).
+//
+// Deliberately NOT importing providers/all here. Charon hand-picks exactly
+// the provider types it currently supports (mirrored by
+// isSupportedNotificationProviderType in notification_service.go) rather
+// than linking every provider the module ships, per the registry design
+// spec (docs/plans/notify_provider_registry_spec.md §3.6.3): Charon's own
+// allowlist — not what happens to be linked into the binary — remains the
+// gate on what's exposed through its API/UI. Importing providers/all would
+// add binary size and transitive dependency surface for providers Charon's
+// allowlist would reject anyway.
+//
+// Keep this list in sync with isSupportedNotificationProviderType's
+// allowlist by hand; notification_service_allowlist_registry_test.go
+// (or equivalent) asserts that allowlist is a subset of
+// notify.RegisteredTypes(), catching drift between the two.
+import (
+ _ "github.com/Wikid82/go_notify_yourself/providers/discord"
+ _ "github.com/Wikid82/go_notify_yourself/providers/email"
+ _ "github.com/Wikid82/go_notify_yourself/providers/gotify"
+ _ "github.com/Wikid82/go_notify_yourself/providers/ntfy"
+ _ "github.com/Wikid82/go_notify_yourself/providers/pushover"
+ _ "github.com/Wikid82/go_notify_yourself/providers/slack"
+ _ "github.com/Wikid82/go_notify_yourself/providers/telegram"
+ _ "github.com/Wikid82/go_notify_yourself/providers/webhook"
+)
diff --git a/docs/plans/notifications_extraction_spec.md b/docs/plans/notifications_extraction_spec.md
new file mode 100644
index 000000000..dd96e2161
--- /dev/null
+++ b/docs/plans/notifications_extraction_spec.md
@@ -0,0 +1,1146 @@
+# Notifications Engine Extraction — Scoping Spec
+
+Status: Scoping/design only. No extraction, no new repo, no code changes performed under this
+spec. This document is the literal move-list and design brief for a **future session** that will
+create the new repository and perform the file-move/refactor/import work.
+
+Owner for this document: **planning** agent.
+Owner for execution (future session): TBD — likely a fresh `management`-orchestrated pipeline once
+the new repo exists, since it touches both a new external repo and Charon itself.
+
+**Revision note (rev 2):** this draft originally recommended a Phase-1-only extraction (the
+SSRF-safe HTTP wrapper alone) and left the provider-payload/template logic and email dispatch for a
+deferred v0.2. The user has since decided on **full provider-layer scope**: the
+Discord/Slack/Gotify/Pushover/Ntfy/webhook payload builders and email dispatch are genericized and
+moved into the new module in this same extraction. §3.1, §3.3, §3.5, §3.6, §4, §5, §6, and §7 are
+revised accordingly. §2 (research findings) and the HTTP wrapper DI seams in §3.2 are unchanged from
+the prior draft.
+
+**Two follow-up inputs folded into this same revision:**
+
+1. **The new repo already exists.** The user has created it at `/projects/go_notify_yourself`
+ (sibling to `/projects/Charon`, remote `github.com/Wikid82/go_notify_yourself`), currently just
+ `LICENSE` + a placeholder `README.md` — no `go.mod` yet. Every placeholder module path in this
+ spec (`github.com/Wikid82/notifyhttp` in the original draft, briefly `github.com/Wikid82/notify`
+ earlier in this revision pass) is now replaced with the real path,
+ **`github.com/Wikid82/go_notify_yourself`**. The extraction session's Phase 1 (§4) scaffolds
+ *into* this existing directory/repo, not a newly-`git init`'d one. The root Go package name is
+ kept as `notify` (not `go_notify_yourself`) since Go package names conventionally avoid
+ underscores — this is a normal, unproblematic mismatch (import path ≠ package identifier; the
+ compiler resolves the identifier from each file's `package notify` declaration, so consuming code
+ still just writes `notify.Message` after `import "github.com/Wikid82/go_notify_yourself"`).
+2. **Long-term direction vs. near-term scope.** The user's eventual goal is for this module to
+ become a Go equivalent of [Apprise](https://github.com/caronc/apprise) — the Python library that
+ unifies notification dispatch across a large number of services via a common interface/URL-scheme
+ convention. **Right now, while both Charon and the user's other small family project are still
+ under active development**, they explicitly do not want this extraction to add any provider
+ Charon doesn't already have (no Twilio/PagerDuty/Matrix/etc.). The move-list in §3.1 stays exactly
+ Charon's existing seven HTTP providers + email — no more (six at the time this note was first
+ drafted; Telegram was folded in as the seventh per §7 risk 1e/§3.6 step 6, since Charon already
+ supports it). The API design in §3.3.3, however, is shaped so that adding providers later is
+ additive, not a breaking change — see the new §8 for the explicit tradeoff and what was (and
+ wasn't) built now to support that.
+
+---
+
+## 1. Introduction
+
+### 1.1 Objective
+
+Charon's owner now maintains multiple projects and wants a **standalone, reusable Go module** for
+notification delivery (SSRF-safe outbound HTTP dispatch, retries, provider payload templating) so
+future projects — and Charon itself — can `go get` it instead of re-implementing notification
+delivery from scratch each time.
+
+### 1.2 Goals
+
+- Produce an exact inventory of what moves to the new module vs. what stays in Charon, with a
+ one-line reason for each file.
+- Identify every point where the current engine reaches into Charon-internal code, and define a
+ dependency-injection seam that removes that coupling.
+- Define the new module's public Go API, generic enough for an unrelated project to adopt.
+- Define the new repo's structure, versioning/release strategy, and CI shape.
+- Define the Charon-side migration plan for consuming the new module once it exists.
+- Surface open questions/risks that the extraction session must resolve or confirm with the user
+ before touching code.
+
+### 1.3 Non-goals
+
+- No new repository is created here.
+- No files are moved, no import paths are rewritten, no code is written under this spec.
+- No decision is made on the new repo's exact GitHub org/visibility — that's the user's call when
+ they set up the workspace.
+
+---
+
+## 2. Research Findings
+
+### 2.1 Existing architecture summary
+
+Charon's notification surface spans four layers, and they are **not** equally coupled:
+
+| Layer | Location | Coupling to Charon |
+|---|---|---|
+| Delivery primitive | `backend/internal/notifications/` | Imports `internal/network` + `internal/security` (SSRF guards) directly. Otherwise pure Go, no GORM, no DB, no Charon config. |
+| Orchestration/business logic | `backend/internal/services/notification_service.go`, `security_notification_service.go`, `enhanced_security_notification_service.go` | Heavily coupled: `*gorm.DB`, `models.NotificationProvider`/`NotificationConfig`, Charon's `Setting` table for feature flags, Charon's `logger`/`util`/`trace` packages, `MailServiceInterface` (Charon SMTP), Charon-branded strings ("[Charon Alert]"), Charon domain concepts (`HostName`, `ServiceCount`, `proxy_host`/`remote_server`/`domain`/`cert`/`uptime`/`security_*` event types). |
+| Persistence | `backend/internal/models/notification*.go` | GORM models with `BeforeCreate` hooks, `gorm:` tags — pure Charon persistence. |
+| Presentation | `frontend/src/{api,pages,hooks,components}/*notification*` | React/TanStack Query UI wired to Charon's REST API and design system. |
+
+This four-layer split is the central finding of this spec: **the reusable "engine" the user asked
+for is materially smaller than the full notification feature.** Section 3.1 lays out the exact
+line, now revised for full provider-layer scope (see the revision note above).
+
+### 2.2 `backend/internal/notifications/` package (the current "engine")
+
+| File | LOC | Purpose |
+|---|---|---|
+| `engine.go` | 23 | `DeliveryEngine` interface + `DispatchRequest` struct. **Dead code** — grep confirms nothing outside this file implements or references `DeliveryEngine`; `EngineNotifyV1` const is unused elsewhere. |
+| `feature_flags.go` | 15 | String constants naming Charon `Setting`-table keys (e.g. `feature.notifications.service.discord.enabled`). These are Charon policy labels, not engine behavior — the lookup logic lives in `notification_service.go` (`getFeatureFlagValue`), not in this package. |
+| `http_client_executor.go` | 8 | Thin `client.Do` wrapper, exists purely as a test seam. |
+| `router.go` | 38 | `Router.ShouldUseNotify()`. Comment in the file itself says `// NOTE: used only in tests`. Grep confirms: zero production call sites outside `router_test.go`. **Dead code.** |
+| `http_wrapper.go` | 541 | The real engine: `HTTPWrapper.Send()` — SSRF-hardened outbound POST with retry/backoff (`RetryPolicy`), redirect guarding, response size caps (256 KiB request / 1 MiB response), header allowlisting, provider error-hint extraction, transport error sanitization. This is genuinely reusable and provider-agnostic. |
+| `http_wrapper_test.go`, `router_test.go` | — | Unit tests, ~31 KB combined coverage of the above. |
+
+**Coupling point**: `http_wrapper.go` imports `internal/network` (for `network.NewSafeHTTPClient`,
+`network.Option`, `network.IsPrivateIP`) and `internal/security` (for `security.ValidateExternalURL`,
+`security.ValidationOption`). Confirmed via `grep -rl` that both packages are **shared Charon
+infrastructure** used well beyond notifications — Caddy client, CrowdSec integration, uptime
+monitoring, auth, config, remote-storage SSRF guards. They must **not** move into the new module;
+they need a DI seam instead (see §3.2).
+
+Both `internal/network` and `internal/security` were read in full: neither imports GORM, Charon
+models, or Charon config beyond `os.Getenv` for two env var overrides
+(`CHARON_NOTIFY_ALLOW_HTTP`, `CHARON_NOTIFY_MAX_REDIRECTS`, both read inside
+`internal/notifications/http_wrapper.go` itself, not in `network`/`security`). This means the seam
+is narrow: two small interfaces, not a deep dependency tree.
+
+### 2.3 `backend/internal/services/notification_service.go` (35.7 KB — the real feature logic)
+
+Confirmed via read of `SendExternal`, `sendJSONPayload`, `dispatchEmail`,
+`emailTemplateForEventType`, `RenderTemplate`, and the CRUD methods:
+
+- `sendJSONPayload` builds provider JSON payloads from Go `text/template`, with two built-in
+ templates (`minimal`, `detailed`) referencing Charon-specific fields: `HostName`, `HostIP`,
+ `ServiceCount`, `Services`. Operates directly on `models.NotificationProvider` (GORM struct), not
+ a generic config type.
+- `dispatchEmail` hardcodes the subject prefix `"[Charon Alert] %s"` and delegates to
+ `MailServiceInterface` (Charon's SMTP service) for template rendering (`email_security_alert.html`,
+ `email_ssl_event.html`, etc.) — those HTML templates live in Charon's mail service, not in
+ `internal/notifications`.
+- `SendExternal` filters providers by Charon domain event types (`proxy_host`, `remote_server`,
+ `domain`, `cert`, `uptime`, `security_waf`, `security_acl`, `security_rate_limit`,
+ `security_crowdsec`, `test`) matched against per-provider boolean columns on the GORM model.
+- Feature-flag gating (`isDispatchEnabled` → `getFeatureFlagValue`) reads `models.Setting` rows via
+ `s.DB` directly — this is Charon's own settings/feature-flag system, not something the new module
+ should own.
+- `httpWrapper *notifications.HTTPWrapper` is the **only** call from this file into the
+ `internal/notifications` package for actual delivery — confirms `HTTPWrapper.Send` is the true
+ reusable primitive and everything else in this file is Charon-specific orchestration built on top
+ of it.
+
+**Conclusion**: this file is not "the engine with some Charon glue" — it's Charon's *product
+feature* built on top of a much smaller generic engine. Fully genericizing it (replacing
+`models.NotificationProvider` with a generic config struct, replacing `HostName`/`ServiceCount`
+with a generic `Data map[string]any`, extracting the Discord/Slack/Gotify/Pushover/Ntfy/webhook
+payload-building into provider packages, defining a `Mailer` interface for email) is real design
+and implementation work, not a mechanical move. **This is now in scope for the same extraction —
+see the revised §3.1 for the function-level split.**
+
+### 2.4 `security_notification_service.go` / `enhanced_security_notification_service.go`
+
+Both operate on `models.SecurityEvent` / `models.NotificationConfig` (GORM), and encode
+Charon-specific security taxonomy (WAF blocks, ACL denies, rate-limit hits, CrowdSec decisions —
+i.e. Charon's own proxy/WAF feature surface). `enhanced_security_notification_service.go` additionally
+implements a legacy-config migration path (`MigrateFromLegacyConfig`, `computeConfigChecksum`)
+that is pure Charon schema-evolution logic. Neither belongs in a generic notifications module —
+they are consumers of it, not part of it.
+
+### 2.5 `backend/internal/models/notification*.go`
+
+All four files (`notification.go`, `notification_config.go`, `notification_provider.go`,
+`notification_template.go`) are GORM models with `gorm:` struct tags and `BeforeCreate` UUID
+hooks. Pure persistence — stay in Charon by definition. A generic module must not depend on GORM
+at all (a future adopter may use Postgres, a different ORM, or no DB).
+
+### 2.6 `backend/integration/notification_http_wrapper_integration_test.go`
+
+Build-tagged `integration` test exercising `notifications.NewNotifyHTTPWrapper()` directly against
+an `httptest.Server` (retry-on-429, no-retry-on-400, tokenized-query rejection). This test only
+exercises the `HTTPWrapper` — it has zero dependency on Charon models/DB. It is a strong candidate
+to move with the engine (it's effectively already an engine-level integration test), with a thin
+Charon-side replacement or deletion once the import path changes.
+
+### 2.7 `docs/features/notifications.md`
+
+Documents the product feature end-to-end (provider setup, JSON template variables including
+Charon-specific ones like `{{.HostName}}`, migration guide, "Charon Test" wording, links to
+`github.com/Wikid82/charon`). This is Charon user documentation, not module documentation — stays
+in Charon. The new module will need its own README/docs describing its generic API, written fresh
+during the extraction session (not migrated from this file).
+
+### 2.8 Frontend
+
+Confirmed by line count and a read of the API client shape: `frontend/src/api/notifications.ts`
+(271 LOC), `pages/Notifications.tsx` (758 LOC), `hooks/useNotifications.ts` (53 LOC),
+`components/NotificationCenter.tsx` (157 LOC), plus their tests. All talk to Charon's REST API
+(`/api/notifications`, `/api/notification-providers`, etc.) and render Charon's design system. This
+is UI for Charon's product feature, not the engine. **Confirms the assumption in the task context
+explicitly**: none of this moves. A Go module has no frontend; a *different* consuming project
+would build its own UI (or none) against its own backend, not reuse Charon's React components.
+
+### 2.9 External dependencies / prior art
+
+- The org already publishes and releases via GoReleaser (`.goreleaser.yaml` at repo root, driven by
+ `.github/workflows/release-goreleaser.yml`) and has an `auto-versioning` workflow tied to
+ Conventional Commits. The new module should reuse this exact pattern rather than invent a new one
+ — same maintainer, same tooling, lower cognitive overhead.
+- No `.codecov.yml` exists at the repo root currently (checked); Charon's coverage gate is enforced
+ via `scripts/go-test-coverage.sh` instead. The new module should carry its own equivalent
+ lightweight script rather than depend on Charon's.
+- Go module path convention: Charon's backend module is
+ `github.com/Wikid82/charon/backend` (go 1.26.6). The new module should follow the same GitHub org
+ (`Wikid82`) unless the user decides otherwise when creating the repo.
+
+---
+
+## 3. Technical Specifications
+
+### 3.1 Exact inventory — move list vs. stay list
+
+**Decision (resolved by the user): full provider-layer scope, in this same extraction.** The
+Discord/Slack/Gotify/Pushover/Ntfy/Telegram/webhook payload builders and email dispatch are
+genericized and moved into the new module now — not deferred to a v0.2. This supersedes the Phase-1-only
+recommendation this section previously carried; risk #1 in §7 (previously "scope question, flag to
+user") is now resolved and reframed as a behavior-parity risk instead.
+
+This re-read `notification_service.go` (1029 lines) in full, plus `mail_service.go` and the
+`templates/*.html` files, to pin exact function boundaries rather than guessing. The seam (per the
+revision brief) is: Charon's service layer maps its GORM `NotificationProvider` row + Charon event
+data into the new module's generic `Message` type and a provider-specific `Config`, calls the
+module's `Sender`/email `Mailer` to dispatch, and logs/persists the result. The module itself ends
+up with **zero** imports of GORM, `models`, or any `github.com/Wikid82/charon/*` package.
+
+#### 3.1.1 Moves to the new module — delivery primitive (unchanged from prior draft)
+
+| File | Reason |
+|---|---|
+| `http_wrapper.go` | SSRF-hardened dispatch, retries, header sanitization. Zero Charon-domain knowledge — only needs the two DI seams in §3.2. |
+| `http_wrapper_test.go` | Moves with its subject. |
+| `http_client_executor.go` | Test seam used by `http_wrapper.go`. |
+| `backend/integration/notification_http_wrapper_integration_test.go` | Exercises only `HTTPWrapper` (§2.6). |
+
+#### 3.1.2 Moves to the new module — provider payload/dispatch logic, function-level (NEW scope)
+
+All of the following are read directly out of `notification_service.go`'s `sendJSONPayload` (lines
+383–677), `RenderTemplate` (788–832), and `dispatchEmail`/`sanitizeForEmail` (286–358), and mapped to
+their destination package. None of these are simple moves — each is genericized per §3.3.
+
+| Current location (`notification_service.go`) | Logic | Destination |
+|---|---|---|
+| `minimalTemplate`/`detailedTemplate` consts (385–386, duplicated 792–793) | Built-in JSON templates | `providers/webhook`, genericized: `.HostName`/`.HostIP`/`.ServiceCount`/`.Services` top-level fields replaced with a single `{{toJSON .Data}}` (see "payload shape change" risk in §7). |
+| Template parse/exec core (388–447, 811–825) — `text/template` + `toJSON` funcmap, 10 KB size cap, 5 s exec timeout | Shared rendering engine | `providers/internal/render` (unexported, shared by all seven provider packages — avoids 7x duplication of the same template plumbing). |
+| `discordWebhookRegex`, `allowedDiscordWebhookHosts`, `normalizeURL`, `validateDiscordWebhookURL`, `validateDiscordProviderURL` (63–127) | Discord webhook URL shape/host validation | `providers/discord` |
+| Discord payload normalization (`content`/`embeds` fallback, 458–475) | Discord-specific JSON shape | `providers/discord` |
+| `slackWebhookRegex`, `validateSlackWebhookURL` (70–77) | Slack webhook URL shape validation | `providers/slack` |
+| Slack payload normalization (`text`/`blocks` fallback, 476–493) + webhook-token substitution (577–586) | Slack-specific JSON shape + dispatch URL resolution | `providers/slack` |
+| Gotify `message`-field validation (494–498) + `X-Gotify-Key` header (544–548) | Gotify-specific JSON shape + auth header | `providers/gotify` |
+| Pushover `message`-field/priority validation (516–524) + URL build, token/user injection, hostname pin (594–627) | Pushover-specific JSON shape + dispatch URL/auth | `providers/pushover` |
+| Ntfy `message`-field validation (525–528) + `Authorization: Bearer` header (588–592) | Ntfy-specific JSON shape + auth header | `providers/ntfy` |
+| Telegram `text`-field validation with `message`-field fallback (499–515) + dispatch URL build from `telegramAPIBaseURL + "/bot" + token + "/sendMessage"` with hostname-pin check, `chat_id` injection from `p.URL` (550–575) | Telegram-specific JSON shape + dispatch URL/auth (bot token embedded in URL path, not a header; `p.URL` repurposed as chat ID) | `providers/telegram` |
+| Generic/custom webhook dispatch (the plain `webhook`/`generic` case) | Passthrough JSON dispatch, no provider-specific shape | `providers/webhook` |
+| `isValidRedirectURL` (685–697) | Generic URL sanity check used before Discord dispatch | Moves with Discord validation into `providers/discord` (only call site). |
+| `webhookDoRequestFunc` test hook (375–377) | Test seam for the raw-dispatch path | **Dropped**, not ported — redundant with `http_client_executor.go`'s seam once Discord/webhook dispatch is consolidated onto the shared `transport.Wrapper` (see flagged inconsistency below). One test seam per module, not two. |
+| `RenderTemplate` (788–832) | Template preview/validation for the provider-editor UI | Logic moves to `providers/webhook.RenderPreview(tmplStr string, msg notify.Message) (json string, parsed any, err error)` — a public function, reusable for previewing any of the seven provider types since they all share the same template mechanism today. Charon's `CreateProvider`/`UpdateProvider` keep a thin wrapper extracting `.Config`/`.Template` from the GORM row and calling it. |
+| `sanitizeForEmail` (286–299) | Control-char stripping for email hygiene | `providers/email` — generic, zero Charon dependency already. |
+| `dispatchEmail`'s message composition (safeTitle/safeMessage, subject formatting, `EmailTemplateData` construction; 322–350) | Email message assembly | `providers/email`, genericized: subject becomes `Config.SubjectPrefix + msg.Title` (prefix `""` by default, no `"[Charon Alert]"` baked in — see §3.3.4). |
+
+**Flagged inconsistency found on this re-read, resolved as part of the extraction:** the plain
+`webhook`/`generic` dispatch path (lines 639–676) does **not** go through `httpWrapper.Send` today —
+unlike gotify/webhook-JSON/telegram/slack/pushover/ntfy (line 531's list, which *does* include
+`"webhook"` for the JSON-template path), this fallback branch calls `security.ValidateExternalURL` +
+`network.NewSafeHTTPClient` directly, bypassing the shared engine's retry/backoff entirely. This
+fallback branch is in practice the **Discord** path plus the literal `"generic"` provider type,
+since every other supported type is caught by the line-531 list first. Recommend consolidating
+`providers/discord` and `providers/webhook`'s dispatch onto the shared `transport.Wrapper` (via the
+Seam 1/2 DI in §3.2) for consistency. This is a genuine behavior change (retry/backoff semantics,
+not just a refactor) — called out as a new risk in §7, not silently folded in.
+
+#### 3.1.3 Stays in Charon (per the revision brief) — GORM CRUD, flag gating, event routing
+
+| Function(s) | Reason |
+|---|---|
+| `SendExternal` (215–284) | Event-type filtering against Charon domain concepts (`proxy_host`/`remote_server`/`domain`/`cert`/`uptime`/`security_*`) mapped to `models.NotificationProvider` boolean columns, plus the GORM `Find` query. Becomes the seam: after filtering + flag-check, maps provider row + event data into `notify.Message` + provider `Config`, calls the module's `Sender`, logs the result. |
+| `isDispatchEnabled`, `getFeatureFlagValue` (148–180) | DB-backed feature-flag gating via `models.Setting` — explicitly named as staying in the revision brief. |
+| `emailTemplateForEventType` (360–371) | Charon event-type → HTML template name mapping. Stays; becomes the `TemplateName` selector Charon passes into its `providers/email` adapter (§3.3.4). |
+| `Create`/`List`/`MarkAsRead`/`MarkAllAsRead` (184–211) | Pure GORM CRUD for the in-app `Notification` bell/log — never part of the engine. |
+| `ListProviders`/`CreateProvider`/`UpdateProvider`/`DeleteProvider` (836–946) | Provider GORM CRUD + Charon's own field-level validation (type immutability, token retention rules). Calls the module's new `providers/webhook.RenderPreview` for custom-template validation (thin wrapper, per §3.1.2). |
+| `ListTemplates`/`GetTemplate`/`CreateTemplate`/`UpdateTemplate`/`DeleteTemplate` (756–786) | GORM CRUD for `NotificationTemplate` rows. |
+| `isSupportedNotificationProviderType`, `supportsJSONTemplates` (129–146) | Charon's own provider-type allowlist gating its REST API/UI input — mirrors the module's provider set but is Charon's validation boundary, not module logic. **Note**: both include `"telegram"`, which is now in scope as the module's seventh provider (§7 risk 1e, resolved). |
+| `EnsureNotifyOnlyProviderMigration` (948–1029) | Pure Charon schema-evolution/migration logic (Discord-only rollout reconciliation). Unrelated to the engine. |
+| `TestProvider`/`TestEmailProvider` (699–753) | Backing logic for Charon's REST "send test notification" handlers. Become thin adapters: build a `notify.Message`, call the module's `Sender`/`email` client, same seam as `SendExternal`. |
+
+**Deleted, not ported (dead code confirmed on this re-read):**
+- `isPrivateIP(ip net.IP) bool` (679–683) in `notification_service.go` — a wrapper around
+ `network.IsPrivateIP` with **zero call sites within the file itself** (confirmed by reading the
+ full 1029 lines; the identically-named functions in `access_list_service.go` and the
+ `hecate/providers/{netbird,zerotier}` clients are unrelated, independently-defined helpers). Drop
+ entirely rather than carry forward.
+
+#### 3.1.4 Stays in Charon — unchanged (persistence, security infra, unrelated features)
+
+| File / area | Reason |
+|---|---|
+| `backend/internal/models/notification.go`, `notification_config.go`, `notification_provider.go`, `notification_template.go` | GORM persistence models; a generic module must not depend on GORM. |
+| `backend/internal/services/security_notification_service.go`, `enhanced_security_notification_service.go` + their tests | Charon-specific security event taxonomy (WAF/ACL/rate-limit/CrowdSec) and legacy-config migration logic. Consumers of the engine, not part of it. |
+| `docs/features/notifications.md` | Charon user-facing product documentation; the new module gets its own fresh README. |
+| `frontend/src/api/notifications.ts`, `pages/Notifications.tsx`, `hooks/useNotifications.ts`, `components/NotificationCenter.tsx` + tests | Charon product UI, confirmed to have no reusable-module role (§2.8). |
+| `backend/internal/network/*`, `backend/internal/security/*` | Shared Charon infrastructure used by Caddy, CrowdSec, uptime monitoring, auth, config — far beyond notifications (confirmed by repo-wide grep in §2.2). Stay in Charon; the new module receives DI seams instead (§3.2). |
+| `mail_service.go`'s SMTP transport (`SendEmail`, `GetSMTPConfig`, connection handling) and the five HTML templates (`templates/*.html`) | SMTP credentials/connection lifecycle and Charon's branded, event-differentiated email design (`email_base.html` says "Charon" / "Charon Reverse Proxy Manager"). Charon supplies these behind the module's `Mailer`/`TemplateRenderer` interfaces (§3.3.4) — they do not move. |
+
+### 3.2 Coupling points to decouple
+
+The delivery-primitive coupling point is unchanged from the prior draft: `http_wrapper.go`'s use of
+`internal/network` and `internal/security` for SSRF-safe HTTP client construction and destination
+URL validation. With full provider-layer scope, this same seam is now consumed by **every** HTTP-based
+provider package (`discord`, `slack`, `gotify`, `pushover`, `ntfy`, `telegram`, `webhook`) via the shared
+`transport.Wrapper` (§3.3.1/§3.5), not just by a single Charon call site — which is exactly what
+resolves the discord-dispatch inconsistency flagged in §3.1.2. The email package (§3.3.4) has no
+equivalent coupling: it never touches `internal/network`/`internal/security` at all, since SMTP
+transport stays entirely behind the host-supplied `Mailer` interface.
+
+#### Seam 1 — safe HTTP client factory
+
+```go
+// new module: package transport (see §3.5 for the module layout)
+
+// ClientFactory builds the *http.Client used for outbound provider requests.
+// The host application is responsible for SSRF hardening (private-IP blocking,
+// DNS-rebinding protection, redirect limits) inside its implementation.
+type ClientFactory func(allowHTTP bool, maxRedirects int) *http.Client
+```
+
+Charon supplies, at the call site where it constructs the wrapper:
+
+```go
+// Charon side, e.g. in services/notification_service.go or a small adapter file
+factory := func(allowHTTP bool, maxRedirects int) *http.Client {
+ opts := []network.Option{network.WithTimeout(10 * time.Second), network.WithMaxRedirects(maxRedirects)}
+ if allowHTTP {
+ opts = append(opts, network.WithAllowLocalhost())
+ }
+ return network.NewSafeHTTPClient(opts...)
+}
+wrapper := transport.NewWrapper(transport.WithClientFactory(factory), ...)
+```
+
+#### Seam 2 — destination URL validator
+
+```go
+// new module
+
+// URLValidator validates and normalizes a destination URL before dispatch,
+// returning the (possibly normalized) URL or an error if the destination is
+// disallowed. Implementations are expected to enforce the host application's
+// SSRF policy (private-IP blocking, scheme allowlisting, etc.).
+type URLValidator func(rawURL string, allowHTTP bool) (string, error)
+```
+
+Charon supplies an adapter that calls `security.ValidateExternalURL` with the equivalent
+`WithAllowHTTP()`/`WithAllowLocalhost()` options translated from the bool flag.
+
+#### Seam 3 — private-IP / destination guard (used by `guardDestination`/`isAllowedDestinationIP`)
+
+The current code calls `network.IsPrivateIP(ip)` directly inside `HTTPWrapper.guardDestination`.
+Fold this into the same `URLValidator` contract by making validator responsible for **all**
+destination-safety decisions (scheme, host, IP-literal, DNS-resolved IPs) rather than splitting
+SSRF logic between the module and the callback. This keeps the new module's dependency-injection
+surface to exactly two functional options (`WithClientFactory`, `WithURLValidator`) instead of
+three overlapping ones, and avoids the module re-implementing partial SSRF logic that could drift
+from Charon's `network.IsPrivateIP`.
+
+**No-op / minimal default**: ship the module with a conservative built-in default validator (reject
+non-HTTPS, reject IP literals resolving to RFC 1918/loopback/link-local/reserved ranges — i.e., a
+self-contained reimplementation of the *IP classification* portion only, which has zero
+Charon-specific dependencies as confirmed in §2.2) so the module is immediately useful standalone
+without forcing every consumer to write SSRF logic from scratch. Charon overrides this default with
+its own `network`/`security`-backed validator via `WithURLValidator` to keep single-source-of-truth
+SSRF policy. This is explicitly called out as an **open question** in §7 — it duplicates ~140 LOC of
+IP-classification logic between Charon's `internal/network` and the new module's default validator,
+which is an acceptable, bounded duplication (public IP-range constants, not business logic) but
+should be a conscious choice, not an accident.
+
+#### Seam 4 — env-var overrides (`CHARON_NOTIFY_ALLOW_HTTP`, `CHARON_NOTIFY_MAX_REDIRECTS`)
+
+Currently read via `os.Getenv` directly inside `http_wrapper.go`. Replace with constructor
+parameters (`allowHTTP bool`, `maxRedirects int`) passed by the caller. Charon's
+`NewNotificationService`/adapter reads its own env vars (still named `CHARON_NOTIFY_*` for
+backward compatibility with existing deployments) and passes the resolved values in. This removes
+the module's only direct env/config coupling and makes it framework-agnostic (a consumer using
+Viper, flags, or hardcoded config all work identically).
+
+### 3.3 Public API surface (new module)
+
+The module now has four layers, not one: the delivery primitive (§3.3.1, unchanged design from the
+prior draft, just relocated to a `transport` subpackage — see the naming note in §3.5/§7), a
+generic `Message` type shared by every provider (§3.3.2), a `Sender` interface implemented per
+provider package (§3.3.3), and an email-specific `Mailer`/`TemplateRenderer` design (§3.3.4). Only
+§3.3.1 was in the original Phase-1 scope; §§3.3.2–3.3.4 are new to this revision.
+
+#### 3.3.1 Delivery primitive (`transport` package)
+
+```go
+package transport
+
+// Wrapper dispatches outbound notification payloads with SSRF-safe validation,
+// retry/backoff, and response-size caps.
+type Wrapper struct { /* unexported */ }
+
+// Option configures a Wrapper at construction time.
+type Option func(*wrapperConfig)
+
+func WithClientFactory(f ClientFactory) Option
+func WithURLValidator(v URLValidator) Option
+func WithRetryPolicy(p RetryPolicy) Option
+func WithAllowHTTP(allow bool) Option
+func WithMaxRedirects(n int) Option
+
+func NewWrapper(opts ...Option) *Wrapper
+
+type RetryPolicy struct {
+ MaxAttempts int
+ BaseDelay time.Duration
+ MaxDelay time.Duration
+}
+
+type Request struct {
+ URL string
+ Headers map[string]string
+ Body []byte
+}
+
+type Result struct {
+ StatusCode int
+ ResponseBody []byte
+ Attempts int
+}
+
+func (w *Wrapper) Send(ctx context.Context, req Request) (*Result, error)
+```
+
+This is a rename/generalization of `HTTPWrapper`/`HTTPWrapperRequest`/`HTTPWrapperResult`. Behavior
+(retry/backoff, header allowlist, size caps, redirect re-validation) is unchanged from
+`http_wrapper.go`. **What's new in this revision**: every HTTP-based provider package in §3.3.3
+calls into this same `Wrapper` rather than making its own `net/http` calls — this is what resolves
+the discord/generic-path inconsistency flagged in §3.1.2 (today, Discord dispatch bypasses the
+wrapper entirely).
+
+#### 3.3.2 Generic `Message` type (module root package, proposed `notify`)
+
+Replaces Charon's `HostName`/`ServiceCount`-style fields with something generic enough for an
+unrelated project's domain:
+
+```go
+package notify
+
+// Message is the generic, provider-agnostic notification payload. Host
+// applications map their own domain events into a Message before calling a
+// Sender or the email package's client.
+type Message struct {
+ // Title is a short headline (was Charon's data["Title"]).
+ Title string
+
+ // Body is the human-readable message text (was Charon's data["Message"]).
+ Body string
+
+ // EventType is a free-form, host-defined category string. Provider
+ // packages treat it as an opaque template field only — never for
+ // routing, access-control, or filtering decisions. (Charon's own
+ // proxy_host/cert/uptime/security_* routing logic stays entirely in
+ // Charon's SendExternal — see §3.1.3. A different adopter would define
+ // its own event-type vocabulary; the module has no opinion on it.)
+ EventType string
+
+ // Timestamp defaults to time.Now() if zero when Send is called.
+ Timestamp time.Time
+
+ // Data holds arbitrary structured extras (replaces Charon's
+ // HostName/HostIP/ServiceCount/Services fields). Provider templates
+ // expose it as {{toJSON .Data}} or {{index .Data "key"}}.
+ Data map[string]any
+}
+```
+
+#### 3.3.3 `Sender` interface and provider packages
+
+```go
+package notify
+
+// Sender dispatches a Message through one specific provider's transport and
+// payload shape. Every providers/* package (including providers/email)
+// returns a type implementing this, so a host application can treat all
+// configured destinations uniformly.
+type Sender interface {
+ Send(ctx context.Context, msg Message) error
+}
+```
+
+Each HTTP-based provider package owns its own `Config`, URL/token validation, JSON payload shape,
+and header/auth construction (per the function-level table in §3.1.2), but shares the same template
+engine (`providers/internal/render`, unexported) and the same `transport.Wrapper` for dispatch.
+Two representative examples — the rest (`slack`, `gotify`, `pushover`, `ntfy`, `telegram`) follow the
+identical shape, differing only in `Config` fields and the provider-specific validation/header logic
+already itemized in §3.1.2:
+
+```go
+package discord
+
+type Config struct {
+ WebhookURL string // validated against discord.com/canary.discord.com per §3.1.2
+ Template string // "minimal" | "detailed" | "custom"
+ CustomTemplate string
+}
+
+func New(cfg Config, w *transport.Wrapper) *Client
+func (c *Client) Send(ctx context.Context, msg notify.Message) error
+```
+
+```go
+package webhook
+
+type Config struct {
+ URL string // arbitrary destination; no host allowlist (unlike discord/slack)
+ Template string
+ CustomTemplate string
+}
+
+func New(cfg Config, w *transport.Wrapper) *Client
+func (c *Client) Send(ctx context.Context, msg notify.Message) error
+
+// RenderPreview renders tmplStr against msg without dispatching — used by a
+// host app's provider-editor UI to validate a custom template before saving
+// it. Replaces Charon's RenderTemplate (§3.1.2); reusable for previewing any
+// of the seven provider types since they share the same template mechanism.
+func RenderPreview(tmplStr string, msg notify.Message) (rendered string, parsed any, err error)
+```
+
+Telegram, unlike in earlier drafts of this spec, **is** one of these packages — the user confirmed
+including it as the module's seventh provider (§7 risk 1e, resolved; §3.6 step 6), so it moves and
+is cut over on the same per-provider commit pattern as the other six (§6).
+
+**Extensibility design, without building it yet (see §8):** the uniform `Sender` interface and the
+"each provider is a fully self-contained package, importable independently, with no central
+switch-statement inside the module" convention are chosen deliberately so that a future
+Apprise-style registry (e.g. `notify.Register(scheme string, factory func(cfg string) (Sender,
+error))`, letting a caller dispatch off a URL like `discord://...`) can be bolted on later as pure
+addition — no existing provider package needs to change shape to support it. This extraction does
+**not** build that registry: with seven known providers and one consumer (Charon), a generic
+registry mechanism today would be premature abstraction. The provider-type-string-to-package
+mapping stays where it already naturally lives — Charon's own `notify_provider_adapter.go` (§3.6) —
+rather than inside the module, which is exactly the seam a future registry would replace without
+touching `providers/discord`, `providers/slack`, etc. individually.
+
+#### 3.3.4 `Mailer`/`TemplateRenderer` and the `providers/email` package
+
+Unlike the other five providers, email dispatch today lives entirely in Charon's `mail_service.go`
+— SMTP transport, connection lifecycle, and five branded HTML templates
+(`email_base.html`/`email_security_alert.html`/`email_ssl_event.html`/`email_uptime_event.html`/
+`email_system_event.html`, all saying "Charon" / "Charon Reverse Proxy Manager"). There is no
+existing `internal/notifications` email code to move — this is new abstraction design layered over
+existing Charon code, which makes it the highest-design-risk piece of this extraction (see §7).
+
+**Design decision**: the module never dials SMTP and never renders HTML directly by default. Two
+interfaces isolate the two concerns the host application owns:
+
+```go
+package email
+
+// Mailer transports an already-composed email. The host application owns
+// SMTP configuration, authentication, and connection lifecycle — the module
+// never sees credentials.
+type Mailer interface {
+ Send(ctx context.Context, recipients []string, subject, htmlBody string) error
+}
+
+// TemplateRenderer renders an HTML email body for a Message using a
+// host-selected template name. Optional: if the host doesn't supply one,
+// the package falls back to its own single neutral built-in template.
+type TemplateRenderer interface {
+ Render(templateName string, msg notify.Message) (htmlBody string, err error)
+}
+
+type Config struct {
+ Recipients []string
+ SubjectPrefix string // "" by default — no "[Charon Alert]" baked in
+ TemplateName func(msg notify.Message) string // optional; host's event-type -> template-name mapping. nil = constant "default".
+ Renderer TemplateRenderer // optional; nil uses the package's built-in neutral template
+ Mailer Mailer // required
+}
+
+func New(cfg Config) *Client
+func (c *Client) Send(ctx context.Context, msg notify.Message) error // implements notify.Sender
+```
+
+**Tradeoff, decided**: ship exactly **one** neutral, unbranded, inline-styled default HTML template
+in the module (no logo, no product name) so the module is immediately useful standalone with zero
+config — a bare `Mailer` implementation is enough to get working email out of a fresh adopter.
+Charon **must** override `Renderer` (wrapping its existing `MailServiceInterface.RenderNotificationEmail`
+and its five branded templates) and `TemplateName` (wrapping `emailTemplateForEventType`, which
+stays in Charon per §3.1.3) — this is not optional, since dropping to the module's neutral default
+would be a user-visible regression of Charon's existing branded, event-differentiated email design.
+This requirement is called out explicitly in the Charon migration plan (§3.6) and as a risk in §7,
+not left implicit.
+
+`sanitizeForEmail`'s control-char stripping (§3.1.2) is applied unconditionally inside `Send` to
+`msg.Title`/`msg.Body` before either subject formatting or template rendering — generic hygiene,
+zero Charon dependency, no reason to make it optional.
+
+**Branding removal, applied module-wide**: the `User-Agent: Charon-Notify/1.0` header
+(`sendJSONPayload`, line 534) becomes a generic module default (e.g. `notify-transport/1.0`),
+overridable per-provider-package `Config` if a host wants its own UA string. `"[Charon Alert]"` and
+`"[Charon Test]"` become Charon-side `SubjectPrefix` values passed into the adapter (§3.6), not
+module defaults.
+
+### 3.4 Database schema changes
+
+None. This extraction touches zero database schema — all persistence stays in Charon's
+`internal/models`.
+
+### 3.5 New repo structure
+
+Provider-specific senders are now built out for real (the prior draft sketched a `providers/`
+layout as "not built now" — that's flipped). **The repo already exists** at
+`/projects/go_notify_yourself` (`github.com/Wikid82/go_notify_yourself`, currently just `LICENSE` +
+a placeholder `README.md`) — the extraction session scaffolds into it, it does not create a new
+repo. The Go package name at the module root is `notify` (see the rev-2 note at the top of this
+document for why that's a deliberate, unproblematic mismatch with the directory/module name).
+
+```
+go_notify_yourself/ # existing repo at /projects/go_notify_yourself — see rev-2 note
+├── go.mod # module github.com/Wikid82/go_notify_yourself
+├── go.sum
+├── LICENSE # match Charon's license
+├── README.md # public API docs, usage examples per provider, SSRF-seam explanation
+├── CHANGELOG.md # Keep a Changelog format, driven by Conventional Commits
+├── .goreleaser.yaml # mirrors Charon's root .goreleaser.yaml, Go-module release only
+├── .github/
+│ └── workflows/
+│ ├── ci.yml # go test ./..., go vet, staticcheck, coverage gate
+│ └── release.yml # tag-triggered GoReleaser run
+├── message.go # Message struct (§3.3.2)
+├── sender.go # Sender interface (§3.3.3)
+├── message_test.go
+├── transport/ # the SSRF-safe delivery primitive (§3.3.1)
+│ ├── wrapper.go # Wrapper, Option, NewWrapper, Send — from http_wrapper.go
+│ ├── wrapper_test.go
+│ ├── client_executor.go # test seam, from http_client_executor.go
+│ ├── retry.go # RetryPolicy + backoff/jitter helpers
+│ ├── validate_default.go # built-in conservative URLValidator default (§3.2 Seam 3)
+│ ├── validate_default_test.go
+│ └── integration/
+│ └── wrapper_integration_test.go # from backend/integration/notification_http_wrapper_integration_test.go
+└── providers/
+ ├── internal/
+ │ └── render/ # unexported shared text/template + toJSON engine (§3.1.2)
+ │ ├── render.go
+ │ └── render_test.go
+ ├── discord/
+ │ ├── discord.go # Config, New, (*Client).Send — webhook validation, content/embeds normalization
+ │ └── discord_test.go
+ ├── slack/
+ │ ├── slack.go # Config, New, (*Client).Send — webhook validation, text/blocks normalization
+ │ └── slack_test.go
+ ├── gotify/
+ │ ├── gotify.go # Config, New, (*Client).Send — X-Gotify-Key header, message-field validation
+ │ └── gotify_test.go
+ ├── pushover/
+ │ ├── pushover.go # Config, New, (*Client).Send — token/user injection, hostname pin
+ │ └── pushover_test.go
+ ├── ntfy/
+ │ ├── ntfy.go # Config, New, (*Client).Send — Bearer auth header
+ │ └── ntfy_test.go
+ ├── telegram/
+ │ ├── telegram.go # Config, New, (*Client).Send — bot-token-in-URL dispatch, chat_id injection, hostname pin
+ │ └── telegram_test.go
+ ├── webhook/
+ │ ├── webhook.go # Config, New, (*Client).Send — generic/custom JSON dispatch
+ │ ├── preview.go # RenderPreview(tmplStr, msg) — public (§3.1.2)
+ │ └── webhook_test.go
+ └── email/
+ ├── email.go # Config, Mailer, TemplateRenderer, New, (*Client).Send (§3.3.4)
+ ├── default_template.go # single neutral, unbranded built-in HTML template
+ └── email_test.go
+```
+
+### 3.6 Migration plan for Charon (after the new module exists)
+
+1. **Add dependency**: `go get github.com/Wikid82/go_notify_yourself@v0.1.0` in `backend/go.mod`.
+2. **Delete extracted files/logic**:
+ - Whole files: `backend/internal/notifications/{http_wrapper,http_wrapper_test,http_client_executor,engine,router,router_test}.go`.
+ - Function-level deletions inside `notification_service.go` (now that the module owns this
+ logic — not kept as dead code): `minimalTemplate`/`detailedTemplate` consts, the template
+ parse/exec block, all Discord/Slack/Gotify/Pushover/Ntfy/Telegram regex/validation/
+ normalization/header/dispatch-URL-build code, `sendJSONPayload` and `RenderTemplate` themselves
+ (replaced by adapter calls), `sanitizeForEmail`, `dispatchEmail`'s message-composition
+ internals, `webhookDoRequestFunc`, and the dead `isPrivateIP` wrapper (§3.1.3).
+ - Keep `feature_flags.go` (per §2.2, it stays — Charon policy, not engine code); fold it into
+ `internal/services` rather than keeping a single-file package once the rest of
+ `internal/notifications` is gone.
+3. **Add Charon-side adapters** (new files):
+ - `notify_client_adapter.go`: wires `network.NewSafeHTTPClient`/`security.ValidateExternalURL`
+ into `transport.ClientFactory`/`URLValidator` (§3.2), resolves `CHARON_NOTIFY_ALLOW_HTTP`/
+ `CHARON_NOTIFY_MAX_REDIRECTS`. One shared `*transport.Wrapper` instance, injected into every
+ HTTP-based provider package.
+ - `notify_provider_adapter.go` (**new**): per-`provider.Type` factory mapping a GORM
+ `models.NotificationProvider` row into the matching `discord.Config`/`slack.Config`/
+ `gotify.Config`/`pushover.Config`/`ntfy.Config`/`telegram.Config`/`webhook.Config` and
+ constructing the corresponding `notify.Sender`.
+ - `notify_email_adapter.go` (**new**): implements `email.Mailer` (wrapping
+ `s.mailService.SendEmail`) and `email.TemplateRenderer` (wrapping
+ `s.mailService.RenderNotificationEmail`), and supplies `TemplateName: emailTemplateForEventType`
+ (kept in Charon, §3.1.3) and `SubjectPrefix: "[Charon Alert] "` to preserve the exact
+ user-visible subject format.
+4. **Update `notification_service.go`**: `SendExternal`'s per-provider dispatch becomes — build a
+ `notify.Message` from `title`/`message`/`eventType`/`data`; use `notify_provider_adapter.go` to
+ build the right `Config` + `Sender` for `provider.Type`; call `sender.Send(ctx, msg)`; log the
+ result. `TestProvider`/`TestEmailProvider` become the same shape. Everything named in §3.1.3
+ (CRUD, `isDispatchEnabled`/`getFeatureFlagValue`, `emailTemplateForEventType`,
+ `EnsureNotifyOnlyProviderMigration`, the provider-type allowlists) is unchanged.
+5. **Resolve the "detailed" template backward-compatibility question** (flagged in §3.1.2/§7): the
+ module's generic `detailed` template nests host-specific fields under `Data` instead of exposing
+ `HostName`/`HostIP`/`ServiceCount`/`Services` at the JSON top level. Decide, explicitly, whether
+ Charon supplies its own `CustomTemplate` string reproducing the old flat shape for
+ already-configured `detailed`-template providers (safer, avoids a silent payload-shape change
+ for existing integrations) or accepts the shape change with a changelog note. Do not let this be
+ decided implicitly by whichever behavior the ported code happens to produce.
+6. **Telegram gap — RESOLVED.** User confirmed: add `providers/telegram` to the module alongside
+ the other six, so all seven of Charon's current provider types are consistent (none bypass the
+ module as a special case). Update §3.1.3, §3.5's `providers/` layout, and the Appendix move-list
+ to include a `providers/telegram` package; it moves through the same commit-per-provider pattern
+ as the other six in §6.
+7. **Update the integration test import**: delete
+ `backend/integration/notification_http_wrapper_integration_test.go` (moved to the new repo); if
+ Charon wants an adapter-level integration test proving the DI seams work end-to-end with real
+ `network`/`security` code, write a **new**, small integration test — not a port of the old one.
+8. **Preserve coverage, and expect substantial test rewrites, not just import changes**: this
+ extraction now removes the majority of `notification_service.go`'s production code (roughly 450
+ of 1029 lines — everything in §3.1.2), not just the ~570 LOC `http_wrapper.go` alone. The three
+ existing test files most affected —
+ `notification_service_test.go`/`notification_service_json_test.go`/
+ `notification_service_discord_only_test.go` (125 KB combined) — assert directly on internals
+ (payload shapes, validation error strings, header construction) that are moving to the module.
+ These suites need **real rewriting** against the new adapter seam, not a mechanical
+ import-path swap. See the relaxed acceptance criterion in §5 and the new risk in §7 — the prior
+ draft's "existing suites pass unmodified" bar does not hold for this scope.
+9. **Update `docs/features/notifications.md`**: no required content change (documents the product
+ feature, not the internal package); optionally credit the new module.
+10. **Update `ARCHITECTURE.md`**: per CLAUDE.md's mandatory rule, add a line noting outbound
+ notification dispatch (all seven provider types plus email) now goes through the external
+ `notify` module with Charon-supplied SSRF/SMTP/template adapters, rather than internal packages.
+
+### 3.7 Error handling / edge cases for the extraction session to watch
+
+- **Import cycle risk**: none anticipated — `internal/network` and `internal/security` don't
+ import `internal/notifications` or `internal/services`, so removing the notifications→network/
+ security edge and replacing it with notifications←(Charon adapter)→network/security is a clean
+ DI inversion, not a cycle fix. This still holds with the larger scope: the new
+ `notify_provider_adapter.go`/`notify_email_adapter.go` adapters are additional inversion points of
+ the same shape, not new dependency directions.
+- **Env var behavior drift**: `allowNotifyHTTPOverride()` currently special-cases
+ `os.Args[0]` ending in `.test` to auto-allow HTTP during `go test`. If this logic moves to
+ Charon's `notify_client_adapter.go` (per Seam 4), the adapter must preserve this test-detection
+ behavior or existing tests that rely on it will start failing against real HTTPS-only validation.
+- **`os.Args[0]` test-detection is itself a code smell** worth flagging to the extraction session:
+ it's exactly the kind of implicit-environment coupling the module boundary should force out.
+ Recommend the Charon adapter accept an explicit `allowHTTP bool` (e.g. from `CHARON_ENV`) rather
+ than sniffing `os.Args[0]`, and that test setup pass it explicitly. This is a minor
+ behavior-preserving refactor to fold into the migration commits, not a new risk.
+- **Provider-specific dispatch-URL construction is not uniform** (confirmed on this re-read):
+ Telegram/Pushover build their dispatch URL from a base-URL + path (with a hostname-pin check
+ against DNS-spoofed base URLs); Slack substitutes a decrypted token as the entire dispatch URL;
+ Gotify/Ntfy dispatch to `provider.URL` directly and add an auth header instead. Each provider
+ package's `Send` must reproduce its specific construction exactly — this is precisely the
+ behavior-parity risk called out in §7, not a detail that can be generalized away.
+
+---
+
+## 4. Implementation Plan (for the future extraction session — not executed here)
+
+This plan is written for reference by the session that actually performs the extraction. It follows
+this repo's phase convention but the "Playwright"/"Frontend" phases are replaced since this is a
+backend-only, cross-repo change with no UI surface. Expanded from the prior 5-phase Phase-1-only
+plan to cover the full provider layer.
+
+### Phase 1: New-repo scaffolding + shared types + transport (new repo)
+- Scaffold into the **existing** `/projects/go_notify_yourself` repo (`LICENSE` already present):
+ `go.mod` (`module github.com/Wikid82/go_notify_yourself`), CI workflow, GoReleaser config (§3.5).
+- Write `message.go` (`Message`, §3.3.2) and `sender.go` (`Sender`, §3.3.3) at the module root.
+- Copy `http_wrapper.go` → `transport/wrapper.go`, renaming exported identifiers per §3.3.1,
+ replacing the two Charon imports with the `ClientFactory`/`URLValidator` seam (§3.2).
+- Copy/adapt `http_wrapper_test.go`, `http_client_executor.go` into `transport/`.
+- Write the built-in default `URLValidator` (§3.2 Seam 3) + its own tests.
+- Copy the integration test (§2.6) into `transport/integration/`, update package/import path.
+- `go test ./...`, `go vet ./...`, `staticcheck ./...` all green.
+
+### Phase 2: Provider packages (new repo)
+- `providers/internal/render`: extract the shared `text/template` + `toJSON` engine (§3.1.2) out of
+ `sendJSONPayload`/`RenderTemplate`, generic over `notify.Message`.
+- `providers/discord`, `providers/slack`, `providers/gotify`, `providers/pushover`, `providers/ntfy`,
+ `providers/telegram`, `providers/webhook`: one package at a time, each porting its slice of §3.1.2's table (URL
+ validation, JSON normalization, dispatch URL/header construction), consolidating Discord onto
+ `transport.Wrapper` per the flagged inconsistency. `providers/webhook` additionally gets
+ `RenderPreview`.
+- `providers/email`: build `Mailer`/`TemplateRenderer`/`Config`/`Client` (§3.3.4), including the one
+ neutral default HTML template.
+- Each package ships with its own tests at parity with (or exceeding) the coverage the equivalent
+ logic had inside `notification_service_*_test.go`.
+- Tag `v0.1.0` once all provider packages + transport are green.
+
+### Phase 3: Charon-side adapters (Charon repo, this repo)
+- Add `github.com/Wikid82/go_notify_yourself` to `backend/go.mod`.
+- Write `notify_client_adapter.go` (transport seam, §3.6 step 3).
+- Write `notify_provider_adapter.go` (per-type `Config`/`Sender` factory, §3.6 step 3).
+- Write `notify_email_adapter.go` (`Mailer`/`TemplateRenderer` wiring, §3.6 step 3).
+- Resolve the "detailed" template backward-compat decision (§3.6 step 5) and the Telegram gap
+ (§3.6 step 6) explicitly, before proceeding to cutover.
+
+### Phase 4: Charon cutover
+- Update `notification_service.go`'s `SendExternal`/`TestProvider`/`TestEmailProvider` to call the
+ new adapters (§3.6 step 4), **split per provider per §6** for reviewability rather than one giant
+ commit.
+- Delete extracted files and dead function-level code from `notification_service.go` and
+ `backend/internal/notifications/` (§3.6 step 2).
+- Delete `backend/integration/notification_http_wrapper_integration_test.go`.
+- Rewrite (not just relink) the affected slices of
+ `notification_service_test.go`/`notification_service_json_test.go`/
+ `notification_service_discord_only_test.go` against the new adapter seam.
+- Run full backend test suite + coverage gate (§3.6 step 8).
+
+### Phase 5: Hardening + docs
+- `ARCHITECTURE.md` update (§3.6 step 10).
+- CodeQL/Trivy re-run on Charon (new external dependency, larger surface than the Phase-1 draft).
+- Confirm `go.sum`/supply-chain scan clean for the new module dependency.
+
+### Phase 6: Deployment
+- Tag Charon release per normal Conventional Commits flow. Unlike the Phase-1-only draft, this is
+ **not** guaranteed behavior-invisible — the "detailed" template shape decision (Phase 3) and the
+ Discord-dispatch consolidation (§3.1.2) are both potential user-visible or operationally-visible
+ changes and should be called out in release notes if either is accepted as-is rather than shimmed
+ for compatibility.
+
+---
+
+## 5. Acceptance Criteria (for the extraction session's Definition of Done)
+
+- [ ] New repo exists, `go test ./...` and `staticcheck` pass with zero findings, tagged `v0.1.0`.
+- [ ] New module has zero imports of anything under `github.com/Wikid82/charon/*`.
+- [ ] Each of the seven provider packages (`discord`, `slack`, `gotify`, `pushover`, `ntfy`,
+ `telegram`, `webhook`) plus `providers/email` has its own test suite at ≥85% coverage (mirrors
+ Charon's own bar).
+- [ ] `providers/webhook.RenderPreview` covers custom-template validation equivalent to the old
+ `RenderTemplate`'s test coverage.
+- [ ] `providers/email`'s default built-in template is neutral/unbranded — a grep for `Charon` (or
+ any other host-app name) across the new repo returns zero hits outside README/CHANGELOG.
+- [ ] Charon's `go.mod` depends on the new module at a pinned semver tag (no `replace` directive
+ left in place post-merge).
+- [ ] `backend/internal/notifications/` package is deleted entirely (unlike the Phase-1 draft,
+ `feature_flags.go` is folded into `internal/services` rather than left as a lone-file package
+ — §3.6 step 2).
+- [ ] `notification_service.go`'s black-box behavior is unchanged **except** for the two explicitly
+ documented, deliberate changes (the "detailed" template payload shape and the Discord-dispatch
+ retry/backoff consolidation) — both must be resolved as conscious decisions per §3.6 steps 5
+ and the risk in §7, not accidental drift. This replaces the prior draft's stronger claim that
+ existing test assertions pass *unmodified*: with this scope, rewriting
+ `notification_service_test.go`/`notification_service_json_test.go`/
+ `notification_service_discord_only_test.go` is expected and required, but the rewritten
+ assertions must still prove equivalent (or knowingly-changed) external behavior.
+- [ ] Charon's backend coverage gate (`scripts/go-test-coverage.sh`, min 85%) still passes after the
+ ~450-line reduction in `notification_service.go` and the corresponding test rewrites.
+- [ ] `ARCHITECTURE.md` updated.
+- [ ] No behavior change observable from the frontend or API beyond the two documented exceptions
+ above.
+
+---
+
+## 6. Commit Slicing Strategy
+
+This spec spans **two repositories**, so "one feature = one PR" applies **per repository**: the new
+module's scaffolding-through-providers work is one PR in the new repo; Charon's consumption of it is
+a second, separate PR in *this* repo (two different features in two different codebases, each
+individually complete and mergeable on its own — not a violation of one-feature-one-PR). Within each
+PR, commits are ordered and logical. The full-scope decision roughly **triples** the new-module PR's
+commit count and requires splitting the Charon cutover per-provider for reviewability, per the
+revision brief.
+
+### New-module repo — PR "Initial notify engine + provider layer"
+
+1. **Commit 1** — Scaffolding: `go.mod`, LICENSE, README stub, CI workflow (no behavior). Gate: `go build ./...`.
+2. **Commit 2** — Shared types: `message.go` (`Message`), `sender.go` (`Sender`). Gate: `go build ./...`.
+3. **Commit 3** — Transport core: `transport/wrapper.go`, `client_executor.go`, `retry.go`, ported
+ from `http_wrapper.go`/`http_client_executor.go` with seam interfaces substituted for direct
+ `network`/`security` calls. Gate: `go vet`, `staticcheck`.
+4. **Commit 4** — Transport tests: `transport/wrapper_test.go`, adapted to inject fake
+ `ClientFactory`/`URLValidator`. Gate: `go test ./...` green, coverage ≥85%.
+5. **Commit 5** — Default validator: `transport/validate_default.go` + tests (§3.2 Seam 3). Gate: tests green.
+6. **Commit 6** — Transport integration test: `transport/integration/wrapper_integration_test.go`.
+ Gate: `go test -tags=integration ./...`.
+7. **Commit 7** — Shared render engine: `providers/internal/render` (§3.1.2). Gate: tests green.
+8. **Commit 8** — `providers/discord` (webhook validation, content/embeds normalization, dispatch
+ consolidated onto `transport.Wrapper`). Gate: `go test ./providers/discord/...` ≥85%.
+9. **Commit 9** — `providers/slack`. Gate: same pattern.
+10. **Commit 10** — `providers/gotify`. Gate: same pattern.
+11. **Commit 11** — `providers/pushover`. Gate: same pattern.
+12. **Commit 12** — `providers/ntfy`. Gate: same pattern.
+13. **Commit 13** — `providers/telegram` (bot-token-in-URL dispatch build, hostname pin, `chat_id`
+ injection, `text`/`message`-field payload validation per §3.1.2). Gate: `go test
+ ./providers/telegram/...` ≥85%.
+14. **Commit 14** — `providers/webhook` (generic dispatch + `RenderPreview`). Gate: same pattern.
+15. **Commit 15** — `providers/email` (`Mailer`/`TemplateRenderer`/`Config`, one neutral default
+ template). Gate: `go test ./providers/email/...` ≥85%; this is the highest-design-risk commit
+ (§7) and should get dedicated review attention, not be rubber-stamped alongside the others.
+16. **Commit 16** — Release plumbing: `.goreleaser.yaml`, release workflow, `CHANGELOG.md` seed.
+ Gate: dry-run `goreleaser release --snapshot`.
+
+Rollback: any commit can be reverted independently since the repo has no existing consumers yet;
+worst case the repo simply isn't tagged until it's right.
+
+### Charon repo — PR "Consume extracted notify module"
+
+1. **Commit 1** — Dependency + transport adapter: add `go.mod` requirement, write
+ `notify_client_adapter.go` + tests. No behavior change yet. Gate: `go build ./...`, adapter tests pass.
+2. **Commit 2** — Provider + email adapters: `notify_provider_adapter.go`,
+ `notify_email_adapter.go` (unused by production code paths yet). Gate: `go build ./...`, adapter
+ tests pass.
+3. **Commit 3** — Cutover: Discord. `SendExternal`/`TestProvider` route Discord dispatch through
+ `providers/discord`. Gate: `notification_service_discord_only_test.go` passes (rewritten per
+ §3.6 step 8) and explicitly documents the retry/backoff behavior change from consolidating onto
+ `transport.Wrapper`. **This commit's description and the release changelog entry must call out
+ the retry-behavior change explicitly** — state it plainly as "Discord notifications now retry on
+ transient failures" (per §7 risk 1c, resolved) rather than letting it read as "just a refactor";
+ it is a user-visible, operator-noticeable improvement, not an implementation detail.
+4. **Commit 4** — Cutover: Slack. Gate: relevant slice of `notification_service_test.go` rewritten and green.
+5. **Commit 5** — Cutover: Gotify. Gate: same pattern.
+6. **Commit 6** — Cutover: Pushover. Gate: same pattern.
+7. **Commit 7** — Cutover: Ntfy. Gate: same pattern.
+8. **Commit 8** — Cutover: Telegram. `SendExternal`/`TestProvider` route Telegram dispatch through
+ `providers/telegram` (bot-token-in-URL dispatch build, `chat_id` injection from `p.URL`,
+ hostname-pin check). Gate: relevant slice of `notification_service_test.go` rewritten and green,
+ same pattern as the other provider cutovers.
+9. **Commit 9** — Cutover: generic Webhook, including replacing `RenderTemplate` call sites in
+ `CreateProvider`/`UpdateProvider` with `providers/webhook.RenderPreview`. Gate: same pattern, plus
+ the "detailed" template backward-compat decision (§3.6 step 5) is implemented here, not deferred.
+10. **Commit 10** — Cutover: Email. `dispatchEmail`/`TestEmailProvider` route through
+ `notify_email_adapter.go`; `SubjectPrefix`/`TemplateName` preserve exact current subject/template
+ behavior. Gate: email-path tests rewritten and green; grep confirms no accidental exposure of the
+ module's neutral default template in production.
+11. **Commit 11** — Cleanup: delete now-dead code — old `sendJSONPayload`/`RenderTemplate`/
+ `dispatchEmail`/`sanitizeForEmail`/validation functions, `isPrivateIP`, all of
+ `backend/internal/notifications/`, the old integration test. Gate: `go build ./...`, no unused
+ imports/symbols (staticcheck).
+12. **Commit 12** — Coverage/lint/docs hardening: re-run `scripts/go-test-coverage.sh`, fix any gate
+ regression; update `ARCHITECTURE.md`. Gate: full Definition of Done per CLAUDE.md.
+
+Rollback for the PR as a whole: since this is a pure dependency swap with no schema/API change, a
+full revert of the PR is safe at any point before merge; post-merge, `go.mod` can be pinned back to
+the pre-extraction commit and the deleted files restored from git history if an unforeseen
+regression surfaces — no data migration to unwind. Per-provider commit slicing (3–10) additionally
+means a single provider's cutover can be reverted in isolation post-merge without unwinding the
+others, which was not possible under the prior single-commit-cutover plan.
+
+Contingency: if the extraction session discovers a provider's DI seam is insufficient (e.g. SSRF
+policy or auth-header handling genuinely can't be expressed through the shared interfaces without
+either leaking Charon internals or weakening a provider's safety), stop before that provider's
+cutover commit and re-scope that one provider — the per-provider commit slicing means this no longer
+blocks the other six providers' cutover from proceeding.
+
+---
+
+## 7. Risks / Open Questions
+
+1. **Scope of "the engine" — RESOLVED.** The user has confirmed full provider-layer scope: the
+ Discord/Slack/Gotify/Pushover/Ntfy/webhook payload builders and email dispatch move into the new
+ module now. This replaces the prior draft's open question. The scope increase introduces the
+ following new risks (1a–1e), which did not exist under the Phase-1-only plan:
+
+ - **1a. Behavior-parity risk across 6+ providers is much higher than for a single HTTP wrapper.**
+ Each provider's URL validation, header construction, and auth-injection quirks (Discord's
+ regex/host-allowlist, Slack's token-substitution, Pushover's hostname-pinned URL build, Gotify's
+ header vs. Ntfy's bearer-auth header) must be reproduced exactly or the 125 KB of existing
+ `notification_service_*_test.go` coverage will catch regressions the extraction session must
+ then triage one provider at a time. Budget real time for this — it is not a mechanical port.
+ - **1b. Template/payload shape change risk.** Genericizing the built-in `detailed` template
+ (dropping top-level `HostName`/`HostIP`/`ServiceCount`/`Services` in favor of a nested
+ `{{toJSON .Data}}`) is a **user-visible breaking change** for any existing custom integration
+ parsing the old flat JSON keys. §3.6 step 5 requires this to be a conscious decision (Charon
+ ships a compatibility `CustomTemplate` for existing providers, or accepts the change with a
+ changelog note) — flag to the user before the cutover commit ships either way.
+ - **1c. Discord/generic-webhook dispatch consolidation — RESOLVED.** §3.1.2 found that Discord
+ dispatch today bypasses `HTTPWrapper` entirely (direct `network`/`security` calls, no
+ retry/backoff). User confirmed: fold Discord onto the shared `transport.Wrapper` along with
+ every other provider — it gains retry/backoff it lacked before, and all providers share one
+ dispatch path with no special case. This is a deliberate, user-approved behavior change (not
+ merely a refactor); document it in the Charon PR description and changelog as "Discord
+ notifications now retry on transient failures," since it's an observable improvement an
+ operator could notice.
+ - **1d. Email is the trickiest single piece.** It currently lives entirely in Charon's
+ `mail_service.go` (SMTP + 5 branded HTML templates), not in `internal/notifications` at all —
+ so unlike the other five providers, there's no existing engine code to port, only a new
+ `Mailer`/`TemplateRenderer` abstraction to design and retrofit around existing Charon code.
+ Higher design risk than any HTTP provider; §6 gives it a dedicated commit in both PRs and flags
+ it for extra review attention rather than folding it in alongside the others.
+ - **1e. Telegram gap — RESOLVED.** Charon supports a 7th provider type (`telegram`) that was
+ outside the original six-provider list. User confirmed: include it — `providers/telegram` is
+ added to the module alongside the other six (§3.6 step 6), so the move-list is now seven HTTP
+ providers + email, not six.
+ - **1f. Scope-creep guardrail for the Apprise-inspired long-term direction (§8).** The provider
+ list in §3.1 is deliberately exactly Charon's existing seven HTTP providers + email (six plus
+ Telegram, per 1e above) — nothing more. The extraction session must resist the temptation to
+ "just add one more" (Matrix,
+ PagerDuty, Twilio, etc.) even though the API is designed to make that easy later (§3.3.3, §8).
+ Adding providers Charon doesn't use today is explicitly out of scope for this extraction and
+ would need its own separate decision from the user once the project is closer to maintenance
+ mode.
+
+2. **Default `URLValidator` duplication** (§3.2 Seam 3): shipping a built-in conservative SSRF
+ validator in the new module duplicates IP-classification logic already in Charon's
+ `internal/network`. Bounded and low-risk (public CIDR constants, not business logic), but worth
+ the user's explicit sign-off since "duplicate SSRF logic" is the kind of thing that should never
+ happen by accident.
+3. **Module name/org — RESOLVED.** The repo already exists at `/projects/go_notify_yourself`
+ (`github.com/Wikid82/go_notify_yourself`, remote confirmed via `git remote -v`), so this is no
+ longer an open placeholder question — every reference in this spec now uses that path, with the
+ Go package name kept as `notify` at the root (module-path/package-name mismatch is intentional,
+ see the rev-2 note at the top of this document). The one remaining sub-decision is whether the
+ delivery-primitive subpackage is literally named `transport` as sketched in §3.3.1/§3.5, or
+ something else — cosmetic, not blocking.
+4. **`os.Args[0]` test-detection removal** (§3.7): behavior-preserving in intent, but any change to
+ how `CHARON_NOTIFY_ALLOW_HTTP` is resolved touches existing test setup across
+ `notification_service_test.go` and friends (125 KB file) — the extraction session should budget
+ time to verify every test relying on the old auto-detect still passes under explicit
+ configuration.
+5. **`feature_flags.go` fate**: stays in Charon (§3.1.2), folded into `internal/services` once the
+ rest of `internal/notifications` is deleted (§3.6 step 2) — a style call, not a functional one.
+6. **GoReleaser artifact shape for a pure library**: Charon's existing `.goreleaser.yaml` builds
+ binaries/Docker images; a library module needs a much lighter GoReleaser config (just changelog
+ + GitHub release, no build/archive stanzas). This is unaffected by the larger provider-layer
+ scope — GoReleaser still just tags the whole module regardless of how many packages it contains.
+ The extraction session should not copy Charon's `.goreleaser.yaml` wholesale — treat it as a
+ reference for style/conventions only.
+7. **CI cost for a single-maintainer module**: recommend the new repo's CI stay to lint + unit test
+ + coverage on PR/push, with release only on tag push — explicitly no CodeQL/Trivy/multi-browser
+ E2E apparatus. The coverage surface is now larger (transport + 6 providers + email vs. just the
+ wrapper), but the CI *policy* is unchanged — flagging so the extraction session doesn't
+ over-engineer CI to match Charon's much larger surface just because the module itself grew.
+8. **Email default-template tradeoff needs explicit sign-off.** §3.3.4 decides to ship one neutral
+ built-in template and require Charon to override it. An unrelated future adopter who *doesn't*
+ override it gets a plain, unbranded email — acceptable for a zero-config default, but the
+ extraction session should confirm this "ship one neutral default, hosts override for anything
+ branded" position with the user before locking in the `TemplateRenderer` interface shape, since
+ it's a design opinion, not a mechanical extraction fact.
+9. **`notification_service_test.go`/`_json_test.go`/`_discord_only_test.go` (125 KB combined) need
+ substantial rewriting, not import/construction changes.** The prior Phase-1-only draft's
+ acceptance criterion — "existing suites pass without modification to their assertions" — no
+ longer holds now that the bulk of the file's production logic (§3.1.2, ~450 of 1029 lines) is
+ deleted outright rather than relinked. §5 has been relaxed accordingly: black-box behavior must
+ remain equivalent (except the two documented deliberate changes in 1b/1c above), but the
+ assertions themselves are expected to be rewritten against the new adapter seam.
+
+---
+
+## 8. Future Direction (context for whoever picks this up later)
+
+**Long-term goal, stated by the user:** `go_notify_yourself` should eventually become a Go
+equivalent of [Apprise](https://github.com/caronc/apprise) — the Python library that lets a caller
+dispatch a single notification across a large, open-ended catalog of services through one common
+interface/URL-scheme convention, rather than hand-rolling per-service integration code.
+
+**Near-term constraint, also stated by the user and binding on this extraction:** Charon and the
+user's other small family project are both still under active development, not yet in "maintenance
+mode." Scope creep into new provider integrations right now would compete with that active-dev time
+for no near-term payoff — there is exactly one consumer (Charon) and it uses exactly seven HTTP
+providers + email. §3.1's move-list is intentionally capped at those seven, and §7 risk 1f exists
+specifically to stop a future session from "just adding one more" opportunistically during this
+extraction.
+
+**What this spec deliberately does do, to keep the Apprise path open without building it now:**
+- The `Sender` interface (§3.3.3) is uniform across every provider — `Send(ctx, Message) error` —
+ regardless of transport (HTTP POST, SMTP) or payload shape. A future registry only needs one
+ interface to key off, not per-provider special cases.
+- Every provider package is fully self-contained and independently importable, with **no** central
+ switch-statement inside the module mapping type-strings to packages — that mapping lives in
+ Charon's own `notify_provider_adapter.go` (§3.6), outside the module. This means the module itself
+ has zero knowledge of "which providers exist" beyond the packages present in the repo, which is
+ exactly the property an Apprise-style URL-scheme registry (`discord://...`, `mailto://...`) would
+ need to slot in as a pure addition later.
+- `providers/internal/render`, the shared template engine (§3.1.2), is already factored out as a
+ reusable internal dependency rather than duplicated per-package — a future provider package (once
+ the scope constraint is lifted) can reuse it immediately instead of re-solving JSON templating.
+
+**What this spec deliberately does NOT do, to avoid scope creep now:**
+- No `notify.Register`/registry type is built in this extraction (§3.3.3's extensibility note) —
+ with one consumer and seven known providers, a generic registry today is premature abstraction, not
+ a real need.
+- No URL-scheme parsing/dispatch convention (Apprise's signature feature) is designed or built here
+ — that's a substantial API-design exercise in its own right and belongs in a dedicated future spec
+ once the user decides it's time to grow past Charon's provider set.
+- No providers beyond Charon's existing seven + email are added, discussed as candidates, or
+ scaffolded as stubs — see §7 risk 1f.
+
+A future session picking this up for "add provider N" or "build the Apprise-style registry" should
+treat this section as the record of *why* the provider list was small at extraction time and
+*which* properties of the API (uniform `Sender`, no in-module type registry, shared internal
+template engine) were chosen specifically so that later work would be additive rather than a
+breaking rework.
+
+---
+
+## Appendix: File-level move list (flat reference)
+
+**Move to new repo (whole files):**
+- `backend/internal/notifications/http_wrapper.go`
+- `backend/internal/notifications/http_wrapper_test.go`
+- `backend/internal/notifications/http_client_executor.go`
+- `backend/integration/notification_http_wrapper_integration_test.go`
+
+**Move to new repo (function-level extraction out of `notification_service.go`, genericized — see
+§3.1.2 for exact line ranges and destination packages; the file itself does not move, it shrinks):**
+- Built-in `minimal`/`detailed` JSON templates → `providers/webhook`
+- Template parse/exec engine (`text/template` + `toJSON` funcmap, size/timeout limits) → `providers/internal/render`
+- Discord webhook regex/host validation/normalization → `providers/discord`
+- Slack webhook regex/validation/token substitution → `providers/slack`
+- Gotify message-field validation + auth header → `providers/gotify`
+- Pushover message/priority validation + URL build + token/user injection → `providers/pushover`
+- Ntfy message-field validation + bearer auth header → `providers/ntfy`
+- Telegram text/message-field validation + bot-token-in-URL dispatch build + `chat_id` injection + hostname pin → `providers/telegram`
+- Generic/custom webhook dispatch → `providers/webhook`
+- `RenderTemplate` → `providers/webhook.RenderPreview`
+- `sanitizeForEmail` + `dispatchEmail`'s message composition → `providers/email`
+
+**Delete (dead code, do not port as-is):**
+- `backend/internal/notifications/engine.go`
+- `backend/internal/notifications/router.go`
+- `backend/internal/notifications/router_test.go`
+- `notification_service.go`'s unused `isPrivateIP(ip net.IP) bool` wrapper (§3.1.3)
+- `notification_service.go`'s `webhookDoRequestFunc` test hook (superseded by the module's own test seam)
+
+**Stays in Charon, unmodified:**
+- `backend/internal/models/notification.go`
+- `backend/internal/models/notification_config.go`
+- `backend/internal/models/notification_provider.go`
+- `backend/internal/models/notification_provider_test.go`
+- `backend/internal/models/notification_template.go`
+- `backend/internal/models/notification_test.go`
+- `backend/internal/services/security_notification_service.go` + test
+- `backend/internal/services/enhanced_security_notification_service.go` + tests
+- `backend/internal/services/uptime_service_notification_test.go`
+- `backend/internal/services/mail_service.go`'s SMTP transport + `templates/*.html` (behind the new `Mailer`/`TemplateRenderer` seam, §3.3.4)
+- `docs/features/notifications.md`
+- `frontend/src/api/notifications.ts` + tests
+- `frontend/src/pages/Notifications.tsx` + tests
+- `frontend/src/hooks/useNotifications.ts` + tests
+- `frontend/src/components/NotificationCenter.tsx` + tests
+- `frontend/src/components/SecurityNotificationSettingsModal.tsx` + tests
+
+**Stays in Charon, modified (Charon migration phase, §3.6):**
+- `backend/internal/notifications/feature_flags.go` (relocate into `internal/services`, don't extract)
+- `backend/internal/services/notification_service.go` (shrinks substantially — §3.1.3 keeps CRUD/flag-gating/event-routing; §3.1.2's logic is deleted, replaced by thin calls into three new adapter files)
+- `ARCHITECTURE.md` (documentation update)
diff --git a/docs/plans/notify_provider_registry_spec.md b/docs/plans/notify_provider_registry_spec.md
new file mode 100644
index 000000000..615dec447
--- /dev/null
+++ b/docs/plans/notify_provider_registry_spec.md
@@ -0,0 +1,989 @@
+# Notify Provider Registry — Self-Registering Factory Pattern
+
+Status: Scoping/design only. No code changes performed under this spec. This document specifies
+the design for a future implementation session, to land as additional commits on the existing,
+open branch `feature/notifications-engine-extraction` (PR #1253), across two repositories.
+
+Owner for this document: **planning** agent.
+Owner for execution (future session): a `management`-orchestrated pipeline for the Charon-side
+commits; direct TDD implementation in `/projects/go_notify_yourself` per that repo's own
+`CLAUDE.md` (which explicitly says not to build out a multi-agent roster there) for the
+module-side commits.
+
+---
+
+## 1. Introduction
+
+### 1.1 Objective
+
+`go_notify_yourself` (`github.com/Wikid82/go_notify_yourself`, currently tagged `v0.1.0`) already
+ships seven HTTP notification providers plus email, each a self-contained package exposing a typed
+`Config` struct and a `New(cfg, wrapper) *Client` constructor implementing the shared
+`notify.Sender` interface (confirmed by reading `sender.go`, `message.go`, and the `discord`,
+`webhook`, and `email` packages in full). There is deliberately **no** registry today — §8 of the
+original extraction spec (`docs/plans/notifications_extraction_spec.md`) called this out as a
+conscious "not yet" decision, made when there was exactly one consumer (Charon) and seven known
+providers.
+
+That deferred need is now live: the user wants to add a Web Push provider to
+`go_notify_yourself` for another project, and wants any future provider — theirs or a third
+party's — to become available to a host application (Charon or otherwise) automatically after a
+version bump, without hand-editing host application code. Confirmed by reading
+`backend/internal/services/notify_provider_adapter.go` (already merged on this branch, commit
+`11d3489c`) and `notification_service.go`: Charon currently hardcodes a `switch p.Type { case
+"discord": ... }` in `buildNotifySender`, plus three more provider-type allowlists
+(`isSupportedNotificationProviderType`, `supportsJSONTemplates`, `isDispatchEnabled`). Adding a
+provider today requires a code change in **both** repos.
+
+### 1.2 Goals
+
+- Design a self-registering factory pattern for `go_notify_yourself` — the `database/sql` /
+ `image` idiom — fitted to what's actually in the repo today (typed per-provider `Config` structs,
+ email's non-serializable `Mailer`/`TemplateRenderer` dependencies), not an idealized redesign.
+- Specify the exact `Register`/`New` signatures, resolving the config-typing question (generic map
+ vs. `json.RawMessage` vs. something else) with a concrete recommendation and rationale.
+- Specify a `providers/all` blank-import bundle package as the closest idiomatic equivalent to true
+ auto-discovery Go can offer.
+- Specify how Charon's adapter layer collapses onto the registry, and flag (not silently resolve)
+ the allowlist-vs-full-discovery design tension this creates for Charon's own UI/API surface.
+- Specify three documentation deliverables the user explicitly asked for:
+ `go_notify_yourself/ARCHITECTURE.md`, a new README section, and
+ `go_notify_yourself/docs/INTEGRATION.md`.
+- Sequence all of this as additional commits on the existing `feature/notifications-engine-extraction`
+ branch/PR — not a new branch, not a new PR.
+
+### 1.3 Non-goals
+
+- No URL-scheme parsing/dispatch convention (Apprise's signature feature, e.g. `discord://...`) —
+ still explicitly out of scope, unchanged from the original spec's §8.
+- No new provider packages are added or scaffolded under this spec (no Web Push implementation) —
+ the registry is the enabling mechanism; the user's Web Push provider is a separate future piece
+ of work that becomes trivial once this lands.
+- No frontend schema-driven form generator is designed here — flagged as an open question (§3.6),
+ not solved.
+- No changes to `docs/plans/current_spec.md` (unrelated active work, per the task constraint).
+- No `go.mod` edits, no branch creation, no code written — this is planning only.
+
+---
+
+## 2. Research Findings
+
+### 2.1 Current state of `go_notify_yourself` (read in full, not assumed)
+
+| File | Finding |
+|---|---|
+| `sender.go` | `Sender` interface: `Send(ctx context.Context, msg Message) error`. Every provider package returns a type implementing this. No registry, no factory type exists anywhere in the repo. |
+| `message.go` | `Message{Title, Body, EventType, Timestamp, Data map[string]any}` + `Normalized()`. Stable, generic, provider-agnostic — no changes needed for the registry. |
+| `providers/discord/discord.go` | `Config{WebhookURL, Template, CustomTemplate}`; `New(cfg Config, w *transport.Wrapper) *Client`. All fields are plain strings — trivially JSON-serializable. |
+| `providers/webhook/webhook.go` | Same shape: `Config{URL, Template, CustomTemplate}`, `New(cfg, w)`. Also exposes `RenderPreview` and re-exports `MinimalTemplate`/`DetailedTemplate` consts. |
+| `providers/slack`, `gotify`, `pushover`, `ntfy`, `telegram` | Same `New(cfg, w *transport.Wrapper) *Client` shape (confirmed by reading all five `Config` structs — see table in §3.1). All fields are plain strings (`URL`, `Token`, `UserKey`, `APIToken`, `BotToken`, `ChatID`, `BaseURL`, `Template`, `CustomTemplate`). **All eight non-email packages are pure-data, JSON-serializable configs.** |
+| `providers/email/email.go` | **The odd one out, confirmed by reading it in full.** `Config{Recipients []string, SubjectPrefix string, TemplateName func(msg notify.Message) string, Renderer TemplateRenderer, Mailer Mailer}`. `Mailer` and `TemplateRenderer` are **behavioral interfaces** the host application implements (e.g. Charon's `notify_email_adapter.go` wraps `MailServiceInterface`/`RenderNotificationEmail`); `TemplateName` is a **Go closure**. None of these three fields can round-trip through JSON. `New(cfg Config) *Client` — no `*transport.Wrapper` parameter at all (email never dials HTTP). This asymmetry is the central design constraint for the registry (see §3.2). |
+| `transport/wrapper.go` | `*transport.Wrapper` is constructed once per host application (`transport.NewWrapper(opts...)`) and injected into every HTTP-based provider's `New`. It is itself DI-configured (`ClientFactory`, `URLValidator`, `RetryPolicy`) — the registry must not bypass or duplicate that construction, only thread the already-built `*Wrapper` through. |
+| Module root | `package notify` at `github.com/Wikid82/go_notify_yourself` (no subpackage) — `Register`/`New` naturally belong here, alongside `Message`/`Sender`, per Go convention (mirrors `sql.Register`/`sql.Open` living in `database/sql` itself, not a subpackage). |
+
+### 2.2 Prior art: `database/sql` and `image` self-registration idiom
+
+- `database/sql`: `sql.Register(name string, driver driver.Driver)` — panics on duplicate
+ registration or nil driver, called from each driver package's `init()`. `sql.Open(driverName,
+ dataSourceName string)` looks up the registered driver and returns a `*DB`. The `dataSourceName`
+ is an opaque string each driver parses itself (e.g. a DSN) — `database/sql` has zero opinion on
+ its shape. This is the closest fit: **the registry core has no opinion on config shape**, it's a
+ keyed lookup over a factory function; shape-parsing is entirely the registered package's problem.
+- `image`: `image.RegisterFormat(name, magic string, decode DecodeFunc, decodeConfig DecodeConfigFunc)`
+ — decoders self-register via blank import (`_ "image/png"`), and `image.Decode` sniffs the magic
+ bytes to pick a decoder. No generic "config" concept at all — irrelevant to the config-typing
+ question here, but reinforces the blank-import-for-discovery pattern (§3.3).
+- Both prior-art examples confirm two things this spec adopts: (1) `Register` panics on
+ double-registration (a programmer error caught at `init()` time, not a runtime error path), and
+ (2) the registry package itself never imports the packages that register into it — the
+ dependency arrow points inward (provider → registry), never outward, which is exactly what keeps
+ `providers/all` (§3.3) as the only place that "knows about" every provider.
+
+### 2.3 Charon-side current coupling (re-confirmed on this branch, post-extraction)
+
+`backend/internal/services/notify_provider_adapter.go` (already on this branch, commit `11d3489c`)
+has `buildNotifySender`, a `switch strings.ToLower(...) provider.Type { case "discord": ...
+discord.New(discord.Config{WebhookURL: provider.URL, ...}, w) ... }` — one case per provider type,
+each mapping specific GORM columns to that provider's specific `Config` field names (documented
+in-file, non-uniformly: `discord.WebhookURL <- provider.URL`, but `slack.WebhookURL <-
+provider.Token`; `pushover.UserKey <- provider.URL`, `pushover.APIToken <- provider.Token`;
+`telegram.BotToken <- provider.Token`, `telegram.ChatID <- provider.URL`). **This field-mapping
+non-uniformity is itself a research finding that constrains the registry design** — see §3.4.
+
+`backend/internal/services/notification_service.go` (lines 126–166, read directly, not
+paraphrased):
+
+```go
+func supportsJSONTemplates(providerType string) bool {
+ switch strings.ToLower(providerType) {
+ case "webhook", "discord", "gotify", "slack", "generic", "telegram", "pushover", "ntfy":
+ return true
+ default:
+ return false
+ }
+}
+
+func isSupportedNotificationProviderType(providerType string) bool {
+ switch strings.ToLower(strings.TrimSpace(providerType)) {
+ case "discord", "email", "gotify", "webhook", "telegram", "slack", "pushover", "ntfy":
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *NotificationService) isDispatchEnabled(providerType string) bool {
+ switch strings.ToLower(strings.TrimSpace(providerType)) {
+ case "discord":
+ return true
+ case "email":
+ return s.getFeatureFlagValue(FlagEmailServiceEnabled, false)
+ case "gotify":
+ return s.getFeatureFlagValue(FlagGotifyServiceEnabled, true)
+ // ... one flag-gated case per provider type ...
+ default:
+ return false
+ }
+}
+```
+
+Three independent hardcoded allowlists, one hardcoded factory switch. All four must be reconciled
+with any registry that makes providers "discoverable" — see §3.5 for why they should **not** all
+collapse onto the registry automatically.
+
+### 2.4 GORM model — `backend/internal/models/notification_provider.go` (read in full)
+
+```go
+type NotificationProvider struct {
+ ID string
+ Name string
+ Type string // provider type discriminator
+ URL string // reused across types: webhook URL, server URL, user key, chat ID...
+ Token string `json:"-"` // reused across types: API token, webhook token, bot token...
+ Config string // JSON payload template for custom webhooks
+ ServiceConfig string `json:"service_config,omitempty" gorm:"type:text"` // JSON blob for typed service config
+ Template string `gorm:"default:minimal"`
+ // ... Notify* preference bools, migration/audit fields ...
+}
+```
+
+**Finding, confirmed by repo-wide grep**: `ServiceConfig` is declared but has **zero read or write
+call sites anywhere in `backend/internal/`** outside its own struct tag. It is dead/reserved
+schema — a column that exists but nothing populates or reads it yet. This is directly relevant:
+the model already has a `text` column earmarked (by its doc comment, "JSON blob for typed service
+config") for exactly the kind of flexible, not-known-in-advance provider config a new provider type
+(Web Push) would need, without a migration. See §3.6 for the recommendation.
+
+The existing `URL`/`Token` pair is a **fixed two-slot** scheme — every provider type today has been
+squeezed into "one URL-ish string, one secret-ish string," which is why the field-mapping table in
+§2.3 is non-uniform (each type has its own private convention for what `URL` vs `Token` *means*).
+A provider needing more than two config values (e.g. Web Push's VAPID public/private keypair +
+subscription endpoint — three values, none of which is a natural fit for "URL" or "Token") cannot
+be expressed in the current two-slot scheme at all. `ServiceConfig` is the natural place for this,
+but is not wired up.
+
+### 2.5 Frontend (confirmed by file search, not deep-read — out of scope per task framing)
+
+`frontend/src/pages/Notifications.tsx` (758 LOC, per the original extraction spec's §2.8) is the
+only frontend file referencing `NotificationProvider`/provider type. No separate
+`NotificationProviderForm.tsx` component exists — the per-type config fields are rendered inline in
+this one page, keyed off `provider.type` (pattern confirmed by grep; the file was not read in full
+under this task's scope, per the instruction to flag rather than solve the frontend question).
+**Finding**: today, adding a provider type to the UI requires editing this file directly, the same
+way adding one to the backend requires editing `notify_provider_adapter.go` — there is no
+schema-driven form rendering today. See §3.6 for how this is flagged as an open question.
+
+---
+
+## 3. Technical Specifications
+
+### 3.1 Provider `Config` field inventory (all eight packages, read directly)
+
+| Package | `Config` fields | Serializable? |
+|---|---|---|
+| `discord` | `WebhookURL, Template, CustomTemplate` | Yes — all strings |
+| `slack` | `WebhookURL, Template, CustomTemplate` | Yes — all strings |
+| `gotify` | `URL, Token, Template, CustomTemplate` | Yes — all strings |
+| `pushover` | `UserKey, APIToken, BaseURL, Template, CustomTemplate` | Yes — all strings |
+| `ntfy` | `URL, Token, Template, CustomTemplate` | Yes — all strings |
+| `telegram` | `BotToken, ChatID, BaseURL, Template, CustomTemplate` | Yes — all strings |
+| `webhook` | `URL, Template, CustomTemplate` | Yes — all strings |
+| `email` | `Recipients []string, SubjectPrefix string, TemplateName func(Message) string, Renderer TemplateRenderer, Mailer Mailer` | **No** — `TemplateName`/`Renderer`/`Mailer` are Go closures/interfaces, not data |
+
+This table is the deciding evidence for §3.2's config-typing recommendation: seven of eight
+packages have a pure-data `Config`; email's does not, and never can while it keeps the
+DI-seam design principle (module never dials SMTP/renders HTML itself) that the original
+extraction spec deliberately chose.
+
+### 3.2 Config-typing decision: `map[string]any`, not `json.RawMessage`
+
+**Recommendation: the registry boundary uses `map[string]any`, not `json.RawMessage`/typed
+generics.**
+
+Rationale, directly from §3.1's evidence:
+
+- `json.RawMessage` (or any JSON-shaped boundary) works cleanly for the seven HTTP providers — each
+ factory would `json.Unmarshal(raw, &Config{})`. It **cannot** work for `email.Config` at all:
+ `Mailer`, `TemplateRenderer`, and `TemplateName` are behavioral Go values a host application
+ constructs at startup (e.g. Charon's `notify_email_adapter.go` wrapping `MailServiceInterface`),
+ not data that arrives over a wire. There is no JSON representation of "call this Go function."
+ Forcing email through a JSON boundary would mean either (a) breaking email out of the registry
+ entirely as a special case — undermining the "one path for every provider" goal that motivated
+ this work — or (b) inventing a side-channel to inject non-JSON deps alongside the JSON blob,
+ which is just `map[string]any` with extra steps.
+- `map[string]any` handles both cases uniformly: for the seven HTTP providers, callers put plain
+ Go strings under well-known keys; for email, the caller puts the actual `Mailer`/`TemplateRenderer`
+ values and `TemplateName` closure directly into the map under their own well-known keys. Each
+ factory type-asserts what it expects and returns a config error (not a panic) on a
+ missing/wrong-typed key.
+- **Tradeoff, stated plainly**: this loses compile-time type safety at the registry boundary — a
+ caller can put a `string` under a key a factory expects to be `*transport.Wrapper` and won't
+ find out until `New()` returns an error at runtime. This is an accepted, explicit cost. The typed
+ per-provider `Config` structs (`discord.Config`, `email.Config`, etc.) **remain the primary,
+ fully type-safe public API** — a host application that wants compile-time safety and doesn't need
+ runtime discovery calls `discord.New(discord.Config{...}, wrapper)` directly, exactly as it does
+ today. The registry is an **additive convenience/discovery layer**, not a replacement for the
+ typed constructors — this must be explicit in the module's docs (§3.7) so users don't think
+ `notify.New` is the only or preferred way to construct a `Sender`.
+- A pure `json.RawMessage`-only design was considered and rejected specifically because it would
+ make email a permanent second-class citizen of the registry (excluded, or requiring an awkward
+ parallel non-JSON registration path) — inconsistent with the goal of one uniform discovery
+ mechanism across all provider types, present and future (Web Push, unlike email, is
+ HTTP-transport-based like the other seven, but the registry design must not special-case around
+ today's provider mix).
+
+### 3.3 `Register`/`New` API (module root, `package notify`)
+
+```go
+// factory.go (new file, module root)
+
+package notify
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+)
+
+// Factory constructs a Sender from a generic configuration map. Each
+// provider package's factory type-asserts the keys/types it expects out of
+// config and returns a descriptive error for anything missing or
+// wrong-typed — Factory implementations must never panic on bad input from
+// a caller (panicking is reserved for Register's own misuse-by-programmer
+// checks, per the database/sql convention — see below).
+//
+// Well-known convention (documented per-package in each provider's doc
+// comment and in ARCHITECTURE.md, §3.7): HTTP-based providers expect a
+// "transport" key holding the shared *transport.Wrapper; provider-specific
+// Config fields are expected under their lowercase snake_case field name
+// (e.g. discord's WebhookURL -> config["webhook_url"]). This module makes
+// no attempt to enforce these conventions structurally — see the open
+// question in §5 risk 2 on whether a stricter typed-key mechanism is worth
+// the added complexity.
+type Factory func(config map[string]any) (Sender, error)
+
+var (
+ registryMu sync.RWMutex
+ registry = map[string]Factory{}
+)
+
+// Register makes a provider Factory available under name (case-insensitive;
+// stored lowercased). Intended to be called from a provider package's
+// init(), mirroring database/sql.Register and image.RegisterFormat.
+//
+// Register panics if factory is nil or if name is already registered —
+// exactly like sql.Register — because a duplicate/nil registration is
+// always a programmer error discoverable at package-init time (e.g. two
+// packages both claiming "webhook"), never a legitimate runtime condition
+// a caller should have to handle.
+func Register(name string, factory Factory) {
+ if factory == nil {
+ panic("notify: Register called with nil Factory for " + name)
+ }
+ key := strings.ToLower(strings.TrimSpace(name))
+ if key == "" {
+ panic("notify: Register called with empty name")
+ }
+ registryMu.Lock()
+ defer registryMu.Unlock()
+ if _, exists := registry[key]; exists {
+ panic("notify: Register called twice for provider " + key)
+ }
+ registry[key] = factory
+}
+
+// New looks up the Factory registered under name (case-insensitive) and
+// invokes it with config. Returns an error — never panics — if name is not
+// registered or if the factory itself returns an error (e.g. a missing
+// required config key).
+func New(name string, config map[string]any) (Sender, error) {
+ key := strings.ToLower(strings.TrimSpace(name))
+ registryMu.RLock()
+ factory, ok := registry[key]
+ registryMu.RUnlock()
+ if !ok {
+ return nil, fmt.Errorf("notify: no provider registered for type %q (registered types: %s)",
+ name, strings.Join(RegisteredTypes(), ", "))
+ }
+ return factory(config)
+}
+
+// RegisteredTypes returns the sorted list of currently registered provider
+// type names. Useful for a host application that wants to validate a
+// config value or populate a UI dropdown against exactly what's compiled
+// in, without hardcoding its own list (see §3.5's discussion of Charon's
+// allowlist-vs-discovery tradeoff).
+func RegisteredTypes() []string {
+ registryMu.RLock()
+ defer registryMu.RUnlock()
+ names := make([]string, 0, len(registry))
+ for name := range registry {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ return names
+}
+```
+
+**Design notes:**
+
+- `sync.RWMutex`-guarded map: registrations happen at `init()` time (effectively single-threaded,
+ before `main` runs), but `New`/`RegisteredTypes` may be called concurrently from request-handling
+ goroutines in a host application (Charon's HTTP handlers), so read-locking those paths is cheap
+ insurance, not overengineering.
+- `Register` panicking on misuse mirrors `database/sql` exactly and is the right call here for the
+ same reason: a duplicate provider name is a build-time-discoverable defect (two packages both
+ registering `"webhook"`), and panicking during `init()` fails the program immediately and loudly
+ rather than silently shadowing one provider with another.
+- `New` returning an error (never panicking) for an *unregistered* name is the opposite case
+ deliberately: "provider type X isn't registered" is a **runtime** condition (a host forgot to
+ blank-import the package, or a config file references a typo'd/future type) that calling code
+ must be able to handle gracefully — e.g. Charon surfacing "unsupported provider type" back to its
+ API caller instead of crashing the process.
+- Placed at the module root (`factory.go`, `package notify`) alongside `message.go`/`sender.go`
+ rather than a new subpackage — avoids an import-cycle problem symmetric to the one in §3.2: if
+ `Register`/`New` lived in a subpackage that provider packages needed to import, and the root
+ `notify` package needed to reference that subpackage's types, the two would have to share
+ identifiers anyway. Keeping the registry in the root package (which every provider package
+ already imports, per `discord.go`'s `notify "github.com/Wikid82/go_notify_yourself"` import) is
+ the only placement with zero new import edges.
+
+### 3.4 Provider package migration — each package's `init()`
+
+Every one of the eight existing packages adds a small registration file/block. Two representative
+examples (HTTP-based and email), the remaining six follow the same shape as `discord`:
+
+```go
+// providers/discord/register.go (new file)
+package discord
+
+import (
+ "fmt"
+
+ notify "github.com/Wikid82/go_notify_yourself"
+ "github.com/Wikid82/go_notify_yourself/transport"
+)
+
+func init() {
+ notify.Register("discord", func(config map[string]any) (notify.Sender, error) {
+ w, ok := config["transport"].(*transport.Wrapper)
+ if !ok || w == nil {
+ return nil, fmt.Errorf(`discord: config["transport"] must be a non-nil *transport.Wrapper`)
+ }
+ cfg := Config{
+ Template: stringField(config, "template"),
+ CustomTemplate: stringField(config, "custom_template"),
+ WebhookURL: stringField(config, "webhook_url"),
+ }
+ return New(cfg, w), nil
+ })
+}
+```
+
+```go
+// providers/email/register.go (new file)
+package email
+
+import (
+ "fmt"
+
+ notify "github.com/Wikid82/go_notify_yourself"
+)
+
+func init() {
+ notify.Register("email", func(config map[string]any) (notify.Sender, error) {
+ mailer, ok := config["mailer"].(Mailer)
+ if !ok || mailer == nil {
+ return nil, fmt.Errorf(`email: config["mailer"] must be a non-nil Mailer`)
+ }
+ cfg := Config{
+ Mailer: mailer,
+ SubjectPrefix: stringField(config, "subject_prefix"),
+ Recipients: stringSliceField(config, "recipients"),
+ }
+ if r, ok := config["renderer"].(TemplateRenderer); ok {
+ cfg.Renderer = r
+ }
+ if tn, ok := config["template_name"].(func(notify.Message) string); ok {
+ cfg.TemplateName = tn
+ }
+ return New(cfg), nil
+ })
+}
+```
+
+(`stringField`/`stringSliceField` are small unexported helpers in a shared internal location,
+e.g. `providers/internal/regconfig`, to avoid duplicating the same type-assert-with-default
+boilerplate across eight `register.go` files — DRY per this repo's own conventions.)
+
+**What breaks / changes in the public API as a result — this is a breaking change:**
+
+- No existing exported identifier changes signature (`Config`, `New(cfg, w)`, `Send` are untouched)
+ — this is purely additive at the Go-API level.
+- **However**, adding `init()`-time registration means: (a) importing any provider package now has
+ a side effect (registering into the global `notify` registry) it didn't have before — a host
+ application that imports `providers/discord` for its types but never calls `notify.New` will
+ still pay the (tiny) registration cost and take a global-map write at startup. This is normal for
+ the self-registration pattern (identical to every `database/sql` driver package) but is a
+ behavior change worth calling out in the changelog, not silently shipping. (b) Two packages
+ registering the same name in the same binary now **panics at init time** — not a concern for the
+ eight packages in this repo (names are fixed and distinct), but is a new failure mode a future
+ third-party provider package could trigger if it collided with a built-in name; document this in
+ `ARCHITECTURE.md` (§3.7) as a naming-collision hazard to design around.
+ - Since this changes both the module's behavior (global registration side effect) and shape
+ (new root-package `Register`/`New`/`RegisteredTypes` exported API, new `map[string]any`
+ convention every provider must honor), this ships as **`v0.2.0`**, not a patch release —
+ consistent with semver: no existing exported signature breaks, but new, previously-absent
+ runtime behavior (init-time global mutation, panic-on-collision) is a real enough shift to
+ warrant a minor bump at minimum. (The module is pre-1.0 per its `v0.1.0` tag, so strictly
+ semver would even permit this as a `v0.1.x`-breaking change without a major bump — recommend
+ `v0.2.0` as the clearer, more conventional signal to the one current consumer.)
+
+### 3.5 `providers/all` bundle package
+
+```go
+// providers/all/all.go (new file)
+
+// Package all blank-imports every provider package shipped in this module,
+// registering all of them into the root notify package's registry as a
+// side effect. Import this package for its side effects only —
+//
+// import _ "github.com/Wikid82/go_notify_yourself/providers/all"
+//
+// — when you want every built-in provider type available to notify.New
+// without importing each provider package individually. This is the
+// closest equivalent Go offers to true runtime auto-discovery: Go has no
+// mechanism to discover and load packages that were not compiled into the
+// binary, so *some* single import is unavoidable — providers/all exists so
+// that import is exactly one line, added once, rather than one line per
+// provider that must be kept in sync by hand as the provider list grows.
+package all
+
+import (
+ _ "github.com/Wikid82/go_notify_yourself/providers/discord"
+ _ "github.com/Wikid82/go_notify_yourself/providers/email"
+ _ "github.com/Wikid82/go_notify_yourself/providers/gotify"
+ _ "github.com/Wikid82/go_notify_yourself/providers/ntfy"
+ _ "github.com/Wikid82/go_notify_yourself/providers/pushover"
+ _ "github.com/Wikid82/go_notify_yourself/providers/slack"
+ _ "github.com/Wikid82/go_notify_yourself/providers/telegram"
+ _ "github.com/Wikid82/go_notify_yourself/providers/webhook"
+)
+```
+
+**Tradeoff, stated explicitly (per the task's ask):** importing `providers/all` means a host
+binary always links every provider package the module ships, even ones the host never configures
+or wants to expose (e.g. Charon linking a future `providers/webpush` it has no UI for yet) — larger
+binary, and every provider's transitive dependencies come along. The alternative — hand-picking
+individual `import _ "…/providers/discord"` lines — keeps binary size and blast radius under the
+host's control but means a host must edit its own import list every time it wants a newly-added
+provider, which is exactly the manual step the user is trying to eliminate for *their* project (not
+necessarily for Charon — see §3.6's recommendation, which treats these as two different answers for
+two different consumers). `providers/all` is offered as **one available choice**, not a mandate —
+`go_notify_yourself` ships it for consumers who want zero-touch discovery; a consumer that wants
+tighter control simply doesn't import it and hand-picks instead. Both remain equally supported by
+the registry design; neither requires a different `Register`/`New` API.
+
+### 3.6 Charon-side changes
+
+**3.6.1 `notify_provider_adapter.go`'s switch collapses to a `notify.New` call.**
+
+`buildNotifySender` (§2.3) becomes, in shape:
+
+```go
+func buildNotifySender(provider models.NotificationProvider, w *transport.Wrapper, mailer email.Mailer) (notify.Sender, error) {
+ tmpl, customTemplate := resolveTemplateFields(provider)
+ config := providerConfigMap(provider, w, mailer, tmpl, customTemplate) // per-type field mapping — see below
+ return notify.New(provider.Type, config)
+}
+```
+
+**Important finding, not glossed over**: collapsing the *constructor dispatch* onto `notify.New`
+does **not** eliminate the per-type field-mapping logic documented in §2.3 — Charon's GORM schema
+reuses `URL`/`Token` with different *meanings* per provider type (discord's `URL` is a webhook URL;
+pushover's `URL` is a user key; telegram's `URL` is a chat ID). Something in Charon must still
+decide "for type X, config key `webhook_url` <- `provider.URL`; for type Y, config key `user_key`
+<- `provider.URL`." The registry removes the "which Go constructor do I call" branch; it does
+**not** remove the "which GORM column means what for this type" branch, because that mapping is a
+Charon persistence-schema fact, not something `go_notify_yourself` can know. `providerConfigMap`
+would likely still contain a `switch provider.Type` internally — smaller in scope (pure data
+mapping, no `discord.New(...)`/`slack.New(...)` calls, no per-package imports needed in this file
+at all once every field name is passed as a map key) but not eliminated outright. This should be
+stated plainly to the user rather than oversold as "the switch statement disappears entirely" — it
+shrinks and stops needing to import every provider package, but a mapping table remains until/unless
+§2.4's `ServiceConfig` idea (below) is adopted for new types.
+
+**3.6.2 The three provider-type allowlists — open design question, not silently resolved.**
+
+`isSupportedNotificationProviderType`, `supportsJSONTemplates`, and `isDispatchEnabled` currently
+hardcode Charon's own opinion about which provider types it exposes in its UI/API — independent of
+what the module happens to support. Two options:
+
+- **Option A — keep Charon's allowlist hardcoded (recommended).** Charon's REST API/UI continues to
+ explicitly enumerate the provider types it supports, same as today. If Charon later imports
+ `providers/all` (or a future `providers/webpush`), that provider becomes *constructible* via
+ `notify.New` but Charon's API still rejects it at `isSupportedNotificationProviderType` until a
+ human deliberately adds it to the allowlist (and, typically, builds UI for it). **Rationale**: a
+ host application importing a module for its providers is not the same decision as committing to
+ support that provider in its own product surface — Charon may want `providers/all` linked for
+ convenience (§3.5) while still curating what it exposes to users, exactly the same reasoning the
+ original extraction spec used for feature-flag gating (`isDispatchEnabled`, which is Charon
+ policy, not engine behavior, and stays Charon's regardless of this change).
+- **Option B — query the registry directly (`notify.RegisteredTypes()`)** for full auto-discovery:
+ Charon's allowlist becomes computed, not hardcoded — any provider the compiled binary happens to
+ have linked (via whatever `providers/*` imports exist) is automatically exposed through Charon's
+ API. **Rationale for considering it**: this is the literal "add a provider, it just works,
+ automatically, after a version bump" behavior the user described wanting. **Rationale against**:
+ it removes Charon's ability to link a provider package (for binary-size/testing/future-readiness
+ reasons) without also immediately exposing it to end users — e.g. Web Push landing in
+ `go_notify_yourself` before Charon has built any UI for it would suddenly appear as a "supported"
+ type in Charon's API with no way to configure it meaningfully from the UI, a broken half-feature
+ exposed by version bump alone.
+
+**Recommendation: Option A**, with `notify.RegisteredTypes()` used only as an internal
+*consistency check* (e.g. a startup assertion or unit test asserting Charon's hardcoded allowlist
+is a subset of `notify.RegisteredTypes()`, catching the case where Charon claims to support a type
+the linked module build doesn't actually have registered) — not as the live source of truth for
+what the API accepts. **This is exactly the kind of decision the task called out as needing the
+user's confirmation before implementation** — flagged here, not silently picked.
+
+**3.6.3 `providers/all` vs. hand-picked imports in Charon — recommendation.**
+
+Recommend Charon **hand-picks** individual provider imports
+(`import _ "github.com/Wikid82/go_notify_yourself/providers/discord"`, one per supported type, in
+`notify_provider_adapter.go` or a dedicated `notify_providers_import.go`) rather than importing
+`providers/all`. This is the direct consequence of the Option A recommendation in §3.6.2: if
+Charon's allowlist is the actual gate on what's exposed (not the registry), then importing
+`providers/all` only adds binary size and a larger transitive dependency surface for providers
+Charon's allowlist will reject anyway — there's no discovery benefit to importing more than
+Charon's own allowlist currently names. `providers/all` remains the right choice for a *different*
+kind of consumer — one that wants Option-B-style full auto-discovery — which is exactly the
+scenario the user described for their *other* project, not necessarily for Charon.
+
+**3.6.4 GORM model — `ServiceConfig` is the identified extension point, not yet wired up.**
+
+Confirmed by re-reading `notification_provider.go` (§2.4): the two-slot `URL`/`Token` scheme is
+sufficient for all eight of today's provider types (each needs at most two secrets/identifiers,
+already squeezed in with per-type reinterpretation), but is **not** sufficient for a
+not-yet-known-in-advance provider needing three or more distinct config values with no natural
+"URL" or "Token" framing (Web Push's VAPID keypair + endpoint being the concrete example driving
+this whole request). No schema change is strictly required to land the registry itself (today's
+eight providers keep working unchanged), but **a future provider needing >2 config values will
+need `ServiceConfig` wired up** — recommend, as a follow-up (not blocking this registry work): treat
+`ServiceConfig` as a JSON-encoded `map[string]string` (or `map[string]any`) column, decoded and
+merged into the `config` map passed to `notify.New` alongside the existing `URL`/`Token`-derived
+keys, giving future provider types an escape hatch without another migration. Flagged as a
+recommendation for a **later** spec/commit, not built now — out of scope for "make existing
+providers discoverable," in scope for "the next provider that doesn't fit two slots."
+
+**3.6.5 Frontend — flagged, not solved.**
+
+`Notifications.tsx` renders per-type config fields inline, keyed off `provider.type` (§2.5) — the
+same "hardcoded per type" pattern this spec removes from the Go backend. Making the *frontend* form
+genuinely schema-driven (e.g. deriving which fields to render from something the backend exposes,
+rather than a hardcoded TSX conditional) is a materially different, larger piece of design work
+(a form-schema wire format, versioning of that schema, backward compat for already-saved configs)
+that this spec does **not** attempt to solve. Flagging per the task's explicit instruction: the
+user asked specifically about the engine being "drop-in ready" at the Go level; whether that
+implies auto-generated UI is a separate question for the user to weigh in on before any frontend
+work is planned.
+
+### 3.7 Documentation deliverables
+
+**3.7.1 `/projects/go_notify_yourself/ARCHITECTURE.md` (new file) — required sections**
+
+Must be precise enough for a human contributor *or a coding agent* to add a provider without
+guessing. Required sections, in order:
+
+1. **Module overview** — one paragraph: what this module is, the four-layer shape (`Message`/
+ `Sender` at root, `transport` for SSRF-safe HTTP, `providers/*` per-service implementations,
+ the registry tying them together), link to README for the quick-start.
+2. **The `Sender` contract** — the interface, its one method, the behavioral expectations already
+ documented in `sender.go`'s doc comment (respect `ctx`, wrap errors, never panic) restated here
+ with a "why" (uniform treatment by host applications fanning a message out to N destinations).
+3. **Adding a new provider — step-by-step**, the core of the document:
+ - File/package layout convention: `providers//.go` (main `Config`/`New`/`Send`),
+ `providers//_test.go`, optionally `providers//register.go` for the `init()`
+ registration (kept separate from the main file — mirrors this spec's §3.4 examples — so the
+ core type/constructor logic isn't cluttered by registry plumbing).
+ - The `Config` struct convention: exported struct, `Template`/`CustomTemplate` fields present
+ *only* if the provider is HTTP/JSON-payload-based (email is the documented exception —
+ explain why, pointing at §3.1/§3.2's reasoning).
+ - The `New(cfg Config, w *transport.Wrapper) *Client` signature convention for HTTP-based
+ providers (email's `New(cfg Config) *Client` documented as the one exception, with the reason
+ — no HTTP transport).
+ - `var _ notify.Sender = (*Client)(nil)` compile-time interface assertion — required in every
+ provider package, per the existing pattern (confirmed present in `discord.go`, `email.go`).
+ - **The `Register`/factory pattern**: exact template for `register.go`'s `init()`, the
+ `map[string]any` key-naming convention (lowercase snake_case of the `Config` field name; the
+ `"transport"` key reserved for `*transport.Wrapper` on HTTP-based providers), and the
+ requirement that factories return errors (never panic) for bad/missing keys.
+ - **Adding to `providers/all`**: the one-line blank-import addition required in
+ `providers/all/all.go`, and why this step is easy to forget (it's not enforced by the
+ compiler — a new provider that registers itself but isn't added to `providers/all` still
+ works for direct `import _ "…/providers/newone"` consumers but silently isn't part of the
+ "one import gets everything" bundle). Recommend a CI check (e.g. a small test in
+ `providers/all` asserting `len(notify.RegisteredTypes()) == ` with a comment requiring the
+ constant be bumped alongside any new provider, catching an accidental omission) — specify this
+ as a required addition, not just a suggestion, since it's the one step with no compiler
+ safety net.
+ - **Naming conventions**: provider package names are lowercase, no underscores, matching the
+ `Register` key exactly (e.g. package `webpush`, `Register("webpush", ...)`) — collisions
+ panic at `init()` per §3.3, so this section should state that explicitly as the reason naming
+ matters.
+ - **Test expectations**, mirroring the existing eight providers' patterns (to be confirmed
+ against actual test files in `providers/discord/discord_test.go` etc. by whoever implements
+ this — this spec specifies *that* the doc must describe them, not their exact content, since
+ verifying each existing test file's structure is implementation-time work): table-driven
+ `Send` tests against a fake `transport.Wrapper` (via injected `ClientFactory`), config
+ validation error-path tests, ≥85% coverage per package (per this repo's own `CLAUDE.md`
+ coverage bar), and — new for this spec — a registration test asserting `notify.New("",
+ validConfig)` succeeds and returns a `Sender`, plus at least one test asserting a
+ missing-required-key config produces an error, not a panic.
+ - **Config validation/error handling conventions**: factories validate structurally (right type
+ present under each expected key) and return `fmt.Errorf`-wrapped errors describing exactly
+ which key/type was expected; the underlying `Config`-consuming `New`/`Send` still perform their
+ own semantic validation (e.g. Discord's webhook host allowlist) exactly as they do today —
+ the registry layer adds a validation step in front of, not instead of, existing validation.
+4. **The registry internals** — brief: where `Register`/`New`/`RegisteredTypes` live (module root,
+ `factory.go`), the panic-on-duplicate/nil-factory contract, the `RWMutex` concurrency note, and
+ an explicit statement that this is an *additive convenience layer* — the typed `New(cfg, w)`
+ constructors remain fully supported and are not deprecated by the registry's existence.
+5. **Versioning note** — this document itself should state that adding a provider (a new package)
+ is a `feat:`/minor-version change under this module's semver policy (unchanged provider API
+ surface for existing providers = non-breaking), while changing `Register`/`New`/`Factory`'s
+ signature is a breaking/major-version change — giving a contributor or agent the semver
+ judgment call up front instead of leaving them to guess at PR time.
+
+**3.7.2 New README section (`/projects/go_notify_yourself/README.md`)**
+
+Brief — a new `## Provider registry` section (placed after the existing "Provider packages" table,
+before "Transport"), roughly 150–250 words: what `notify.Register`/`notify.New` are, the one-line
+`providers/all` blank-import quick-start, a pointer ("see `ARCHITECTURE.md` for how to add a new
+provider, and `docs/INTEGRATION.md` for a full integration walkthrough"), and one sentence stating
+the typed constructors remain the recommended path when a caller doesn't need runtime discovery
+(consistent with §3.2's "additive, not a replacement" framing, so the README doesn't contradict
+`ARCHITECTURE.md`).
+
+**3.7.3 `/projects/go_notify_yourself/docs/INTEGRATION.md` (new file) — required structure (Five Ws
+and One H)**
+
+Written for someone integrating the module into their **own, unrelated** project — not Charon.
+Required sections, in this order, each specified so a future writer doesn't have to invent
+structure:
+
+1. **Who this is for** — the target reader: a Go developer building a self-hosted or small-team
+ application (the README's own framing: "most projects... end up re-implementing the same things
+ badly") who needs to fire outbound alerts to chat/push/email destinations and doesn't want to
+ hand-roll SSRF-safe HTTP dispatch, retries, or per-service payload quirks. Explicitly *not* for:
+ someone needing an Apprise-style URL-scheme dispatcher today (§1.3/non-goals), or someone who
+ needs inbound/two-way messaging (this module is send-only).
+2. **What it does / doesn't do** — a two-column or two-list breakdown. Does: SSRF-safe outbound
+ HTTP with retry/backoff (`transport.Wrapper`), a uniform `Sender` interface across eight
+ built-in provider types, JSON payload templating with a shared `text/template` + `toJSON`
+ engine, a self-registering factory/discovery layer (this spec's addition). Doesn't: own any
+ database, config file format, or HTTP framework; provide inbound webhook receiving; provide
+ scheduling/queueing/retry-after-process-restart (retries are in-process, in-request only);
+ provide a URL-scheme dispatch convention (yet — link to §8 of the extraction spec / this
+ module's own long-term-direction note for the Apprise aspiration).
+3. **When to reach for it vs. rolling your own** — a short decision checklist: reach for it if you
+ need ≥2 of {Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, email} dispatch,
+ want retry/backoff and SSRF hardening without writing it yourself, and are fine supplying your
+ own HTTP client factory / SSRF policy / SMTP mailer via the DI seams. Roll your own if you need
+ exactly one destination type with a very custom payload shape *and* don't want any of the shared
+ machinery, or need transport types this module doesn't have (SMS, inbound webhooks, message
+ queues).
+4. **Where it fits in a typical app's architecture** — a short architecture sketch (text or a
+ simple diagram) showing: your app's business logic layer maps a domain event into a
+ `notify.Message`; a startup-time wiring step builds one shared `*transport.Wrapper` (with your
+ `ClientFactory`/`URLValidator` seams) and, if using email, your `Mailer`/`TemplateRenderer`
+ implementations; either call typed constructors directly or use `notify.New(type, config)` via
+ `providers/all`; the resulting `Sender`(s) are invoked from wherever your app currently fires
+ alerts (a notification service, an error handler, a monitoring loop). Explicitly locate this as
+ "a dispatch layer your service layer calls into," not a framework that owns your request
+ lifecycle.
+5. **Why it's built this way** — the DI-seam philosophy: the module has zero DB/framework/HTTP-server
+ knowledge by design (restate the non-negotiable rule from this module's own `CLAUDE.md`); every
+ environment-specific concern is a constructor-injected interface; this is what makes the module
+ equally usable from Charon (GORM/Gin), a CLI tool, or a serverless function, and is why the
+ registry (this spec) uses `map[string]any` rather than forcing a specific config-file format or
+ framework binding (§3.2's rationale, restated briefly for this different audience).
+6. **How to integrate — end-to-end walkthrough**: a real, copy-pasteable sequence —
+ `go get github.com/Wikid82/go_notify_yourself@v0.2.0`; blank-import `providers/all` (or
+ hand-pick, with the same tradeoff explanation as §3.5, written for this general audience rather
+ than Charon-specifically); construct a `transport.Wrapper`; construct one or more senders via
+ `notify.New` with a worked `map[string]any` example for at least one HTTP provider and email;
+ dispatch a message; handle/log a `Send` error; a short "testing your integration" pointer to the
+ README's existing "Testing your own integration" section (don't duplicate it, link to it).
+
+### 3.8 Public API surface summary (new/changed in this spec)
+
+| Symbol | Package | Status |
+|---|---|---|
+| `type Factory func(config map[string]any) (Sender, error)` | `notify` (root) | New |
+| `func Register(name string, factory Factory)` | `notify` (root) | New |
+| `func New(name string, config map[string]any) (Sender, error)` | `notify` (root) | New |
+| `func RegisteredTypes() []string` | `notify` (root) | New |
+| `package all` (blank-import bundle) | `providers/all` | New package |
+| `func init()` in each of 8 provider packages | `providers/*` | New (registration side effect) |
+| `Config`, `New(cfg, w)`, `Send` in each of 8 provider packages | `providers/*` | **Unchanged** — no breaking signature change |
+
+### 3.9 Database schema changes
+
+None required to land the registry itself. §3.6.4 identifies `ServiceConfig` (already present,
+currently unused) as the extension point a *future* provider needing >2 config values would need
+wired up — explicitly deferred, not part of this spec's implementation.
+
+### 3.10 Error handling / edge cases
+
+- **Unregistered type at `notify.New`**: returns a wrapped error listing currently-registered
+ types (§3.3) — Charon surfaces this as its existing "unsupported provider type" API error, no new
+ Charon-side error path needed for this case since `buildNotifySender`'s `default:` branch already
+ returns an error today.
+- **Missing/wrong-typed required config key inside a factory** (e.g. `config["transport"]` absent
+ or not a `*transport.Wrapper`): each factory returns a descriptive `fmt.Errorf` — never panics
+ — per §3.4's examples. This is a *caller* bug (Charon's adapter built the map wrong), distinct
+ from the *programmer* bugs `Register` panics on.
+- **Duplicate registration** (two packages registering the same name in one binary): panics at
+ `init()`, crashing the process immediately at startup — by design (§3.3), surfaces the
+ misconfiguration as loudly and early as possible rather than silently picking one.
+- **`providers/all` omission**: a new provider that registers correctly but isn't added to
+ `providers/all` doesn't break anything for a consumer hand-picking imports; it silently isn't
+ part of the "everything" bundle for `providers/all` consumers. Mitigated by the CI count-check
+ recommended in §3.7.1, not by any runtime mechanism (Go cannot detect "a package that exists in
+ this repo but wasn't imported").
+- **Charon allowlist drift from the registry** (§3.6.2 Option A risk): Charon's hardcoded allowlist
+ could, over time, diverge from what's actually registered in the linked module build (claiming
+ support for a type whose provider package isn't imported, or vice versa). Mitigated by the
+ recommended consistency check (`isSupportedNotificationProviderType`'s set ⊆
+ `notify.RegisteredTypes()`), run as a Charon unit test, not a runtime assertion (avoids a
+ startup-time panic risk in production for what's fundamentally a build-configuration mismatch
+ best caught in CI).
+
+---
+
+## 4. Implementation Plan (for the future execution session — not executed here)
+
+### Phase 1: `go_notify_yourself` — registry core + provider migrations (module repo)
+
+- Write `factory.go` at module root (`Factory`, `Register`, `New`, `RegisteredTypes`, §3.3) +
+ `factory_test.go` (duplicate-registration panic, nil-factory panic, unregistered-name error,
+ concurrent-access test).
+- Add `register.go` to each of the eight provider packages (§3.4), plus a shared
+ `providers/internal/regconfig` helper package for the `stringField`/`stringSliceField`
+ boilerplate (DRY — avoids 8x duplication).
+- Add `providers/all/all.go` (§3.5) + a test asserting `len(notify.RegisteredTypes()) == 8` (the
+ CI safety net from §3.7.1).
+- `go build ./...`, `go vet ./...`, `staticcheck ./...`, `go test ./...` (≥85% coverage on new
+ code) all green.
+
+### Phase 2: `go_notify_yourself` — documentation (module repo)
+
+- Write `ARCHITECTURE.md` per §3.7.1's required sections.
+- Add the README section per §3.7.2.
+- Write `docs/INTEGRATION.md` per §3.7.3's required sections.
+- Update `CHANGELOG.md`'s `[Unreleased]` section with the registry addition, explicitly noting the
+ `v0.2.0` semver bump and its rationale (§3.4).
+- Tag `v0.2.0` once Phase 1 + Phase 2 are both merged in this repo.
+
+### Phase 3: Charon — adapter simplification (this repo, same branch)
+
+- Bump `backend/go.mod` to `github.com/Wikid82/go_notify_yourself v0.2.0`.
+- Add hand-picked provider imports (§3.6.3) — one `import _ "…/providers/"` line per
+ Charon-supported type, in a new small file (e.g. `notify_providers_import.go`) rather than
+ scattered across existing files, so the "what's linked" list stays in one obvious place.
+- Rewrite `buildNotifySender` to call `notify.New(provider.Type, config)` (§3.6.1), with
+ `providerConfigMap` doing the remaining per-type field-name mapping (still present, smaller
+ scope — no direct `providers/*` package function calls left in this file, only the blank imports
+ above plus `transport`/`email` types needed to build the config map's values).
+- Add the recommended consistency-check unit test (§3.6.2/§3.10):
+ `isSupportedNotificationProviderType`'s set is a subset of `notify.RegisteredTypes()` given
+ Charon's actual linked imports.
+- Update `ARCHITECTURE.md` per this repo's own mandatory-update rule.
+
+### Phase 4: Integration and testing (Charon repo)
+
+- Rewrite/extend `notify_provider_adapter_test.go` to cover the new `notify.New`-based dispatch
+ path — assert the same per-type behaviors the existing switch-based tests already assert (§2.3),
+ now via the registry, plus new tests for the "unregistered/unsupported type" and
+ "missing-transport-in-config" error paths.
+- Full backend suite + `scripts/go-test-coverage.sh` (≥85%) green.
+- No frontend changes in this phase (§3.6.5 is explicitly deferred) — no Playwright changes needed
+ since no user-observable behavior changes (same provider types, same UI, same API responses).
+
+### Phase 5: Documentation and deployment (both repos)
+
+- Confirm `go.sum` supply-chain scan clean for the bumped dependency.
+- No `docs/features/notifications.md` change required (product-facing behavior is unchanged;
+ optional credit-the-module line only, per the original extraction spec's precedent).
+- Release Charon per normal Conventional Commits flow on this existing PR/branch.
+
+---
+
+## 5. Acceptance Criteria
+
+- [ ] `go_notify_yourself`: `Register`/`New`/`RegisteredTypes` exist at module root, `go test
+ ./...` and `staticcheck` pass with zero findings, tagged `v0.2.0`.
+- [ ] All eight provider packages self-register via `init()`; `providers/all` blank-imports all
+ eight and its count-check test passes.
+- [ ] `notify.New("discord", map[string]any{"transport": w, "webhook_url": "...", "template":
+ "minimal"})` returns a working `*discord.Client` equivalent to `discord.New(discord.Config{...},
+ w)` — behavioral parity between the typed constructor and the registry path is asserted by
+ test, not assumed.
+- [ ] `ARCHITECTURE.md`, README's new section, and `docs/INTEGRATION.md` exist and cover every
+ required subsection listed in §3.7.
+- [ ] Charon's `notify_provider_adapter.go`'s `buildNotifySender` calls `notify.New`; the file's
+ import list no longer imports the eight `providers/*` packages directly for constructor calls
+ (blank-imports for registration live in a separate, clearly-named file).
+- [ ] Charon's three provider-type allowlists (`isSupportedNotificationProviderType`,
+ `supportsJSONTemplates`, `isDispatchEnabled`) remain hardcoded per §3.6.2's Option A
+ recommendation, with a new test asserting they're a subset of `notify.RegisteredTypes()`.
+- [ ] No change to `docs/plans/current_spec.md`.
+- [ ] No behavior change observable from Charon's frontend or REST API — same provider types
+ supported, same request/response shapes, same dispatch semantics (retry/backoff, SSRF policy
+ unchanged from the already-merged extraction).
+- [ ] Charon's backend coverage gate (`scripts/go-test-coverage.sh`, min 85%) still passes.
+- [ ] `ARCHITECTURE.md` (Charon's) updated per the mandatory rule.
+
+---
+
+## 6. Commit Slicing Strategy
+
+**Decision**: this work is **not** a new feature/new PR — it folds into the existing, still-open
+PR #1253 on `feature/notifications-engine-extraction`, exactly as the task specified. Per this
+repo's own commit-slicing convention, it spans two repositories (module repo, then Charon repo),
+each with its own ordered commit sequence, landing on the *same* existing branch/PR shape the
+original extraction already established (two-repo-two-PR-shaped-as-one-feature, not a new branch).
+
+### `go_notify_yourself` repo — additional commits (same repo, no PR needed there per its own
+### CLAUDE.md's "direct TDD, no multi-agent roster" note — but still ordered/bisectable commits)
+
+1. **Commit 1** — Registry core: `factory.go` + `factory_test.go` (§3.3, Phase 1). Gate: `go build
+ ./...`, `go vet ./...`, `go test ./...` green, new code ≥85% coverage.
+2. **Commit 2** — Shared registration helper: `providers/internal/regconfig` (`stringField`/
+ `stringSliceField` + tests). Gate: same pattern. Dependency: Commit 1.
+3. **Commit 3** — Discord + Slack registration (`register.go` in each, §3.4). Gate: `go test
+ ./providers/discord/... ./providers/slack/...` green, registry round-trip test passes.
+ Dependency: Commits 1–2.
+4. **Commit 4** — Gotify + Pushover + Ntfy registration. Gate: same pattern. Dependency: 1–2.
+5. **Commit 5** — Telegram + Webhook registration. Gate: same pattern. Dependency: 1–2.
+6. **Commit 6** — Email registration (highest design-risk piece, per the original extraction
+ spec's precedent for treating email specially — the non-serializable `Mailer`/`TemplateRenderer`
+ type-assertion path deserves dedicated review attention). Gate: `go test ./providers/email/...`
+ green, explicit test for missing-`Mailer` error path. Dependency: 1–2.
+7. **Commit 7** — `providers/all` bundle + count-check test (§3.5). Gate: `go test
+ ./providers/all/...` asserts `len(notify.RegisteredTypes()) == 8`. Dependency: Commits 3–6 (all
+ eight must be registered first).
+8. **Commit 8** — Documentation: `ARCHITECTURE.md`, README section, `docs/INTEGRATION.md` (§3.7).
+ Gate: manual review against §3.7's required-sections checklist (no automated gate for doc
+ content). Dependency: Commits 1–7 (docs describe the finished API).
+9. **Commit 9** — `CHANGELOG.md` update + tag `v0.2.0`. Gate: `goreleaser release --snapshot`
+ dry-run succeeds. Dependency: Commit 8.
+
+Rollback: any commit 3–7 can be reverted independently (each provider's registration is additive
+and isolated); Commit 1 is the sole hard dependency for everything after it — reverting it reverts
+the whole registry addition cleanly since nothing else in the module depended on a registry
+existing before this work.
+
+### Charon repo — additional commits on the existing `feature/notifications-engine-extraction`
+### branch (same PR #1253)
+
+1. **Commit 1** — Dependency bump: `go.mod`/`go.sum` to `go_notify_yourself v0.2.0`. Gate: `go
+ build ./...`. Dependency: module repo's `v0.2.0` tag must exist first.
+2. **Commit 2** — Registration imports: new `notify_providers_import.go` with hand-picked
+ blank-imports (§3.6.3) for Charon's eight currently-supported types. No behavior change yet
+ (nothing calls `notify.New` still). Gate: `go build ./...`.
+3. **Commit 3** — Adapter rewrite: `buildNotifySender` calls `notify.New` (§3.6.1); `resolveTemplateFields`
+ and the per-type `providerConfigMap` helper carry forward from the existing switch, adapted to
+ produce `map[string]any` instead of typed `Config` structs. Gate: `notify_provider_adapter_test.go`
+ rewritten and green, asserting identical behavior to the pre-registry switch for all eight types
+ (parity, not just "compiles"). Dependency: Commits 1–2.
+4. **Commit 4** — Allowlist consistency test: new test asserting
+ `isSupportedNotificationProviderType`'s set ⊆ `notify.RegisteredTypes()` (§3.6.2/§3.10). Gate:
+ test passes given Commit 2's imports. Dependency: Commit 3.
+5. **Commit 5** — Cleanup: remove now-unused direct `providers/*` package imports from
+ `notify_provider_adapter.go` if any remain post-rewrite; confirm no unused imports via
+ staticcheck. Gate: `go build ./...`, staticcheck clean. Dependency: Commit 3.
+6. **Commit 6** — Docs: `ARCHITECTURE.md` update noting the registry-based dispatch (supersedes
+ the switch-statement description added by the original extraction's Commit 12). Gate: manual
+ review. Dependency: Commit 3.
+7. **Commit 7** — Coverage/hardening: re-run `scripts/go-test-coverage.sh`, fix any regression.
+ Gate: full Definition of Done per CLAUDE.md. Dependency: Commits 1–6.
+
+Rollback for the Charon side: since PR #1253 is still open (not yet merged to `development`), any
+of Commits 1–7 can be reverted or the whole set squashed out of the branch before merge with zero
+production impact — there is no live consumer of the registry-based path yet. Post-merge, the
+dependency bump (Commit 1) is the natural revert boundary: rolling back to the pre-`v0.2.0` pin and
+restoring the switch-based `buildNotifySender` (Commits 2–5) is a clean, self-contained revert since
+no schema/API/GORM change accompanies this work.
+
+Contingency: if Phase 3/4 discovers the `map[string]any` boundary is meaningfully harder to keep in
+sync with GORM's `URL`/`Token` reinterpretation-per-type scheme than expected (§3.6.1's caveat),
+stop before Charon's Commit 3 and re-scope `providerConfigMap` as its own small design pass — this
+does not block the module-repo commits (1–9 above), which are self-contained and useful to the
+user's other project regardless of exactly how Charon's adapter ends up shaped.
+
+---
+
+## 7. Risks / Open Questions (for the user to confirm before implementation)
+
+1. **Allowlist vs. full discovery (§3.6.2) — needs explicit user sign-off.** Recommended: Option A
+ (Charon keeps its own hardcoded allowlist; `notify.RegisteredTypes()` used only as a CI/test
+ consistency check, not a live API gate). Alternative: Option B (Charon's API directly reflects
+ `notify.RegisteredTypes()`, true zero-touch exposure). This is a genuine product decision about
+ how eagerly Charon should surface engine capabilities it hasn't built UI/support for yet — not a
+ technical question this spec can resolve unilaterally.
+2. **Config-typing at the registry boundary (§3.2) — `map[string]any`, recommended, with a stated
+ type-safety cost.** Confirm the user is comfortable losing compile-time safety at this one
+ boundary (the typed constructors remain fully available and are the recommended path when
+ runtime discovery isn't needed) in exchange for a single uniform mechanism that also
+ accommodates email's non-serializable `Mailer`/`TemplateRenderer` dependencies (§3.1's evidence
+ for why `json.RawMessage` alone doesn't work).
+3. **`providers/all` vs. hand-picked imports, per consumer (§3.5/§3.6.3).** Recommended: Charon
+ hand-picks (tighter control, consistent with the Option A allowlist decision); the user's other
+ project (the original motivating use case) more plausibly wants `providers/all` for genuine
+ zero-touch discovery. Confirm this isn't meant to be a uniform policy across both consumers —
+ the spec currently treats it as a per-consumer choice, which the user should explicitly agree
+ is the right framing rather than assuming one answer fits both projects.
+4. **`ServiceConfig` wiring (§3.6.4) is flagged, not built.** A future provider needing >2 config
+ values (Web Push being the concrete driver) will need this GORM field actually wired up — this
+ spec identifies it as the extension point and recommends a shape (JSON-encoded
+ `map[string]string`, merged into the registry's `config` map) but treats implementing it as
+ follow-up work, not part of this commit sequence. Confirm this sequencing is acceptable — i.e.,
+ that the user wants the registry to land first, generically, with the schema question addressed
+ only once Web Push (or another >2-field provider) is actually being built against
+ `go_notify_yourself`.
+5. **Frontend schema-driven forms (§3.6.5) — explicitly out of scope for this pass.** Confirm the
+ user agrees "drop-in ready" for this round means the Go module/Charon backend only, and that
+ frontend auto-generation is a separate, later decision (potentially never, if Charon's product
+ philosophy prefers hand-built, polished per-provider forms over generic ones — a design opinion
+ worth surfacing explicitly rather than assuming).
+6. **Naming-collision hazard for third-party providers (§3.4).** `Register` panics on a duplicate
+ name. This is fine for the eight built-in providers (fixed, non-colliding names) but is a new
+ failure mode once truly third-party provider packages exist (per the user's stated long-term
+ goal of others contributing providers) — e.g. two unrelated third-party packages both choosing
+ `Register("webpush", ...)`. No global namespace-reservation mechanism is proposed here (would be
+ premature — there's no third-party provider ecosystem yet); flagging so the user is aware this
+ is a real, if distant, consequence of the self-registration pattern, same as it is for
+ `database/sql` drivers today.
diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md
index 46f5606ec..0746eeea5 100644
--- a/docs/reports/qa_report.md
+++ b/docs/reports/qa_report.md
@@ -106,3 +106,105 @@ This PR's doc changes introduce zero new lint findings. Pre-existing lint debt i
2. SECURITY.md/ARCHITECTURE.md carry substantial pre-existing markdownlint debt (207 combined findings) unrelated to this PR. Worth a future standalone cleanup pass, but explicitly out of scope here per this feature's CI/shell-script-only mandate.
## Final Overall Verdict: **PASS — ready to be marked done.**
+
+---
+---
+
+# QA/Security Audit — Notify Provider Registry (Self-Registering Factory) (Independent Verification)
+
+**Branch**: `feature/notifications-engine-extraction`
+**Commit range reviewed**: `a28f0db9..HEAD` (`e3e971ec`, `326d28e9`, `14421c06`, `567de4e7`, `7c48f009`)
+**Reviewed by**: qa-security agent
+**Date**: 2026-08-17
+**Scope**: Backend-only. Replaces a hardcoded `switch p.Type { case "discord": ... }` provider-construction dispatch in `backend/internal/services/notify_provider_adapter.go` with a call into a new self-registering factory registry (`notify.New`) shipped by a companion module, `github.com/Wikid82/go_notify_yourself` (pinned `v0.2.0` via a local-path `replace` directive to `/projects/go_notify_yourself`, branch `feature/provider-registry`, not yet pushed to GitHub — expected/tracked, not a defect).
+**Prior reviews**: `backend-dev` implementation pass + `supervisor` code review — **APPROVED WITH MINOR NOTES** (no blocking issues; email construction path and the local-path `replace` directive both flagged as known, non-blocking).
+**Purpose**: Independent re-verification of all Definition of Done gates plus a dedicated security review of the new `map[string]any` config-boundary and provider registry, per `docs/plans/notify_provider_registry_spec.md` §3 and §5.
+
+## Summary Verdict: **READY TO MERGE** — no blocking issues found. All Definition of Done gates independently re-run and passed with real, non-cached numbers where applicable.
+
+---
+
+## 1. Definition of Done Gates (all independently re-run from repo root / `backend/`)
+
+| Gate | Command | Result |
+|---|---|---|
+| Backend build | `cd backend && go build ./...` | **PASS** — clean, zero errors |
+| Static vet | `cd backend && go vet ./...` | **PASS** — zero findings |
+| Staticcheck | `make lint-staticcheck-only` | **PASS** — `0 issues.` (backend), `0 issues.` (agent) |
+| Full backend test suite | `cd backend && go test ./...` | **PASS** — all packages `ok`, zero failures. `internal/services` (the touched package) ran uncached, 111.308s, all green. |
+| Backend coverage gate | `bash scripts/go-test-coverage.sh` (`CHARON_MIN_COVERAGE` default 87%) | **PASS** — Statement coverage **89.3%**, line coverage **89.2%** (gate: line coverage ≥ 87%). Console: `Coverage gate (line coverage): minimum required 87% / Coverage requirement met`. |
+| Local patch coverage preflight | `bash scripts/local-patch-report.sh` | **PASS** — Backend: 235/235 changed lines covered = **100.0%** patch coverage (threshold 85%). Overall/Frontend/Agent all 100.0% (0 changed lines outside backend). Artifacts confirmed at `test-results/local-patch-report.md` and `test-results/local-patch-report.json`. |
+| GORM security scan | `./scripts/scan-gorm-security.sh --check` | **PASS** — `CRITICAL: 0`, `HIGH: 0`, `MEDIUM: 0`, `INFO: 2` (both pre-existing, in `backend/internal/models/user.go`'s `UserPermittedHost` struct — unrelated to this change; 61 files / 3675 lines scanned). |
+| Lefthook pre-commit | `lefthook run pre-commit` | **N/A / clean** — working tree is clean (no staged changes; this is an audit pass on already-committed code, `git status` confirmed clean at session start), so every hook reported `(skip) no matching staged files`. The equivalent checks (`go vet`, staticcheck) were independently re-run directly above and passed. |
+
+Playwright E2E: **not run**, per task scope. Verified during the security review (§3 below) that provider dispatch behavior is byte-for-byte unchanged — `notify_provider_adapter_test.go`'s existing per-provider tests assert the exact same dispatch URL, headers, and JSON payload shape as the pre-registry switch (e.g. Gotify's `X-Gotify-Key` header, Pushover's hardcoded production URL + `user`/`token` payload fields, Telegram's `bot/sendMessage` URL). No new HTTP payload/header/URL behavior was introduced for any provider type, so the Playwright skip condition in the task brief is satisfied.
+
+---
+
+## 2. Commit-Range Diff Verification
+
+- `git diff --stat a28f0db9..HEAD`: 7 files changed, 223 insertions(+), 84 deletions(-) — `ARCHITECTURE.md`, `backend/go.mod`, `backend/go.sum`, `notification_service_registry_consistency_test.go` (new), `notify_provider_adapter.go`, `notify_provider_adapter_test.go`, `notify_providers_import.go` (new).
+- `git diff a28f0db9..HEAD -- backend/internal/models/notification_provider.go` → **empty**. Confirms the `Token` field's `json:"-"` GORM protection is untouched.
+- `git diff a28f0db9..HEAD -- backend/internal/services/notification_service.go` → **empty**. Confirms `isSupportedNotificationProviderType`, `supportsJSONTemplates`, `isDispatchEnabled` are byte-for-byte unchanged, per the spec's hard design requirement (§3.6.2 Option A).
+- `grep -rn "providers/all" backend/` → only 2 matches, both inside a comment in `notify_providers_import.go` explicitly explaining *why* `providers/all` is deliberately **not** imported. No actual import anywhere in `backend/`.
+
+---
+
+## 3. Security-Specific Findings
+
+### 3.1 No token/secret leakage into logs or errors — **PASS**
+
+Read `backend/internal/services/notify_provider_adapter.go` and `notify_providers_import.go` in full, plus every `providers/*/register.go` in `/projects/go_notify_yourself` (discord, slack, gotify, pushover, ntfy, telegram, webhook, email) and `factory.go`.
+
+- The only error-wrapping call site in `notify_provider_adapter.go` is `fmt.Errorf("notify provider adapter: %w", err)` (line 175) — wraps, never re-formats field values.
+- Every registry-level error (`notify.New`'s "no provider registered for type %q", each factory's `config["transport"] must be a non-nil *transport.Wrapper` / `config["mailer"] must be a non-nil Mailer`) references only **field/key names and the provider type discriminator** — never `provider.URL`, `provider.Token`, or any config map value.
+- Log call sites that consume `buildNotifySender`'s error (`notification_service.go:312`, `:324`) log only `util.SanitizeForLog(p.Name)` plus the wrapped error — never `p.URL`/`p.Token`.
+- No `fmt.Println`, `log.Print`, or debug statements were introduced anywhere in the diff.
+
+**Conclusion**: no Gotify token, webhook URL-as-secret, or any provider credential can reach logs, error messages, or test artifacts through this code path. SECURITY.md's "Gotify Token Hygiene" requirement is satisfied.
+
+### 3.2 `Token` field GORM protection untouched — **PASS**
+
+Confirmed via the empty diff above (§2). `Token string \`json:"-"\`` is unchanged in `backend/internal/models/notification_provider.go`.
+
+### 3.3 `map[string]any` config-boundary type-confusion risk — **PASS, verified independently, not just re-trusted**
+
+Read `/projects/go_notify_yourself/factory.go` and all eight `providers/*/register.go` files directly (not assumed from the spec). Every factory:
+
+- Type-asserts `config["transport"].(*transport.Wrapper)` (or `config["mailer"].(Mailer)` for email) with the two-value `ok` form and an explicit nil check — **never a bare/panicking assertion**.
+- Returns a descriptive `fmt.Errorf` (never panics) when the assertion fails or the value is nil.
+- Delegates all scalar/slice field extraction to `providers/internal/regconfig.StringField`/`StringSliceField`, both of which are deliberately lenient: a wrong-typed or missing key produces the zero value (`""`/`nil`), never a panic. Verified via `regconfig`'s own test table, which explicitly covers `wrong type int`, `wrong type nil value`, `any slice with non-string element`, and `wrong type entirely` — all resolve to zero values, not panics.
+- `Register` itself panics only on programmer misuse (`nil` factory, empty name, duplicate registration) at `init()` time — never on caller-supplied runtime data, matching the `database/sql`/`image` prior-art convention the spec cites.
+
+Charon's own tests (`notify_provider_adapter_test.go`) independently exercise this boundary: `TestBuildNotifySenderMissingTransportErrors` and `TestBuildNotifySenderInvalidTransportInConfigMapErrors` both assert a nil/invalid transport produces an error containing "transport", not a panic. `TestBuildNotifySenderUnsupportedTypeErrors` asserts an unregistered type ("carrier-pigeon") produces a "no provider registered" error, not a panic.
+
+**Conclusion**: no type-confusion panic path exists at the registry boundary. Supervisor's prior claim is independently confirmed, not merely re-trusted.
+
+### 3.4 `isSupportedNotificationProviderType` / `supportsJSONTemplates` / `isDispatchEnabled` unchanged — **PASS** (see §2, empty diff)
+
+### 3.5 `providers/all` not imported anywhere in `backend/` — **PASS** (see §2)
+
+`notify_providers_import.go` hand-picks exactly Charon's eight supported types (`discord`, `email`, `gotify`, `ntfy`, `pushover`, `slack`, `telegram`, `webhook`), matching `isSupportedNotificationProviderType`'s allowlist. A new consistency test, `TestSupportedProviderAllowlistIsSubsetOfRegisteredTypes` (`notification_service_registry_consistency_test.go`), asserts this allowlist is a subset of `notify.RegisteredTypes()` — guarding against future drift between the hand-picked imports and the allowlist.
+
+### 3.6 `go.mod` local-path replace directive — tracked, not a defect
+
+```
+replace github.com/Wikid82/go_notify_yourself => /projects/go_notify_yourself
+```
+
+Annotated in `go.mod` with a `TODO` explaining it must be removed once `go_notify_yourself v0.2.0` is pushed/tagged upstream. This is a real, temporary condition (confirmed: `/projects/go_notify_yourself` is on local branch `feature/provider-registry`, not yet on GitHub) and matches what the task brief and supervisor's prior review both already flagged as expected. Not a merge blocker for the Charon-side PR per the spec's own commit-slicing sequencing, but **must** be resolved before this local-path pin would work in CI or for any other contributor — flagged below as a required follow-up.
+
+---
+
+## 4. Non-Blocking Follow-Ups (tracked, not blocking this PR)
+
+1. **`go.mod` local-path `replace` directive** (`backend/go.mod:9`) must be removed and re-pinned to the real published `go_notify_yourself v0.2.0` tag once `/projects/go_notify_yourself`'s `feature/provider-registry` branch is pushed and tagged on GitHub. Already tracked via the in-file `TODO` comment; CI/other contributors cannot build this branch until resolved.
+2. Email's construction (`notification_service.go:350`, `dispatchEmailViaNotify`) still calls `email.New(...)` directly rather than routing through `notify.New` — consistent with pre-existing architecture (email's `Mailer`/`TemplateRenderer` DI seam predates this work) and not a regression, per supervisor's prior note. No action required by this PR.
+
+## Blocking Issues
+
+**None.**
+
+## Final Overall Verdict: **READY TO MERGE**
+
+All seven independently re-run Definition of Done gates pass with real numbers (89.3%/89.2% overall backend coverage against an 87% gate; 100% patch coverage on the 235 changed lines against an 85% gate; 0 CRITICAL/HIGH/MEDIUM GORM findings; 0 staticcheck/vet findings; full backend suite green). The security-specific review independently verified — by reading the actual factory/register code in `/projects/go_notify_yourself`, not by re-trusting the prior supervisor review — that no token/secret can reach logs or error messages, that the `map[string]any` registry boundary cannot panic on caller-controlled input, that the `Token` field's `json:"-"` protection and Charon's three provider allowlists are byte-for-byte unchanged from before this work, and that `providers/all` is never imported in `backend/`. The one open item (local-path `go.mod` replace directive) is already tracked and does not block merging this Charon-side PR per the spec's own two-repo commit-slicing sequencing.
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 083f2e39c..d49a70d22 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -449,9 +449,9 @@
}
},
"node_modules/@csstools/color-helpers": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
- "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
+ "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
"dev": true,
"funding": [
{
@@ -493,9 +493,9 @@
}
},
"node_modules/@csstools/css-color-parser": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
- "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz",
+ "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==",
"dev": true,
"funding": [
{
@@ -509,7 +509,7 @@
],
"license": "MIT",
"dependencies": {
- "@csstools/color-helpers": "^6.1.0",
+ "@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.3.0"
},
"engines": {
@@ -544,9 +544,9 @@
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
- "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz",
+ "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==",
"dev": true,
"funding": [
{
@@ -5371,9 +5371,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.406",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.406.tgz",
- "integrity": "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==",
+ "version": "1.5.407",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
+ "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==",
"dev": true,
"license": "ISC"
},
@@ -6909,9 +6909,9 @@
}
},
"node_modules/immer": {
- "version": "11.1.16",
- "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz",
- "integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==",
+ "version": "11.1.17",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.17.tgz",
+ "integrity": "sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q==",
"license": "MIT",
"funding": {
"type": "opencollective",