diff --git a/backend/internal/services/mail_service.go b/backend/internal/services/mail_service.go index e0897a8a..5d306d67 100644 --- a/backend/internal/services/mail_service.go +++ b/backend/internal/services/mail_service.go @@ -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 diff --git a/backend/internal/services/mail_service_template_test.go b/backend/internal/services/mail_service_template_test.go new file mode 100644 index 00000000..91abe7e1 --- /dev/null +++ b/backend/internal/services/mail_service_template_test.go @@ -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, "") + }) + } +} + +func TestRenderNotificationEmail_XSSPrevention(t *testing.T) { + ms := &MailService{} + + data := EmailTemplateData{ + EventType: "security_waf", + Title: "", + Message: "", + Timestamp: "2026-03-07T10:00:00Z", + SourceIP: "injected", + } + + result, err := ms.RenderNotificationEmail("email_security_alert.html", data) + require.NoError(t, err) + + assert.NotContains(t, result, "