diff --git a/backend/internal/api/handlers/notification_provider_handler.go b/backend/internal/api/handlers/notification_provider_handler.go index 9b2649aa..2dc406f3 100644 --- a/backend/internal/api/handlers/notification_provider_handler.go +++ b/backend/internal/api/handlers/notification_provider_handler.go @@ -306,6 +306,23 @@ func (h *NotificationProviderHandler) Test(c *gin.Context) { return } + // Email providers use global SMTP + recipients from the URL field; they don't require a saved provider ID. + if providerType == "email" { + provider := models.NotificationProvider{ + ID: strings.TrimSpace(req.ID), + Name: req.Name, + Type: req.Type, + URL: req.URL, + } + if err := h.service.TestEmailProvider(provider); err != nil { + code, category, message := classifyProviderTestFailure(err) + respondSanitizedProviderError(c, http.StatusBadRequest, code, category, message) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"}) + return + } + providerID := strings.TrimSpace(req.ID) if providerID == "" { respondSanitizedProviderError(c, http.StatusBadRequest, "MISSING_PROVIDER_ID", "validation", "Trusted provider ID is required for test dispatch") diff --git a/backend/internal/api/handlers/notification_provider_handler_test.go b/backend/internal/api/handlers/notification_provider_handler_test.go index b8ee74ec..1b6cffdb 100644 --- a/backend/internal/api/handlers/notification_provider_handler_test.go +++ b/backend/internal/api/handlers/notification_provider_handler_test.go @@ -510,3 +510,74 @@ func TestNotificationProviderHandler_Create_ResponseHasHasToken(t *testing.T) { assert.Equal(t, true, raw["has_token"]) assert.NotContains(t, w.Body.String(), "app-token-123") } + +func TestNotificationProviderHandler_Test_Email_NoMailService_Returns400(t *testing.T) { + r, _ := setupNotificationProviderTest(t) + + // mailService is nil in test setup — email test should return 400 (not MISSING_PROVIDER_ID) + payload := map[string]interface{}{ + "type": "email", + "url": "user@example.com", + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestNotificationProviderHandler_Test_Email_EmptyURL_Returns400(t *testing.T) { + r, _ := setupNotificationProviderTest(t) + + payload := map[string]interface{}{ + "type": "email", + "url": "", + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestNotificationProviderHandler_Test_Email_DoesNotRequireProviderID(t *testing.T) { + r, _ := setupNotificationProviderTest(t) + + // No ID field — email path must not return MISSING_PROVIDER_ID + payload := map[string]interface{}{ + "type": "email", + "url": "user@example.com", + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + var resp map[string]interface{} + _ = json.Unmarshal(w.Body.Bytes(), &resp) + assert.NotEqual(t, "MISSING_PROVIDER_ID", resp["code"]) +} + +func TestNotificationProviderHandler_Test_NonEmail_StillRequiresProviderID(t *testing.T) { + r, _ := setupNotificationProviderTest(t) + + payload := map[string]interface{}{ + "type": "discord", + "url": "https://discord.com/api/webhooks/123/abc", + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var resp map[string]interface{} + _ = json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, "MISSING_PROVIDER_ID", resp["code"]) +} diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index be7842b5..92f7793f 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -560,6 +560,37 @@ func (s *NotificationService) TestProvider(provider models.NotificationProvider) return s.sendJSONPayload(context.Background(), provider, data) } +// TestEmailProvider sends a test email to the recipients configured in provider.URL. +// It bypasses the JSON-template path used by TestProvider and uses the SMTP mail service directly. +func (s *NotificationService) TestEmailProvider(provider models.NotificationProvider) error { + if s.mailService == nil || !s.mailService.IsConfigured() { + return fmt.Errorf("email service is not configured; configure SMTP settings before testing email providers") + } + rawRecipients := strings.Split(provider.URL, ",") + recipients := make([]string, 0, len(rawRecipients)) + for _, r := range rawRecipients { + if trimmed := strings.TrimSpace(r); trimmed != "" { + recipients = append(recipients, trimmed) + } + } + if len(recipients) == 0 { + return fmt.Errorf("no recipients configured; add at least one recipient email address") + } + data := EmailTemplateData{ + EventType: "test", + Title: "Test Notification", + Message: "This is a test notification from Charon. If you received this email, your email notification provider is configured correctly.", + Timestamp: time.Now().Format(time.RFC3339), + } + htmlBody, renderErr := s.mailService.RenderNotificationEmail("email_system_event.html", data) + if renderErr != nil { + htmlBody = "Test Notification
This is a test notification from Charon. If you received this email, your email notification provider is configured correctly." + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return s.mailService.SendEmail(ctx, recipients, "[Charon Test] Test Notification", htmlBody) +} + // ListTemplates returns all external notification templates stored in the database. func (s *NotificationService) ListTemplates() ([]models.NotificationTemplate, error) { var list []models.NotificationTemplate diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 29643f73..b72dd6ad 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -2879,3 +2879,112 @@ func TestDispatchEmail_TemplateFallback(t *testing.T) { assert.Contains(t, mock.calls[0].body, "Fallback Title") assert.Contains(t, mock.calls[0].body, "Fallback Message") } + +// --- TestEmailProvider unit tests --- + +func TestEmailProvider_MailServiceNil(t *testing.T) { +db := setupNotificationTestDB(t) +svc := NewNotificationService(db, nil) + +p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "a@b.com"} +err := svc.TestEmailProvider(p) +require.Error(t, err) +assert.Contains(t, err.Error(), "email service is not configured") +} + +func TestEmailProvider_MailServiceNotConfigured(t *testing.T) { +db := setupNotificationTestDB(t) +mock := &mockMailService{isConfigured: false} +svc := NewNotificationService(db, mock) + +p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "a@b.com"} +err := svc.TestEmailProvider(p) +require.Error(t, err) +assert.Contains(t, err.Error(), "email service is not configured") +} + +func TestEmailProvider_EmptyURL(t *testing.T) { +db := setupNotificationTestDB(t) +mock := &mockMailService{isConfigured: true} +svc := NewNotificationService(db, mock) + +p := models.NotificationProvider{Name: "test-email", Type: "email", URL: ""} +err := svc.TestEmailProvider(p) +require.Error(t, err) +assert.Contains(t, err.Error(), "no recipients configured") +assert.Zero(t, mock.callCount()) +} + +func TestEmailProvider_BlankWhitespaceURL(t *testing.T) { +db := setupNotificationTestDB(t) +mock := &mockMailService{isConfigured: true} +svc := NewNotificationService(db, mock) + +p := models.NotificationProvider{Name: "test-email", Type: "email", URL: " , , "} +err := svc.TestEmailProvider(p) +require.Error(t, err) +assert.Contains(t, err.Error(), "no recipients configured") +} + +func TestEmailProvider_ValidRecipient(t *testing.T) { +db := setupNotificationTestDB(t) +mock := &mockMailService{isConfigured: true} +svc := NewNotificationService(db, mock) + +p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "user@example.com"} +err := svc.TestEmailProvider(p) +require.NoError(t, err) +require.Equal(t, 1, mock.callCount()) +call := mock.firstCall() +assert.Equal(t, []string{"user@example.com"}, call.to) +assert.Equal(t, "[Charon Test] Test Notification", call.subject) +} + +func TestEmailProvider_MultipleRecipients(t *testing.T) { +db := setupNotificationTestDB(t) +mock := &mockMailService{isConfigured: true} +svc := NewNotificationService(db, mock) + +p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "a@b.com, c@d.com , e@f.com"} +err := svc.TestEmailProvider(p) +require.NoError(t, err) +require.Equal(t, 1, mock.callCount()) +assert.Equal(t, []string{"a@b.com", "c@d.com", "e@f.com"}, mock.firstCall().to) +} + +func TestEmailProvider_SendError(t *testing.T) { +db := setupNotificationTestDB(t) +mock := &mockMailService{isConfigured: true, sendEmailErr: fmt.Errorf("smtp: connection refused")} +svc := NewNotificationService(db, mock) + +p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "a@b.com"} +err := svc.TestEmailProvider(p) +require.Error(t, err) +assert.Contains(t, err.Error(), "smtp") +assert.Equal(t, 1, mock.callCount()) +} + +func TestEmailProvider_TemplateFallback(t *testing.T) { +db := setupNotificationTestDB(t) +mock := &mockMailService{isConfigured: true, renderErr: fmt.Errorf("template not found")} +svc := NewNotificationService(db, mock) + +p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "a@b.com"} +err := svc.TestEmailProvider(p) +require.NoError(t, err) +require.Equal(t, 1, mock.callCount()) +assert.Contains(t, mock.firstCall().body, "Test Notification") +} + +func TestEmailProvider_UsesRenderedTemplate(t *testing.T) { +db := setupNotificationTestDB(t) +rendered := "Rendered test email" +mock := &mockMailService{isConfigured: true, renderResult: rendered} +svc := NewNotificationService(db, mock) + +p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "a@b.com"} +err := svc.TestEmailProvider(p) +require.NoError(t, err) +require.Equal(t, 1, mock.callCount()) +assert.Equal(t, rendered, mock.firstCall().body) +}