From 19b4aef95b2238d9b25691102e9e84e8bef075e0 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 15:14:57 +0100 Subject: [PATCH] feat(server): dark-themed HTML email notification template --- server/internal/notify/smtp.go | 18 +--- server/internal/notify/template.go | 167 +++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 server/internal/notify/template.go 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`+ + `%s`+ + ``, + colTextMuted, k, colText, esc(v)) + } + + message := "" + if ev.Message != "" { + message = fmt.Sprintf( + `

%s

`, + colSurface2, colBorder, colText, esc(ev.Message)) + } + + transition := esc(ev.OldStatus) + " → " + esc(ev.NewStatus) + + return fmt.Sprintf(` + + + + + + +
+ + + + + + + + +
+ + + + +
Vantage
+ %s +

%s

+

%s check

+ %s + + %s + %s + %s +
+
+

Sent by Vantage · self-hosted service monitoring

+
+
+ +`, + colBg, + colBg, + colSurface, colBorder, + accent, + colText, + accent, accent, label, + colText, esc(ev.MonitorName), + colTextMuted, esc(ev.Type), + message, + colBorder, + row("Status", transition), + row("Type", ev.Type), + row("Time", ev.Time.Format("2006-01-02 15:04:05 MST")), + colBorder, + colTextMuted, + ) +} + +// textEmail renders the plain-text fallback. +func textEmail(ev Event) string { + return strings.Join([]string{ + ev.title(), + "", + "Monitor: " + ev.MonitorName, + "Type: " + ev.Type, + "Status: " + ev.OldStatus + " -> " + ev.NewStatus, + "Message: " + ev.Message, + "Time: " + ev.Time.Format("2006-01-02 15:04:05 MST"), + "", + "Sent by Vantage · self-hosted service monitoring", + }, "\r\n") +}