package notifications
import (
"fmt"
"html"
"strings"
"time"
"unicode"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
)
// titleCase capitalizes the first letter of each word (simple ASCII-safe version)
func titleCase(s string) string {
var result strings.Builder
capitalizeNext := true
for _, r := range s {
if unicode.IsSpace(r) {
capitalizeNext = true
result.WriteRune(r)
} else if capitalizeNext {
result.WriteRune(unicode.ToUpper(r))
capitalizeNext = false
} else {
result.WriteRune(unicode.ToLower(r))
}
}
return result.String()
}
// alertNodeDisplay returns the display name for an alert's node, falling back
// to the raw node name if no display name is set.
func alertNodeDisplay(alert *alerts.Alert) string {
if alert.NodeDisplayName != "" {
return alert.NodeDisplayName
}
return alert.Node
}
// EmailTemplate generates a professional HTML email template for alerts
func EmailTemplate(alertList []*alerts.Alert, isSingle bool) (subject, htmlBody, textBody string) {
if isSingle && len(alertList) == 1 {
return singleAlertTemplate(alertList[0])
}
return groupedAlertTemplate(alertList)
}
func singleAlertTemplate(alert *alerts.Alert) (subject, htmlBody, textBody string) {
levelColor := "#ff6b6b"
levelBg := "#fee"
if alert.Level == "warning" {
levelColor = "#ffd93d"
levelBg = "#fffaeb"
}
// Properly format alert type (CPU, Memory, etc.)
alertType := alert.Type
switch strings.ToLower(alertType) {
case "cpu":
alertType = "CPU"
case "memory":
alertType = "Memory"
case "disk":
alertType = "Disk"
case "io":
alertType = "I/O"
default:
alertType = titleCase(alertType)
}
subject = fmt.Sprintf("[Pulse Alert] %s: %s on %s",
titleCase(string(alert.Level)), alertType, alert.ResourceName)
escapedLevel := html.EscapeString(string(alert.Level))
escapedResourceName := html.EscapeString(alert.ResourceName)
escapedMessage := html.EscapeString(alert.Message)
escapedCurrentValue := html.EscapeString(formatMetricValue(alert.Type, alert.Value))
escapedThresholdValue := html.EscapeString(formatMetricThreshold(alert.Type, alert.Threshold))
escapedResourceID := html.EscapeString(alert.ResourceID)
escapedAlertType := html.EscapeString(alertType)
escapedNode := html.EscapeString(alertNodeDisplay(alert))
escapedInstance := html.EscapeString(alert.Instance)
escapedStarted := html.EscapeString(formatAlertStartTime(alert.StartTime))
escapedDuration := html.EscapeString(formatDuration(time.Since(alert.StartTime)))
htmlBody = fmt.Sprintf(`
Resource ID
%s
Alert Type
%s
Node
%s
Instance
%s
Started
%s
Duration
%s
`,
levelBg, levelColor, levelColor,
escapedLevel,
escapedResourceName,
escapedMessage,
escapedCurrentValue,
escapedThresholdValue,
escapedResourceID,
escapedAlertType,
escapedNode,
escapedInstance,
escapedStarted,
escapedDuration,
)
// Plain text version
textBody = fmt.Sprintf(`PULSE MONITORING ALERT
%s ALERT: %s
Resource: %s (%s)
Type: %s
Current Value: %s (Threshold: %s)
Message: %s
Details:
- Node: %s
- Instance: %s
- Started: %s
- Duration: %s
This is an automated notification from Pulse Monitoring.
View alerts and configure settings in your Pulse dashboard.`,
strings.ToUpper(string(alert.Level)),
alert.ResourceName,
alert.ResourceName,
alert.ResourceID,
alert.Type,
formatMetricValue(alert.Type, alert.Value),
formatMetricThreshold(alert.Type, alert.Threshold),
alert.Message,
alertNodeDisplay(alert),
alert.Instance,
formatAlertStartTime(alert.StartTime),
formatDuration(time.Since(alert.StartTime)),
)
return subject, htmlBody, textBody
}
func groupedAlertTemplate(alertList []*alerts.Alert) (subject, htmlBody, textBody string) {
critical := 0
warning := 0
for _, alert := range alertList {
if alert.Level == "critical" {
critical++
} else {
warning++
}
}
// Subject line
if critical > 0 && warning > 0 {
subject = fmt.Sprintf("[Pulse Alert] %d Critical, %d Warning alerts", critical, warning)
} else if critical > 0 {
subject = fmt.Sprintf("[Pulse Alert] %d Critical alert%s", critical, pluralize(critical))
} else {
subject = fmt.Sprintf("[Pulse Alert] %d Warning alert%s", warning, pluralize(warning))
}
// Build alert rows
var alertRows strings.Builder
for _, alert := range alertList {
levelColor := "#ff6b6b"
if alert.Level == "warning" {
levelColor = "#ffd93d"
}
escapedResourceName := html.EscapeString(alert.ResourceName)
escapedType := html.EscapeString(alert.Type)
escapedNode := html.EscapeString(alertNodeDisplay(alert))
escapedLevel := html.EscapeString(string(alert.Level))
escapedValue := html.EscapeString(formatMetricValue(alert.Type, alert.Value))
escapedThreshold := html.EscapeString(formatMetricThreshold(alert.Type, alert.Threshold))
escapedDuration := html.EscapeString(formatDuration(time.Since(alert.StartTime)))
alertRows.WriteString(fmt.Sprintf(`
|
|
%s
|
%s
of %s
|
%s ago
|
`,
levelColor,
escapedResourceName,
escapedType, escapedNode,
levelColor, escapedLevel,
escapedValue, escapedThreshold,
escapedDuration,
))
}
htmlBody = fmt.Sprintf(`
%d New Alert%s
`,
len(alertList), pluralize(len(alertList)))
if critical > 0 {
htmlBody += fmt.Sprintf(`
`, critical)
}
if warning > 0 {
htmlBody += fmt.Sprintf(`
`, warning)
}
htmlBody += fmt.Sprintf(`
| Resource |
Level |
Value |
Duration |
%s
`, alertRows.String())
// Plain text version
var textBuilder strings.Builder
textBuilder.WriteString("PULSE MONITORING ALERT SUMMARY\n\n")
textBuilder.WriteString(fmt.Sprintf("%d New Alert%s\n", len(alertList), pluralize(len(alertList))))
if critical > 0 {
textBuilder.WriteString(fmt.Sprintf("Critical: %d\n", critical))
}
if warning > 0 {
textBuilder.WriteString(fmt.Sprintf("Warning: %d\n", warning))
}
textBuilder.WriteString("\nAlert Details:\n")
textBuilder.WriteString("─────────────────────────────────────────────────────────────\n")
for i, alert := range alertList {
textBuilder.WriteString(fmt.Sprintf("\n%d. %s (%s)\n", i+1, alert.ResourceName, alert.ResourceID))
textBuilder.WriteString(fmt.Sprintf(" Level: %s | Type: %s\n", strings.ToUpper(string(alert.Level)), alert.Type))
textBuilder.WriteString(fmt.Sprintf(" Value: %s (Threshold: %s)\n", formatMetricValue(alert.Type, alert.Value), formatMetricThreshold(alert.Type, alert.Threshold)))
textBuilder.WriteString(fmt.Sprintf(" Node: %s | Started: %s ago\n", alertNodeDisplay(alert), formatDuration(time.Since(alert.StartTime))))
textBuilder.WriteString(fmt.Sprintf(" Message: %s\n", alert.Message))
}
textBuilder.WriteString("\n─────────────────────────────────────────────────────────────\n")
textBuilder.WriteString("This is an automated notification from Pulse Monitoring.\n")
textBuilder.WriteString("Configure alert settings in the Pulse dashboard.")
textBody = textBuilder.String()
return subject, htmlBody, textBody
}
// formatAlertStartTime renders in the server's local zone with the zone name
// visible. Alert start times are stored in UTC; without the conversion and
// label the email showed a bare UTC clock that read as local time (#1582).
func formatAlertStartTime(t time.Time) string {
return t.Local().Format("Jan 2, 2006 at 3:04 PM MST")
}
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%d seconds", int(d.Seconds()))
} else if d < time.Hour {
return fmt.Sprintf("%d minutes", int(d.Minutes()))
} else if d < 24*time.Hour {
return fmt.Sprintf("%.1f hours", d.Hours())
}
return fmt.Sprintf("%.1f days", d.Hours()/24)
}
func pluralize(count int) string {
if count == 1 {
return ""
}
return "s"
}
// formatMetricValue formats a metric value with the appropriate unit
func formatMetricValue(metricType string, value float64) string {
switch strings.ToLower(metricType) {
case "diskread", "diskwrite", "networkin", "networkout":
return fmt.Sprintf("%.1f MB/s", value)
case "temperature":
return fmt.Sprintf("%.1f°C", value)
case "cpu", "memory", "disk", "usage":
return fmt.Sprintf("%.1f%%", value)
default:
return fmt.Sprintf("%.1f", value)
}
}
// formatMetricThreshold formats a metric threshold with the appropriate unit
func formatMetricThreshold(metricType string, threshold float64) string {
switch strings.ToLower(metricType) {
case "diskread", "diskwrite", "networkin", "networkout":
return fmt.Sprintf("%.0f MB/s", threshold)
case "temperature":
return fmt.Sprintf("%.0f°C", threshold)
case "cpu", "memory", "disk", "usage":
return fmt.Sprintf("%.0f%%", threshold)
default:
return fmt.Sprintf("%.0f", threshold)
}
}