diff --git a/server/internal/notify/smtp.go b/server/internal/notify/smtp.go index e8d90d9..a7830e2 100644 --- a/server/internal/notify/smtp.go +++ b/server/internal/notify/smtp.go @@ -79,24 +79,16 @@ func dispatchSMTP(ch models.NotificationChannel, ev Event) error { } } - title := ev.title() - msg := strings.Join([]string{ - "From: " + from, - "To: " + to, - "Subject: " + title, - "", - title, - "", - "Monitor: " + ev.MonitorName, - "Status: " + ev.OldStatus + " -> " + ev.NewStatus, - "Time: " + ev.Time.String(), - }, "\r\n") + msg, err := buildMIME(from, to, ev.title(), textEmail(ev), htmlEmail(ev)) + if err != nil { + return fmt.Errorf("smtp: build message: %w", err) + } w, err := c.Data() if err != nil { return fmt.Errorf("smtp: data: %w", err) } - if _, err := w.Write([]byte(msg)); err != nil { + if _, err := w.Write(msg); err != nil { return fmt.Errorf("smtp: write: %w", err) } if err := w.Close(); err != nil { diff --git a/server/internal/notify/template.go b/server/internal/notify/template.go new file mode 100644 index 0000000..028a61d --- /dev/null +++ b/server/internal/notify/template.go @@ -0,0 +1,167 @@ +package notify + +import ( + "fmt" + "html" + "mime/multipart" + "net/textproto" + "strings" + + "github.com/mrhid6/vantage/server/internal/models" +) + +// App theme colors (mirrors web/tailwind.config.ts). +const ( + colBg = "#0f1117" + colSurface = "#1a1d27" + colSurface2 = "#232635" + colBorder = "#2e3147" + colText = "#e8eaf0" + colTextMuted = "#9095a8" + colAccent = "#6366f1" + colSuccess = "#22c55e" + colDanger = "#ef4444" +) + +// statusColor returns the accent color for a monitor status. +func statusColor(status string) string { + switch status { + case models.StatusUp: + return colSuccess + case models.StatusDown: + return colDanger + default: + return colTextMuted + } +} + +// buildMIME assembles a multipart/alternative message (plain + HTML) with the +// standard email headers, ready to hand to the SMTP DATA command. +func buildMIME(from, to, subject, text, htmlBody string) ([]byte, error) { + var buf strings.Builder + w := multipart.NewWriter(&buf) + + var head strings.Builder + head.WriteString("From: " + from + "\r\n") + head.WriteString("To: " + to + "\r\n") + head.WriteString("Subject: " + subject + "\r\n") + head.WriteString("MIME-Version: 1.0\r\n") + head.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n\r\n", w.Boundary())) + + textPart, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}}) + if err != nil { + return nil, err + } + textPart.Write([]byte(text)) + + htmlPart, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}}) + if err != nil { + return nil, err + } + htmlPart.Write([]byte(htmlBody)) + + if err := w.Close(); err != nil { + return nil, err + } + return []byte(head.String() + buf.String()), nil +} + +// htmlEmail renders the alert as a dark-themed HTML email matching the app. +func htmlEmail(ev Event) string { + accent := statusColor(ev.NewStatus) + label := "Recovered" + if ev.NewStatus == models.StatusDown { + label = "Down" + } + + esc := html.EscapeString + row := func(k, v string) string { + if v == "" { + v = "—" + } + return fmt.Sprintf( + `
%s
`, + colSurface2, colBorder, colText, esc(ev.Message)) + } + + transition := esc(ev.OldStatus) + " → " + esc(ev.NewStatus) + + return fmt.Sprintf(` + + +| + + | +