Innovenergy_trunk/csharp/App/Backend/Services/ReportEmailService.cs

424 lines
21 KiB
C#

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
{
/// <summary>
/// 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.
/// </summary>
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<MailerConfig> ReadMailerConfig()
{
await using var fileStream = File.OpenRead(MailerConfig.DefaultFile);
var config = await JsonSerializer.DeserializeAsync<MailerConfig>(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 Ein/Aus",
SolarEnergyUsed: "Genutzte Solarenergie",
StayedAtHome: "direkt genutzt",
EstMoneySaved: "Geschätzte Ersparnis",
AtRate: "bei 0.27 CHF/kWh",
SolarCoverage: "Solare Deckung",
FromSolar: "durch Solar",
BatteryEff: "Batterie-Eff.",
OutVsIn: "Aus vs. Ein",
Day: "Tag",
Load: "Last",
GridIn: "Netz Ein",
GridOut: "Netz Aus",
BattInOut: "Batt. Ein/Aus",
Footer: "Erstellt von <strong style=\"color:#666\">Inesco Energy Monitor Platform</strong> · Powered by Mistral AI"
),
"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 Entrée/Sortie",
SolarEnergyUsed: "Énergie solaire utilisée",
StayedAtHome: "autoconsommée",
EstMoneySaved: "Économies estimées",
AtRate: "à 0.27 CHF/kWh",
SolarCoverage: "Couverture solaire",
FromSolar: "depuis le solaire",
BatteryEff: "Eff. batterie",
OutVsIn: "sortie vs entrée",
Day: "Jour",
Load: "Charge",
GridIn: "Réseau Ent.",
GridOut: "Réseau Sor.",
BattInOut: "Batt. Ent./Sor.",
Footer: "Généré par <strong style=\"color:#666\">Inesco Energy Monitor Platform</strong> · Propulsé par Mistral AI"
),
"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 Ent./Usc.",
SolarEnergyUsed: "Energia solare utilizzata",
StayedAtHome: "rimasta in casa",
EstMoneySaved: "Risparmio stimato",
AtRate: "a 0.27 CHF/kWh",
SolarCoverage: "Copertura solare",
FromSolar: "dal solare",
BatteryEff: "Eff. batteria",
OutVsIn: "uscita vs entrata",
Day: "Giorno",
Load: "Carico",
GridIn: "Rete Ent.",
GridOut: "Rete Usc.",
BattInOut: "Batt. Ent./Usc.",
Footer: "Generato da <strong style=\"color:#666\">Inesco Energy Monitor Platform</strong> · Powered by Mistral AI"
),
_ => 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 In/Out",
SolarEnergyUsed: "Solar Energy Used",
StayedAtHome: "stayed at home",
EstMoneySaved: "Est. Money Saved",
AtRate: "at 0.27 CHF/kWh",
SolarCoverage: "Solar Coverage",
FromSolar: "from solar",
BatteryEff: "Battery Eff.",
OutVsIn: "out vs in",
Day: "Day",
Load: "Load",
GridIn: "Grid In",
GridOut: "Grid Out",
BattInOut: "Batt In/Out",
Footer: "Generated by <strong style=\"color:#666\">Inesco Energy Monitor Platform</strong> · Powered by Mistral AI"
)
};
// ── 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 <li> 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
? "<ul style=\"margin:0;padding-left:20px\">" +
string.Join("", insightLines.Select(l => $"<li style=\"margin-bottom:8px;line-height:1.6\">{FormatInsightLine(l)}</li>")) +
"</ul>"
: $"<p style=\"margin:0;line-height:1.6\">{FormatInsightLine(r.AiInsight)}</p>";
// Daily rows
var dailyRows = "";
foreach (var d in r.DailyData)
{
var dayName = DateTime.Parse(d.Date).ToString("ddd");
var isCurrentWeek = string.Compare(d.Date, r.PeriodStart, StringComparison.Ordinal) >= 0;
var bgColor = isCurrentWeek ? "#ffffff" : "#f9f9f9";
dailyRows += $@"
<tr style=""background:{bgColor}"">
<td style=""padding:6px 10px;border-bottom:1px solid #eee"">{dayName} {d.Date}</td>
<td style=""padding:6px 10px;border-bottom:1px solid #eee;text-align:right"">{d.PvProduction:F1}</td>
<td style=""padding:6px 10px;border-bottom:1px solid #eee;text-align:right"">{d.LoadConsumption:F1}</td>
<td style=""padding:6px 10px;border-bottom:1px solid #eee;text-align:right"">{d.GridImport:F1}</td>
<td style=""padding:6px 10px;border-bottom:1px solid #eee;text-align:right"">{d.GridExport:F1}</td>
<td style=""padding:6px 10px;border-bottom:1px solid #eee;text-align:right"">{d.BatteryCharged:F1}/{d.BatteryDischarged:F1}</td>
</tr>";
}
// Week-over-week comparison rows
var comparisonHtml = prev != null
? $@"
<tr>
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.PvProduction}</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalPvProduction:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalPvProduction:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(r.PvChangePercent)}"">{FormatChange(r.PvChangePercent)}</td>
</tr>
<tr>
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.Consumption}</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalConsumption:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalConsumption:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(-r.ConsumptionChangePercent)}"">{FormatChange(r.ConsumptionChangePercent)}</td>
</tr>
<tr>
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridImport}</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalGridImport:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalGridImport:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(-r.GridImportChangePercent)}"">{FormatChange(r.GridImportChangePercent)}</td>
</tr>
<tr>
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridExport}</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalGridExport:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalGridExport:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right"">—</td>
</tr>
<tr>
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.BatteryInOut}</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalBatteryCharged:F1}/{prev.TotalBatteryDischarged:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right"">—</td>
</tr>"
: $@"
<tr><td style=""padding:8px 12px"">{s.PvProduction}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalPvProduction:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.Consumption}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalConsumption:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.GridImport}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridImport:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.GridExport}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridExport:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.BatteryInOut}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh</td></tr>";
var comparisonHeaders = prev != null
? $@"<th style=""padding:8px 12px;text-align:right"">{s.ThisWeek}</th>
<th style=""padding:8px 12px;text-align:right"">{s.LastWeek}</th>
<th style=""padding:8px 12px;text-align:right"">{s.Change}</th>"
: $@"<th style=""padding:8px 12px;text-align:right"">{s.ThisWeek}</th>";
return $@"
<!DOCTYPE html>
<html>
<head><meta charset=""utf-8""></head>
<body style=""margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333"">
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""background:#f4f4f4;padding:20px 0"">
<tr><td align=""center"">
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08)"">
<!-- Header -->
<tr>
<td style=""background:#2c3e50;padding:24px 30px;color:#ffffff"">
<div style=""font-size:20px;font-weight:bold"">{s.Title}</div>
<div style=""font-size:14px;margin-top:6px;opacity:0.9"">{r.InstallationName}</div>
<div style=""font-size:13px;margin-top:2px;opacity:0.7"">{r.PeriodStart} — {r.PeriodEnd}</div>
</td>
</tr>
<!-- Weekly Insights (top) -->
<tr>
<td style=""padding:24px 30px 0"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Insights}</div>
<div style=""background:#fef9e7;border-left:4px solid #f39c12;padding:14px 18px;border-radius:0 6px 6px 0;font-size:14px;color:#333"">
{insightHtml}
</div>
</td>
</tr>
<!-- Weekly Totals -->
<tr>
<td style=""padding:24px 30px"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Summary}</div>
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px"">
<tr style=""background:#f8f9fa"">
<th style=""padding:8px 12px;text-align:left"">{s.Metric}</th>
{comparisonHeaders}
</tr>
{comparisonHtml}
</table>
</td>
</tr>
<!-- Key Ratios -->
<tr>
<td style=""padding:0 30px 24px"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.SavingsHeader}</div>
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
<tr>
{SavingsBox(s.SolarEnergyUsed, $"{r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport:F1} kWh", s.StayedAtHome, "#27ae60")}
{SavingsBox(s.EstMoneySaved, $"~{(r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport) * 0.27:F1} CHF", s.AtRate, "#2980b9")}
{SavingsBox(s.SolarCoverage, $"{r.SelfSufficiencyPercent:F0}%", s.FromSolar, "#8e44ad")}
{SavingsBox(s.BatteryEff, $"{r.BatteryEfficiencyPercent:F0}%", s.OutVsIn, "#e67e22")}
</tr>
</table>
</td>
</tr>
<!-- Daily Breakdown -->
<tr>
<td style=""padding:0 30px 24px"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.DailyBreakdown}</div>
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px;font-size:13px"">
<tr style=""background:#f8f9fa"">
<th style=""padding:6px 10px;text-align:left"">{s.Day}</th>
<th style=""padding:6px 10px;text-align:right"">PV</th>
<th style=""padding:6px 10px;text-align:right"">{s.Load}</th>
<th style=""padding:6px 10px;text-align:right"">{s.GridIn}</th>
<th style=""padding:6px 10px;text-align:right"">{s.GridOut}</th>
<th style=""padding:6px 10px;text-align:right"">{s.BattInOut}</th>
</tr>
{dailyRows}
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
{s.Footer}
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>";
}
private static string SavingsBox(string label, string value, string subtitle, string color) =>
$@"<td width=""25%"" style=""text-align:center"">
<div style=""background:#f8f9fa;border-radius:6px;padding:12px 4px"">
<div style=""font-size:22px;font-weight:bold;color:{color}"">{value}</div>
<div style=""font-size:12px;font-weight:bold;color:#444;margin-top:4px"">{label}</div>
<div style=""font-size:10px;color:#888;margin-top:2px"">{subtitle}</div>
</div>
</td>";
// 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 = $"<strong>{title}</strong>{rest}";
}
else
{
result = line;
}
// Bold numbers followed by units
result = System.Text.RegularExpressions.Regex.Replace(
result,
@"(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))",
"<strong>$1</strong>");
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";
}