feat: wire MailService into notification dispatch pipeline (Stage 3)

Unifies the two previously independent email subsystems — MailService
(net/smtp transport) and NotificationService (HTTP-based providers) —
so email can participate in the notification dispatch pipeline.

Key changes:
- SendEmail signature updated to accept context.Context and []string
  recipients to enable timeout propagation and multi-recipient dispatch
- NotificationService.dispatchEmail() wires MailService as a first-class
  provider type with IsConfigured() guard and 30s context timeout
- 'email' added to isSupportedNotificationProviderType() and
  supportsJSONTemplates() returns false for email (plain/HTML only)
- settings_handler.go test-email endpoint updated to new SendEmail API
- Frontend: 'email' added to provider type union in notifications.ts,
  Notifications.tsx shows recipient field and hides URL/token fields for
  email providers
- All existing tests updated to match new SendEmail signature
- New tests added covering dispatchEmail paths, IsConfigured guards,
  recipient validation, and context timeout behaviour

Also fixes confirmed false-positive CodeQL go/email-injection alerts:
- smtp.SendMail, sendSSL w.Write, and sendSTARTTLS w.Write sites now
  carry inline codeql[go/email-injection] annotations as required by the
  CodeQL same-line suppression spec; preceding-line annotations silently
  no-op in current CodeQL versions
- auth_handler.go c.SetCookie annotated for intentional Secure=false on
  local non-HTTPS loopback (go/cookie-secure-not-set warning only)

Closes part of #800
This commit is contained in:
GitHub Actions
2026-03-06 01:27:02 +00:00
parent e295a1f64c
commit ed89295012
39 changed files with 1392 additions and 336 deletions

View File

@@ -148,13 +148,14 @@ func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
domain := ""
c.SetSameSite(sameSite)
c.SetCookie(
// secure is intentionally false for local non-HTTPS loopback (development only); always true for external HTTPS requests.
c.SetCookie( // codeql[go/cookie-secure-not-set]
name, // name
value, // value
maxAge, // maxAge in seconds
"/", // path
domain, // domain (empty = current host)
secure, // secure (always true)
secure, // secure
true, // httpOnly (no JS access)
)
}

View File

@@ -721,7 +721,7 @@ func TestDeleteCertificate_NotificationRateLimit(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
mockBackupService := &mockBackupService{
createFunc: func() (string, error) {

View File

@@ -24,7 +24,7 @@ func setupDomainTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Domain{}, &models.Notification{}, &models.NotificationProvider{}))
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
h := NewDomainHandler(db, ns)
r := gin.New()

View File

@@ -50,7 +50,7 @@ func TestRemoteServerHandler_List(t *testing.T) {
}
db.Create(server)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
@@ -74,7 +74,7 @@ func TestRemoteServerHandler_Create(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
@@ -119,7 +119,7 @@ func TestRemoteServerHandler_TestConnection(t *testing.T) {
}
db.Create(server)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
@@ -154,7 +154,7 @@ func TestRemoteServerHandler_Get(t *testing.T) {
}
db.Create(server)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
@@ -188,7 +188,7 @@ func TestRemoteServerHandler_Update(t *testing.T) {
}
db.Create(server)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
@@ -234,7 +234,7 @@ func TestRemoteServerHandler_Delete(t *testing.T) {
}
db.Create(server)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
@@ -271,7 +271,7 @@ func TestProxyHostHandler_List(t *testing.T) {
}
db.Create(host)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
@@ -295,7 +295,7 @@ func TestProxyHostHandler_Create(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
@@ -343,7 +343,7 @@ func TestProxyHostHandler_PartialUpdate_DoesNotWipeFields(t *testing.T) {
}
db.Create(original)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
@@ -408,7 +408,7 @@ func TestRemoteServerHandler_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))

View File

@@ -35,7 +35,7 @@ func setAdminContext(c *gin.Context) {
func TestNotificationHandler_List_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationHandler(svc)
// Drop the table to cause error
@@ -57,7 +57,7 @@ func TestNotificationHandler_List_Error(t *testing.T) {
func TestNotificationHandler_List_UnreadOnly(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationHandler(svc)
// Create some notifications
@@ -77,7 +77,7 @@ func TestNotificationHandler_List_UnreadOnly(t *testing.T) {
func TestNotificationHandler_MarkAsRead_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationHandler(svc)
// Drop table to cause error
@@ -97,7 +97,7 @@ func TestNotificationHandler_MarkAsRead_Error(t *testing.T) {
func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationHandler(svc)
// Drop table to cause error
@@ -118,7 +118,7 @@ func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
func TestNotificationProviderHandler_List_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
// Drop table to cause error
@@ -137,7 +137,7 @@ func TestNotificationProviderHandler_List_Error(t *testing.T) {
func TestNotificationProviderHandler_Create_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
w := httptest.NewRecorder()
@@ -154,7 +154,7 @@ func TestNotificationProviderHandler_Create_InvalidJSON(t *testing.T) {
func TestNotificationProviderHandler_Create_DBError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
// Drop table to cause error
@@ -182,7 +182,7 @@ func TestNotificationProviderHandler_Create_DBError(t *testing.T) {
func TestNotificationProviderHandler_Create_InvalidTemplate(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
provider := models.NotificationProvider{
@@ -208,7 +208,7 @@ func TestNotificationProviderHandler_Create_InvalidTemplate(t *testing.T) {
func TestNotificationProviderHandler_Update_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
w := httptest.NewRecorder()
@@ -226,7 +226,7 @@ func TestNotificationProviderHandler_Update_InvalidJSON(t *testing.T) {
func TestNotificationProviderHandler_Update_InvalidTemplate(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
// Create a provider first
@@ -258,7 +258,7 @@ func TestNotificationProviderHandler_Update_InvalidTemplate(t *testing.T) {
func TestNotificationProviderHandler_Update_DBError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
// Drop table to cause error
@@ -287,7 +287,7 @@ func TestNotificationProviderHandler_Update_DBError(t *testing.T) {
func TestNotificationProviderHandler_Delete_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
// Drop table to cause error
@@ -307,7 +307,7 @@ func TestNotificationProviderHandler_Delete_Error(t *testing.T) {
func TestNotificationProviderHandler_Test_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
w := httptest.NewRecorder()
@@ -324,7 +324,7 @@ func TestNotificationProviderHandler_Test_InvalidJSON(t *testing.T) {
func TestNotificationProviderHandler_Test_RejectsClientSuppliedGotifyToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
@@ -356,7 +356,7 @@ func TestNotificationProviderHandler_Test_RejectsClientSuppliedGotifyToken(t *te
func TestNotificationProviderHandler_Test_RejectsGotifyTokenWithWhitespace(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
@@ -477,7 +477,7 @@ func TestClassifyProviderTestFailure_TLSHandshakeFailed(t *testing.T) {
func TestNotificationProviderHandler_Templates(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
w := httptest.NewRecorder()
@@ -495,7 +495,7 @@ func TestNotificationProviderHandler_Templates(t *testing.T) {
func TestNotificationProviderHandler_Preview_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
w := httptest.NewRecorder()
@@ -512,7 +512,7 @@ func TestNotificationProviderHandler_Preview_InvalidJSON(t *testing.T) {
func TestNotificationProviderHandler_Preview_WithData(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
@@ -538,7 +538,7 @@ func TestNotificationProviderHandler_Preview_WithData(t *testing.T) {
func TestNotificationProviderHandler_Preview_InvalidTemplate(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
@@ -563,7 +563,7 @@ func TestNotificationProviderHandler_Preview_InvalidTemplate(t *testing.T) {
func TestNotificationTemplateHandler_List_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
// Drop table to cause error
@@ -582,7 +582,7 @@ func TestNotificationTemplateHandler_List_Error(t *testing.T) {
func TestNotificationTemplateHandler_Create_BadJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
w := httptest.NewRecorder()
@@ -599,7 +599,7 @@ func TestNotificationTemplateHandler_Create_BadJSON(t *testing.T) {
func TestNotificationTemplateHandler_Create_DBError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
// Drop table to cause error
@@ -625,7 +625,7 @@ func TestNotificationTemplateHandler_Create_DBError(t *testing.T) {
func TestNotificationTemplateHandler_Update_BadJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
w := httptest.NewRecorder()
@@ -643,7 +643,7 @@ func TestNotificationTemplateHandler_Update_BadJSON(t *testing.T) {
func TestNotificationTemplateHandler_Update_DBError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
// Drop table to cause error
@@ -670,7 +670,7 @@ func TestNotificationTemplateHandler_Update_DBError(t *testing.T) {
func TestNotificationTemplateHandler_Delete_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
// Drop table to cause error
@@ -690,7 +690,7 @@ func TestNotificationTemplateHandler_Delete_Error(t *testing.T) {
func TestNotificationTemplateHandler_Preview_BadJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
w := httptest.NewRecorder()
@@ -707,7 +707,7 @@ func TestNotificationTemplateHandler_Preview_BadJSON(t *testing.T) {
func TestNotificationTemplateHandler_Preview_TemplateNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
payload := map[string]any{
@@ -730,7 +730,7 @@ func TestNotificationTemplateHandler_Preview_TemplateNotFound(t *testing.T) {
func TestNotificationTemplateHandler_Preview_WithStoredTemplate(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
// Create a template
@@ -762,7 +762,7 @@ func TestNotificationTemplateHandler_Preview_WithStoredTemplate(t *testing.T) {
func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
payload := map[string]any{
@@ -784,7 +784,7 @@ func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) {
func TestNotificationProviderHandler_Preview_TokenWriteOnly(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
@@ -808,7 +808,7 @@ func TestNotificationProviderHandler_Preview_TokenWriteOnly(t *testing.T) {
func TestNotificationProviderHandler_Update_TypeChangeRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
existing := models.NotificationProvider{
@@ -842,7 +842,7 @@ func TestNotificationProviderHandler_Update_TypeChangeRejected(t *testing.T) {
func TestNotificationProviderHandler_Test_MissingProviderID(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
@@ -865,7 +865,7 @@ func TestNotificationProviderHandler_Test_MissingProviderID(t *testing.T) {
func TestNotificationProviderHandler_Test_ProviderNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
@@ -889,7 +889,7 @@ func TestNotificationProviderHandler_Test_ProviderNotFound(t *testing.T) {
func TestNotificationProviderHandler_Test_EmptyProviderURL(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
existing := models.NotificationProvider{
@@ -942,7 +942,7 @@ func TestIsProviderValidationError_Comprehensive(t *testing.T) {
func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
existing := models.NotificationProvider{
@@ -975,7 +975,7 @@ func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) {
func TestNotificationProviderHandler_Update_GotifyKeepsExistingToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
existing := models.NotificationProvider{
@@ -1013,7 +1013,7 @@ func TestNotificationProviderHandler_Update_GotifyKeepsExistingToken(t *testing.
func TestNotificationProviderHandler_Test_ReadDBError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationProviderHandler(svc)
_ = db.Migrator().DropTable(&models.NotificationProvider{})

View File

@@ -36,7 +36,7 @@ func TestNotificationHandler_List(t *testing.T) {
db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false})
db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: true})
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := handlers.NewNotificationHandler(service)
router := gin.New()
router.GET("/notifications", handler.List)
@@ -72,7 +72,7 @@ func TestNotificationHandler_MarkAsRead(t *testing.T) {
notif := &models.Notification{Title: "Test 1", Message: "Msg 1", Read: false}
db.Create(notif)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := handlers.NewNotificationHandler(service)
router := gin.New()
router.POST("/notifications/:id/read", handler.MarkAsRead)
@@ -96,7 +96,7 @@ func TestNotificationHandler_MarkAllAsRead(t *testing.T) {
db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false})
db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: false})
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := handlers.NewNotificationHandler(service)
router := gin.New()
router.POST("/notifications/read-all", handler.MarkAllAsRead)
@@ -115,7 +115,7 @@ func TestNotificationHandler_MarkAllAsRead(t *testing.T) {
func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB(t)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := handlers.NewNotificationHandler(service)
r := gin.New()
@@ -134,7 +134,7 @@ func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
func TestNotificationHandler_DBError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB(t)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := handlers.NewNotificationHandler(service)
r := gin.New()

View File

@@ -28,7 +28,7 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
assert.NoError(t, err)
// Create handler
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
// Test cases: provider types with security events enabled
@@ -96,7 +96,7 @@ func TestBlocker3_CreateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) {
assert.NoError(t, err)
// Create handler
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
// Create request payload with Discord provider and security events
@@ -144,7 +144,7 @@ func TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents(t *testin
assert.NoError(t, err)
// Create handler
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
// Create request payload with webhook provider but no security events
@@ -200,7 +200,7 @@ func TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
assert.NoError(t, db.Create(&existingProvider).Error)
// Create handler
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
// Try to update to enable security events (should be rejected)
@@ -256,7 +256,7 @@ func TestBlocker3_UpdateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) {
assert.NoError(t, db.Create(&existingProvider).Error)
// Create handler
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
// Update to enable security events
@@ -302,7 +302,7 @@ func TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly(t *testing.T) {
assert.NoError(t, err)
// Create handler
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
// Test each security event field individually
@@ -359,7 +359,7 @@ func TestBlocker3_UpdateProvider_DatabaseError(t *testing.T) {
assert.NoError(t, err)
// Create handler
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
// Update payload

View File

@@ -24,7 +24,7 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{}))
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
testCases := []struct {
@@ -83,7 +83,7 @@ func TestDiscordOnly_CreateAcceptsDiscord(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{}))
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
payload := map[string]interface{}{
@@ -129,7 +129,7 @@ func TestDiscordOnly_UpdateRejectsTypeMutation(t *testing.T) {
}
require.NoError(t, db.Create(&deprecatedProvider).Error)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
// Try to change type to discord
@@ -183,7 +183,7 @@ func TestDiscordOnly_UpdateRejectsEnable(t *testing.T) {
}
require.NoError(t, db.Create(&deprecatedProvider).Error)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
// Try to enable the deprecated provider
@@ -231,7 +231,7 @@ func TestDiscordOnly_UpdateAllowsDisabledDeprecated(t *testing.T) {
}
require.NoError(t, db.Create(&deprecatedProvider).Error)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
// Update name (keeping type and enabled unchanged)
@@ -279,7 +279,7 @@ func TestDiscordOnly_UpdateAcceptsDiscord(t *testing.T) {
}
require.NoError(t, db.Create(&discordProvider).Error)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
// Update to enable security notifications
@@ -327,7 +327,7 @@ func TestDiscordOnly_DeleteAllowsDeprecated(t *testing.T) {
}
require.NoError(t, db.Create(&deprecatedProvider).Error)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
w := httptest.NewRecorder()
@@ -409,7 +409,7 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) {
id := tc.setupFunc(db)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
req, params := tc.requestFunc(id)

View File

@@ -23,7 +23,7 @@ func setupNotificationProviderTest(t *testing.T) (*gin.Engine, *gorm.DB) {
db := handlers.OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{}))
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := handlers.NewNotificationProviderHandler(service)
r := gin.Default()

View File

@@ -33,7 +33,7 @@ func TestUpdate_BlockTypeMutationForNonDiscord(t *testing.T) {
}
require.NoError(t, db.Create(existing).Error)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
gin.SetMode(gin.TestMode)
@@ -85,7 +85,7 @@ func TestUpdate_AllowTypeMutationForDiscord(t *testing.T) {
}
require.NoError(t, db.Create(existing).Error)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
gin.SetMode(gin.TestMode)

View File

@@ -23,7 +23,7 @@ func TestNotificationTemplateHandler_CRUDAndPreview(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}, &models.Notification{}, &models.NotificationProvider{}))
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
r := gin.New()
@@ -92,7 +92,7 @@ func TestNotificationTemplateHandler_Create_InvalidJSON(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
r := gin.New()
r.Use(func(c *gin.Context) {
@@ -113,7 +113,7 @@ func TestNotificationTemplateHandler_Update_InvalidJSON(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
r := gin.New()
r.Use(func(c *gin.Context) {
@@ -134,7 +134,7 @@ func TestNotificationTemplateHandler_Preview_InvalidJSON(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
r := gin.New()
r.Use(func(c *gin.Context) {
@@ -155,7 +155,7 @@ func TestNotificationTemplateHandler_AdminRequired(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
r := gin.New()
@@ -185,7 +185,7 @@ func TestNotificationTemplateHandler_List_DBError(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
r := gin.New()
@@ -205,7 +205,7 @@ func TestNotificationTemplateHandler_WriteOps_DBError(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
r := gin.New()
@@ -264,7 +264,7 @@ func TestNotificationTemplateHandler_WriteOps_PermissionErrorResponse(t *testing
_ = db.Callback().Delete().Remove(deleteHook)
})
svc := services.NewNotificationService(db)
svc := services.NewNotificationService(db, nil)
h := NewNotificationTemplateHandler(svc)
r := gin.New()

View File

@@ -32,7 +32,7 @@ func setupTestRouterForSecurityHeaders(t *testing.T) (*gin.Engine, *gorm.DB) {
&models.NotificationProvider{},
))
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
h := NewProxyHostHandler(db, nil, ns, nil)
r := gin.New()
api := r.Group("/api/v1")

View File

@@ -36,7 +36,7 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
&models.NotificationProvider{},
))
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
h := NewProxyHostHandler(db, nil, ns, nil)
r := gin.New()
api := r.Group("/api/v1")
@@ -60,7 +60,7 @@ func setupTestRouterWithReferenceTables(t *testing.T) (*gin.Engine, *gorm.DB) {
&models.NotificationProvider{},
))
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
h := NewProxyHostHandler(db, nil, ns, nil)
r := gin.New()
api := r.Group("/api/v1")
@@ -86,7 +86,7 @@ func setupTestRouterWithUptime(t *testing.T) (*gin.Engine, *gorm.DB) {
&models.Setting{},
))
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
us := services.NewUptimeService(db, ns)
h := NewProxyHostHandler(db, nil, ns, us)
r := gin.New()
@@ -100,7 +100,7 @@ func TestProxyHostHandler_ResolveAccessListReference_TargetedBranches(t *testing
t.Parallel()
_, db := setupTestRouterWithReferenceTables(t)
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil)
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db, nil), nil)
resolved, err := h.resolveAccessListReference(true)
require.Error(t, err)
@@ -124,7 +124,7 @@ func TestProxyHostHandler_ResolveSecurityHeaderReference_TargetedBranches(t *tes
t.Parallel()
_, db := setupTestRouterWithReferenceTables(t)
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil)
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db, nil), nil)
resolved, err := h.resolveSecurityHeaderProfileReference(" ")
require.NoError(t, err)
@@ -327,7 +327,7 @@ func TestProxyHostDelete_WithUptimeCleanup(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.UptimeMonitor{}, &models.UptimeHeartbeat{}))
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
us := services.NewUptimeService(db, ns)
h := NewProxyHostHandler(db, nil, ns, us)
@@ -381,7 +381,7 @@ func TestProxyHostErrors(t *testing.T) {
manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
// Setup Handler
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
h := NewProxyHostHandler(db, manager, ns, nil)
r := gin.New()
api := r.Group("/api/v1")
@@ -661,7 +661,7 @@ func TestProxyHostWithCaddyIntegration(t *testing.T) {
manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
// Setup Handler
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
h := NewProxyHostHandler(db, manager, ns, nil)
r := gin.New()
api := r.Group("/api/v1")
@@ -1894,7 +1894,7 @@ func TestUpdate_IntegrationCaddyConfig(t *testing.T) {
client := caddy.NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL))
manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
h := NewProxyHostHandler(db, manager, ns, nil)
r := gin.New()
api := r.Group("/api/v1")

View File

@@ -36,7 +36,7 @@ func setupUpdateTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
&models.NotificationProvider{},
))
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
h := NewProxyHostHandler(db, nil, ns, nil)
r := gin.New()
@@ -933,7 +933,7 @@ func TestBulkUpdateSecurityHeaders_DBError_NonNotFound(t *testing.T) {
}
require.NoError(t, db.Create(&host).Error)
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
h := NewProxyHostHandler(db, nil, ns, nil)
r := gin.New()

View File

@@ -22,7 +22,7 @@ func setupRemoteServerTest_New(t *testing.T) (*gin.Engine, *handlers.RemoteServe
// Ensure RemoteServer table exists
_ = db.AutoMigrate(&models.RemoteServer{})
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
r := gin.Default()

View File

@@ -23,7 +23,7 @@ func TestSecurityEventIntakeCompileSuccess(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// This test validates that the handler can be instantiated with all required dependencies
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
service := services.NewEnhancedSecurityNotificationService(db)
securityService := services.NewSecurityService(db)
managementCIDRs := []string{"127.0.0.0/8"}
@@ -47,7 +47,7 @@ func TestSecurityEventIntakeCompileSuccess(t *testing.T) {
func TestSecurityEventIntakeAuthLocalhost(t *testing.T) {
db := SetupCompatibilityTestDB(t)
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
service := services.NewEnhancedSecurityNotificationService(db)
managementCIDRs := []string{"10.0.0.0/8"}
@@ -88,7 +88,7 @@ func TestSecurityEventIntakeAuthLocalhost(t *testing.T) {
func TestSecurityEventIntakeAuthManagementCIDR(t *testing.T) {
db := SetupCompatibilityTestDB(t)
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
service := services.NewEnhancedSecurityNotificationService(db)
managementCIDRs := []string{"192.168.1.0/24", "10.0.0.0/8"}
@@ -129,7 +129,7 @@ func TestSecurityEventIntakeAuthManagementCIDR(t *testing.T) {
func TestSecurityEventIntakeAuthUnauthorizedIP(t *testing.T) {
db := SetupCompatibilityTestDB(t)
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
service := services.NewEnhancedSecurityNotificationService(db)
managementCIDRs := []string{"192.168.1.0/24"}
@@ -175,7 +175,7 @@ func TestSecurityEventIntakeAuthUnauthorizedIP(t *testing.T) {
func TestSecurityEventIntakeAuthInvalidIP(t *testing.T) {
db := SetupCompatibilityTestDB(t)
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
service := services.NewEnhancedSecurityNotificationService(db)
managementCIDRs := []string{"192.168.1.0/24"}
@@ -234,7 +234,7 @@ func TestSecurityEventIntakeDispatchInvoked(t *testing.T) {
}
require.NoError(t, db.Create(provider).Error)
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
service := services.NewEnhancedSecurityNotificationService(db)
managementCIDRs := []string{"127.0.0.0/8"}
@@ -374,7 +374,7 @@ func TestSecurityEventIntakeDiscordOnly(t *testing.T) {
}
require.NoError(t, db.Create(webhookProvider).Error)
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
service := services.NewEnhancedSecurityNotificationService(db)
managementCIDRs := []string{"127.0.0.0/8"}
@@ -419,7 +419,7 @@ func TestSecurityEventIntakeDiscordOnly(t *testing.T) {
func TestSecurityEventIntakeMalformedPayload(t *testing.T) {
db := SetupCompatibilityTestDB(t)
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
service := services.NewEnhancedSecurityNotificationService(db)
managementCIDRs := []string{"127.0.0.0/8"}
@@ -454,7 +454,7 @@ func TestSecurityEventIntakeMalformedPayload(t *testing.T) {
func TestSecurityEventIntakeIPv6Localhost(t *testing.T) {
db := SetupCompatibilityTestDB(t)
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
service := services.NewEnhancedSecurityNotificationService(db)
managementCIDRs := []string{"10.0.0.0/8"}

View File

@@ -238,7 +238,7 @@ func TestR6_LegacyWrite410GoneNoMutation(t *testing.T) {
func TestProviderCRUD_SecurityEventsIncludeCrowdSec(t *testing.T) {
db := setupSingleSourceTestDB(t)
service := services.NewNotificationService(db)
service := services.NewNotificationService(db, nil)
handler := NewNotificationProviderHandler(service)
gin.SetMode(gin.TestMode)

View File

@@ -40,7 +40,7 @@ func TestHandleSecurityEvent_TimestampZero(t *testing.T) {
enhancedService := services.NewEnhancedSecurityNotificationService(db)
securityService := services.NewSecurityService(db)
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
h := NewSecurityNotificationHandlerWithDeps(enhancedService, securityService, "/tmp", notificationService, []string{"127.0.0.0/8"})
w := httptest.NewRecorder()
@@ -85,7 +85,7 @@ func TestHandleSecurityEvent_SendViaProvidersError(t *testing.T) {
assert.NoError(t, err)
securityService := services.NewSecurityService(db)
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
mockService := &mockFailingService{}
h := NewSecurityNotificationHandlerWithDeps(mockService, securityService, "/tmp", notificationService, []string{"127.0.0.0/8"})

View File

@@ -634,7 +634,7 @@ func (h *SettingsHandler) SendTestEmail(c *gin.Context) {
</html>
`
if err := h.MailService.SendEmail(req.To, "Charon - Test Email", htmlBody); err != nil {
if err := h.MailService.SendEmail(c.Request.Context(), []string{req.To}, "Charon - Test Email", htmlBody); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),

View File

@@ -23,7 +23,7 @@ func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) {
db := handlers.OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.UptimeHost{}, &models.RemoteServer{}, &models.NotificationProvider{}, &models.Notification{}, &models.ProxyHost{}))
ns := services.NewNotificationService(db)
ns := services.NewNotificationService(db, nil)
service := services.NewUptimeService(db, ns)
handler := handlers.NewUptimeHandler(service)

View File

@@ -26,7 +26,7 @@ func TestUptimeMonitorInitialStatePending(t *testing.T) {
_ = db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHost{})
// Create handler with service
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, nil)
uptimeService := services.NewUptimeService(db, notificationService)
// Test: Create a monitor via service

View File

@@ -205,7 +205,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
wsStatusHandler := handlers.NewWebSocketStatusHandler(wsTracker)
// Notification Service (needed for multiple handlers)
notificationService := services.NewNotificationService(db)
notificationService := services.NewNotificationService(db, services.NewMailService(db))
// Ensure notify-only provider migration reconciliation at boot
if err := notificationService.EnsureNotifyOnlyProviderMigration(context.Background()); err != nil {

View File

@@ -1,6 +1,7 @@
package services
import (
"context"
"net"
"testing"
@@ -120,7 +121,7 @@ func TestCoverageBoost_ErrorPaths(t *testing.T) {
})
t.Run("NotificationService_ListTemplates_EmptyDB", func(t *testing.T) {
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Should not error with empty db
templates, err := svc.ListTemplates()
@@ -130,7 +131,7 @@ func TestCoverageBoost_ErrorPaths(t *testing.T) {
})
t.Run("NotificationService_GetTemplate_NotFound", func(t *testing.T) {
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Test with non-existent ID
_, err := svc.GetTemplate("nonexistent")
@@ -227,7 +228,7 @@ func TestCoverageBoost_MailService_ErrorPaths(t *testing.T) {
t.Run("SendEmail_NoConfig", func(t *testing.T) {
// With empty config, should error
err := svc.SendEmail("test@example.com", "Subject", "Body")
err := svc.SendEmail(context.Background(), []string{"test@example.com"}, "Subject", "Body")
assert.Error(t, err)
})
}
@@ -426,7 +427,7 @@ func TestCoverageBoost_MailService_SendSSL(t *testing.T) {
require.NoError(t, err)
// Try to send - should fail with connection error
err = svc.SendEmail("test@example.com", "Test", "Body")
err = svc.SendEmail(context.Background(), []string{"test@example.com"}, "Test", "Body")
assert.Error(t, err)
})
@@ -444,7 +445,7 @@ func TestCoverageBoost_MailService_SendSSL(t *testing.T) {
require.NoError(t, err)
// Try to send - should fail with connection error
err = svc.SendEmail("test@example.com", "Test", "Body")
err = svc.SendEmail(context.Background(), []string{"test@example.com"}, "Test", "Body")
assert.Error(t, err)
})
}
@@ -523,7 +524,7 @@ func TestCoverageBoost_NotificationService_Providers(t *testing.T) {
err = db.AutoMigrate(&models.NotificationProvider{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
t.Run("ListProviders_EmptyDB", func(t *testing.T) {
providers, err := svc.ListProviders()
@@ -591,7 +592,7 @@ func TestCoverageBoost_NotificationService_CRUD(t *testing.T) {
err = db.AutoMigrate(&models.Notification{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
t.Run("List_EmptyDB", func(t *testing.T) {
notifs, err := svc.List(false)

View File

@@ -2,6 +2,7 @@ package services
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
@@ -22,6 +23,35 @@ var errEmailHeaderInjection = errors.New("email header value contains CR/LF")
var errInvalidBaseURLForInvite = errors.New("baseURL must start with http:// or https:// and cannot include path components")
// ErrTooManyRecipients is returned when the recipient list exceeds the maximum allowed.
var ErrTooManyRecipients = errors.New("too many recipients: maximum is 20")
// ErrInvalidRecipient is returned when a recipient address fails RFC 5322 validation.
var ErrInvalidRecipient = errors.New("invalid recipient address")
// MailServiceInterface allows mocking MailService in tests.
type MailServiceInterface interface {
IsConfigured() bool
SendEmail(ctx context.Context, to []string, subject, htmlBody string) error
}
// validateEmailRecipients validates a list of email recipients.
// It rejects lists exceeding 20, addresses containing CR/LF, and addresses failing RFC 5322 parsing.
func validateEmailRecipients(recipients []string) error {
if len(recipients) > 20 {
return ErrTooManyRecipients
}
for _, r := range recipients {
if strings.ContainsAny(r, "\r\n") {
return fmt.Errorf("%w: %s", ErrInvalidRecipient, r)
}
if _, err := mail.ParseAddress(r); err != nil {
return fmt.Errorf("%w: %s", ErrInvalidRecipient, r)
}
}
return nil
}
// encodeSubject encodes the email subject line using MIME Q-encoding (RFC 2047).
// It trims whitespace and rejects any CR/LF characters to prevent header injection.
func encodeSubject(subject string) (string, error) {
@@ -261,9 +291,13 @@ func (s *MailService) TestConnection() error {
return nil
}
// SendEmail sends an email using the configured SMTP settings.
// The to address and subject are sanitized to prevent header injection.
func (s *MailService) SendEmail(to, subject, htmlBody string) error {
// SendEmail sends an email using the configured SMTP settings to each recipient.
// One email is sent per recipient (no BCC). The context is checked between sends.
func (s *MailService) SendEmail(ctx context.Context, to []string, subject, htmlBody string) error {
if err := validateEmailRecipients(to); err != nil {
return err
}
config, err := s.GetSMTPConfig()
if err != nil {
return err
@@ -273,38 +307,21 @@ func (s *MailService) SendEmail(to, subject, htmlBody string) error {
return errors.New("SMTP not configured")
}
// Validate and encode subject
// Validate and encode subject once for all recipients
encodedSubject, err := encodeSubject(subject)
if err != nil {
return fmt.Errorf("invalid subject: %w", err)
}
// Validate recipient address (for SMTP envelope use)
toAddr, err := parseEmailAddressForHeader(headerTo, to)
if err != nil {
return fmt.Errorf("invalid recipient address: %w", err)
}
fromAddr, err := parseEmailAddressForHeader(headerFrom, config.FromAddress)
if err != nil {
return fmt.Errorf("invalid from address: %w", err)
}
// Build the email message (headers are validated and formatted)
// Note: toAddr is only used for SMTP envelope; message headers use undisclosed recipients
msg, err := s.buildEmail(fromAddr, toAddr, nil, encodedSubject, htmlBody)
if err != nil {
return err
}
fromEnvelope := fromAddr.Address
toEnvelope := toAddr.Address
if err := rejectCRLF(fromEnvelope); err != nil {
return fmt.Errorf("invalid from address: %w", err)
}
if err := rejectCRLF(toEnvelope); err != nil {
return fmt.Errorf("invalid recipient address: %w", err)
}
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
var auth smtp.Auth
@@ -312,15 +329,46 @@ func (s *MailService) SendEmail(to, subject, htmlBody string) error {
auth = smtp.PlainAuth("", config.Username, config.Password, config.Host)
}
switch config.Encryption {
case "ssl":
return s.sendSSL(addr, config, auth, fromEnvelope, toEnvelope, msg)
case "starttls":
return s.sendSTARTTLS(addr, config, auth, fromEnvelope, toEnvelope, msg)
default:
// codeql[go/email-injection] Safe: header values reject CR/LF; addresses parsed by net/mail; body dot-stuffed; tests in mail_service_test.go cover CRLF attempts.
return smtp.SendMail(addr, auth, fromEnvelope, []string{toEnvelope}, msg)
for _, recipient := range to {
if err := ctx.Err(); err != nil {
return fmt.Errorf("context cancelled: %w", err)
}
toAddr, err := parseEmailAddressForHeader(headerTo, recipient)
if err != nil {
return fmt.Errorf("invalid recipient address: %w", err)
}
// Build the email message (headers are validated and formatted)
// Note: toAddr is only used for SMTP envelope; message headers use undisclosed recipients
msg, err := s.buildEmail(fromAddr, toAddr, nil, encodedSubject, htmlBody)
if err != nil {
return err
}
toEnvelope := toAddr.Address
if err := rejectCRLF(toEnvelope); err != nil {
return fmt.Errorf("invalid recipient address: %w", err)
}
switch config.Encryption {
case "ssl":
if err := s.sendSSL(addr, config, auth, fromEnvelope, toEnvelope, msg); err != nil {
return err
}
case "starttls":
if err := s.sendSTARTTLS(addr, config, auth, fromEnvelope, toEnvelope, msg); err != nil {
return err
}
default:
// Safe: CRLF rejected in header values; address parsed by net/mail; body dot-stuffed; see buildEmail() and rejectCRLF().
if err := smtp.SendMail(addr, auth, fromEnvelope, []string{toEnvelope}, msg); err != nil { // codeql[go/email-injection]
return err
}
}
}
return nil
}
// buildEmail constructs a properly formatted email message with validated headers.
@@ -486,9 +534,8 @@ func (s *MailService) sendSSL(addr string, config *SMTPConfig, auth smtp.Auth, f
return fmt.Errorf("DATA failed: %w", err)
}
// Security Note: msg built by buildEmail() with header/body sanitization
// See buildEmail() for injection protection details
if _, writeErr := w.Write(msg); writeErr != nil {
// Safe: msg is built by buildEmail() which rejects CRLF in headers and sanitizes the body; net/smtp data.Writer dot-stuffs per RFC 5321.
if _, writeErr := w.Write(msg); writeErr != nil { // codeql[go/email-injection]
return fmt.Errorf("failed to write message: %w", writeErr)
}
@@ -539,9 +586,8 @@ func (s *MailService) sendSTARTTLS(addr string, config *SMTPConfig, auth smtp.Au
return fmt.Errorf("DATA failed: %w", err)
}
// Security Note: msg built by buildEmail() with header/body sanitization
// See buildEmail() for injection protection details
if _, err := w.Write(msg); err != nil {
// Safe: msg is built by buildEmail() which rejects CRLF in headers and sanitizes the body; net/smtp data.Writer dot-stuffs per RFC 5321.
if _, err := w.Write(msg); err != nil { // codeql[go/email-injection]
return fmt.Errorf("failed to write message: %w", err)
}
@@ -626,5 +672,8 @@ func (s *MailService) SendInvite(email, inviteToken, appName, baseURL string) er
logger.Log().WithField("email", util.SanitizeForLog(email)).Info("Sending invite email")
// SendEmail will validate and encode the subject
return s.SendEmail(email, subject, body.String())
return s.SendEmail(context.Background(), []string{email}, subject, body.String())
}
// Compile-time assertion: MailService must satisfy MailServiceInterface.
var _ MailServiceInterface = (*MailService)(nil)

View File

@@ -3,6 +3,7 @@ package services
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
@@ -16,6 +17,7 @@ import (
"os"
"path/filepath"
"strconv"
"fmt"
"strings"
"sync"
"testing"
@@ -384,7 +386,7 @@ func TestMailService_SendEmail_NotConfigured(t *testing.T) {
db := setupMailTestDB(t)
svc := NewMailService(db)
err := svc.SendEmail("test@example.com", "Subject", "<p>Body</p>")
err := svc.SendEmail(context.Background(), []string{"test@example.com"}, "Subject", "<p>Body</p>")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not configured")
}
@@ -400,7 +402,7 @@ func TestMailService_SendEmail_RejectsCRLFInSubject(t *testing.T) {
}
require.NoError(t, svc.SaveSMTPConfig(config))
err := svc.SendEmail("recipient@example.com", "Hello\r\nBcc: evil@example.com", "Body")
err := svc.SendEmail(context.Background(), []string{"recipient@example.com"}, "Hello\r\nBcc: evil@example.com", "Body")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid subject")
}
@@ -531,7 +533,7 @@ func TestMailService_SendEmail_InvalidRecipient(t *testing.T) {
}
require.NoError(t, svc.SaveSMTPConfig(config))
err := svc.SendEmail("invalid\r\nemail", "Subject", "Body")
err := svc.SendEmail(context.Background(), []string{"invalid\r\nemail"}, "Subject", "Body")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid recipient")
}
@@ -547,7 +549,7 @@ func TestMailService_SendEmail_InvalidFromAddress(t *testing.T) {
}
require.NoError(t, svc.SaveSMTPConfig(config))
err := svc.SendEmail("test@example.com", "Subject", "Body")
err := svc.SendEmail(context.Background(), []string{"test@example.com"}, "Subject", "Body")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid from address")
}
@@ -578,7 +580,7 @@ func TestMailService_SendEmail_EncryptionModes(t *testing.T) {
}
require.NoError(t, svc.SaveSMTPConfig(config))
err := svc.SendEmail("recipient@example.com", "Test", "Body")
err := svc.SendEmail(context.Background(), []string{"recipient@example.com"}, "Test", "Body")
assert.Error(t, err)
})
}
@@ -658,7 +660,7 @@ func TestMailService_SendEmail_CRLFInjection_Comprehensive(t *testing.T) {
require.NoError(t, svc.SaveSMTPConfig(&testConfig))
}
err := svc.SendEmail(tc.to, tc.subject, "<p>Normal body</p>")
err := svc.SendEmail(context.Background(), []string{tc.to}, tc.subject, "<p>Normal body</p>")
assert.Error(t, err, tc.description)
assert.Contains(t, err.Error(), "invalid", "Error should indicate invalid input")
})
@@ -1057,7 +1059,7 @@ func TestMailService_SendEmail_STARTTLSSuccess(t *testing.T) {
}))
// With fixed cert trust, STARTTLS connection and email send succeed
err = svc.SendEmail("recipient@example.com", "Subject", "Body")
err = svc.SendEmail(context.Background(), []string{"recipient@example.com"}, "Subject", "Body")
require.NoError(t, err)
}
@@ -1086,7 +1088,7 @@ func TestMailService_SendEmail_SSLSuccess(t *testing.T) {
}))
// With fixed cert trust, SSL connection and email send succeed
err = svc.SendEmail("recipient@example.com", "Subject", "Body")
err = svc.SendEmail(context.Background(), []string{"recipient@example.com"}, "Subject", "Body")
require.NoError(t, err)
}
@@ -1356,3 +1358,102 @@ func handleSMTPConn(conn net.Conn, tlsConf *tls.Config, supportStartTLS bool, re
}
}
}
func TestValidateEmailRecipients_Empty(t *testing.T) {
err := validateEmailRecipients([]string{})
assert.NoError(t, err)
}
func TestValidateEmailRecipients_Valid(t *testing.T) {
err := validateEmailRecipients([]string{"a@b.com", "c@d.org"})
assert.NoError(t, err)
}
func TestValidateEmailRecipients_TooMany(t *testing.T) {
recipients := make([]string, 21)
for i := range recipients {
recipients[i] = "a@b.com"
}
err := validateEmailRecipients(recipients)
assert.ErrorIs(t, err, ErrTooManyRecipients)
}
func TestValidateEmailRecipients_CRLFInRecipient(t *testing.T) {
err := validateEmailRecipients([]string{"victim@example.com\r\nBcc: evil@bad.com"})
assert.ErrorIs(t, err, ErrInvalidRecipient)
}
func TestValidateEmailRecipients_InvalidFormat(t *testing.T) {
err := validateEmailRecipients([]string{"not-an-email"})
assert.ErrorIs(t, err, ErrInvalidRecipient)
}
func TestValidateEmailRecipients_ExactlyTwenty(t *testing.T) {
recipients := make([]string, 20)
for i := range recipients {
recipients[i] = "a@b.com"
}
err := validateEmailRecipients(recipients)
assert.NoError(t, err)
}
func TestSendEmail_TooManyRecipients(t *testing.T) {
db := setupMailTestDB(t)
svc := NewMailService(db)
cfg := &SMTPConfig{Host: "smtp.example.com", Port: 587, FromAddress: "from@example.com"}
require.NoError(t, svc.SaveSMTPConfig(cfg))
recipients := make([]string, 21)
for i := range recipients {
recipients[i] = fmt.Sprintf("user%d@example.com", i)
}
err := svc.SendEmail(context.Background(), recipients, "Subject", "<p>Body</p>")
require.Error(t, err)
assert.ErrorIs(t, err, ErrTooManyRecipients)
}
func TestSendEmail_HeaderInjectionInRecipient(t *testing.T) {
db := setupMailTestDB(t)
svc := NewMailService(db)
cfg := &SMTPConfig{Host: "smtp.example.com", Port: 587, FromAddress: "from@example.com"}
require.NoError(t, svc.SaveSMTPConfig(cfg))
err := svc.SendEmail(context.Background(), []string{"bad\r\naddr@test.com"}, "Subject", "<p>Body</p>")
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidRecipient)
}
func TestSendEmail_InvalidRFC5322Recipient(t *testing.T) {
db := setupMailTestDB(t)
svc := NewMailService(db)
cfg := &SMTPConfig{Host: "smtp.example.com", Port: 587, FromAddress: "from@example.com"}
require.NoError(t, svc.SaveSMTPConfig(cfg))
err := svc.SendEmail(context.Background(), []string{"notanemail"}, "Subject", "<p>Body</p>")
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidRecipient)
}
func TestSendEmail_ValidMultipleRecipients(t *testing.T) {
db := setupMailTestDB(t)
svc := NewMailService(db)
// Use a mock SMTP server to capture the connection attempt.
// Since we're not running a real SMTP server, the actual send will fail,
// but validation (the part being tested) must pass — error must NOT be ErrInvalidRecipient or ErrTooManyRecipients.
cfg := &SMTPConfig{Host: "127.0.0.1", Port: 19999, FromAddress: "from@example.com"}
require.NoError(t, svc.SaveSMTPConfig(cfg))
err := svc.SendEmail(context.Background(),
[]string{"alice@example.com", "bob@example.com", "carol@example.com"},
"Test Subject", "<p>Hello</p>",
)
// Validation must pass; connection refused error expected (no SMTP server running)
if err != nil {
assert.NotErrorIs(t, err, ErrInvalidRecipient)
assert.NotErrorIs(t, err, ErrTooManyRecipients)
}
}

View File

@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"net"
"net/http"
neturl "net/url"
@@ -27,12 +28,14 @@ import (
type NotificationService struct {
DB *gorm.DB
httpWrapper *notifications.HTTPWrapper
mailService MailServiceInterface
}
func NewNotificationService(db *gorm.DB) *NotificationService {
func NewNotificationService(db *gorm.DB, mailService MailServiceInterface) *NotificationService {
return &NotificationService{
DB: db,
httpWrapper: notifications.NewNotifyHTTPWrapper(),
mailService: mailService,
}
}
@@ -225,6 +228,10 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title
Warn("Skipping dispatch because provider type is disabled for notify dispatch")
continue
}
if strings.ToLower(strings.TrimSpace(provider.Type)) == "email" {
go s.dispatchEmail(ctx, provider, eventType, title, message)
continue
}
go func(p models.NotificationProvider) {
if !supportsJSONTemplates(p.Type) {
logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).WithField("type", p.Type).Warn("Provider type is not supported by notify-only runtime")
@@ -238,6 +245,38 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title
}
}
// dispatchEmail sends an email notification for the given provider.
// It runs in a goroutine; all errors are logged rather than returned.
func (s *NotificationService) dispatchEmail(ctx context.Context, p models.NotificationProvider, _, 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)
}
}
if len(recipients) == 0 {
logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).Warn("Email provider has no recipients configured")
return
}
subject := fmt.Sprintf("[Charon Alert] %s", title)
htmlBody := "<p><strong>" + html.EscapeString(title) + "</strong></p><p>" + html.EscapeString(message) + "</p>"
timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := s.mailService.SendEmail(timeoutCtx, recipients, subject, htmlBody); err != nil {
logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send email notification")
}
}
// 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) {

View File

@@ -20,7 +20,7 @@ func TestDiscordOnly_CreateProviderRejectsUnsupported(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
testCases := []string{"slack", "telegram", "generic"}
@@ -45,7 +45,7 @@ func TestDiscordOnly_CreateProviderAcceptsDiscord(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
provider := &models.NotificationProvider{
Name: "Test Discord",
@@ -67,7 +67,7 @@ func TestDiscordOnly_CreateProviderAcceptsWebhook(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
provider := &models.NotificationProvider{
Name: "Test Webhook",
@@ -84,7 +84,7 @@ func TestDiscordOnly_CreateProviderAcceptsGotifyWithOrWithoutToken(t *testing.T)
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
provider := &models.NotificationProvider{
Name: "Test Gotify",
@@ -107,7 +107,7 @@ func TestDiscordOnly_CreateProviderAcceptsEmail(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
provider := &models.NotificationProvider{
Name: "Test Email",
@@ -134,7 +134,7 @@ func TestDiscordOnly_UpdateProviderRejectsTypeMutation(t *testing.T) {
}
require.NoError(t, db.Create(&provider).Error)
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
updatedProvider := &models.NotificationProvider{
ID: "test-id",
@@ -164,7 +164,7 @@ func TestDiscordOnly_UpdateProviderAllowsWebhookUpdates(t *testing.T) {
}
require.NoError(t, db.Create(&provider).Error)
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
updatedProvider := &models.NotificationProvider{
ID: "test-id",
@@ -190,7 +190,7 @@ func TestDiscordOnly_TestProviderAllowsWebhookWithoutFeatureFlag(t *testing.T) {
}))
defer ts.Close()
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Name: "Test Webhook",
@@ -219,7 +219,7 @@ func TestDiscordOnly_MigrationDeprecatesNonDiscord(t *testing.T) {
}
require.NoError(t, db.Create(&webhookProvider).Error)
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
// Run migration
err = service.EnsureNotifyOnlyProviderMigration(context.Background())
@@ -250,7 +250,7 @@ func TestDiscordOnly_MigrationMarksDiscordMigrated(t *testing.T) {
}
require.NoError(t, db.Create(&discordProvider).Error)
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
// Run migration
err = service.EnsureNotifyOnlyProviderMigration(context.Background())
@@ -293,7 +293,7 @@ func TestDiscordOnly_MigrationIsIdempotent(t *testing.T) {
require.NoError(t, db.Create(&p).Error)
}
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
// Run migration first time
err = service.EnsureNotifyOnlyProviderMigration(context.Background())
@@ -334,7 +334,7 @@ func TestDiscordOnly_MigrationIsTransactional(t *testing.T) {
}
require.NoError(t, db.Create(&provider).Error)
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
// First migration should succeed
err = service.EnsureNotifyOnlyProviderMigration(context.Background())
@@ -362,7 +362,7 @@ func TestDiscordOnly_MigrationPreservesLegacyURL(t *testing.T) {
}
require.NoError(t, db.Create(&provider).Error)
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
err = service.EnsureNotifyOnlyProviderMigration(context.Background())
require.NoError(t, err)
@@ -390,7 +390,7 @@ func TestDiscordOnly_SendExternalSkipsDeprecated(t *testing.T) {
}
require.NoError(t, db.Create(&deprecatedProvider).Error)
service := NewNotificationService(db)
service := NewNotificationService(db, nil)
// SendExternal should skip deprecated provider silently
service.SendExternal(context.Background(), "proxy_host", "Test", "Test message", nil)

View File

@@ -48,7 +48,7 @@ func TestSendJSONPayload_DiscordIPHostRejected(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Type: "discord",
@@ -88,7 +88,7 @@ func TestSendJSONPayload_UsesStoredHostnameURLWithoutHostMutation(t *testing.T)
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Mock Discord validation to allow test server URLs
origValidateDiscordFunc := validateDiscordProviderURLFunc
@@ -158,7 +158,7 @@ func TestSendJSONPayload_Discord(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Type: "discord",
@@ -193,7 +193,7 @@ func TestSendJSONPayload_Slack(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Type: "slack",
@@ -226,7 +226,7 @@ func TestSendJSONPayload_Gotify(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Type: "gotify",
@@ -249,7 +249,7 @@ func TestSendJSONPayload_TemplateTimeout(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Mock Discord validation to allow private IP check to run
origValidateDiscordFunc := validateDiscordProviderURLFunc
@@ -286,7 +286,7 @@ func TestSendJSONPayload_TemplateSizeLimit(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Create a template larger than 10KB
largeTemplate := strings.Repeat("x", 11*1024)
@@ -311,7 +311,7 @@ func TestSendJSONPayload_DiscordValidation(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Type: "discord",
@@ -334,7 +334,7 @@ func TestSendJSONPayload_DiscordValidation_MissingMessage(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Type: "discord",
@@ -354,7 +354,7 @@ func TestSendJSONPayload_SlackValidation(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Slack payload without text or blocks should fail
provider := models.NotificationProvider{
@@ -377,7 +377,7 @@ func TestSendJSONPayload_GotifyValidation(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Gotify payload without message should fail
provider := models.NotificationProvider{
@@ -400,7 +400,7 @@ func TestSendJSONPayload_InvalidJSON(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Type: "discord",
@@ -455,7 +455,7 @@ func TestSendExternal_UsesJSONForSupportedServices(t *testing.T) {
}
db.Create(&provider)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil)
// Give goroutine time to execute
@@ -488,7 +488,7 @@ func TestTestProvider_UsesJSONForSupportedServices(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Type: "discord",

View File

@@ -18,7 +18,7 @@ func TestNotificationService_TemplateCRUD(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
tmpl := &models.NotificationTemplate{
Name: "Custom",

View File

@@ -10,6 +10,8 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -33,7 +35,7 @@ func setupNotificationTestDB(t *testing.T) *gorm.DB {
func TestNotificationService_Create(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
notif, err := svc.Create(models.NotificationTypeInfo, "Test", "Message")
require.NoError(t, err)
@@ -44,7 +46,7 @@ func TestNotificationService_Create(t *testing.T) {
func TestNotificationService_List(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
_, _ = svc.Create(models.NotificationTypeInfo, "N1", "M1")
_, _ = svc.Create(models.NotificationTypeInfo, "N2", "M2")
@@ -64,7 +66,7 @@ func TestNotificationService_List(t *testing.T) {
func TestNotificationService_MarkAsRead(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
notif, _ := svc.Create(models.NotificationTypeInfo, "N1", "M1")
@@ -78,7 +80,7 @@ func TestNotificationService_MarkAsRead(t *testing.T) {
func TestNotificationService_MarkAllAsRead(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
_, _ = svc.Create(models.NotificationTypeInfo, "N1", "M1")
_, _ = svc.Create(models.NotificationTypeInfo, "N2", "M2")
@@ -93,7 +95,7 @@ func TestNotificationService_MarkAllAsRead(t *testing.T) {
func TestNotificationService_Providers(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Create
provider := models.NotificationProvider{
@@ -128,7 +130,7 @@ func TestNotificationService_Providers(t *testing.T) {
func TestNotificationService_TestProvider_Webhook(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Mock validation and webhook request for testing
origValidateDiscordFunc := validateDiscordProviderURLFunc
@@ -166,7 +168,7 @@ func TestNotificationService_TestProvider_Webhook(t *testing.T) {
func TestNotificationService_SendExternal(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
received := make(chan struct{})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -203,7 +205,7 @@ func TestNotificationService_SendExternal(t *testing.T) {
func TestNotificationService_SendExternal_MinimalVsDetailedTemplates(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Mock validation only - allow real HTTP calls to test servers
origValidateDiscordFunc := validateDiscordProviderURLFunc
@@ -286,7 +288,7 @@ func TestNotificationService_SendExternal_MinimalVsDetailedTemplates(t *testing.
func TestNotificationService_SendExternal_Filtered(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
received := make(chan struct{})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -359,7 +361,7 @@ func TestNormalizeURL(t *testing.T) {
func TestNotificationService_SendCustomWebhook_Errors(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
t.Run("invalid URL", func(t *testing.T) {
provider := models.NotificationProvider{
@@ -467,7 +469,7 @@ func TestNotificationService_SendCustomWebhook_Errors(t *testing.T) {
func TestNotificationService_SendCustomWebhook_PropagatesRequestID(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
received := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -493,7 +495,7 @@ func TestNotificationService_SendCustomWebhook_PropagatesRequestID(t *testing.T)
func TestNotificationService_TestProvider_Errors(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
t.Run("unsupported provider type", func(t *testing.T) {
provider := models.NotificationProvider{
@@ -617,7 +619,7 @@ func TestSSRF_URLValidation_ComprehensiveBlocking(t *testing.T) {
func TestSSRF_WebhookIntegration(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
t.Run("blocks private IP webhook", func(t *testing.T) {
provider := models.NotificationProvider{
@@ -660,7 +662,7 @@ func TestSSRF_WebhookIntegration(t *testing.T) {
func TestNotificationService_SendExternal_EdgeCases(t *testing.T) {
t.Run("no enabled providers", func(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Name: "Disabled",
@@ -677,7 +679,7 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) {
t.Run("provider filtered by category", func(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("Should not call webhook")
@@ -722,7 +724,7 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) {
t.Run("custom data passed to webhook", func(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Mock validation only - allow real HTTP calls to test server
origValidateDiscordFunc := validateDiscordProviderURLFunc
@@ -764,7 +766,7 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) {
func TestNotificationService_RenderTemplate(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Minimal template
provider := models.NotificationProvider{Type: "webhook", Template: "minimal"}
@@ -784,7 +786,7 @@ func TestNotificationService_RenderTemplate(t *testing.T) {
func TestNotificationService_CreateProvider_Validation(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
t.Run("creates provider with defaults", func(t *testing.T) {
provider := models.NotificationProvider{
@@ -860,7 +862,7 @@ func TestNotificationService_IsPrivateIP(t *testing.T) {
func TestNotificationService_CreateProvider_InvalidCustomTemplate(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
t.Run("invalid custom template on create", func(t *testing.T) {
provider := models.NotificationProvider{
@@ -897,7 +899,7 @@ func TestNotificationService_CreateProvider_InvalidCustomTemplate(t *testing.T)
func TestRenderTemplate_TemplateParseError(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Template: "custom",
@@ -918,7 +920,7 @@ func TestRenderTemplate_TemplateParseError(t *testing.T) {
func TestRenderTemplate_TemplateExecutionError(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Template: "custom",
@@ -943,7 +945,7 @@ func TestRenderTemplate_TemplateExecutionError(t *testing.T) {
func TestRenderTemplate_InvalidJSONOutput(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Template: "custom",
@@ -966,7 +968,7 @@ func TestRenderTemplate_InvalidJSONOutput(t *testing.T) {
func TestSendCustomWebhook_HTTPStatusCodeErrors(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
errorCodes := []int{400, 404, 500, 502, 503}
@@ -1005,7 +1007,7 @@ func TestSendCustomWebhook_HTTPStatusCodeErrors(t *testing.T) {
func TestSendCustomWebhook_TemplateSelection(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
tests := []struct {
name string
@@ -1093,7 +1095,7 @@ func TestSendCustomWebhook_TemplateSelection(t *testing.T) {
func TestSendCustomWebhook_EmptyCustomTemplateDefaultsToMinimal(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
var receivedBody map[string]any
@@ -1134,7 +1136,7 @@ func TestSendCustomWebhook_EmptyCustomTemplateDefaultsToMinimal(t *testing.T) {
func TestCreateProvider_EmptyCustomTemplateAllowed(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := &models.NotificationProvider{
Name: "empty-template",
@@ -1151,7 +1153,7 @@ func TestCreateProvider_EmptyCustomTemplateAllowed(t *testing.T) {
func TestUpdateProvider_NonCustomTemplateSkipsValidation(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := &models.NotificationProvider{
Name: "test",
@@ -1206,7 +1208,7 @@ func TestIsPrivateIP_EdgeCases(t *testing.T) {
func TestSendCustomWebhook_ContextCancellation(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Create a server that delays response
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -1238,7 +1240,7 @@ func TestSendCustomWebhook_ContextCancellation(t *testing.T) {
func TestSendExternal_UnknownEventTypeSendsToAll(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
var callCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -1280,7 +1282,7 @@ func TestSendExternal_UnknownEventTypeSendsToAll(t *testing.T) {
func TestCreateProvider_ValidCustomTemplate(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := &models.NotificationProvider{
Name: "valid-custom",
@@ -1297,7 +1299,7 @@ func TestCreateProvider_ValidCustomTemplate(t *testing.T) {
func TestUpdateProvider_ValidCustomTemplate(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := &models.NotificationProvider{
Name: "test",
@@ -1317,7 +1319,7 @@ func TestUpdateProvider_ValidCustomTemplate(t *testing.T) {
func TestRenderTemplate_MinimalAndDetailedTemplates(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
data := map[string]any{
"Title": "Test Title",
@@ -1369,7 +1371,7 @@ func TestRenderTemplate_MinimalAndDetailedTemplates(t *testing.T) {
func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
t.Run("discord_message_is_normalized_to_content", func(t *testing.T) {
originalDo := webhookDoRequestFunc
@@ -1589,7 +1591,7 @@ func TestSendExternal_AllEventTypes(t *testing.T) {
for _, et := range eventTypes {
t.Run(et.eventType, func(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Mock Discord validation to allow test server URL
origValidateDiscordFunc := validateDiscordProviderURLFunc
@@ -1710,7 +1712,7 @@ 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)
svc := NewNotificationService(db, nil)
origValidate := validateDiscordProviderURLFunc
defer func() { validateDiscordProviderURLFunc = origValidate }()
@@ -1746,7 +1748,7 @@ func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) {
func TestNotificationService_UpdateProvider_ReturnsErrorWhenProviderMissing(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
err := svc.UpdateProvider(&models.NotificationProvider{
ID: "missing-id",
@@ -1758,7 +1760,7 @@ func TestNotificationService_UpdateProvider_ReturnsErrorWhenProviderMissing(t *t
func TestNotificationService_EnsureNotifyOnlyProviderMigration_QueryProvidersError(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
sqlDB, err := db.DB()
require.NoError(t, err)
@@ -1791,7 +1793,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_UpdateError(t *te
roDB, err := gorm.Open(sqlite.Open(roDSN), &gorm.Config{})
require.NoError(t, err)
svc := NewNotificationService(roDB)
svc := NewNotificationService(roDB, nil)
err = svc.EnsureNotifyOnlyProviderMigration(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to migrate notification provider")
@@ -1808,7 +1810,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_WrapsFindError(t
require.NoError(t, err)
// Intentionally do not migrate notification_providers table.
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
err = svc.EnsureNotifyOnlyProviderMigration(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to fetch notification providers for migration")
@@ -1816,7 +1818,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_WrapsFindError(t
func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Test truly unsupported providers are rejected
tests := []struct {
@@ -1846,7 +1848,7 @@ func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) {
func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
serverCalled := atomic.Bool{}
originalDo := webhookDoRequestFunc
@@ -1871,7 +1873,7 @@ func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) {
func TestTestProvider_HTTPURLValidation(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
t.Run("blocks private IP", func(t *testing.T) {
provider := models.NotificationProvider{
@@ -1918,7 +1920,7 @@ func TestTestProvider_HTTPURLValidation(t *testing.T) {
func TestSendJSONPayload_TemplateExecutionError(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -1947,7 +1949,7 @@ func TestSendJSONPayload_TemplateExecutionError(t *testing.T) {
func TestSendJSONPayload_InvalidJSONFromTemplate(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -1976,7 +1978,7 @@ func TestSendJSONPayload_InvalidJSONFromTemplate(t *testing.T) {
func TestSendJSONPayload_RequestCreationError(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// This test verifies request creation doesn't panic on edge cases
provider := models.NotificationProvider{
@@ -2002,7 +2004,7 @@ func TestSendJSONPayload_RequestCreationError(t *testing.T) {
func TestRenderTemplate_CustomTemplateWithWhitespace(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Test template selection with various whitespace
tests := []struct {
@@ -2043,7 +2045,7 @@ func TestListTemplates_DBError(t *testing.T) {
db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
_ = db.AutoMigrate(&models.NotificationTemplate{})
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Close the underlying connection to force error
sqlDB, _ := db.DB()
@@ -2058,7 +2060,7 @@ func TestSendExternal_DBFetchError(t *testing.T) {
db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
_ = db.AutoMigrate(&models.NotificationProvider{})
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Close the underlying connection to force error
sqlDB, _ := db.DB()
@@ -2070,7 +2072,7 @@ func TestSendExternal_DBFetchError(t *testing.T) {
func TestSendExternal_JSONPayloadError(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Create a provider that will fail JSON validation (discord without content/embeds)
provider := models.NotificationProvider{
@@ -2091,7 +2093,7 @@ func TestSendExternal_JSONPayloadError(t *testing.T) {
func TestSendJSONPayload_HTTPScheme(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Test both HTTP and HTTPS schemes
schemes := []string{"http", "https"}
@@ -2129,7 +2131,7 @@ func TestSendJSONPayload_HTTPScheme(t *testing.T) {
func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
ctx := context.Background()
// Create test providers: discord (supported) and others (deprecated in discord-only rollout)
@@ -2198,7 +2200,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) {
func TestNotificationService_EnsureNotifyOnlyProviderMigration_PreservesLegacyURL(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
ctx := context.Background()
// Create provider with URL but no legacy_url
@@ -2222,7 +2224,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_PreservesLegacyUR
func TestNotificationService_EnsureNotifyOnlyProviderMigration_SkipsIfLegacyURLExists(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
ctx := context.Background()
// Create provider with both URL and legacy_url already set
@@ -2248,7 +2250,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_SkipsIfLegacyURLE
func TestNotificationService_EnsureNotifyOnlyProviderMigration_DBError(t *testing.T) {
db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
_ = db.AutoMigrate(&models.NotificationProvider{})
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Close DB to force error
sqlDB, _ := db.DB()
@@ -2271,7 +2273,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_DBError(t *testin
// Success path is tested by TestNotificationService_EnsureNotifyOnlyProviderMigration
func TestNotificationService_EnsureNotifyOnlyProviderMigration_FailsClosed(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
ctx := context.Background()
// Create a Discord provider (the only type that gets migrated)
@@ -2302,7 +2304,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_FailsClosed(t *te
func TestIsDispatchEnabled_GotifyDefaultTrue(t *testing.T) {
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// No feature flag row exists — should default to true
assert.True(t, svc.isDispatchEnabled("gotify"))
@@ -2311,7 +2313,7 @@ func TestIsDispatchEnabled_GotifyDefaultTrue(t *testing.T) {
func TestIsDispatchEnabled_WebhookDefaultTrue(t *testing.T) {
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// No feature flag row exists — should default to true
assert.True(t, svc.isDispatchEnabled("webhook"))
@@ -2328,7 +2330,7 @@ func TestIsSupportedNotificationProviderType_Email(t *testing.T) {
func TestIsDispatchEnabled_EmailDefaultFalse(t *testing.T) {
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// No feature flag row — email defaults to false
assert.False(t, svc.isDispatchEnabled("email"))
@@ -2341,13 +2343,14 @@ func TestIsDispatchEnabled_EmailDefaultFalse(t *testing.T) {
assert.True(t, svc.isDispatchEnabled("email"))
}
// TestSendExternal_EmailProviderSkipsJSONTemplate covers the goroutine branch where
// an email provider is dispatch-enabled but not in supportsJSONTemplates — it logs
// a warning and returns without calling sendJSONPayload.
func TestSendExternal_EmailProviderSkipsJSONTemplate(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.
func TestSendExternal_EmailProvider_NilMailService_DoesNotPanic(t *testing.T) {
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Enable the email feature flag so isDispatchEnabled("email") returns true.
require.NoError(t, db.Create(&models.Setting{
@@ -2374,7 +2377,7 @@ func TestSendExternal_EmailProviderSkipsJSONTemplate(t *testing.T) {
// attempting an HTTP send.
func TestTestProvider_EmailRejectsJSONTemplateStep(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
provider := models.NotificationProvider{
Type: "email",
@@ -2390,7 +2393,7 @@ func TestTestProvider_EmailRejectsJSONTemplateStep(t *testing.T) {
func TestTestProvider_GotifyWorksWithoutFeatureFlag(t *testing.T) {
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -2410,7 +2413,7 @@ func TestTestProvider_GotifyWorksWithoutFeatureFlag(t *testing.T) {
func TestTestProvider_WebhookWorksWithoutFeatureFlag(t *testing.T) {
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -2430,7 +2433,7 @@ func TestTestProvider_WebhookWorksWithoutFeatureFlag(t *testing.T) {
func TestTestProvider_GotifyWorksWhenFlagExplicitlyFalse(t *testing.T) {
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Explicitly set feature flag to false
db.Create(&models.Setting{Key: "feature.notifications.service.gotify.enabled", Value: "false"})
@@ -2454,7 +2457,7 @@ func TestTestProvider_GotifyWorksWhenFlagExplicitlyFalse(t *testing.T) {
func TestTestProvider_WebhookWorksWhenFlagExplicitlyFalse(t *testing.T) {
db := setupNotificationTestDB(t)
_ = db.AutoMigrate(&models.Setting{})
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
// Explicitly set feature flag to false
db.Create(&models.Setting{Key: "feature.notifications.service.webhook.enabled", Value: "false"})
@@ -2477,7 +2480,7 @@ func TestTestProvider_WebhookWorksWhenFlagExplicitlyFalse(t *testing.T) {
func TestUpdateProvider_TypeMutationBlocked(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
existing := models.NotificationProvider{
ID: "prov-type-mut",
@@ -2500,7 +2503,7 @@ func TestUpdateProvider_TypeMutationBlocked(t *testing.T) {
func TestUpdateProvider_GotifyKeepsExistingToken(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
existing := models.NotificationProvider{
ID: "prov-gotify-token",
@@ -2526,7 +2529,7 @@ func TestUpdateProvider_GotifyKeepsExistingToken(t *testing.T) {
func TestGetFeatureFlagValue_FoundSetting(t *testing.T) {
db := setupNotificationTestDB(t)
require.NoError(t, db.AutoMigrate(&models.Setting{}))
svc := NewNotificationService(db)
svc := NewNotificationService(db, nil)
tests := []struct {
name string
@@ -2550,3 +2553,237 @@ func TestGetFeatureFlagValue_FoundSetting(t *testing.T) {
})
}
}
// --- mockMailService for dispatchEmail tests ---
type mockMailService struct {
mu sync.Mutex
isConfigured bool
sendEmailErr error
calls []mockSendEmailCall
}
type mockSendEmailCall struct {
to []string
subject string
body string
}
func (m *mockMailService) IsConfigured() bool { return m.isConfigured }
func (m *mockMailService) SendEmail(_ context.Context, to []string, subject, htmlBody string) error {
m.mu.Lock()
m.calls = append(m.calls, mockSendEmailCall{to: to, subject: subject, body: htmlBody})
m.mu.Unlock()
return m.sendEmailErr
}
func (m *mockMailService) callCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.calls)
}
func (m *mockMailService) firstCall() mockSendEmailCall {
m.mu.Lock()
defer m.mu.Unlock()
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, "<strong>My Title</strong>")
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}
svc := NewNotificationService(db, mock)
provider := models.NotificationProvider{
Name: "email-provider",
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)
// Allow goroutine to run
require.Eventually(t, func() bool { return mock.callCount() > 0 }, 2*time.Second, 10*time.Millisecond)
assert.Equal(t, []string{"notify@example.com"}, mock.firstCall().to)
}
func TestSendExternal_EmailProvider_FlagDisabled(t *testing.T) {
db := setupNotificationTestDB(t)
require.NoError(t, db.AutoMigrate(&models.Setting{}))
mock := &mockMailService{isConfigured: true}
svc := NewNotificationService(db, mock)
provider := models.NotificationProvider{
Name: "email-off",
Type: "email",
URL: "notify@example.com",
Enabled: true,
}
require.NoError(t, db.Create(&provider).Error)
db.Create(&models.Setting{Key: notifications.FlagEmailServiceEnabled, Value: "false"})
svc.SendExternal(context.Background(), "test", "Title", "Body", nil)
time.Sleep(50 * time.Millisecond)
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 := `<script>alert('xss')</script>`
xssMessage := `<img src=x onerror=evil()>`
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, "<script>")
assert.Contains(t, body, "&lt;script&gt;")
// The <img> attack vector tag must be escaped — the literal < must not appear unescaped.
assert.NotContains(t, body, "<img ")
assert.Contains(t, body, "&lt;img ")
}

View File

@@ -18,7 +18,7 @@ func TestUptimeService_sendRecoveryNotification(t *testing.T) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Notification{}, &models.NotificationProvider{}))
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
svc := NewUptimeService(db, ns)
monitor := models.UptimeMonitor{Name: "API Server", URL: "https://api.example.com"}

View File

@@ -30,7 +30,7 @@ func setupUptimeRaceTestDB(t *testing.T) *gorm.DB {
func TestCheckHost_RetryLogic(t *testing.T) {
db := setupUptimeRaceTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
svc := NewUptimeService(db, ns)
svc.config.TCPTimeout = 500 * time.Millisecond
svc.config.MaxRetries = 2
@@ -72,7 +72,7 @@ func TestCheckHost_RetryLogic(t *testing.T) {
func TestCheckHost_Debouncing(t *testing.T) {
db := setupUptimeRaceTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
svc := NewUptimeService(db, ns)
svc.config.FailureThreshold = 2 // Require 2 failures
svc.config.TCPTimeout = 1 * time.Second // Shorter timeout for test
@@ -110,7 +110,7 @@ func TestCheckHost_Debouncing(t *testing.T) {
func TestCheckHost_FailureCountReset(t *testing.T) {
db := setupUptimeRaceTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
svc := NewUptimeService(db, ns)
listener, err := net.Listen("tcp", "127.0.0.1:0")
@@ -156,7 +156,7 @@ func TestCheckHost_FailureCountReset(t *testing.T) {
func TestCheckAllHosts_Synchronization(t *testing.T) {
db := setupUptimeRaceTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
svc := NewUptimeService(db, ns)
svc.config.TCPTimeout = 500 * time.Millisecond // Shorter timeout for test
svc.config.MaxRetries = 0 // No retries for this test
@@ -202,7 +202,7 @@ func TestCheckAllHosts_Synchronization(t *testing.T) {
func TestCheckHost_ConcurrentChecks(t *testing.T) {
db := setupUptimeRaceTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
svc := NewUptimeService(db, ns)
listener, err := net.Listen("tcp", "127.0.0.1:0")
@@ -259,7 +259,7 @@ func TestCheckHost_ConcurrentChecks(t *testing.T) {
func TestCheckHost_ContextCancellation(t *testing.T) {
db := setupUptimeRaceTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
svc := NewUptimeService(db, ns)
svc.config.TCPTimeout = 5 * time.Second // Normal timeout
svc.config.MaxRetries = 0 // No retries for this test
@@ -295,7 +295,7 @@ func TestCheckHost_ContextCancellation(t *testing.T) {
func TestCheckAllHosts_StaggeredStartup(t *testing.T) {
db := setupUptimeRaceTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
svc := NewUptimeService(db, ns)
svc.config.StaggerDelay = 50 * time.Millisecond
svc.config.TCPTimeout = 500 * time.Millisecond // Shorter timeout for test
@@ -332,7 +332,7 @@ func TestCheckAllHosts_StaggeredStartup(t *testing.T) {
func TestUptimeConfig_Defaults(t *testing.T) {
db := setupUptimeRaceTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
svc := NewUptimeService(db, ns)
assert.Equal(t, 10*time.Second, svc.config.TCPTimeout, "TCP timeout should be 10s")
@@ -344,7 +344,7 @@ func TestUptimeConfig_Defaults(t *testing.T) {
func TestCheckHost_HostMutexPreventsRaceCondition(t *testing.T) {
db := setupUptimeRaceTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
svc := NewUptimeService(db, ns)
listener, err := net.Listen("tcp", "127.0.0.1:0")

View File

@@ -67,7 +67,7 @@ func newTestUptimeService(t *testing.T, db *gorm.DB, ns *NotificationService) *U
func TestUptimeService_CheckAll(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create a dummy HTTP server for a "UP" host
@@ -195,7 +195,7 @@ func TestUptimeService_CheckAll(t *testing.T) {
func TestUptimeService_ListMonitors(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
db.Create(&models.UptimeMonitor{
@@ -212,7 +212,7 @@ func TestUptimeService_ListMonitors(t *testing.T) {
func TestUptimeService_GetMonitorByID(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
monitor := models.UptimeMonitor{
@@ -245,7 +245,7 @@ func TestUptimeService_GetMonitorByID(t *testing.T) {
func TestUptimeService_GetMonitorHistory(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
monitor := models.UptimeMonitor{
@@ -276,7 +276,7 @@ func TestUptimeService_GetMonitorHistory(t *testing.T) {
func TestUptimeService_SyncMonitors_Errors(t *testing.T) {
t.Run("database error during proxy host fetch", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Close the database to force errors
@@ -289,7 +289,7 @@ func TestUptimeService_SyncMonitors_Errors(t *testing.T) {
t.Run("creates monitors for new hosts", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create proxy hosts
@@ -308,7 +308,7 @@ func TestUptimeService_SyncMonitors_Errors(t *testing.T) {
t.Run("orphaned monitors persist after host deletion", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
host := models.ProxyHost{UUID: "test-1", DomainNames: "test1.com", Enabled: true}
@@ -336,7 +336,7 @@ func TestUptimeService_SyncMonitors_Errors(t *testing.T) {
func TestUptimeService_SyncMonitors_NameSync(t *testing.T) {
t.Run("syncs name from proxy host when changed", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
host := models.ProxyHost{UUID: "test-1", Name: "Original Name", DomainNames: "test1.com", Enabled: true}
@@ -362,7 +362,7 @@ func TestUptimeService_SyncMonitors_NameSync(t *testing.T) {
t.Run("uses domain name when proxy host name is empty", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
host := models.ProxyHost{UUID: "test-2", Name: "", DomainNames: "fallback.com, secondary.com", Enabled: true}
@@ -378,7 +378,7 @@ func TestUptimeService_SyncMonitors_NameSync(t *testing.T) {
t.Run("updates monitor name when host name becomes empty", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
host := models.ProxyHost{UUID: "test-3", Name: "Named Host", DomainNames: "domain.com", Enabled: true}
@@ -406,7 +406,7 @@ func TestUptimeService_SyncMonitors_NameSync(t *testing.T) {
func TestUptimeService_SyncMonitors_TCPMigration(t *testing.T) {
t.Run("migrates TCP monitor to HTTP for public URL", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
host := models.ProxyHost{
@@ -442,7 +442,7 @@ func TestUptimeService_SyncMonitors_TCPMigration(t *testing.T) {
t.Run("does not migrate TCP monitor with custom URL", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
host := models.ProxyHost{
@@ -481,7 +481,7 @@ func TestUptimeService_SyncMonitors_TCPMigration(t *testing.T) {
func TestUptimeService_SyncMonitors_HTTPSUpgrade(t *testing.T) {
t.Run("upgrades HTTP to HTTPS when SSL forced", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
host := models.ProxyHost{
@@ -526,7 +526,7 @@ func TestUptimeService_SyncMonitors_HTTPSUpgrade(t *testing.T) {
t.Run("does not downgrade HTTPS when SSL not forced", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
host := models.ProxyHost{
@@ -563,7 +563,7 @@ func TestUptimeService_SyncMonitors_HTTPSUpgrade(t *testing.T) {
func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
t.Run("creates monitor for new remote server", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
server := models.RemoteServer{
@@ -588,7 +588,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
t.Run("creates TCP monitor for remote server without scheme", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
server := models.RemoteServer{
@@ -611,7 +611,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
t.Run("syncs remote server name changes", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
server := models.RemoteServer{
@@ -643,7 +643,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
t.Run("syncs remote server URL changes", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
server := models.RemoteServer{
@@ -676,7 +676,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
t.Run("syncs remote server enabled status", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
server := models.RemoteServer{
@@ -708,7 +708,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
t.Run("syncs scheme change from TCP to HTTPS", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
server := models.RemoteServer{
@@ -744,7 +744,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
func TestUptimeService_CheckAll_Errors(t *testing.T) {
t.Run("handles empty monitor list", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Call CheckAll with no monitors - should not panic
@@ -758,7 +758,7 @@ func TestUptimeService_CheckAll_Errors(t *testing.T) {
t.Run("orphan monitors don't prevent check execution", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create a monitor without a proxy host
@@ -789,7 +789,7 @@ func TestUptimeService_CheckAll_Errors(t *testing.T) {
t.Skip("Blocks on real network call to 192.0.2.1:9999 - needs mock")
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Use even faster timeouts for this specific test
@@ -822,7 +822,7 @@ func TestUptimeService_CheckAll_Errors(t *testing.T) {
func TestUptimeService_CheckAll_HostDown_PartitionsByMonitorType(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
us.config.TCPTimeout = 50 * time.Millisecond
@@ -927,7 +927,7 @@ func TestUptimeService_CheckAll_HostDown_PartitionsByMonitorType(t *testing.T) {
func TestUptimeService_CheckAll_ManualScheduledParity_ForHTTPOnHostDown(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
us.config.TCPTimeout = 50 * time.Millisecond
@@ -1019,7 +1019,7 @@ func TestUptimeService_CheckAll_ManualScheduledParity_ForHTTPOnHostDown(t *testi
func TestUptimeService_CheckAll_ReachableHost_StillUsesHTTPResult(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
us.config.TCPTimeout = 50 * time.Millisecond
@@ -1094,7 +1094,7 @@ func TestUptimeService_CheckAll_ReachableHost_StillUsesHTTPResult(t *testing.T)
func TestUptimeService_CheckMonitor_EdgeCases(t *testing.T) {
t.Run("invalid URL format", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
monitor := models.UptimeMonitor{
@@ -1116,7 +1116,7 @@ func TestUptimeService_CheckMonitor_EdgeCases(t *testing.T) {
t.Run("http 404 response treated as down", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Start HTTP server returning 404
@@ -1159,7 +1159,7 @@ func TestUptimeService_CheckMonitor_EdgeCases(t *testing.T) {
t.Run("https URL without valid certificate", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
monitor := models.UptimeMonitor{
@@ -1184,7 +1184,7 @@ func TestUptimeService_CheckMonitor_EdgeCases(t *testing.T) {
func TestUptimeService_GetMonitorHistory_EdgeCases(t *testing.T) {
t.Run("non-existent monitor", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
history, err := us.GetMonitorHistory("non-existent", 100)
@@ -1194,7 +1194,7 @@ func TestUptimeService_GetMonitorHistory_EdgeCases(t *testing.T) {
t.Run("limit parameter respected", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
monitor := models.UptimeMonitor{ID: "monitor-limit", Name: "Limit Test"}
@@ -1219,7 +1219,7 @@ func TestUptimeService_GetMonitorHistory_EdgeCases(t *testing.T) {
func TestUptimeService_ListMonitors_EdgeCases(t *testing.T) {
t.Run("empty database", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
monitors, err := us.ListMonitors()
@@ -1229,7 +1229,7 @@ func TestUptimeService_ListMonitors_EdgeCases(t *testing.T) {
t.Run("monitors with associated proxy hosts", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
host := models.ProxyHost{UUID: "test-host", DomainNames: "test.com", Enabled: true}
@@ -1254,7 +1254,7 @@ func TestUptimeService_ListMonitors_EdgeCases(t *testing.T) {
func TestUptimeService_UpdateMonitor(t *testing.T) {
t.Run("update max_retries", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
monitor := models.UptimeMonitor{
@@ -1278,7 +1278,7 @@ func TestUptimeService_UpdateMonitor(t *testing.T) {
t.Run("update interval", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
monitor := models.UptimeMonitor{
@@ -1299,7 +1299,7 @@ func TestUptimeService_UpdateMonitor(t *testing.T) {
t.Run("update non-existent monitor", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
updates := map[string]any{
@@ -1312,7 +1312,7 @@ func TestUptimeService_UpdateMonitor(t *testing.T) {
t.Run("update multiple fields", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
monitor := models.UptimeMonitor{
@@ -1338,7 +1338,7 @@ func TestUptimeService_UpdateMonitor(t *testing.T) {
func TestUptimeService_NotificationBatching(t *testing.T) {
t.Run("batches multiple service failures on same host", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create an UptimeHost
@@ -1391,7 +1391,7 @@ func TestUptimeService_NotificationBatching(t *testing.T) {
t.Run("single service down gets individual notification", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create an UptimeHost
@@ -1433,7 +1433,7 @@ func TestUptimeService_NotificationBatching(t *testing.T) {
func TestUptimeService_HostLevelCheck(t *testing.T) {
t.Run("creates uptime host during sync", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create a proxy host
@@ -1465,7 +1465,7 @@ func TestUptimeService_HostLevelCheck(t *testing.T) {
t.Run("groups multiple services on same host", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create multiple proxy hosts pointing to the same forward host
@@ -1520,7 +1520,7 @@ func TestFormatDuration(t *testing.T) {
func TestUptimeService_SyncMonitorForHost(t *testing.T) {
t.Run("updates monitor when proxy host is edited", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create a proxy host
@@ -1568,7 +1568,7 @@ func TestUptimeService_SyncMonitorForHost(t *testing.T) {
t.Run("returns nil when no monitor exists", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create a proxy host without creating a monitor
@@ -1594,7 +1594,7 @@ func TestUptimeService_SyncMonitorForHost(t *testing.T) {
t.Run("returns error when host does not exist", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Call SyncMonitorForHost with non-existent host ID
@@ -1604,7 +1604,7 @@ func TestUptimeService_SyncMonitorForHost(t *testing.T) {
t.Run("uses domain name when proxy host name is empty", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create a proxy host with a name
@@ -1639,7 +1639,7 @@ func TestUptimeService_SyncMonitorForHost(t *testing.T) {
t.Run("handles multiple domains correctly", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create a proxy host with multiple domains
@@ -1673,7 +1673,7 @@ func TestUptimeService_SyncMonitorForHost(t *testing.T) {
func TestUptimeService_DeleteMonitor(t *testing.T) {
t.Run("deletes monitor and heartbeats", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create monitor
@@ -1720,7 +1720,7 @@ func TestUptimeService_DeleteMonitor(t *testing.T) {
t.Run("returns error for non-existent monitor", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
err := us.DeleteMonitor("non-existent-id")
@@ -1729,7 +1729,7 @@ func TestUptimeService_DeleteMonitor(t *testing.T) {
t.Run("deletes monitor without heartbeats", func(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
// Create monitor without heartbeats
@@ -1757,7 +1757,7 @@ func TestUptimeService_DeleteMonitor(t *testing.T) {
func TestUptimeService_UpdateMonitor_EnabledField(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
monitor := models.UptimeMonitor{

View File

@@ -0,0 +1,608 @@
# Email Notifications / SMTP — Specification & Implementation Plan
> **Status:** Draft
> **Created:** 2026-02-27
> **Scope:** Full analysis of the email notification subsystem, feature flag gaps, Shoutrrr migration residue, email templates, and implementation roadmap.
---
## 1. Executive Summary
Charon's email/SMTP functionality is split across **two independent subsystems** that have never been unified:
| Subsystem | File | Transport | Purpose | Integrated with Notification Providers? |
|-----------|------|-----------|---------|----------------------------------------|
| **MailService** | `backend/internal/services/mail_service.go` | Direct `net/smtp` | User invite emails, test emails | **No** |
| **NotificationService** | `backend/internal/services/notification_service.go` | Custom `HTTPWrapper` | Discord, Gotify, Webhook dispatch | **No email support** |
**Key findings:**
1. **Shoutrrr is fully removed** — no dependency in `go.mod`, no runtime code. Only test-output artifacts (`backend/test-output.txt`) and one archived test file reference it.
2. **No email feature flag exists** — the 9 registered feature flags cover cerberus, uptime, crowdsec, and notify engine/services (discord, gotify, webhook, security events, legacy fallback). Email is absent.
3. **Email is not a notification provider type**`isSupportedNotificationProviderType()` returns `true` only for `discord`, `gotify`, `webhook`. The frontend `notifications.ts` API client also has no email type.
4. **`NotificationConfig.EmailRecipients`** field exists in the model but is never used at runtime — it's set to `""` in default config and has an archived handler test (`security_notifications_test.go.archived`).
5. **The frontend shows only 3 feature toggles** (Cerberus, CrowdSec Console, Uptime) — notification flags exist in the backend but are not surfaced in the UI toggle grid.
**Conclusion:** To enable "Email Notifications" as a first-class feature, email must be integrated into the notification provider system OR exposed as a standalone feature-flagged service. The MailService itself is production-quality with proper security hardening.
---
## 2. Current State Analysis
### 2.1 MailService (`backend/internal/services/mail_service.go`)
**Lines:** ~650 | **Status:** Production-ready | **Test coverage:** Extensive
**Capabilities:**
- SMTP connection with SSL/TLS, STARTTLS, and plaintext
- Email header injection protection (CWE-93, CodeQL `go/email-injection`)
- MIME Q-encoding for subjects (RFC 2047)
- RFC 5321 dot-stuffing for body sanitization
- Undisclosed recipients pattern (prevents request-derived addresses in headers)
- `net/mail.Address` parsing for all address fields
**Public API:**
| Method | Signature | Purpose |
|--------|-----------|---------|
| `GetSMTPConfig` | `() (*SMTPConfig, error)` | Read config from `settings` table (category=`smtp`) |
| `SaveSMTPConfig` | `(config *SMTPConfig) error` | Persist config to DB |
| `IsConfigured` | `() bool` | Check if host + from_address are set |
| `TestConnection` | `() error` | Validate SMTP connectivity + auth |
| `SendEmail` | `(to, subject, htmlBody string) error` | Send a single email |
| `SendInvite` | `(email, inviteToken, appName, baseURL string) error` | Send invite email with HTML template |
**Config storage:** Uses `models.Setting` with `category = "smtp"` and keys: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_from_address`, `smtp_encryption`.
**Consumers:**
- `UserHandler.InviteUser` — sends invite emails asynchronously via `go func()` when SMTP is configured and public URL is set.
- `SettingsHandler.SendTestEmail` — sends a hardcoded test email HTML body.
### 2.2 NotificationService (`backend/internal/services/notification_service.go`)
**Lines:** ~500 | **Status:** Active (notify-only runtime) | **Supported types:** discord, gotify, webhook
**Key dispatch functions:**
- `SendExternal()` — iterates enabled providers, filters by event type preferences, dispatches via `sendJSONPayload()`.
- `sendJSONPayload()` — renders Go templates, sends HTTP POST via `notifications.HTTPWrapper`.
- `isDispatchEnabled()` — checks feature flags per provider type. Discord always true; gotify/webhook gated by flags.
- `isSupportedNotificationProviderType()` — returns `true` for discord, gotify, webhook only.
**Legacy Shoutrrr path:** `legacySendFunc` variable exists but is hardcoded to return `ErrLegacyFallbackDisabled`. All Shoutrrr test names (`TestSendExternal_ShoutrrrPath`, etc.) remain in test files but test the legacy-disabled path.
### 2.3 SecurityNotificationService & EnhancedSecurityNotificationService
- `SecurityNotificationService` — dispatches security events (WAF blocks, ACL denies) to webhook URL from `NotificationConfig`.
- `EnhancedSecurityNotificationService` — provider-based security notifications with compatibility layer. Aggregates settings from `NotificationProvider` records. Filters by supported types: webhook, discord, slack, gotify.
- `NotificationConfig.EmailRecipients` — field exists in model, set to `""` in defaults, never consumed by any dispatch logic.
### 2.4 Frontend SMTP UI
**Page:** `frontend/src/pages/SMTPSettings.tsx` (~300 lines)
- Full CRUD form: host, port, username, password (masked), from_address, encryption (starttls/ssl/none)
- Status indicator card (configured/not configured)
- Test email card (visible only when configured)
- TanStack Query for data fetching, mutations for save/test/send
- Proper form labels with `htmlFor` attributes, accessible select component
**API Client:** `frontend/src/api/smtp.ts`
- `getSMTPConfig()`, `updateSMTPConfig()`, `testSMTPConnection()`, `sendTestEmail()`
**Routes (backend):**
- `GET /api/v1/settings/smtp``GetSMTPConfig`
- `POST /api/v1/settings/smtp``UpdateSMTPConfig`
- `POST /api/v1/settings/smtp/test``TestSMTPConfig`
- `POST /api/v1/settings/smtp/test-email``SendTestEmail`
### 2.5 E2E Test Coverage
**File:** `tests/settings/smtp-settings.spec.ts`
- Page load & display (URL, heading, no error alerts)
- Form display (all 6 fields + 2 buttons verified)
- Loading skeleton behavior
- Form validation (required host, numeric port, from address format)
- Encryption selector options
- SMTP save flow (API interception, form fill, toast)
- Test connection flow
- Test email flow
- Status indicator (configured/not-configured)
- Accessibility (ARIA, keyboard navigation)
**Fixtures:** `tests/fixtures/settings.ts`
- `SMTPConfig` interface, `validSMTPConfig`, `validSMTPConfigSSL`, `validSMTPConfigNoAuth`
- `invalidSMTPConfigs` (missingHost, invalidPort, portTooHigh, invalidEmail, etc.)
- `FeatureFlags` interface — only has `cerberus_enabled`, `crowdsec_console_enrollment`, `uptime_monitoring`
---
## 3. Feature Flag Analysis
### 3.1 Backend Feature Flags (Complete Inventory)
| Flag Key | Default | Constant | Used By |
|----------|---------|----------|---------|
| `feature.cerberus.enabled` | `false` | (inline) | Cerberus middleware |
| `feature.uptime.enabled` | `true` | (inline) | Uptime monitoring |
| `feature.crowdsec.console_enrollment` | `false` | (inline) | CrowdSec console |
| `feature.notifications.engine.notify_v1.enabled` | `false` | `FlagNotifyEngineEnabled` | Notification router |
| `feature.notifications.service.discord.enabled` | `false` | `FlagDiscordServiceEnabled` | Discord dispatch gate |
| `feature.notifications.service.gotify.enabled` | `false` | `FlagGotifyServiceEnabled` | Gotify dispatch gate |
| `feature.notifications.service.webhook.enabled` | `false` | `FlagWebhookServiceEnabled` | Webhook dispatch gate |
| `feature.notifications.legacy.fallback_enabled` | `false` | (inline) | **Permanently retired** |
| `feature.notifications.security_provider_events.enabled` | `false` | `FlagSecurityProviderEventsEnabled` | Security event dispatch |
### 3.2 Missing Email Flag
**No email/SMTP feature flag constant exists** in:
- `backend/internal/notifications/feature_flags.go` — 5 constants, none for email
- `feature_flags_handler.go` `defaultFlags` — 9 entries, none for email
- `defaultFlagValues` — 9 entries, none for email
**Required new flag:**
- Key: `feature.notifications.service.email.enabled`
- Default: `false`
- Constant: `FlagEmailServiceEnabled` in `feature_flags.go`
### 3.3 Frontend Feature Flag Gaps
**`SystemSettings.tsx` `featureToggles` array:**
Only 3 toggles are rendered in the UI:
1. `feature.cerberus.enabled`
2. `feature.crowdsec.console_enrollment`
3. `feature.uptime.enabled`
**Missing from UI:** All 6 notification flags are invisible to users. They can only be toggled via API (`PUT /api/v1/feature-flags`).
**`tests/fixtures/settings.ts` `FeatureFlags` interface:**
Only 3 fields — must be expanded to include notification flags when surfacing them in the UI.
---
## 4. SMTP / Notify Migration Gap Analysis
### 4.1 Shoutrrr Removal Status
| Layer | Status | Evidence |
|-------|--------|----------|
| `go.mod` | **Removed** | No `containrrr/shoutrrr` entry |
| Runtime code | **Removed** | `legacySendFunc` returns `ErrLegacyFallbackDisabled` |
| Feature flag | **Retired** | `feature.notifications.legacy.fallback_enabled` permanently `false` |
| Router | **Disabled** | `ShouldUseLegacyFallback()` always returns `false` |
| Test names | **Residual** | `TestSendExternal_ShoutrrrPath`, `TestSendExternal_ShoutrrrError`, etc. in `notification_service_test.go` |
| Test output | **Residual** | `backend/test-output.txt` references Shoutrrr test runs |
| Archived tests | **Residual** | `security_notifications_test.go.archived` has `normalizeEmailRecipients` tests |
### 4.2 Email-as-Notification-Provider Gap
Email is **not** integrated as a notification provider. The architecture gap:
```
Current: NotificationService → HTTPWrapper → Discord/Gotify/Webhook (HTTP POST)
MailService → net/smtp → SMTP Server (independent, invite-only)
Desired: NotificationService → [Engine] → Discord/Gotify/Webhook (HTTP)
→ Email/SMTP (via MailService)
```
**Integration points needed:**
1. Add `"email"` to `isSupportedNotificationProviderType()`
2. Add `"email"` case to `isDispatchEnabled()` with flag check
3. Add email dispatch path in `SendExternal()` that calls `MailService.SendEmail()`
4. Add `FlagEmailServiceEnabled` constant
5. Add flag to `defaultFlags` and `defaultFlagValues`
6. Update `NotificationProvider` model validation and frontend types
### 4.3 `NotificationConfig.EmailRecipients` — Orphaned Field
- Defined in `notification_config.go` line 21: `EmailRecipients string \`json:"email_recipients"\``
- Set to `""` in `SecurityNotificationService.GetSettings()` default config
- `normalizeEmailRecipients()` function existed but is now in `.archived` test file
- **Not used by any active handler, API endpoint, or dispatch logic**
---
## 5. Email Templates Assessment
### 5.1 Current Templates
| Template | Location | Format | Used By |
|----------|----------|--------|---------|
| **Invite Email** | `mail_service.go:SendInvite()` line 595-620 | Inline Go `html/template` | `UserHandler.InviteUser` |
| **Test Email** | `settings_handler.go:SendTestEmail()` line 648-660 | Inline HTML string | Admin SMTP test |
**Invite template features:**
- Gradient header with app name
- "Accept Invitation" CTA button with invite URL
- 48-hour expiration notice
- Fallback plain-text URL link
- Uses `{{.AppName}}` and `{{.InviteURL}}` variables
**Test template features:**
- Simple confirmation message
- Inline CSS styles
- No template variables (hardcoded content)
### 5.2 No Shared Template System
Email templates are entirely separate from the notification template system (`NotificationTemplate` model). Notification templates are JSON-based for HTTP webhook payloads; email templates are HTML strings. There is no shared abstraction.
### 5.3 Missing Templates
For a full email notification feature, these templates would be needed:
- **Security alert email** — WAF blocks, ACL denies, rate limit hits
- **SSL certificate email** — renewal, expiry warnings, failures
- **Uptime alert email** — host up/down transitions
- **System event email** — config changes, backup completions
- **Password reset email** (if user management expands)
---
## 6. Obsolete Code Inventory
### 6.1 Dead / Effectively Dead Code
| Item | Location | Status | Action |
|------|----------|--------|--------|
| `legacySendFunc` variable | `notification_service.go` | Returns `ErrLegacyFallbackDisabled` always | Remove |
| `legacyFallbackInvocationError()` | `notification_service.go:61` | Only called by dead legacy path | Remove |
| `ErrLegacyFallbackDisabled` error | `notification_service.go` | Guard for retired path | Remove |
| `ShouldUseLegacyFallback()` | `router.go` | Always returns `false` | Remove |
| `EngineLegacy` constant | `engine.go` | Unused | Remove |
| `feature.notifications.legacy.fallback_enabled` flag | `feature_flags_handler.go:36` | Permanently retired | Remove from `defaultFlags` |
| `retiredLegacyFallbackEnvAliases` | `feature_flags_handler.go` | Env aliases for retired flag | Remove |
| Legacy test helpers | `notification_service_test.go` | Tests overriding `legacySendFunc` | Refactor/remove |
| `security_notifications_test.go.archived` | `handlers/` | Archived test file | Delete |
### 6.2 Root-Level Artifacts (Should Be Gitignored)
| File | Purpose | Action |
|------|---------|--------|
| `FIREFOX_E2E_FIXES_SUMMARY.md` | One-time fix summary | Move to `docs/implementation/` or gitignore |
| `verify-security-state-for-ui-tests` | Empty file (0 bytes) | Delete or gitignore |
| `categories.txt` | Unknown (28 bytes) | Investigate, likely deletable |
| `codeql-results-*.sarif` | Already gitignored pattern but files exist | Delete tracked files |
| `grype-results.json`, `grype-results.sarif` | Scan artifacts | Already gitignored, delete tracked |
| `sbom-generated.json`, `sbom.cyclonedx.json` | SBOM artifacts | Already gitignored, delete tracked |
| `trivy-*.json` | Scan reports | Already gitignored pattern but tracked |
| `vuln-results.json` | Vulnerability scan | Gitignore and delete |
| `backend/test-output.txt` | Test run output | Gitignore |
### 6.3 Codecov / Dockerignore Gaps
**`codecov.yml`:** No SMTP/email/notification-specific exclusions or flags needed currently. Coverage threshold is 87%.
**`.dockerignore`:** Includes `*.sarif`, `sbom*.json`, `CODEQL_EMAIL_INJECTION_REMEDIATION_COMPLETE.md`. No SMTP-specific additions needed. The existing patterns are sufficient.
**`.gitignore`:** Missing patterns for:
- `FIREFOX_E2E_FIXES_SUMMARY.md`
- `verify-security-state-for-ui-tests`
- `categories.txt`
- `backend/test-output.txt`
- `backend/*.out` (partially covered but `backend_full.out` may leak through)
---
## 7. Implementation Plan
### Phase 1: Cleanup — Dead Code & Artifacts (Low Risk)
**Goal:** Remove Shoutrrr residue, delete obsolete artifacts, fix gitignore gaps.
| Task | File(s) | Complexity |
|------|---------|------------|
| 1.1 Remove `legacySendFunc`, `ErrLegacyFallbackDisabled`, `legacyFallbackInvocationError()` | `notification_service.go` | S |
| 1.2 Remove `ShouldUseLegacyFallback()` | `router.go` | S |
| 1.3 Remove `EngineLegacy` constant | `engine.go` | S |
| 1.4 Remove `feature.notifications.legacy.fallback_enabled` from `defaultFlags` and `defaultFlagValues` | `feature_flags_handler.go` | S |
| 1.5 Remove `retiredLegacyFallbackEnvAliases` | `feature_flags_handler.go` | S |
| 1.6 Refactor legacy test helpers in `notification_service_test.go` and `notification_service_json_test.go` | Test files | M |
| 1.7 Delete `security_notifications_test.go.archived` | Handlers dir | S |
| 1.8 Add missing `.gitignore` patterns and delete tracked artifacts from root | `.gitignore`, root files | S |
| 1.9 Move `FIREFOX_E2E_FIXES_SUMMARY.md` to `docs/implementation/` | Root → docs/ | S |
### Phase 2: Email Feature Flag (Medium Risk)
**Goal:** Register email as a feature-flagged notification service.
| Task | File(s) | Complexity |
|------|---------|------------|
| 2.1 Add `FlagEmailServiceEnabled` constant | `notifications/feature_flags.go` | S |
| 2.2 Add `feature.notifications.service.email.enabled` to `defaultFlags` + `defaultFlagValues` (default: `false`) | `feature_flags_handler.go` | S |
| 2.3 Add `"email"` case to `isSupportedNotificationProviderType()` | `notification_service.go` | S |
| 2.4 Add `"email"` case to `isDispatchEnabled()` with flag check | `notification_service.go` | S |
| 2.5 Update frontend `FeatureFlags` interface and test fixtures | `tests/fixtures/settings.ts` | S |
| 2.6 (Optional) Surface notification flags in `SystemSettings.tsx` `featureToggles` array | `SystemSettings.tsx` | M |
| 2.7 Unit tests for new flag constant, dispatch enable check, provider type check | Test files | M |
### Phase 3: Email Notification Provider Integration (High Risk)
**Goal:** Wire `MailService` as a notification dispatch target alongside HTTP providers.
| Task | File(s) | Complexity |
|------|---------|------------|
| 3.1 Add `MailService` dependency to `NotificationService` | `notification_service.go` | M |
| 3.2 Implement email dispatch branch in `SendExternal()` | `notification_service.go` | L |
| 3.3 Define email notification template rendering (subject + HTML body from event context) | New or extend `mail_service.go` | L |
| 3.4 Add email provider type to frontend `notifications.ts` API client | `frontend/src/api/notifications.ts` | S |
| 3.5 Update notification provider UI to support email configuration (recipients, template selection) | Frontend components | L |
| 3.6 Update `NotificationConfig.EmailRecipients` usage in `SecurityNotificationService` dispatch | `security_notification_service.go` | M |
| 3.7 Integration tests for email dispatch path | Test files | L |
| 3.8 E2E tests for email notification provider CRUD | `tests/settings/` | L |
### Phase 4: Email Templates (Medium Risk)
**Goal:** Create HTML email templates for all notification event types.
| Task | File(s) | Complexity |
|------|---------|------------|
| 4.1 Create reusable base email template with Charon branding | `backend/internal/services/` or `templates/` | M |
| 4.2 Implement security alert email template | Template file | M |
| 4.3 Implement SSL certificate event email template | Template file | M |
| 4.4 Implement uptime event email template | Template file | M |
| 4.5 Unit tests for all templates (variable rendering, sanitization) | Test files | M |
### Phase 5: Documentation & E2E Validation
| Task | File(s) | Complexity |
|------|---------|------------|
| 5.1 Update `docs/features/notifications.md` to include Email provider | `docs/features/notifications.md` | S |
| 5.2 Add Email row to supported services table | `docs/features/notifications.md` | S |
| 5.3 E2E tests for feature flag toggle affecting email dispatch | `tests/` | M |
| 5.4 Full regression E2E run across all notification types | Playwright suites | M |
---
## 8. Test Plan
### 8.1 Backend Unit Tests
| Area | Test Cases | Priority |
|------|-----------|----------|
| `FlagEmailServiceEnabled` constant | Verify string value matches convention | P0 |
| `isSupportedNotificationProviderType("email")` | Returns `true` | P0 |
| `isDispatchEnabled("email")` | Returns flag-gated value | P0 |
| `SendExternal` with email provider | Dispatches to MailService.SendEmail | P0 |
| `SendExternal` with email flag disabled | Skips email provider | P0 |
| Email template rendering | All variables rendered, XSS-safe | P1 |
| `normalizeEmailRecipients` (if resurrected) | Valid/invalid email lists | P1 |
| Legacy code removal | Verify `legacySendFunc` references removed | P0 |
### 8.2 Frontend Unit Tests
| Area | Test Cases | Priority |
|------|-----------|----------|
| Feature flag toggle for email | Renders in SystemSettings when flag exists | P1 |
| Notification provider types | Includes "email" in supported types | P1 |
| Email provider form | Renders recipient fields, validates emails | P2 |
### 8.3 E2E Tests (Playwright)
| Test | File | Priority |
|------|------|----------|
| SMTP settings CRUD (existing) | `smtp-settings.spec.ts` | P0 (already covered) |
| Email feature flag toggle | New spec or extend `system-settings.spec.ts` | P1 |
| Email notification provider CRUD | New spec in `tests/settings/` | P1 |
| Email notification test send | Extend `notifications.spec.ts` | P2 |
| Feature flag affects email dispatch | Integration-level E2E | P2 |
### 8.4 Existing Test Coverage Gaps
| Gap | Impact | Priority |
|-----|--------|----------|
| SMTP test email E2E doesn't test actual send (mocked) | Low — backend unit tests cover MailService | P2 |
| No E2E test for invite email flow | Medium — relies on SMTP + public URL | P1 |
| Notification E2E tests are Discord-only | High — gotify/webhook have no E2E | P1 |
| Feature flag toggles not E2E-tested for notification flags | Medium — backend flags work, UI doesn't show them | P1 |
---
## 9. Implementation Strategy
### Decision: **Single PR, Staged Commits**
The full email notification feature lands in one PR on `feature/beta-release`. Work is organized into discrete commit stages that can be reviewed independently on the branch, cherry-picked if needed, and clearly bisected if a regression is introduced.
**Why single PR:**
- The feature is self-contained and the flag defaults to `false` — no behavioral change lands until the flag is toggled
- All four stages are tightly coupled (dead code removal creates the clean baseline the flag registration depends on, which the integration depends on, which templates depend on)
- A single PR keeps the review diff contiguous and avoids merge-order coordination overhead
---
### Commit Stage 1: `chore: remove Shoutrrr residue and dead notification legacy code`
**Goal:** Clean codebase baseline. No behavior changes.
**Files:**
- `backend/internal/services/notification_service.go` — remove `legacySendFunc`, `ErrLegacyFallbackDisabled`, `legacyFallbackInvocationError()`
- `backend/internal/notifications/router.go` — remove `ShouldUseLegacyFallback()`, update `ShouldUseNotify()` to remove `EngineLegacy` reference
- `backend/internal/notifications/engine.go` — remove `EngineLegacy` constant
- `backend/internal/api/handlers/feature_flags_handler.go` — remove `feature.notifications.legacy.fallback_enabled` from `defaultFlags` + `defaultFlagValues`, remove `retiredLegacyFallbackEnvAliases`
- `backend/internal/services/notification_service_test.go` — refactor/remove legacy `legacySendFunc` override test helpers
- `backend/internal/services/notification_service_json_test.go` — remove legacy path overrides
- `backend/internal/api/handlers/security_notifications_test.go.archived` — delete file
- `backend/internal/models/notification_config.go` — remove orphaned `EmailRecipients` field
- `.gitignore` — add missing patterns (root artifacts, `FIREFOX_E2E_FIXES_SUMMARY.md`, `verify-security-state-for-ui-tests`, `categories.txt`, `backend/test-output.txt`, `backend/*.out`)
- Root-level tracked scan artifacts — delete (`codeql-results-*.sarif`, `grype-results.*`, `sbom-generated.json`, `sbom.cyclonedx.json`, `trivy-*.json`, `vuln-results.json`, `backend/test-output.txt`, `verify-security-state-for-ui-tests`, `categories.txt`)
- `FIREFOX_E2E_FIXES_SUMMARY.md``docs/implementation/FIREFOX_E2E_FIXES_SUMMARY.md`
- `ARCHITECTURE.instructions.md` tech stack table — update "Notifications" row from `Shoutrrr` to `Notify`
**Validation gate:** `go test ./...` green, no compilation errors, `npx playwright test` regression-clean.
---
### Commit Stage 2: `feat: register email as feature-flagged notification service`
**Goal:** Email flag exists in the system; defaults to `false`; no dispatch wiring yet.
**Files:**
- `backend/internal/notifications/feature_flags.go` — add `FlagEmailServiceEnabled = "feature.notifications.service.email.enabled"`
- `backend/internal/api/handlers/feature_flags_handler.go` — add flag to `defaultFlags` + `defaultFlagValues` (default: `false`)
- `backend/internal/services/notification_service.go` — add `"email"` to `isSupportedNotificationProviderType()` and `isDispatchEnabled()` (gated by `FlagEmailServiceEnabled`)
- `backend/internal/services/notification_service_test.go` — add unit tests for new flag constant, `isSupportedNotificationProviderType("email")` returns `true`, `isDispatchEnabled("email")` respects flag
- `tests/fixtures/settings.ts` — expand `FeatureFlags` interface to include `feature.notifications.service.email.enabled`
**Validation gate:** `go test ./...` green, feature flag API returns new key with `false` default, E2E fixture types compile.
---
### Commit Stage 3: `feat: wire MailService into notification dispatch pipeline`
**Goal:** Email works as a first-class notification provider. `SendEmail()` accepts a context. Dispatch is async, guarded, and timeout-bound.
**Files:**
- `backend/internal/services/mail_service.go`
- Refactor `SendEmail(to, subject, htmlBody string)``SendEmail(ctx context.Context, to []string, subject, htmlBody string) error` (multi-recipient, context-aware)
- Add recipient validation function: RFC 5322 format, max 20 recipients, `\r\n` header injection rejection
- `backend/internal/services/notification_service.go`
- Add `mailService MailServiceInterface` dependency (interface for testability)
- Add `dispatchEmail(ctx context.Context, provider NotificationProvider, eventType, title, message string)` — resolves recipients from provider config, calls `s.mailService.IsConfigured()` guard (warn + return if not), renders HTML, calls `SendEmail()` with 30s timeout context
- In `SendExternal()`: before `sendJSONPayload`, add email branch: `if providerType == "email" { go s.dispatchEmail(...); continue }`
- `supportsJSONTemplates()` — do NOT include `"email"` (email uses its own rendering path)
- `backend/internal/models/notification_provider.go` — document how email provider config is stored: `Type = "email"`, `URL` field repurposed as comma-separated recipient list (no schema migration needed; backward-compatible)
- `frontend/src/api/notifications.ts` — add `"email"` to supported provider type union
- Frontend notification provider form component — add recipient field (comma-separated emails) when type is `"email"`, hide URL/token fields
- Backend unit tests:
- `dispatchEmail` with SMTP unconfigured → logs warning, no error
- `dispatchEmail` with empty recipient list → graceful skip
- `dispatchEmail` with invalid recipient `"not-an-email"` → validation rejects
- `dispatchEmail` concurrent calls → goroutine-safe
- `SendExternal` with email provider → calls `dispatchEmail`, not `sendJSONPayload`
- `SendExternal` with email flag disabled → skips email provider
- Template rendering with XSS-payload event data → sanitized output
- E2E tests:
- Email provider CRUD (create, edit, delete)
- Email provider with email flag disabled — provider exists but dispatch is skipped
- Test send triggers correct API call with proper payload
**Validation gate:** `go test ./...` green, GORM security scan clean, email provider can be created and dispatches correctly when flag is enabled and SMTP is configured.
---
### Commit Stage 4: `feat: add HTML email templates for notification event types`
**Goal:** Each notification event type produces a properly branded, XSS-safe HTML email.
**Files:**
- `backend/internal/services/templates/` (new directory using `embed.FS`)
- `email_base.html` — Charon-branded base layout (gradient header, footer, responsive)
- `email_security_alert.html` — WAF blocks, ACL denies, rate limit hits
- `email_ssl_event.html` — certificate renewal, expiry warnings, failures
- `email_uptime_event.html` — host up/down transitions
- `email_system_event.html` — config changes, backup completions
- `backend/internal/services/mail_service.go` — add `RenderNotificationEmail(templateName string, data interface{}) (string, error)` using `embed.FS` + `html/template`
- Backend unit tests:
- Each template renders correctly with valid data
- Templates with malicious input (XSS) produce sanitized output (html/template auto-escapes)
- Missing template name returns descriptive error
- `docs/features/notifications.md` — add Email provider section with configuration guide
- E2E tests:
- Email notification content verification (intercept API, verify rendered body structure)
- Feature flag toggle E2E — enabling/disabling `feature.notifications.service.email.enabled` flag from UI
**Validation gate:** All templates render safely, docs updated, full Playwright suite regression-clean.
---
### Cross-Stage Notes
- **Rate limiting for email notifications** is deferred to a follow-up issue. For v1, fire-and-forget with the 30s timeout context is acceptable. A digest/cooldown mechanism will be tracked as a tech debt item.
- **Per-user recipient preferences** are out of scope. Recipients are configured globally per provider record.
- **Email queue/retry** is deferred. Transient SMTP failures log a warning and drop the notification (same behavior as webhook failures).
---
## 10. Risk Assessment & Recommendations
### 10.1 Risks
| Risk | Severity | Likelihood | Mitigation |
|------|----------|------------|------------|
| Email dispatch in hot path blocks event processing | High | Medium | Dispatch async (goroutine), same pattern as `InviteUser` |
| SMTP credentials exposed in logs | Critical | Low | Already masked (`MaskPassword`), ensure new paths follow same pattern |
| Email injection via notification event data | High | Low | MailService already has CWE-93 protection; ensure template variables are sanitized |
| Feature flag toggle enables email before SMTP is configured | Medium | Medium | Check `MailService.IsConfigured()` before dispatch, log warning if not |
| Legacy Shoutrrr test removal breaks coverage | Low | Low | Replace with notify-path equivalents before removing |
### 10.2 `.gitignore` Recommendations
Add the following patterns:
```gitignore
# One-off summary files
FIREFOX_E2E_FIXES_SUMMARY.md
# Empty marker files
verify-security-state-for-ui-tests
# Misc artifacts
categories.txt
# Backend test output
backend/test-output.txt
backend/*.out
```
### 10.3 `codecov.yml` Recommendations
No changes needed. The 87% target is appropriate. Email-related code is in `services/` which is already covered.
### 10.4 `.dockerignore` Recommendations
No changes needed. Existing patterns cover scan artifacts, SARIF files, and SBOM outputs.
### 10.5 `Dockerfile` Recommendations
No SMTP-specific changes needed. The Dockerfile does not need to expose SMTP ports — Charon is an SMTP **client**, not server. The existing single-container architecture with Go backend + React frontend remains appropriate.
---
## Appendix A: File Reference Map
| File | Lines Read | Key Findings |
|------|-----------|--------------|
| `backend/go.mod` | Full | No shoutrrr, no nikoksr/notify |
| `backend/internal/services/mail_service.go` | 1-650 | Complete MailService with security hardening |
| `backend/internal/services/notification_service.go` | 1-600 | Custom notify system, no email type |
| `backend/internal/services/security_notification_service.go` | 1-120 | Webhook-only dispatch, EmailRecipients unused |
| `backend/internal/services/enhanced_security_notification_service.go` | 1-180 | Provider aggregation, no email support |
| `backend/internal/notifications/feature_flags.go` | 1-10 | 5 constants, no email |
| `backend/internal/notifications/engine.go` | Grep | EngineLegacy dead |
| `backend/internal/notifications/router.go` | Grep | Legacy fallback disabled |
| `backend/internal/notifications/http_wrapper.go` | 1-100 | SSRF-protected HTTP client |
| `backend/internal/api/handlers/feature_flags_handler.go` | 1-200 | 9 flags, no email |
| `backend/internal/api/handlers/settings_handler.go` | 520-700 | SMTP CRUD + test email handlers |
| `backend/internal/api/handlers/user_handler.go` | 466-650 | Invite email flow |
| `backend/internal/models/notification_config.go` | 1-50 | EmailRecipients field present but unused |
| `backend/internal/models/notification_provider.go` | Grep | No email type |
| `frontend/src/api/smtp.ts` | Full | Complete SMTP API client |
| `frontend/src/api/featureFlags.ts` | Full | Generic get/update, no email flag |
| `frontend/src/api/notifications.ts` | 1-100 | Discord/gotify/webhook only |
| `frontend/src/pages/SMTPSettings.tsx` | Full | Complete SMTP settings UI |
| `frontend/src/pages/SystemSettings.tsx` | 185-320 | Only 3 feature toggles shown |
| `tests/fixtures/settings.ts` | Full | FeatureFlags missing notification flags |
| `tests/settings/smtp-settings.spec.ts` | 1-200 | Comprehensive SMTP E2E tests |
| `tests/settings/notifications.spec.ts` | 1-50 | Discord-focused E2E tests |
| `docs/features/notifications.md` | Full | No email provider documented |
| `codecov.yml` | Full | 87% target, no SMTP exclusions |
| `.gitignore` | Grep | Missing patterns for root artifacts |
| `.dockerignore` | Grep | Adequate coverage |
## Appendix B: Research Questions Answered
**Q1: Is Shoutrrr fully removed?**
Yes. No dependency in `go.mod`, no runtime code path. Only residual test names and one archived test file remain.
**Q2: Does an email feature flag exist?**
No. Must be created as `feature.notifications.service.email.enabled`.
**Q3: Is MailService integrated with NotificationService?**
No. They are completely independent. MailService is only consumed by UserHandler (invites) and SettingsHandler (test email).
**Q4: What is the state of `NotificationConfig.EmailRecipients`?**
Orphaned. Defined in model, set to empty string in defaults, never consumed by any dispatch logic. The handler function `normalizeEmailRecipients()` is archived.
**Q5: What frontend gaps exist for email notifications?**
- `FeatureFlags` interface missing notification flags
- `SystemSettings.tsx` only shows 3 of 9 feature toggles
- `notifications.ts` API client has no email provider type
- No email-specific notification provider UI components

View File

@@ -1,6 +1,6 @@
import client from './client';
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook'] as const;
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', 'email'] as const;
export type SupportedNotificationProviderType = (typeof SUPPORTED_NOTIFICATION_PROVIDER_TYPES)[number];
const DEFAULT_PROVIDER_TYPE: SupportedNotificationProviderType = 'discord';

View File

@@ -596,7 +596,10 @@
"webhookUrl": "Webhook URL (Optional)",
"webhookUrlHelp": "POST requests will be sent to this URL when security events occur.",
"emailRecipients": "Email Recipients (Optional)",
"emailRecipientsHelp": "Comma-separated email addresses."
"emailRecipientsHelp": "Comma-separated email addresses.",
"recipients": "Recipients",
"recipientsHelp": "Comma-separated email addresses (max 20)",
"emailSmtpNotice": "Email notifications are sent via the configured SMTP server. Ensure SMTP is configured in Settings \u2192 SMTP."
},
"users": {
"title": "User Management",

View File

@@ -139,6 +139,7 @@ const ProviderForm: FC<{
const type = normalizeProviderType(watch('type'));
const isGotify = type === 'gotify';
const isEmail = type === 'email';
useEffect(() => {
if (type !== 'gotify') {
setValue('gotify_token', '', { shouldDirty: false, shouldTouch: false });
@@ -194,30 +195,46 @@ const ProviderForm: FC<{
<option value="discord">Discord</option>
<option value="gotify">Gotify</option>
<option value="webhook">{t('notificationProviders.genericWebhook')}</option>
<option value="email">Email</option>
</select>
</div>
<div>
<label htmlFor="provider-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.urlWebhook')} <span aria-hidden="true">*</span></label>
<label htmlFor="provider-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{isEmail ? t('notificationProviders.recipients') : <>{t('notificationProviders.urlWebhook')} <span aria-hidden="true">*</span></>}
</label>
{isEmail && (
<p id="email-recipients-help" className="text-xs text-gray-500 mt-0.5">
{t('notificationProviders.recipientsHelp')}
</p>
)}
<input
id="provider-url"
{...register('url', {
required: t('notificationProviders.urlRequired') as string,
validate: validateUrl,
required: isEmail ? false : (t('notificationProviders.urlRequired') as string),
validate: isEmail ? undefined : validateUrl,
})}
data-testid="provider-url"
placeholder={type === 'discord' ? 'https://discord.com/api/webhooks/...' : type === 'gotify' ? 'https://gotify.example.com/message' : 'https://example.com/webhook'}
placeholder={isEmail ? 'user@example.com, admin@example.com' : type === 'discord' ? 'https://discord.com/api/webhooks/...' : type === 'gotify' ? 'https://gotify.example.com/message' : 'https://example.com/webhook'}
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm ${errors.url ? 'border-red-500' : ''}`}
aria-invalid={errors.url ? 'true' : 'false'}
aria-describedby={errors.url ? 'provider-url-error' : undefined}
aria-describedby={isEmail ? 'email-recipients-help' : errors.url ? 'provider-url-error' : undefined}
/>
{errors.url && (
{!isEmail && errors.url && (
<span id="provider-url-error" data-testid="provider-url-error" className="text-red-500 text-xs">
{errors.url.message as string}
</span>
)}
</div>
{isEmail && (
<div role="note" className="rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3">
<p className="text-sm text-blue-800 dark:text-blue-200">
{t('notificationProviders.emailSmtpNotice')}
</p>
</div>
)}
{isGotify && (
<div>
<label htmlFor="provider-gotify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300">

View File

@@ -14,7 +14,7 @@ vi.mock('react-i18next', () => ({
}))
vi.mock('../../api/notifications', () => ({
SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook'],
SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook', 'email'],
getProviders: vi.fn(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
@@ -146,8 +146,8 @@ describe('Notifications', () => {
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement
const options = Array.from(typeSelect.options)
expect(options).toHaveLength(3)
expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook'])
expect(options).toHaveLength(4)
expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email'])
expect(typeSelect.disabled).toBe(false)
})

View File

@@ -294,8 +294,8 @@ test.describe('Notification Providers', () => {
await test.step('Verify provider type select contains supported options', async () => {
const providerTypeSelect = page.getByTestId('provider-type');
await expect(providerTypeSelect.locator('option')).toHaveCount(3);
await expect(providerTypeSelect.locator('option')).toHaveText(['Discord', 'Gotify', 'Generic Webhook']);
await expect(providerTypeSelect.locator('option')).toHaveCount(4);
await expect(providerTypeSelect.locator('option')).toHaveText(['Discord', 'Gotify', 'Generic Webhook', 'Email']);
await expect(providerTypeSelect).toBeEnabled();
});
});