diff --git a/backend/internal/service/ops_alert_evaluator_service.go b/backend/internal/service/ops_alert_evaluator_service.go index 11c5d5ce..c6a58a1b 100644 --- a/backend/internal/service/ops_alert_evaluator_service.go +++ b/backend/internal/service/ops_alert_evaluator_service.go @@ -686,6 +686,21 @@ func (s *OpsAlertEvaluatorService) maybeSendAlertEmail(ctx context.Context, runt if !s.emailLimiter.Allow(time.Now().UTC()) { continue } + if s.emailService.notificationEmailService != nil { + if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventOpsAlert, + RecipientEmail: addr, + RecipientName: emailRecipientName(addr), + SourceType: "ops_alert", + SourceID: fmt.Sprintf("%d", event.ID), + Variables: opsAlertEmailVariables(rule, event), + }); err == nil { + anySent = true + continue + } else if !shouldFallbackNotificationEmail(err) { + continue + } + } if err := s.emailService.SendEmail(ctx, addr, subject, body); err != nil { // Ignore per-recipient failures; continue best-effort. continue @@ -699,6 +714,46 @@ func (s *OpsAlertEvaluatorService) maybeSendAlertEmail(ctx context.Context, runt return anySent } +func opsAlertEmailVariables(rule *OpsAlertRule, event *OpsAlertEvent) map[string]string { + variables := map[string]string{ + "rule_name": "-", + "severity": "-", + "alert_status": "-", + "metric_type": "-", + "operator": "-", + "metric_value": "-", + "threshold_value": "-", + "triggered_at": time.Now().UTC().Format(time.RFC3339), + "alert_description": "-", + } + if rule != nil { + variables["rule_name"] = strings.TrimSpace(rule.Name) + variables["severity"] = strings.TrimSpace(rule.Severity) + variables["metric_type"] = strings.TrimSpace(rule.MetricType) + variables["operator"] = strings.TrimSpace(rule.Operator) + variables["threshold_value"] = fmt.Sprintf("%.2f", rule.Threshold) + if strings.TrimSpace(rule.Description) != "" { + variables["alert_description"] = strings.TrimSpace(rule.Description) + } + } + if event != nil { + variables["alert_status"] = strings.TrimSpace(event.Status) + if event.MetricValue != nil { + variables["metric_value"] = fmt.Sprintf("%.2f", *event.MetricValue) + } + if event.ThresholdValue != nil { + variables["threshold_value"] = fmt.Sprintf("%.2f", *event.ThresholdValue) + } + if !event.FiredAt.IsZero() { + variables["triggered_at"] = event.FiredAt.UTC().Format(time.RFC3339) + } + if strings.TrimSpace(event.Description) != "" { + variables["alert_description"] = strings.TrimSpace(event.Description) + } + } + return variables +} + func buildOpsAlertEmailBody(rule *OpsAlertRule, event *OpsAlertEvent) string { if rule == nil || event == nil { return "" diff --git a/backend/internal/service/ops_scheduled_report_service.go b/backend/internal/service/ops_scheduled_report_service.go index 98b2045d..54aad114 100644 --- a/backend/internal/service/ops_scheduled_report_service.go +++ b/backend/internal/service/ops_scheduled_report_service.go @@ -337,6 +337,7 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc } subject := fmt.Sprintf("[Ops Report] %s", strings.TrimSpace(report.Name)) + templateVariables := opsScheduledReportEmailVariables(report, now) attempts := 0 for _, to := range recipients { @@ -345,6 +346,24 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc continue } attempts++ + if s.emailService.notificationEmailService != nil { + if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventOpsScheduledReport, + RecipientEmail: addr, + RecipientName: emailRecipientName(addr), + SourceType: "ops_scheduled_report", + SourceID: opsScheduledReportDeliverySourceID(report), + ReminderKey: now.UTC().Format("2006-01-02T15:04"), + Variables: templateVariables, + RawHTMLVariables: map[string]string{ + "report_html": content, + }, + }); err == nil { + continue + } else if !shouldFallbackNotificationEmail(err) { + continue + } + } if err := s.emailService.SendEmail(ctx, addr, subject, content); err != nil { // Ignore per-recipient failures; continue best-effort. continue @@ -353,6 +372,46 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc return attempts, nil } +func opsScheduledReportDeliverySourceID(report *opsScheduledReport) string { + if report == nil { + return "scheduled_report" + } + parts := []string{ + strings.TrimSpace(report.ReportType), + strings.TrimSpace(report.Name), + strings.TrimSpace(report.Schedule), + } + joined := strings.Trim(strings.Join(parts, ":"), ":") + if joined == "" { + return "scheduled_report" + } + return joined +} + +func opsScheduledReportEmailVariables(report *opsScheduledReport, now time.Time) map[string]string { + end := now.UTC() + start := end + name := "Ops report" + reportType := "scheduled_report" + if report != nil { + if strings.TrimSpace(report.Name) != "" { + name = strings.TrimSpace(report.Name) + } + if strings.TrimSpace(report.ReportType) != "" { + reportType = strings.TrimSpace(report.ReportType) + } + if report.TimeRange > 0 { + start = end.Add(-report.TimeRange) + } + } + return map[string]string{ + "report_name": name, + "report_type": reportType, + "report_start_time": start.Format(time.RFC3339), + "report_end_time": end.Format(time.RFC3339), + } +} + func (s *OpsScheduledReportService) generateReportHTML(ctx context.Context, report *opsScheduledReport, now time.Time) (string, error) { if s == nil || s.opsService == nil || report == nil { return "", fmt.Errorf("service not initialized")