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

424 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: "Energie gespart",
StayedAtHome: "Solar + Batterie, nicht vom Netz",
EstMoneySaved: "Geschätzte Ersparnis",
AtRate: "bei 0.27 CHF/kWh",
SolarCoverage: "Eigenversorgung",
FromSolar: "aus Solar + Batterie",
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 économisée",
StayedAtHome: "solaire + batterie, non achetée au réseau",
EstMoneySaved: "Économies estimées",
AtRate: "à 0.27 CHF/kWh",
SolarCoverage: "Autosuffisance",
FromSolar: "du solaire + batterie",
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 risparmiata",
StayedAtHome: "solare + batteria, non acquistata dalla rete",
EstMoneySaved: "Risparmio stimato",
AtRate: "a 0.27 CHF/kWh",
SolarCoverage: "Autosufficienza",
FromSolar: "da solare + batteria",
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: "Energy Saved",
StayedAtHome: "solar + battery, not bought from grid",
EstMoneySaved: "Est. Money Saved",
AtRate: "at 0.27 CHF/kWh",
SolarCoverage: "Self-Sufficiency",
FromSolar: "from solar + battery",
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.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")}
</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 all numbers: time ranges (14:0018: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+)",
"<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";
}