263 lines
14 KiB
C#
263 lines
14 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.
|
|
/// Uses MailKit directly (same config as existing Mailer library) but with HTML support.
|
|
/// </summary>
|
|
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail)
|
|
{
|
|
var subject = $"Weekly Energy Report — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})";
|
|
var html = BuildHtmlEmail(report);
|
|
|
|
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");
|
|
}
|
|
|
|
// ── HTML email template ─────────────────────────────────────────────
|
|
|
|
public static string BuildHtmlEmail(WeeklyReportResponse r)
|
|
{
|
|
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"">PV Production</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"">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"">Grid Import</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"">Grid Export</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"">Battery In/Out</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"">PV Production</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalPvProduction:F1} kWh</td></tr>
|
|
<tr><td style=""padding:8px 12px"">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"">Grid Import</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridImport:F1} kWh</td></tr>
|
|
<tr><td style=""padding:8px 12px"">Grid Export</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridExport:F1} kWh</td></tr>
|
|
<tr><td style=""padding:8px 12px"">Battery In/Out</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"">This Week</th>
|
|
<th style=""padding:8px 12px;text-align:right"">Last Week</th>
|
|
<th style=""padding:8px 12px;text-align:right"">Change</th>"
|
|
: @"<th style=""padding:8px 12px;text-align:right"">This Week</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"">Weekly Performance Report</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"">Weekly 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"">Weekly 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"">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"">Your Savings This Week</div>
|
|
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
|
|
<tr>
|
|
{SavingsBox("Solar Energy Used", $"{r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport:F1} kWh", "stayed at home", "#27ae60")}
|
|
{SavingsBox("Est. Money Saved", $"~{(r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport) * 0.27:F1} CHF", "at 0.27 CHF/kWh", "#2980b9")}
|
|
{SavingsBox("Solar Coverage", $"{r.SelfSufficiencyPercent:F0}%", "from solar", "#8e44ad")}
|
|
{SavingsBox("Battery Eff.", $"{r.BatteryEfficiencyPercent:F0}%", "out vs in", "#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"">Daily Breakdown (kWh)</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"">Day</th>
|
|
<th style=""padding:6px 10px;text-align:right"">PV</th>
|
|
<th style=""padding:6px 10px;text-align:right"">Load</th>
|
|
<th style=""padding:6px 10px;text-align:right"">Grid In</th>
|
|
<th style=""padding:6px 10px;text-align:right"">Grid Out</th>
|
|
<th style=""padding:6px 10px;text-align:right"">Batt In/Out</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"">
|
|
Generated by <strong style=""color:#666"">Inesco Energy Monitor Platform</strong> · Powered by Mistral AI
|
|
</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";
|
|
}
|