using System.Text.Json; using InnovEnergy.App.Backend.DataTypes; using InnovEnergy.Lib.Mailer; using MailKit.Net.Smtp; using MailKit.Security; using MimeKit; namespace InnovEnergy.App.Backend.Services; public static class ReportEmailService { /// /// Sends the weekly report as a nicely formatted HTML email in the user's language. /// Uses MailKit directly (same config as existing Mailer library) but with HTML support. /// public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en") { var strings = GetStrings(language); var subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})"; var html = BuildHtmlEmail(report, strings); var config = await ReadMailerConfig(); var from = new MailboxAddress(config.SenderName, config.SenderAddress); var to = new MailboxAddress(recipientEmail, recipientEmail); var msg = new MimeMessage { From = { from }, To = { to }, Subject = subject, Body = new TextPart("html") { Text = html } }; Console.WriteLine($"[ReportEmailService] SMTP: {config.SmtpUsername}@{config.SmtpServerUrl}:{config.SmtpPort}"); using var smtp = new SmtpClient(); await smtp.ConnectAsync(config.SmtpServerUrl, config.SmtpPort, SecureSocketOptions.StartTls); await smtp.AuthenticateAsync(config.SmtpUsername, config.SmtpPassword); await smtp.SendAsync(msg); await smtp.DisconnectAsync(true); Console.WriteLine($"[ReportEmailService] Report sent to {recipientEmail}"); } private static async Task ReadMailerConfig() { await using var fileStream = File.OpenRead(MailerConfig.DefaultFile); var config = await JsonSerializer.DeserializeAsync(fileStream); return config ?? throw new InvalidOperationException("Failed to read MailerConfig.json"); } // ── Translation strings ───────────────────────────────────────────────── private record EmailStrings( string Title, string Insights, string Summary, string SavingsHeader, string DailyBreakdown, string Metric, string ThisWeek, string LastWeek, string Change, string PvProduction, string Consumption, string GridImport, string GridExport, string BatteryInOut, string SolarEnergyUsed, string StayedAtHome, string EstMoneySaved, string AtRate, string SolarCoverage, string FromSolar, string BatteryEff, string OutVsIn, string Day, string Load, string GridIn, string GridOut, string BattInOut, string Footer ); private static EmailStrings GetStrings(string language) => language switch { "de" => new EmailStrings( Title: "Wöchentlicher Leistungsbericht", Insights: "Wöchentliche Erkenntnisse", Summary: "Wöchentliche Zusammenfassung", SavingsHeader: "Ihre Ersparnisse diese Woche", DailyBreakdown: "Tägliche Aufschlüsselung (kWh)", Metric: "Kennzahl", ThisWeek: "Diese Woche", LastWeek: "Letzte Woche", Change: "Änderung", PvProduction: "PV-Produktion", Consumption: "Verbrauch", GridImport: "Netzbezug", GridExport: "Netzeinspeisung", BatteryInOut: "Batterie Laden / Entladen", SolarEnergyUsed: "Energie gespart", StayedAtHome: "Solar + Batterie, nicht vom Netz", EstMoneySaved: "Geschätzte Ersparnis", AtRate: "bei 0.39 CHF/kWh", SolarCoverage: "Eigenversorgung", FromSolar: "aus Solar + Batterie", BatteryEff: "Batterie-Eff.", OutVsIn: "Entladung vs. Ladung", Day: "Tag", Load: "Last", GridIn: "Netz Ein", GridOut: "Netz Aus", BattInOut: "Batt. Laden/Entl.", Footer: "Erstellt von inesco Energy Monitor" ), "fr" => new EmailStrings( Title: "Rapport de performance hebdomadaire", Insights: "Aperçus de la semaine", Summary: "Résumé de la semaine", SavingsHeader: "Vos économies cette semaine", DailyBreakdown: "Détail quotidien (kWh)", Metric: "Indicateur", ThisWeek: "Cette semaine", LastWeek: "Semaine dernière", Change: "Variation", PvProduction: "Production PV", Consumption: "Consommation", GridImport: "Import réseau", GridExport: "Export réseau", BatteryInOut: "Batterie Charge / Décharge", SolarEnergyUsed: "Énergie économisée", StayedAtHome: "solaire + batterie, non achetée au réseau", EstMoneySaved: "Économies estimées", AtRate: "à 0.39 CHF/kWh", SolarCoverage: "Autosuffisance", FromSolar: "du solaire + batterie", BatteryEff: "Eff. batterie", OutVsIn: "décharge vs charge", Day: "Jour", Load: "Charge", GridIn: "Réseau Ent.", GridOut: "Réseau Sor.", BattInOut: "Batt. Ch./Déch.", Footer: "Généré par inesco Energy Monitor" ), "it" => new EmailStrings( Title: "Rapporto settimanale delle prestazioni", Insights: "Approfondimenti settimanali", Summary: "Riepilogo settimanale", SavingsHeader: "I tuoi risparmi questa settimana", DailyBreakdown: "Dettaglio giornaliero (kWh)", Metric: "Metrica", ThisWeek: "Questa settimana", LastWeek: "La settimana scorsa", Change: "Variazione", PvProduction: "Produzione PV", Consumption: "Consumo", GridImport: "Import dalla rete", GridExport: "Export nella rete", BatteryInOut: "Batteria Carica / Scarica", SolarEnergyUsed: "Energia risparmiata", StayedAtHome: "solare + batteria, non acquistata dalla rete", EstMoneySaved: "Risparmio stimato", AtRate: "a 0.39 CHF/kWh", SolarCoverage: "Autosufficienza", FromSolar: "da solare + batteria", BatteryEff: "Eff. batteria", OutVsIn: "scarica vs carica", Day: "Giorno", Load: "Carico", GridIn: "Rete Ent.", GridOut: "Rete Usc.", BattInOut: "Batt. Car./Sc.", Footer: "Generato da inesco Energy Monitor" ), _ => new EmailStrings( Title: "Weekly Performance Report", Insights: "Weekly Insights", Summary: "Weekly Summary", SavingsHeader: "Your Savings This Week", DailyBreakdown: "Daily Breakdown (kWh)", Metric: "Metric", ThisWeek: "This Week", LastWeek: "Last Week", Change: "Change", PvProduction: "PV Production", Consumption: "Consumption", GridImport: "Grid Import", GridExport: "Grid Export", BatteryInOut: "Battery Charge / Discharge", SolarEnergyUsed: "Energy Saved", StayedAtHome: "solar + battery, not bought from grid", EstMoneySaved: "Est. Money Saved", AtRate: "at 0.39 CHF/kWh", SolarCoverage: "Self-Sufficiency", FromSolar: "from solar + battery", BatteryEff: "Battery Eff.", OutVsIn: "discharge vs charge", Day: "Day", Load: "Load", GridIn: "Grid In", GridOut: "Grid Out", BattInOut: "Batt. Ch./Dis.", Footer: "Generated by inesco Energy Monitor" ) }; // ── HTML email template ───────────────────────────────────────────── public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en") => BuildHtmlEmail(r, GetStrings(language)); private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s) { var cur = r.CurrentWeek; var prev = r.PreviousWeek; // Parse AI insight into
  • bullet points (split on newlines, strip leading "- " or "1. ") var insightLines = r.AiInsight .Split('\n', StringSplitOptions.RemoveEmptyEntries) .Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "")) .Where(l => l.Length > 0) .ToList(); var insightHtml = insightLines.Count > 1 ? "
      " + string.Join("", insightLines.Select(l => $"
    • {FormatInsightLine(l)}
    • ")) + "
    " : $"

    {FormatInsightLine(r.AiInsight)}

    "; // Detect which components are present across all daily data var showPv = r.DailyData.Any(d => d.PvProduction > 0.1); var showGrid = r.DailyData.Any(d => d.GridImport > 0.1); // Daily rows — colorful bar chart (pixel widths, email-safe) // Scale each day's bars so their combined total always fills maxBarPx (right-edge aligned). // This replicates the web page's CSS flexbox flex-shrink:1 behaviour. const int maxBarPx = 400; var dailyRows = ""; foreach (var d in r.DailyData) { var dayName = DateTime.Parse(d.Date).ToString("ddd dd.MM"); var isCurrentWeek = string.Compare(d.Date, r.PeriodStart, StringComparison.Ordinal) >= 0; var opacity = isCurrentWeek ? "1" : "0.55"; var fontWeight = isCurrentWeek ? "bold" : "normal"; var dayTotal = (showPv ? d.PvProduction : 0) + d.LoadConsumption + (showGrid ? d.GridImport : 0); if (dayTotal < 0.1) dayTotal = 0.1; var pvPx = showPv ? (int)(d.PvProduction / dayTotal * maxBarPx) : 0; var ldPx = (int)(d.LoadConsumption / dayTotal * maxBarPx); var giPx = showGrid ? (int)(d.GridImport / dayTotal * maxBarPx) : 0; var pvSpan = showPv ? $@"" : ""; var gridSpan = showGrid ? $@"" : ""; var ldRadius = (!showPv ? "border-radius:2px 0 0 2px;" : "") + (!showGrid ? "border-radius:0 2px 2px 0;" : ""); var valueText = (showPv ? $"PV {d.PvProduction:F1} | " : "") + $"{s.Load} {d.LoadConsumption:F1}" + (showGrid ? $" | {s.GridIn} {d.GridImport:F1}" : "") + " kWh"; dailyRows += $@" {dayName}
    {valueText}
    {pvSpan}{gridSpan}
    "; } // Week-over-week comparison rows var comparisonHtml = prev != null ? $@" {s.PvProduction} {cur.TotalPvProduction:F1} kWh {prev.TotalPvProduction:F1} kWh {FormatChange(r.PvChangePercent)} {s.Consumption} {cur.TotalConsumption:F1} kWh {prev.TotalConsumption:F1} kWh {FormatChange(r.ConsumptionChangePercent)} {s.GridImport} {cur.TotalGridImport:F1} kWh {prev.TotalGridImport:F1} kWh {FormatChange(r.GridImportChangePercent)} {s.GridExport} {cur.TotalGridExport:F1} kWh {prev.TotalGridExport:F1} kWh — {s.BatteryInOut} {cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh {prev.TotalBatteryCharged:F1}/{prev.TotalBatteryDischarged:F1} kWh — " : $@" {s.PvProduction}{cur.TotalPvProduction:F1} kWh {s.Consumption}{cur.TotalConsumption:F1} kWh {s.GridImport}{cur.TotalGridImport:F1} kWh {s.GridExport}{cur.TotalGridExport:F1} kWh {s.BatteryInOut}{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh"; var comparisonHeaders = prev != null ? $@"{s.ThisWeek} {s.LastWeek} {s.Change}" : $@"{s.ThisWeek}"; return $@"
    {s.Title}
    {r.InstallationName}
    {r.PeriodStart} — {r.PeriodEnd}
    {s.Insights}
    {insightHtml}
    {s.Summary}
    {comparisonHeaders} {comparisonHtml}
    {s.Metric}
    {s.SavingsHeader}
    {SavingsBox(s.SolarEnergyUsed, $"{r.TotalEnergySaved:F1} kWh", s.StayedAtHome, "#27ae60")} {SavingsBox(s.EstMoneySaved, $"~{r.TotalSavingsCHF:F0} CHF", s.AtRate, "#2980b9")} {SavingsBox(s.SolarCoverage, $"{r.SelfSufficiencyPercent:F0}%", s.FromSolar, "#8e44ad")} {SavingsBox(s.BatteryEff, $"{r.BatteryEfficiencyPercent:F0}%", s.OutVsIn, "#e67e22")}
    {s.DailyBreakdown}
    {dailyRows}
    {(showPv ? @$"PV   " : "")} {s.Load}    {(showGrid ? @$"{s.GridIn}" : "")}
    {s.Footer}
    "; } private static string SavingsBox(string label, string value, string subtitle, string color) => $@"
    {value}
    {label}
    {subtitle}
    "; // Bolds "Title" before first colon, and numbers+units in the rest private static string FormatInsightLine(string line) { var colonIdx = line.IndexOf(':'); string result; if (colonIdx > 0) { var title = line[..colonIdx]; var rest = line[colonIdx..]; // includes the colon result = $"{title}{rest}"; } else { result = line; } // Bold all numbers: time ranges (14:00–18:00), times (09:00), decimals, integers result = System.Text.RegularExpressions.Regex.Replace( result, @"(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)", "$1"); return result; } private static string FormatChange(double pct) => pct == 0 ? "—" : pct > 0 ? $"+{pct:F1}%" : $"{pct:F1}%"; private static string ChangeColor(double pct) => pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888"; }