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:
Oliver Kieselbach
2026-10-08 17:59:25 +02:00
parent 9d50ff8901
commit a5a1039d22
4 changed files with 232 additions and 67 deletions
@@ -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 &lt;kid=abc&gt;", html); // section text escaped
Assert.Contains("<td style=\"padding:3px 0;\">Platform</td>", html); // facts as a table
Assert.Contains("status 401 &lt;kid=abc&gt;", html); // section text escaped
Assert.DoesNotContain("<kid=abc>", html);
Assert.Contains("href=\"https://portal.example.invalid/admin/settings/alerts\"", html);
Assert.Contains("&lt;script&gt;", html); // payload escaped
Assert.DoesNotContain("<script>", html);
Assert.Contains("PushDeliveryFailed</p>", html); // event type in the footer
Assert.Contains("<td style=\"padding:3px 0;\">&lt;script&gt;</td>", html); // payload as rows, escaped
Assert.Contains("<td style=\"padding:3px 0;\">{&quot;a&quot;: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>();
@@ -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:
/// &lt;title&gt;" 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)
@@ -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);