mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
E-mail alerts read like messages, not like log lines (junk-folder fix)
The first channel mail landed in Junk although SPF, DKIM (mte1/mte2) and DMARC are in order, so the content was the tell: a bracketed, shouting subject built from the machine title "Ops Alert: Platform/PushDeliveryFailed", a raw JSON block, hardly any prose, no text part. Now: subject "Autopilot Monitor: Push delivery failed (Platform)" (event name as words, no tags, no severity word), one introductory sentence naming the severity and category, the event as a heading, facts and the opted-in payload as key/value tables (nested values as compact JSON, non-object payloads still capped), a visible portal link, a footer that says why the mail was sent, a plain-text part of our own (auto_text off for alerts) and the RFC 3834 Auto-Submitted header. Template mails are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -139,38 +139,59 @@ public class EmailChannelProviderTests
|
||||
// ── Rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Renderer_builds_a_prefixed_subject_and_escaped_html_with_facts_sections_links_and_payload()
|
||||
public void Renderer_reads_like_a_message_not_a_log_line()
|
||||
{
|
||||
var alert = Alert();
|
||||
alert.DataJson = "{\"service\":\"apple\",\"statusCode\":401,\"note\":\"<script>\"}";
|
||||
alert.DataJson = "{\"service\":\"apple\",\"statusCode\":401,\"note\":\"<script>\",\"nested\":{\"a\":1}}";
|
||||
|
||||
Assert.Equal("[Autopilot Monitor] Error: Ops Alert: Platform/PushDeliveryFailed", EmailAlertRenderer.Subject(alert));
|
||||
// No brackets, no severity shouting, the event name as words, the category in parentheses.
|
||||
Assert.Equal("Autopilot Monitor: Push delivery failed (Platform)", EmailAlertRenderer.Subject(alert));
|
||||
|
||||
var html = EmailAlertRenderer.Html(alert);
|
||||
Assert.Contains("Ops Alert: Platform/PushDeliveryFailed", html);
|
||||
Assert.Contains("Autopilot Monitor raised an error in the Platform category.", html); // one sentence first
|
||||
Assert.Contains("<h1 style=\"font-size:18px;margin:0 0 8px 0;\">Push delivery failed</h1>", html);
|
||||
Assert.DoesNotContain("Ops Alert: Platform/PushDeliveryFailed", html); // the machine title stays out
|
||||
Assert.Contains("A push service refused the platform key", html);
|
||||
Assert.Contains("<td style=\"padding:3px 0;\">Platform</td>", html);
|
||||
Assert.Contains("status 401 <kid=abc>", html); // section text escaped
|
||||
Assert.Contains("<td style=\"padding:3px 0;\">Platform</td>", html); // facts as a table
|
||||
Assert.Contains("status 401 <kid=abc>", html); // section text escaped
|
||||
Assert.DoesNotContain("<kid=abc>", html);
|
||||
Assert.Contains("href=\"https://portal.example.invalid/admin/settings/alerts\"", html);
|
||||
Assert.Contains("<script>", html); // payload escaped
|
||||
Assert.DoesNotContain("<script>", html);
|
||||
Assert.Contains("PushDeliveryFailed</p>", html); // event type in the footer
|
||||
Assert.Contains("<td style=\"padding:3px 0;\"><script></td>", html); // payload as rows, escaped
|
||||
Assert.Contains("<td style=\"padding:3px 0;\">{"a":1}</td>", html); // nested values stay compact JSON
|
||||
Assert.DoesNotContain("<pre", html); // no raw JSON block for an object
|
||||
Assert.Contains("Open alerts: <a href=\"https://portal.example.invalid/admin/settings/alerts\"", html); // visible link
|
||||
Assert.Contains("sent automatically by Autopilot Monitor", html);
|
||||
|
||||
var text = EmailAlertRenderer.Text(alert);
|
||||
Assert.StartsWith("Autopilot Monitor raised an error in the Platform category.", text);
|
||||
Assert.Contains("Push delivery failed\n", text);
|
||||
Assert.Contains("Category: Platform\n", text);
|
||||
Assert.Contains("note: <script>\n", text); // text part is not HTML-escaped
|
||||
Assert.Contains("Open alerts: https://portal.example.invalid/admin/settings/alerts", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renderer_drops_non_http_links_caps_the_payload_and_survives_an_empty_alert()
|
||||
public void Renderer_drops_non_http_links_caps_a_non_object_payload_and_survives_an_empty_alert()
|
||||
{
|
||||
var alert = new NotificationAlert { Title = "", Summary = "", DataJson = new string('x', EmailAlertRenderer.MaxPayloadChars + 100) };
|
||||
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "bad", Url = "javascript:alert(1)" });
|
||||
|
||||
Assert.Equal("[Autopilot Monitor] Notification", EmailAlertRenderer.Subject(alert));
|
||||
Assert.Equal("Autopilot Monitor: Notification", EmailAlertRenderer.Subject(alert));
|
||||
var html = EmailAlertRenderer.Html(alert);
|
||||
Assert.DoesNotContain("javascript:", html);
|
||||
Assert.Contains("(truncated)", html);
|
||||
Assert.True(html.Length < EmailAlertRenderer.MaxPayloadChars + 3000);
|
||||
Assert.Equal("Autopilot Monitor: Hello world", EmailAlertRenderer.Subject(new NotificationAlert { Title = "Hello world", Summary = "" }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PushDeliveryFailed", "Push delivery failed")]
|
||||
[InlineData("SlaBreachNotification", "Sla breach notification")]
|
||||
[InlineData("SLAEvaluation", "SLA evaluation")]
|
||||
[InlineData("enrollment_failed", "Enrollment failed")]
|
||||
[InlineData("TenantSignup", "Tenant signup")]
|
||||
public void Humanize_splits_event_names_into_words(string eventName, string expected)
|
||||
=> Assert.Equal(expected, EmailAlertRenderer.Humanize(eventName));
|
||||
|
||||
// ── Sending through the provider path ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -185,9 +206,12 @@ public class EmailChannelProviderTests
|
||||
Assert.Equal("Sent to 2 recipient(s). Provider status: sent, id abc,abc.", result.Message);
|
||||
Assert.Equal(2, handler.Bodies.Count);
|
||||
Assert.Contains("\"to\":[{\"email\":\"ops@example.invalid\"", handler.Bodies[0]);
|
||||
Assert.Contains("\"subject\":\"[Autopilot Monitor] Error: Ops Alert: Platform/PushDeliveryFailed\"", handler.Bodies[0]);
|
||||
Assert.Contains("\"subject\":\"Autopilot Monitor: Push delivery failed (Platform)\"", handler.Bodies[0]);
|
||||
Assert.Contains("\"tags\":[\"alert\"]", handler.Bodies[0]);
|
||||
Assert.Contains("\"track_opens\":false", handler.Bodies[0]);
|
||||
Assert.Contains("\"text\":\"Autopilot Monitor raised an error", handler.Bodies[0]); // own text part
|
||||
Assert.Contains("\"auto_text\":false", handler.Bodies[0]);
|
||||
Assert.Contains("\"headers\":{\"Auto-Submitted\":\"auto-generated\",\"X-Auto-Response-Suppress\":\"All\"}", handler.Bodies[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -173,7 +173,8 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
/// (<see cref="Notifications.EmailNotificationService"/>) on the same provider path as the
|
||||
/// transactional mails. Returns true when the provider accepted it. Never throws.
|
||||
/// </summary>
|
||||
public virtual async Task<EmailSendOutcome> SendMessageAsync(string toEmail, string subject, string html, string tag, CancellationToken ct = default)
|
||||
public virtual async Task<EmailSendOutcome> SendMessageAsync(string toEmail, string subject, string html, string tag,
|
||||
string? text = null, IReadOnlyDictionary<string, string>? headers = null, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
{
|
||||
@@ -182,7 +183,7 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(toEmail))
|
||||
return EmailSendOutcome.NotSent;
|
||||
return await SendViaMandrillAsync(toEmail, subject, html, tag, ct);
|
||||
return await SendViaMandrillAsync(toEmail, subject, html, tag, ct, text, headers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -192,7 +193,8 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
/// <c>reject_reason</c> in the warning). Never throws; the outcome carries the provider's status
|
||||
/// word and message id so a mail that never arrived can be looked up in its activity log.
|
||||
/// </summary>
|
||||
private async Task<EmailSendOutcome> SendViaMandrillAsync(string toEmail, string subject, string html, string tag, CancellationToken ct)
|
||||
private async Task<EmailSendOutcome> SendViaMandrillAsync(string toEmail, string subject, string html, string tag, CancellationToken ct,
|
||||
string? text = null, IReadOnlyDictionary<string, string>? headers = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -206,7 +208,11 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
To = new[] { new MandrillRecipient { Email = toEmail, Type = "to" } },
|
||||
Subject = subject,
|
||||
Html = html,
|
||||
AutoText = true,
|
||||
// A text part of our own (the alert mails) beats the provider's derived one; the
|
||||
// templates still let the provider derive it.
|
||||
Text = text,
|
||||
AutoText = text is null,
|
||||
Headers = headers is { Count: > 0 } ? new Dictionary<string, string>(headers) : null,
|
||||
TrackOpens = false,
|
||||
TrackClicks = false,
|
||||
Tags = new[] { tag },
|
||||
@@ -275,7 +281,9 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
[JsonPropertyName("to")] public MandrillRecipient[] To { get; set; } = Array.Empty<MandrillRecipient>();
|
||||
[JsonPropertyName("subject")] public string Subject { get; set; } = string.Empty;
|
||||
[JsonPropertyName("html")] public string Html { get; set; } = string.Empty;
|
||||
[JsonPropertyName("text")] public string? Text { get; set; }
|
||||
[JsonPropertyName("auto_text")] public bool AutoText { get; set; }
|
||||
[JsonPropertyName("headers")] public Dictionary<string, string>? Headers { get; set; }
|
||||
[JsonPropertyName("track_opens")] public bool TrackOpens { get; set; }
|
||||
[JsonPropertyName("track_clicks")] public bool TrackClicks { get; set; }
|
||||
[JsonPropertyName("tags")] public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
|
||||
+171
-49
@@ -1,31 +1,40 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a <see cref="NotificationAlert"/> as an e-mail: subject and a self-contained HTML body
|
||||
/// (inline styles, no images, no scripts) that reads well in every mail client and in its
|
||||
/// auto-generated plain-text form. Every value from the alert is HTML-escaped; the structured
|
||||
/// payload (<see cref="NotificationAlert.DataJson"/>, present only when the rule opted in) is
|
||||
/// shown as preformatted text, capped so a large details object cannot bloat the mail.
|
||||
/// Renders a <see cref="NotificationAlert"/> as an e-mail that reads like a message, not like a
|
||||
/// log line — the difference between the inbox and the junk folder for a transactional sender
|
||||
/// whose SPF, DKIM and DMARC are already in order. Subject without brackets or shouting, one
|
||||
/// introductory sentence, the facts as a table, the structured payload (present only when the
|
||||
/// rule opted in) as a key/value list instead of raw JSON, a visible portal link, and a
|
||||
/// plain-text part of its own so the multipart message is balanced. Every value from the alert
|
||||
/// is HTML-escaped; the payload is capped so a large details object cannot bloat the mail.
|
||||
/// </summary>
|
||||
public static class EmailAlertRenderer
|
||||
{
|
||||
public const int MaxPayloadChars = 8 * 1024;
|
||||
public const string SubjectPrefix = "[Autopilot Monitor]";
|
||||
public const int MaxPayloadRows = 40;
|
||||
public const string Brand = "Autopilot Monitor";
|
||||
|
||||
private static readonly Regex OpsAlertTitle = new(@"^Ops Alert:\s*(?<category>[^/]+)/(?<event>[A-Za-z0-9_]+)\s*$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// "Autopilot Monitor: Push delivery failed (Platform)" for an ops alert, "Autopilot Monitor:
|
||||
/// <title>" for everything else. No bracketed tags, no severity words — those are the
|
||||
/// patterns bulk filters learn to dislike; the severity is the first line of the body.
|
||||
/// </summary>
|
||||
public static string Subject(NotificationAlert alert)
|
||||
{
|
||||
var severity = alert.Severity switch
|
||||
{
|
||||
NotificationSeverity.Error => "Error: ",
|
||||
NotificationSeverity.Warning => "Warning: ",
|
||||
_ => string.Empty,
|
||||
};
|
||||
var title = OneLine(alert.Title, 160);
|
||||
return $"{SubjectPrefix} {severity}{(title.Length > 0 ? title : "Notification")}";
|
||||
var (category, eventName) = SplitOpsTitle(alert.Title);
|
||||
var core = eventName != null
|
||||
? $"{Humanize(eventName)} ({category})"
|
||||
: OneLine(alert.Title, 120);
|
||||
return $"{Brand}: {(core.Length > 0 ? core : "Notification")}";
|
||||
}
|
||||
|
||||
public static string Html(NotificationAlert alert)
|
||||
@@ -33,63 +42,176 @@ public static class EmailAlertRenderer
|
||||
var accent = alert.Severity switch
|
||||
{
|
||||
NotificationSeverity.Error => "#dc2626",
|
||||
NotificationSeverity.Warning => "#f59e0b",
|
||||
NotificationSeverity.Warning => "#d97706",
|
||||
NotificationSeverity.Success => "#16a34a",
|
||||
_ => "#0284c7",
|
||||
};
|
||||
var (category, eventName) = SplitOpsTitle(alert.Title);
|
||||
var heading = eventName != null ? Humanize(eventName) : OneLine(alert.Title, 160);
|
||||
|
||||
var sb = new StringBuilder(2048);
|
||||
sb.Append("<!DOCTYPE html><html><body style=\"margin:0;padding:24px;background:#f9fafb;font-family:Segoe UI,Helvetica,Arial,sans-serif;color:#111827;\">");
|
||||
sb.Append("<!DOCTYPE html><html><body style=\"margin:0;padding:24px;background:#f9fafb;font-family:Segoe UI,Helvetica,Arial,sans-serif;color:#111827;font-size:15px;line-height:1.5;\">");
|
||||
sb.Append("<div style=\"max-width:640px;margin:0 auto;background:#ffffff;border-radius:8px;border-left:4px solid ").Append(accent).Append(";padding:20px 24px;\">");
|
||||
sb.Append("<h1 style=\"font-size:18px;margin:0 0 8px 0;\">").Append(E(alert.Title)).Append("</h1>");
|
||||
if (!string.IsNullOrWhiteSpace(alert.Summary))
|
||||
sb.Append("<p style=\"margin:0 0 16px 0;font-size:14px;color:#374151;\">").Append(E(alert.Summary)).Append("</p>");
|
||||
sb.Append("<p style=\"margin:0 0 12px 0;\">").Append(E(Intro(alert, category))).Append("</p>");
|
||||
sb.Append("<h1 style=\"font-size:18px;margin:0 0 8px 0;\">").Append(E(heading)).Append("</h1>");
|
||||
if (!string.IsNullOrWhiteSpace(alert.Summary) && !string.Equals(alert.Summary.Trim(), heading, StringComparison.Ordinal))
|
||||
sb.Append("<p style=\"margin:0 0 16px 0;color:#374151;\">").Append(E(alert.Summary)).Append("</p>");
|
||||
|
||||
var facts = alert.Facts.Where(f => f != null && !string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value)).ToList();
|
||||
var facts = Facts(alert);
|
||||
if (facts.Count > 0)
|
||||
{
|
||||
sb.Append("<table style=\"border-collapse:collapse;font-size:14px;margin:0 0 16px 0;\">");
|
||||
foreach (var fact in facts)
|
||||
{
|
||||
sb.Append("<tr><td style=\"padding:3px 16px 3px 0;color:#6b7280;vertical-align:top;white-space:nowrap;\">").Append(E(fact.Name))
|
||||
.Append("</td><td style=\"padding:3px 0;\">").Append(E(fact.Value)).Append("</td></tr>");
|
||||
}
|
||||
sb.Append("</table>");
|
||||
}
|
||||
AppendTable(sb, facts);
|
||||
|
||||
foreach (var section in alert.Sections.Where(s => s != null && !string.IsNullOrWhiteSpace(s.Text)))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(section.Title))
|
||||
sb.Append("<h2 style=\"font-size:14px;margin:16px 0 4px 0;\">").Append(E(section.Title)).Append("</h2>");
|
||||
sb.Append("<p style=\"margin:0 0 12px 0;font-size:14px;white-space:pre-wrap;\">").Append(E(section.Text)).Append("</p>");
|
||||
sb.Append("<h2 style=\"font-size:15px;margin:16px 0 4px 0;\">").Append(E(section.Title)).Append("</h2>");
|
||||
sb.Append("<p style=\"margin:0 0 12px 0;white-space:pre-wrap;\">").Append(E(section.Text)).Append("</p>");
|
||||
}
|
||||
|
||||
var links = alert.Actions.Where(a => a != null && IsHttpUrl(a.Url)).ToList();
|
||||
if (links.Count > 0)
|
||||
var payload = PayloadRows(alert.DataJson);
|
||||
if (payload.Rows.Count > 0)
|
||||
{
|
||||
sb.Append("<p style=\"margin:16px 0 0 0;font-size:14px;\">");
|
||||
foreach (var action in links)
|
||||
{
|
||||
sb.Append("<a href=\"").Append(E(action.Url)).Append("\" style=\"color:#0369a1;margin-right:16px;\">")
|
||||
.Append(E(string.IsNullOrWhiteSpace(action.Title) ? action.Url : action.Title)).Append("</a>");
|
||||
}
|
||||
sb.Append("</p>");
|
||||
sb.Append("<h2 style=\"font-size:15px;margin:16px 0 4px 0;\">Details</h2>");
|
||||
AppendTable(sb, payload.Rows);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(alert.DataJson))
|
||||
else if (payload.Raw != null)
|
||||
{
|
||||
var payload = alert.DataJson.Length > MaxPayloadChars ? alert.DataJson[..MaxPayloadChars] + "\n… (truncated)" : alert.DataJson;
|
||||
sb.Append("<h2 style=\"font-size:14px;margin:16px 0 4px 0;\">Details</h2>");
|
||||
sb.Append("<pre style=\"margin:0;padding:12px;background:#f3f4f6;border-radius:6px;font-size:12px;overflow:auto;white-space:pre-wrap;\">").Append(E(payload)).Append("</pre>");
|
||||
sb.Append("<h2 style=\"font-size:15px;margin:16px 0 4px 0;\">Details</h2>");
|
||||
sb.Append("<pre style=\"margin:0;padding:12px;background:#f3f4f6;border-radius:6px;font-size:12px;overflow:auto;white-space:pre-wrap;\">").Append(E(payload.Raw)).Append("</pre>");
|
||||
}
|
||||
|
||||
sb.Append("<p style=\"margin:20px 0 0 0;font-size:12px;color:#9ca3af;\">Sent by Autopilot Monitor");
|
||||
if (!string.IsNullOrWhiteSpace(alert.EventType))
|
||||
sb.Append(" · ").Append(E(alert.EventType));
|
||||
sb.Append("</p></div></body></html>");
|
||||
foreach (var action in Links(alert))
|
||||
{
|
||||
sb.Append("<p style=\"margin:16px 0 0 0;\">").Append(E(action.Title)).Append(": <a href=\"").Append(E(action.Url))
|
||||
.Append("\" style=\"color:#0369a1;\">").Append(E(action.Url)).Append("</a></p>");
|
||||
}
|
||||
|
||||
sb.Append("<p style=\"margin:20px 0 0 0;font-size:12px;color:#6b7280;\">This message was sent automatically by ").Append(Brand)
|
||||
.Append(" because an alert rule in your platform settings is bound to an e-mail channel. Change the rule or the channel in the portal to stop it.</p>");
|
||||
sb.Append("</div></body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>The plain-text twin of <see cref="Html"/>: same content, no markup.</summary>
|
||||
public static string Text(NotificationAlert alert)
|
||||
{
|
||||
var (category, eventName) = SplitOpsTitle(alert.Title);
|
||||
var heading = eventName != null ? Humanize(eventName) : OneLine(alert.Title, 160);
|
||||
var sb = new StringBuilder(1024);
|
||||
sb.Append(Intro(alert, category)).Append("\n\n");
|
||||
sb.Append(heading).Append('\n');
|
||||
if (!string.IsNullOrWhiteSpace(alert.Summary) && !string.Equals(alert.Summary.Trim(), heading, StringComparison.Ordinal))
|
||||
sb.Append(alert.Summary.Trim()).Append('\n');
|
||||
sb.Append('\n');
|
||||
foreach (var (name, value) in Facts(alert))
|
||||
sb.Append(name).Append(": ").Append(value).Append('\n');
|
||||
foreach (var section in alert.Sections.Where(s => s != null && !string.IsNullOrWhiteSpace(s.Text)))
|
||||
{
|
||||
sb.Append('\n');
|
||||
if (!string.IsNullOrWhiteSpace(section.Title))
|
||||
sb.Append(section.Title.Trim()).Append('\n');
|
||||
sb.Append(section.Text.Trim()).Append('\n');
|
||||
}
|
||||
var payload = PayloadRows(alert.DataJson);
|
||||
if (payload.Rows.Count > 0 || payload.Raw != null)
|
||||
{
|
||||
sb.Append("\nDetails\n");
|
||||
foreach (var (name, value) in payload.Rows)
|
||||
sb.Append(name).Append(": ").Append(value).Append('\n');
|
||||
if (payload.Raw != null)
|
||||
sb.Append(payload.Raw).Append('\n');
|
||||
}
|
||||
foreach (var action in Links(alert))
|
||||
sb.Append('\n').Append(action.Title).Append(": ").Append(action.Url).Append('\n');
|
||||
sb.Append("\n--\nThis message was sent automatically by ").Append(Brand)
|
||||
.Append(" because an alert rule in your platform settings is bound to an e-mail channel. Change the rule or the channel in the portal to stop it.\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── pieces ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static string Intro(NotificationAlert alert, string? category)
|
||||
{
|
||||
var severity = alert.Severity switch
|
||||
{
|
||||
NotificationSeverity.Error => "an error",
|
||||
NotificationSeverity.Warning => "a warning",
|
||||
NotificationSeverity.Success => "a success notification",
|
||||
_ => "an informational notification",
|
||||
};
|
||||
return category != null
|
||||
? $"{Brand} raised {severity} in the {category} category."
|
||||
: $"{Brand} sent you {severity}.";
|
||||
}
|
||||
|
||||
private static (string? Category, string? Event) SplitOpsTitle(string? title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title)) return (null, null);
|
||||
var m = OpsAlertTitle.Match(title.Trim());
|
||||
return m.Success ? (m.Groups["category"].Value.Trim(), m.Groups["event"].Value) : (null, null);
|
||||
}
|
||||
|
||||
/// <summary>"PushDeliveryFailed" → "Push delivery failed"; "SLA" stays upper-case; snake_case is split too.</summary>
|
||||
public static string Humanize(string value)
|
||||
{
|
||||
var words = Regex.Replace(value.Replace('_', ' '), @"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ").Trim();
|
||||
if (words.Length == 0) return value;
|
||||
var parts = words.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select((w, i) => w.All(char.IsUpper) && w.Length > 1 ? w : i == 0 ? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant() : w.ToLowerInvariant());
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
|
||||
private static List<(string Name, string Value)> Facts(NotificationAlert alert)
|
||||
=> alert.Facts.Where(f => f != null && !string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value))
|
||||
.Select(f => (f.Name.Trim(), f.Value.Trim())).ToList();
|
||||
|
||||
private static List<NotificationAction> Links(NotificationAlert alert)
|
||||
=> alert.Actions.Where(a => a != null && IsHttpUrl(a.Url))
|
||||
.Select(a => new NotificationAction { Type = a.Type, Url = a.Url, Title = string.IsNullOrWhiteSpace(a.Title) ? "Open in the portal" : a.Title.Trim() })
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// A JSON object becomes key/value rows (nested values stay compact JSON); anything else is
|
||||
/// kept as capped raw text. Rows are capped too, so a details object never turns into a wall.
|
||||
/// </summary>
|
||||
private static (List<(string Name, string Value)> Rows, string? Raw) PayloadRows(string? dataJson)
|
||||
{
|
||||
var rows = new List<(string, string)>();
|
||||
if (string.IsNullOrWhiteSpace(dataJson))
|
||||
return (rows, null);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(dataJson);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (rows.Count >= MaxPayloadRows) { rows.Add(("…", "further values omitted")); break; }
|
||||
var value = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() ?? string.Empty : prop.Value.GetRawText();
|
||||
rows.Add((prop.Name, OneLine(value, 400)));
|
||||
}
|
||||
return (rows, null);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not JSON: shown as text below.
|
||||
}
|
||||
var raw = dataJson.Length > MaxPayloadChars ? dataJson[..MaxPayloadChars] + "\n… (truncated)" : dataJson;
|
||||
return (rows, raw);
|
||||
}
|
||||
|
||||
private static void AppendTable(StringBuilder sb, List<(string Name, string Value)> rows)
|
||||
{
|
||||
sb.Append("<table style=\"border-collapse:collapse;margin:0 0 16px 0;\">");
|
||||
foreach (var (name, value) in rows)
|
||||
{
|
||||
sb.Append("<tr><td style=\"padding:3px 16px 3px 0;color:#6b7280;vertical-align:top;white-space:nowrap;\">").Append(E(name))
|
||||
.Append("</td><td style=\"padding:3px 0;\">").Append(E(value)).Append("</td></tr>");
|
||||
}
|
||||
sb.Append("</table>");
|
||||
}
|
||||
|
||||
private static string E(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
|
||||
private static string OneLine(string? value, int max)
|
||||
|
||||
+12
-1
@@ -18,6 +18,16 @@ public class EmailNotificationService : IEmailChannelSender
|
||||
public const int MaxRecipients = 5;
|
||||
public const string Tag = "alert";
|
||||
|
||||
/// <summary>
|
||||
/// Declares the mail as machine-generated (RFC 3834): auto-responders stay quiet, and filters
|
||||
/// read it as a notification rather than as someone pretending to write a letter.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Auto-Submitted"] = "auto-generated",
|
||||
["X-Auto-Response-Suppress"] = "All",
|
||||
};
|
||||
|
||||
private readonly EmailService _email;
|
||||
private readonly ILogger<EmailNotificationService> _logger;
|
||||
private readonly TelemetryClient? _telemetry;
|
||||
@@ -74,9 +84,10 @@ public class EmailNotificationService : IEmailChannelSender
|
||||
|
||||
var subject = EmailAlertRenderer.Subject(alert);
|
||||
var html = EmailAlertRenderer.Html(alert);
|
||||
var text = EmailAlertRenderer.Text(alert);
|
||||
var outcomes = new List<EmailSendOutcome>();
|
||||
foreach (var address in addresses.Take(MaxRecipients))
|
||||
outcomes.Add(await _email.SendMessageAsync(address, subject, html, Tag).ConfigureAwait(false));
|
||||
outcomes.Add(await _email.SendMessageAsync(address, subject, html, Tag, text, Headers).ConfigureAwait(false));
|
||||
|
||||
var attempted = outcomes.Count;
|
||||
var accepted = outcomes.Count(o => o.Accepted);
|
||||
|
||||
Reference in New Issue
Block a user