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(`

Pulse Monitoring Alert

%s Alert
%s
%s
Current Value
%s
Threshold
%s
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 on %s
%s
%s
of %s
%s ago `, levelColor, escapedResourceName, escapedType, escapedNode, levelColor, escapedLevel, escapedValue, escapedThreshold, escapedDuration, )) } htmlBody = fmt.Sprintf(`

Pulse Monitoring Alert Summary

%d New Alert%s

`, len(alertList), pluralize(len(alertList))) if critical > 0 { htmlBody += fmt.Sprintf(`
%d
Critical
`, critical) } if warning > 0 { htmlBody += fmt.Sprintf(`
%d
Warning
`, warning) } htmlBody += fmt.Sprintf(`
%s
Resource Level Value Duration
`, 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) } }