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, "