feat: add email notification provider with HTML templates
- Implemented email notification functionality in the NotificationService. - Added support for rendering email templates based on event types. - Created HTML templates for various notification types (security alerts, SSL events, uptime events, and system events). - Updated the dispatchEmail method to utilize the new email templates. - Added tests for email template rendering and fallback mechanisms. - Enhanced documentation to include email notification setup and usage instructions. - Introduced end-to-end tests for the email notification provider in the settings.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
@@ -30,10 +31,27 @@ 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")
|
||||
|
||||
//go:embed templates/*
|
||||
var emailTemplates embed.FS
|
||||
|
||||
type EmailTemplateData struct {
|
||||
EventType string
|
||||
Title string
|
||||
Message string
|
||||
Timestamp string
|
||||
SourceIP string
|
||||
Domain string
|
||||
ExpiryDate string
|
||||
HostName string
|
||||
StatusCode string
|
||||
Content template.HTML
|
||||
}
|
||||
|
||||
// MailServiceInterface allows mocking MailService in tests.
|
||||
type MailServiceInterface interface {
|
||||
IsConfigured() bool
|
||||
SendEmail(ctx context.Context, to []string, subject, htmlBody string) error
|
||||
RenderNotificationEmail(templateName string, data EmailTemplateData) (string, error)
|
||||
}
|
||||
|
||||
// validateEmailRecipients validates a list of email recipients.
|
||||
@@ -152,6 +170,44 @@ func NewMailService(db *gorm.DB) *MailService {
|
||||
return &MailService{db: db}
|
||||
}
|
||||
|
||||
func (s *MailService) RenderNotificationEmail(templateName string, data EmailTemplateData) (string, error) {
|
||||
contentBytes, err := emailTemplates.ReadFile("templates/" + templateName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("template %q not found: %w", templateName, err)
|
||||
}
|
||||
|
||||
baseBytes, err := emailTemplates.ReadFile("templates/email_base.html")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("base template not found: %w", err)
|
||||
}
|
||||
|
||||
contentTmpl, err := template.New(templateName).Parse(string(contentBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse template %q: %w", templateName, err)
|
||||
}
|
||||
|
||||
var contentBuf bytes.Buffer
|
||||
err = contentTmpl.Execute(&contentBuf, data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render template %q: %w", templateName, err)
|
||||
}
|
||||
|
||||
data.Content = template.HTML(contentBuf.String())
|
||||
|
||||
baseTmpl, err := template.New("email_base.html").Parse(string(baseBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse base template: %w", err)
|
||||
}
|
||||
|
||||
var baseBuf bytes.Buffer
|
||||
err = baseTmpl.Execute(&baseBuf, data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render base template: %w", err)
|
||||
}
|
||||
|
||||
return baseBuf.String(), nil
|
||||
}
|
||||
|
||||
// GetSMTPConfig retrieves SMTP settings from the database.
|
||||
func (s *MailService) GetSMTPConfig() (*SMTPConfig, error) {
|
||||
var settings []models.Setting
|
||||
|
||||
210
backend/internal/services/mail_service_template_test.go
Normal file
210
backend/internal/services/mail_service_template_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRenderNotificationEmail_ValidTemplates(t *testing.T) {
|
||||
ms := &MailService{}
|
||||
|
||||
templates := []struct {
|
||||
name string
|
||||
data EmailTemplateData
|
||||
wantTitle string
|
||||
}{
|
||||
{
|
||||
name: "email_security_alert.html",
|
||||
data: EmailTemplateData{
|
||||
EventType: "security_waf",
|
||||
Title: "WAF Block Detected",
|
||||
Message: "Blocked suspicious request",
|
||||
Timestamp: "2026-03-07T10:00:00Z",
|
||||
SourceIP: "192.168.1.100",
|
||||
},
|
||||
wantTitle: "WAF Block Detected",
|
||||
},
|
||||
{
|
||||
name: "email_ssl_event.html",
|
||||
data: EmailTemplateData{
|
||||
EventType: "cert",
|
||||
Title: "Certificate Expiring",
|
||||
Message: "Certificate will expire soon",
|
||||
Timestamp: "2026-03-07T10:00:00Z",
|
||||
Domain: "example.com",
|
||||
ExpiryDate: "2026-04-07",
|
||||
},
|
||||
wantTitle: "Certificate Expiring",
|
||||
},
|
||||
{
|
||||
name: "email_uptime_event.html",
|
||||
data: EmailTemplateData{
|
||||
EventType: "uptime",
|
||||
Title: "Host Down",
|
||||
Message: "Host is unreachable",
|
||||
Timestamp: "2026-03-07T10:00:00Z",
|
||||
HostName: "web-server-01",
|
||||
StatusCode: "503",
|
||||
},
|
||||
wantTitle: "Host Down",
|
||||
},
|
||||
{
|
||||
name: "email_system_event.html",
|
||||
data: EmailTemplateData{
|
||||
EventType: "proxy_host",
|
||||
Title: "Proxy Host Updated",
|
||||
Message: "Configuration has changed",
|
||||
Timestamp: "2026-03-07T10:00:00Z",
|
||||
},
|
||||
wantTitle: "Proxy Host Updated",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range templates {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := ms.RenderNotificationEmail(tc.name, tc.data)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, tc.wantTitle)
|
||||
assert.Contains(t, result, "Charon")
|
||||
assert.Contains(t, result, "Charon Reverse Proxy Manager")
|
||||
assert.Contains(t, result, tc.data.Timestamp)
|
||||
assert.Contains(t, result, tc.data.EventType)
|
||||
assert.Contains(t, result, "<!DOCTYPE html>")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNotificationEmail_XSSPrevention(t *testing.T) {
|
||||
ms := &MailService{}
|
||||
|
||||
data := EmailTemplateData{
|
||||
EventType: "security_waf",
|
||||
Title: "<script>alert('xss')</script>",
|
||||
Message: "<img src=x onerror=evil()>",
|
||||
Timestamp: "2026-03-07T10:00:00Z",
|
||||
SourceIP: "<b>injected</b>",
|
||||
}
|
||||
|
||||
result, err := ms.RenderNotificationEmail("email_security_alert.html", data)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, result, "<script>")
|
||||
assert.Contains(t, result, "<script>")
|
||||
assert.NotContains(t, result, "<img ")
|
||||
assert.Contains(t, result, "<img ")
|
||||
assert.NotContains(t, result, "<b>injected</b>")
|
||||
assert.Contains(t, result, "<b>injected</b>")
|
||||
}
|
||||
|
||||
func TestRenderNotificationEmail_MissingTemplate(t *testing.T) {
|
||||
ms := &MailService{}
|
||||
|
||||
data := EmailTemplateData{Title: "Test"}
|
||||
|
||||
_, err := ms.RenderNotificationEmail("nonexistent.html", data)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nonexistent.html")
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestRenderNotificationEmail_EmptyFields(t *testing.T) {
|
||||
ms := &MailService{}
|
||||
|
||||
data := EmailTemplateData{
|
||||
EventType: "security_waf",
|
||||
Title: "Alert Title",
|
||||
Message: "Alert message body",
|
||||
Timestamp: "2026-03-07T10:00:00Z",
|
||||
}
|
||||
|
||||
result, err := ms.RenderNotificationEmail("email_security_alert.html", data)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, result, "{{.SourceIP}}")
|
||||
assert.NotContains(t, result, "{{.Domain}}")
|
||||
assert.NotContains(t, result, "{{.ExpiryDate}}")
|
||||
assert.NotContains(t, result, "{{.HostName}}")
|
||||
assert.NotContains(t, result, "{{.StatusCode}}")
|
||||
|
||||
assert.Contains(t, result, "Alert Title")
|
||||
assert.Contains(t, result, "Alert message body")
|
||||
|
||||
result2, err := ms.RenderNotificationEmail("email_ssl_event.html", data)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, result2, "{{.Domain}}")
|
||||
assert.NotContains(t, result2, "{{.ExpiryDate}}")
|
||||
|
||||
result3, err := ms.RenderNotificationEmail("email_uptime_event.html", data)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, result3, "{{.HostName}}")
|
||||
assert.NotContains(t, result3, "{{.StatusCode}}")
|
||||
}
|
||||
|
||||
func TestRenderNotificationEmail_OptionalFieldsRendered(t *testing.T) {
|
||||
ms := &MailService{}
|
||||
|
||||
data := EmailTemplateData{
|
||||
EventType: "cert",
|
||||
Title: "SSL Event",
|
||||
Message: "Certificate info",
|
||||
Timestamp: "2026-03-07T10:00:00Z",
|
||||
Domain: "example.com",
|
||||
ExpiryDate: "2026-04-07",
|
||||
}
|
||||
|
||||
result, err := ms.RenderNotificationEmail("email_ssl_event.html", data)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "example.com")
|
||||
assert.Contains(t, result, "2026-04-07")
|
||||
}
|
||||
|
||||
func TestEmailTemplateForEventType(t *testing.T) {
|
||||
tests := []struct {
|
||||
eventType string
|
||||
want string
|
||||
}{
|
||||
{"security_waf", "email_security_alert.html"},
|
||||
{"security_acl", "email_security_alert.html"},
|
||||
{"security_rate_limit", "email_security_alert.html"},
|
||||
{"security_crowdsec", "email_security_alert.html"},
|
||||
{"SECURITY_WAF", "email_security_alert.html"},
|
||||
{"cert", "email_ssl_event.html"},
|
||||
{"uptime", "email_uptime_event.html"},
|
||||
{"proxy_host", "email_system_event.html"},
|
||||
{"remote_server", "email_system_event.html"},
|
||||
{"domain", "email_system_event.html"},
|
||||
{"test", "email_system_event.html"},
|
||||
{"unknown", "email_system_event.html"},
|
||||
{"", "email_system_event.html"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
got := emailTemplateForEventType(tc.eventType)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNotificationEmail_BaseTemplateStructure(t *testing.T) {
|
||||
ms := &MailService{}
|
||||
|
||||
data := EmailTemplateData{
|
||||
EventType: "test",
|
||||
Title: "Structure Test",
|
||||
Message: "Verify base template wraps content",
|
||||
Timestamp: "2026-03-07T10:00:00Z",
|
||||
}
|
||||
|
||||
result, err := ms.RenderNotificationEmail("email_system_event.html", data)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, strings.HasPrefix(strings.TrimSpace(result), "<!DOCTYPE html>"))
|
||||
assert.Contains(t, result, "<h1")
|
||||
assert.Contains(t, result, "Charon</h1>")
|
||||
assert.Contains(t, result, "#1a1a2e")
|
||||
assert.Contains(t, result, "600px")
|
||||
}
|
||||
@@ -262,7 +262,7 @@ func sanitizeForEmail(s string) string {
|
||||
|
||||
// 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) {
|
||||
func (s *NotificationService) dispatchEmail(ctx context.Context, p models.NotificationProvider, eventType, title, message string) {
|
||||
if s.mailService == nil || !s.mailService.IsConfigured() {
|
||||
logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).Warn("Email provider is not configured, skipping dispatch")
|
||||
return
|
||||
@@ -284,19 +284,32 @@ func (s *NotificationService) dispatchEmail(ctx context.Context, p models.Notifi
|
||||
safeTitle := sanitizeForEmail(title)
|
||||
safeMessage := sanitizeForEmail(message)
|
||||
subject := fmt.Sprintf("[Charon Alert] %s", safeTitle)
|
||||
var bodyBuilder strings.Builder
|
||||
if safeTitle != "" {
|
||||
bodyBuilder.WriteString("<strong>")
|
||||
bodyBuilder.WriteString(html.EscapeString(safeTitle))
|
||||
bodyBuilder.WriteString("</strong>")
|
||||
|
||||
templateName := emailTemplateForEventType(eventType)
|
||||
data := EmailTemplateData{
|
||||
EventType: eventType,
|
||||
Title: safeTitle,
|
||||
Message: safeMessage,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
if safeMessage != "" {
|
||||
if bodyBuilder.Len() > 0 {
|
||||
bodyBuilder.WriteString("<br>")
|
||||
|
||||
htmlBody, renderErr := s.mailService.RenderNotificationEmail(templateName, data)
|
||||
if renderErr != nil {
|
||||
logger.Log().WithError(renderErr).WithField("template", templateName).Warn("Email template rendering failed, using fallback")
|
||||
var bodyBuilder strings.Builder
|
||||
if safeTitle != "" {
|
||||
bodyBuilder.WriteString("<strong>")
|
||||
bodyBuilder.WriteString(html.EscapeString(safeTitle))
|
||||
bodyBuilder.WriteString("</strong>")
|
||||
}
|
||||
bodyBuilder.WriteString(html.EscapeString(safeMessage))
|
||||
if safeMessage != "" {
|
||||
if bodyBuilder.Len() > 0 {
|
||||
bodyBuilder.WriteString("<br>")
|
||||
}
|
||||
bodyBuilder.WriteString(html.EscapeString(safeMessage))
|
||||
}
|
||||
htmlBody = bodyBuilder.String()
|
||||
}
|
||||
htmlBody := bodyBuilder.String()
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -306,6 +319,19 @@ func (s *NotificationService) dispatchEmail(ctx context.Context, p models.Notifi
|
||||
}
|
||||
}
|
||||
|
||||
func emailTemplateForEventType(eventType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(eventType)) {
|
||||
case "security_waf", "security_acl", "security_rate_limit", "security_crowdsec":
|
||||
return "email_security_alert.html"
|
||||
case "cert":
|
||||
return "email_ssl_event.html"
|
||||
case "uptime":
|
||||
return "email_uptime_event.html"
|
||||
default:
|
||||
return "email_system_event.html"
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -2561,6 +2561,8 @@ type mockMailService struct {
|
||||
isConfigured bool
|
||||
sendEmailErr error
|
||||
calls []mockSendEmailCall
|
||||
renderResult string
|
||||
renderErr error
|
||||
}
|
||||
|
||||
type mockSendEmailCall struct {
|
||||
@@ -2578,6 +2580,13 @@ func (m *mockMailService) SendEmail(_ context.Context, to []string, subject, htm
|
||||
return m.sendEmailErr
|
||||
}
|
||||
|
||||
func (m *mockMailService) RenderNotificationEmail(_ string, _ EmailTemplateData) (string, error) {
|
||||
if m.renderResult != "" || m.renderErr != nil {
|
||||
return m.renderResult, m.renderErr
|
||||
}
|
||||
return "", fmt.Errorf("template rendering not configured in mock")
|
||||
}
|
||||
|
||||
func (m *mockMailService) callCount() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -2843,3 +2852,30 @@ func TestDispatchEmail_CRLFInMessage_StrippedBeforeBody(t *testing.T) {
|
||||
assert.NotContains(t, call.body, "\n")
|
||||
assert.Contains(t, call.body, "msg.MAIL FROM:<attacker>")
|
||||
}
|
||||
|
||||
func TestDispatchEmail_UsesTemplate(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
renderedHTML := "<html><body>Rendered template content</body></html>"
|
||||
mock := &mockMailService{isConfigured: true, renderResult: renderedHTML}
|
||||
svc := NewNotificationService(db, mock)
|
||||
|
||||
p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"}
|
||||
svc.dispatchEmail(context.Background(), p, "security_waf", "WAF Alert", "Blocked request")
|
||||
|
||||
require.Len(t, mock.calls, 1)
|
||||
assert.Equal(t, renderedHTML, mock.calls[0].body)
|
||||
assert.NotContains(t, mock.calls[0].body, "<strong>")
|
||||
}
|
||||
|
||||
func TestDispatchEmail_TemplateFallback(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
mock := &mockMailService{isConfigured: true, renderErr: fmt.Errorf("template parse error")}
|
||||
svc := NewNotificationService(db, mock)
|
||||
|
||||
p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"}
|
||||
svc.dispatchEmail(context.Background(), p, "alert", "Fallback Title", "Fallback Message")
|
||||
|
||||
require.Len(t, mock.calls, 1)
|
||||
assert.Contains(t, mock.calls[0].body, "<strong>Fallback Title</strong>")
|
||||
assert.Contains(t, mock.calls[0].body, "Fallback Message")
|
||||
}
|
||||
|
||||
34
backend/internal/services/templates/email_base.html
Normal file
34
backend/internal/services/templates/email_base.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Charon Notification</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;" align="center">
|
||||
<table role="presentation" style="width: 600px; max-width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); padding: 24px 30px; border-radius: 8px 8px 0 0; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">Charon</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none;">
|
||||
{{.Content}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background: #fafafa; padding: 16px 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; text-align: center;">
|
||||
<p style="color: #999999; font-size: 12px; margin: 0;">{{.Timestamp}}</p>
|
||||
<p style="color: #999999; font-size: 12px; margin: 4px 0 0 0;">Charon Reverse Proxy Manager</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<h2 style="color: #1a1a2e; margin: 0 0 16px 0; font-size: 20px;">{{.Title}}</h2>
|
||||
<p style="color: #333333; font-size: 15px; line-height: 1.6; margin: 0 0 16px 0;">{{.Message}}</p>
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-top: 16px; background: #f8f9fa; border-radius: 6px;">
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; color: #666666; font-size: 13px;">
|
||||
<strong>Event:</strong> {{.EventType}}
|
||||
</td>
|
||||
</tr>
|
||||
{{if .SourceIP}}<tr>
|
||||
<td style="padding: 4px 16px 12px 16px; color: #666666; font-size: 13px;">
|
||||
<strong>Source IP:</strong> {{.SourceIP}}
|
||||
</td>
|
||||
</tr>{{end}}
|
||||
</table>
|
||||
19
backend/internal/services/templates/email_ssl_event.html
Normal file
19
backend/internal/services/templates/email_ssl_event.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<h2 style="color: #1a1a2e; margin: 0 0 16px 0; font-size: 20px;">{{.Title}}</h2>
|
||||
<p style="color: #333333; font-size: 15px; line-height: 1.6; margin: 0 0 16px 0;">{{.Message}}</p>
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-top: 16px; background: #f8f9fa; border-radius: 6px;">
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; color: #666666; font-size: 13px;">
|
||||
<strong>Event:</strong> {{.EventType}}
|
||||
</td>
|
||||
</tr>
|
||||
{{if .Domain}}<tr>
|
||||
<td style="padding: 4px 16px 12px 16px; color: #666666; font-size: 13px;">
|
||||
<strong>Domain:</strong> {{.Domain}}
|
||||
</td>
|
||||
</tr>{{end}}
|
||||
{{if .ExpiryDate}}<tr>
|
||||
<td style="padding: 4px 16px 12px 16px; color: #666666; font-size: 13px;">
|
||||
<strong>Expiry:</strong> {{.ExpiryDate}}
|
||||
</td>
|
||||
</tr>{{end}}
|
||||
</table>
|
||||
@@ -0,0 +1,9 @@
|
||||
<h2 style="color: #1a1a2e; margin: 0 0 16px 0; font-size: 20px;">{{.Title}}</h2>
|
||||
<p style="color: #333333; font-size: 15px; line-height: 1.6; margin: 0 0 16px 0;">{{.Message}}</p>
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-top: 16px; background: #f8f9fa; border-radius: 6px;">
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; color: #666666; font-size: 13px;">
|
||||
<strong>Event:</strong> {{.EventType}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
19
backend/internal/services/templates/email_uptime_event.html
Normal file
19
backend/internal/services/templates/email_uptime_event.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<h2 style="color: #1a1a2e; margin: 0 0 16px 0; font-size: 20px;">{{.Title}}</h2>
|
||||
<p style="color: #333333; font-size: 15px; line-height: 1.6; margin: 0 0 16px 0;">{{.Message}}</p>
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-top: 16px; background: #f8f9fa; border-radius: 6px;">
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; color: #666666; font-size: 13px;">
|
||||
<strong>Event:</strong> {{.EventType}}
|
||||
</td>
|
||||
</tr>
|
||||
{{if .HostName}}<tr>
|
||||
<td style="padding: 4px 16px 12px 16px; color: #666666; font-size: 13px;">
|
||||
<strong>Host:</strong> {{.HostName}}
|
||||
</td>
|
||||
</tr>{{end}}
|
||||
{{if .StatusCode}}<tr>
|
||||
<td style="padding: 4px 16px 12px 16px; color: #666666; font-size: 13px;">
|
||||
<strong>Status Code:</strong> {{.StatusCode}}
|
||||
</td>
|
||||
</tr>{{end}}
|
||||
</table>
|
||||
@@ -18,9 +18,26 @@ Notifications can be triggered by various events:
|
||||
| **Discord** | ✅ Yes | ✅ Webhooks | ✅ Embeds |
|
||||
| **Gotify** | ✅ Yes | ✅ HTTP API | ✅ Priority + Extras |
|
||||
| **Custom Webhook** | ✅ Yes | ✅ HTTP API | ✅ Template-Controlled |
|
||||
| **Email** | ❌ No | ✅ SMTP | ✅ HTML Branded Templates |
|
||||
|
||||
Additional providers are planned for later staged releases.
|
||||
|
||||
### Email Notifications
|
||||
|
||||
Email notifications send HTML-branded alerts directly to one or more email addresses using your SMTP server.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Navigate to **Settings** → **SMTP** and configure your mail server connection
|
||||
2. Go to **Settings** → **Notifications** and click **"Add Provider"**
|
||||
3. Select **Email** as the service type
|
||||
4. Enter one or more recipient email addresses
|
||||
5. Configure notification triggers and save
|
||||
|
||||
Email notifications use built-in HTML templates with Charon branding — no JSON template editing is required.
|
||||
|
||||
> **Feature Flag:** Email notifications must be enabled via `feature.notifications.service.email.enabled` in **Settings** → **Feature Flags** before the Email provider option appears.
|
||||
|
||||
### Why JSON Templates?
|
||||
|
||||
JSON templates give you complete control over notification formatting, allowing you to:
|
||||
@@ -43,7 +60,7 @@ JSON templates give you complete control over notification formatting, allowing
|
||||
|
||||
### JSON Template Support
|
||||
|
||||
For current services (Discord, Gotify, and Custom Webhook), you can choose from three template options.
|
||||
For JSON-based services (Discord, Gotify, and Custom Webhook), you can choose from three template options. Email uses its own built-in HTML templates and does not use JSON templates.
|
||||
|
||||
#### 1. Minimal Template (Default)
|
||||
|
||||
@@ -324,6 +341,7 @@ Use separate Discord providers for different event types:
|
||||
Be mindful of service limits:
|
||||
|
||||
- **Discord**: 5 requests per 2 seconds per webhook
|
||||
- **Email**: Subject to your SMTP server's sending limits
|
||||
|
||||
### 6. Keep Templates Maintainable
|
||||
|
||||
|
||||
33
tests/fixtures/notifications.ts
vendored
33
tests/fixtures/notifications.ts
vendored
@@ -20,7 +20,8 @@ export type NotificationProviderType =
|
||||
| 'gotify'
|
||||
| 'telegram'
|
||||
| 'generic'
|
||||
| 'webhook';
|
||||
| 'webhook'
|
||||
| 'email';
|
||||
|
||||
/**
|
||||
* Notification provider configuration interface
|
||||
@@ -235,6 +236,36 @@ export const customWebhookProvider: NotificationProviderConfig = {
|
||||
notify_uptime: true,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Email Provider Fixtures
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Valid email notification provider configuration
|
||||
*/
|
||||
export const emailProvider: NotificationProviderConfig = {
|
||||
name: generateProviderName('email'),
|
||||
type: 'email',
|
||||
url: 'admin@example.com, alerts@example.com',
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Email provider with single recipient
|
||||
*/
|
||||
export const emailProviderSingleRecipient: NotificationProviderConfig = {
|
||||
name: generateProviderName('email-single'),
|
||||
type: 'email',
|
||||
url: 'ops@example.com',
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: true,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Invalid Provider Fixtures (for validation testing)
|
||||
// ============================================================================
|
||||
|
||||
543
tests/settings/email-notification-provider.spec.ts
Normal file
543
tests/settings/email-notification-provider.spec.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* Email Notification Provider E2E Tests
|
||||
*
|
||||
* Tests the email notification provider type added in PR #800.
|
||||
* Covers form rendering, CRUD operations, payload contracts,
|
||||
* and validation behavior specific to the email provider type.
|
||||
*/
|
||||
|
||||
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
|
||||
import { waitForLoadingComplete, waitForAPIResponse } from '../utils/wait-helpers';
|
||||
|
||||
function generateProviderName(prefix: string = 'email-test'): string {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
test.describe('Email Notification Provider', () => {
|
||||
test.beforeEach(async ({ page, adminUser }) => {
|
||||
await loginUser(page, adminUser);
|
||||
await waitForLoadingComplete(page);
|
||||
await page.goto('/settings/notifications');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
test.describe('Form Rendering', () => {
|
||||
test('should show recipients field and hide URL/token when email type selected', async ({ page }) => {
|
||||
await test.step('Open Add Provider form', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Select email provider type', async () => {
|
||||
await page.getByTestId('provider-type').selectOption('email');
|
||||
});
|
||||
|
||||
await test.step('Verify recipients label replaces URL label', async () => {
|
||||
const recipientsLabel = page.getByText(/recipients/i);
|
||||
await expect(recipientsLabel.first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify recipients help text is shown', async () => {
|
||||
const helpText = page.locator('#email-recipients-help');
|
||||
await expect(helpText).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify recipients placeholder', async () => {
|
||||
const urlInput = page.getByTestId('provider-url');
|
||||
await expect(urlInput).toHaveAttribute('placeholder', /user@example\.com/);
|
||||
});
|
||||
|
||||
await test.step('Verify Gotify token field is hidden', async () => {
|
||||
await expect(page.getByTestId('provider-gotify-token')).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Verify JSON template section is hidden for email', async () => {
|
||||
await expect(page.getByTestId('provider-config')).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Verify SMTP notice is shown', async () => {
|
||||
const smtpNotice = page.getByRole('note');
|
||||
await expect(smtpNotice).toBeVisible();
|
||||
await expect(smtpNotice).toContainText(/smtp/i);
|
||||
});
|
||||
|
||||
await test.step('Verify save button is accessible', async () => {
|
||||
const saveButton = page.getByTestId('provider-save-btn');
|
||||
await expect(saveButton).toBeVisible();
|
||||
await expect(saveButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should toggle form fields when switching between email and discord types', async ({ page }) => {
|
||||
await test.step('Open Add Provider form', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify discord is default with URL label', async () => {
|
||||
await expect(page.getByTestId('provider-type')).toHaveValue('discord');
|
||||
const urlLabel = page.getByText(/url.*webhook/i);
|
||||
await expect(urlLabel.first()).toBeVisible();
|
||||
await expect(page.getByRole('note')).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Switch to email and verify recipients label', async () => {
|
||||
await page.getByTestId('provider-type').selectOption('email');
|
||||
const recipientsLabel = page.getByText(/recipients/i);
|
||||
await expect(recipientsLabel.first()).toBeVisible();
|
||||
await expect(page.getByRole('note')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Switch back to discord and verify URL label returns', async () => {
|
||||
await page.getByTestId('provider-type').selectOption('discord');
|
||||
const urlLabel = page.getByText(/url.*webhook/i);
|
||||
await expect(urlLabel.first()).toBeVisible();
|
||||
await expect(page.getByRole('note')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('CRUD Operations', () => {
|
||||
test('should create email notification provider', async ({ page }) => {
|
||||
const providerName = generateProviderName('email-create');
|
||||
let capturedPayload: Record<string, unknown> | null = null;
|
||||
|
||||
await test.step('Mock create endpoint to capture payload', async () => {
|
||||
const createdProviders: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
const payload = (await request.postDataJSON()) as Record<string, unknown>;
|
||||
capturedPayload = payload;
|
||||
const created = { id: `email-provider-1`, ...payload };
|
||||
createdProviders.push(created);
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(created),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createdProviders),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Open form and select email type', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByTestId('provider-type').selectOption('email');
|
||||
});
|
||||
|
||||
await test.step('Fill email provider form', async () => {
|
||||
await page.getByTestId('provider-name').fill(providerName);
|
||||
await page.getByTestId('provider-url').fill('admin@example.com, alerts@example.com');
|
||||
});
|
||||
|
||||
await test.step('Configure event notifications', async () => {
|
||||
await page.getByTestId('notify-proxy-hosts').check();
|
||||
await page.getByTestId('notify-certs').check();
|
||||
});
|
||||
|
||||
await test.step('Save provider', async () => {
|
||||
await page.getByTestId('provider-save-btn').click();
|
||||
});
|
||||
|
||||
await test.step('Verify provider appears in list', async () => {
|
||||
const providerInList = page.getByText(providerName);
|
||||
await expect(providerInList.first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Verify outgoing payload contract', async () => {
|
||||
expect(capturedPayload).toBeTruthy();
|
||||
expect(capturedPayload?.type).toBe('email');
|
||||
expect(capturedPayload?.name).toBe(providerName);
|
||||
expect(capturedPayload?.url).toBe('admin@example.com, alerts@example.com');
|
||||
expect(capturedPayload?.token).toBeUndefined();
|
||||
expect(capturedPayload?.gotify_token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('should edit email notification provider recipients', async ({ page }) => {
|
||||
let updatedPayload: Record<string, unknown> | null = null;
|
||||
|
||||
await test.step('Mock existing email provider', async () => {
|
||||
let providers = [
|
||||
{
|
||||
id: 'email-edit-id',
|
||||
name: 'Email Alert Provider',
|
||||
type: 'email',
|
||||
url: 'old-recipient@example.com',
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: false,
|
||||
},
|
||||
];
|
||||
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(providers),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/notifications/providers/*', async (route, request) => {
|
||||
if (request.method() === 'PUT') {
|
||||
updatedPayload = (await request.postDataJSON()) as Record<string, unknown>;
|
||||
providers = providers.map((p) =>
|
||||
p.id === 'email-edit-id' ? { ...p, ...updatedPayload } : p
|
||||
);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Reload to get mocked provider', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify email provider is displayed', async () => {
|
||||
await expect(page.getByText('Email Alert Provider')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Click edit on email provider', async () => {
|
||||
const providerRow = page.getByTestId('provider-row-email-edit-id');
|
||||
const sendTestButton = providerRow.getByRole('button', { name: /send test/i });
|
||||
await expect(sendTestButton).toBeVisible({ timeout: 5000 });
|
||||
await sendTestButton.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify form loads with email type', async () => {
|
||||
await expect(page.getByTestId('provider-type')).toHaveValue('email');
|
||||
});
|
||||
|
||||
await test.step('Update recipients', async () => {
|
||||
const urlInput = page.getByTestId('provider-url');
|
||||
await urlInput.clear();
|
||||
await urlInput.fill('new-admin@example.com, ops@example.com');
|
||||
});
|
||||
|
||||
await test.step('Save changes', async () => {
|
||||
const updateResponsePromise = waitForAPIResponse(
|
||||
page,
|
||||
/\/api\/v1\/notifications\/providers\/email-edit-id/,
|
||||
{ status: 200 }
|
||||
);
|
||||
const refreshResponsePromise = waitForAPIResponse(
|
||||
page,
|
||||
/\/api\/v1\/notifications\/providers$/,
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
await page.getByTestId('provider-save-btn').click();
|
||||
await updateResponsePromise;
|
||||
await refreshResponsePromise;
|
||||
});
|
||||
|
||||
await test.step('Verify update success', async () => {
|
||||
await expect(page.getByText('Email Alert Provider').first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Verify updated payload', async () => {
|
||||
expect(updatedPayload).toBeTruthy();
|
||||
expect(updatedPayload?.type).toBe('email');
|
||||
expect(updatedPayload?.url).toBe('new-admin@example.com, ops@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
test('should delete email notification provider', async ({ page }) => {
|
||||
await test.step('Mock existing email provider', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 'email-delete-id',
|
||||
name: 'Email To Delete',
|
||||
type: 'email',
|
||||
url: 'delete-me@example.com',
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/notifications/providers/*', async (route, request) => {
|
||||
if (request.method() === 'DELETE') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Reload to get mocked provider', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify email provider is displayed', async () => {
|
||||
await expect(page.getByText('Email To Delete')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Delete provider', async () => {
|
||||
page.on('dialog', async (dialog) => {
|
||||
expect(dialog.type()).toBe('confirm');
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
const deleteButton = page.getByRole('button', { name: /delete/i })
|
||||
.or(page.locator('button').filter({ has: page.locator('svg.lucide-trash2, svg[class*="trash"]') }));
|
||||
await deleteButton.first().click();
|
||||
});
|
||||
|
||||
await test.step('Verify deletion feedback', async () => {
|
||||
const successIndicator = page.locator('[data-testid="toast-success"]')
|
||||
.or(page.getByRole('status').filter({ hasText: /deleted|removed/i }))
|
||||
.or(page.getByText(/no.*providers/i));
|
||||
await expect(successIndicator.first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('should display email provider type badge in list', async ({ page }) => {
|
||||
await test.step('Mock providers with email type', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ id: 'email-badge-id', name: 'Email Notifications', type: 'email', url: 'team@example.com', enabled: true },
|
||||
{ id: 'discord-badge-id', name: 'Discord Alerts', type: 'discord', url: 'https://discord.com/api/webhooks/test', enabled: true },
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Reload to get mocked response', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify email type badge is displayed', async () => {
|
||||
const emailRow = page.getByTestId('provider-row-email-badge-id');
|
||||
await expect(emailRow).toBeVisible();
|
||||
const emailBadge = emailRow.getByText(/^email$/i);
|
||||
await expect(emailBadge).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify email is a supported type with full actions', async () => {
|
||||
const emailRow = page.getByTestId('provider-row-email-badge-id');
|
||||
const rowButtons = emailRow.getByRole('button');
|
||||
const buttonCount = await rowButtons.count();
|
||||
expect(buttonCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Validation', () => {
|
||||
test('should allow saving email provider without URL (recipients optional)', async ({ page }) => {
|
||||
let createCalled = false;
|
||||
|
||||
await test.step('Mock create endpoint', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
createCalled = true;
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'email-no-url', ...((await request.postDataJSON()) as Record<string, unknown>) }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Open form and select email', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByTestId('provider-type').selectOption('email');
|
||||
});
|
||||
|
||||
await test.step('Fill name but leave recipients empty', async () => {
|
||||
await page.getByTestId('provider-name').fill(generateProviderName('email-empty-recipients'));
|
||||
});
|
||||
|
||||
await test.step('Save and verify no URL validation error for email type', async () => {
|
||||
await page.getByTestId('provider-save-btn').click();
|
||||
await expect(page.getByTestId('provider-url-error')).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Verify create was called (URL not required for email)', async () => {
|
||||
expect(createCalled).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('should require URL for non-email types but not for email', async ({ page }) => {
|
||||
let createCalled = false;
|
||||
|
||||
await test.step('Intercept create calls', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
createCalled = true;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Open form with discord type (default)', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByTestId('provider-type')).toHaveValue('discord');
|
||||
});
|
||||
|
||||
await test.step('Submit discord form with name but no URL', async () => {
|
||||
await page.getByTestId('provider-name').fill('discord-validation-test');
|
||||
await page.getByTestId('provider-save-btn').click();
|
||||
});
|
||||
|
||||
await test.step('Verify URL error shown for discord', async () => {
|
||||
await expect(page.getByTestId('provider-url-error')).toBeVisible();
|
||||
expect(createCalled).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
test('should not show URL validation error for email type', async ({ page }) => {
|
||||
await test.step('Open form and select email', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByTestId('provider-type').selectOption('email');
|
||||
});
|
||||
|
||||
await test.step('Verify URL error element is not rendered for email', async () => {
|
||||
await expect(page.getByTestId('provider-url-error')).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Submit form with only name filled', async () => {
|
||||
await page.getByTestId('provider-name').fill('email-no-url-error-test');
|
||||
await page.getByTestId('provider-save-btn').click();
|
||||
});
|
||||
|
||||
await test.step('Verify no URL-related error appears', async () => {
|
||||
await expect(page.getByTestId('provider-url-error')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate name is required for email provider', async ({ page }) => {
|
||||
await test.step('Open form and select email', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByTestId('provider-type').selectOption('email');
|
||||
});
|
||||
|
||||
await test.step('Leave name empty and fill recipients', async () => {
|
||||
await page.getByTestId('provider-url').fill('test@example.com');
|
||||
});
|
||||
|
||||
await test.step('Attempt to save', async () => {
|
||||
await page.getByTestId('provider-save-btn').click();
|
||||
});
|
||||
|
||||
await test.step('Verify name validation error', async () => {
|
||||
const nameInput = page.getByTestId('provider-name');
|
||||
const inputHasError = await nameInput.evaluate((el) =>
|
||||
el.getAttribute('aria-invalid') === 'true'
|
||||
).catch(() => false);
|
||||
|
||||
const errorMessage = page.getByText(/required/i);
|
||||
const hasError = await errorMessage.isVisible().catch(() => false);
|
||||
|
||||
expect(hasError || inputHasError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Payload Contract', () => {
|
||||
test('should send correct payload for email provider create', async ({ page }) => {
|
||||
let capturedPayload: Record<string, unknown> | null = null;
|
||||
|
||||
await test.step('Mock create endpoint to capture payload', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
capturedPayload = (await request.postDataJSON()) as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'email-payload-id', ...capturedPayload }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Create email provider with recipients', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.getByTestId('provider-type').selectOption('email');
|
||||
await page.getByTestId('provider-name').fill('payload-test-email');
|
||||
await page.getByTestId('provider-url').fill('alert@example.com, ops@example.com');
|
||||
|
||||
await page.getByTestId('provider-save-btn').click();
|
||||
});
|
||||
|
||||
await test.step('Verify email payload contract', async () => {
|
||||
expect(capturedPayload).toBeTruthy();
|
||||
expect(capturedPayload?.type).toBe('email');
|
||||
expect(capturedPayload?.name).toBe('payload-test-email');
|
||||
expect(capturedPayload?.url).toBe('alert@example.com, ops@example.com');
|
||||
expect(capturedPayload?.token).toBeUndefined();
|
||||
expect(capturedPayload?.gotify_token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user