diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs index 3d473786..a4ca89a2 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs @@ -113,7 +113,12 @@ public sealed class EmailServiceTests Assert.Equal("to", message.GetProperty("to")[0].GetProperty("type").GetString()); Assert.Equal(EmailTemplates.PreviewApprovedSubject, message.GetProperty("subject").GetString()); Assert.Contains("contoso.invalid", message.GetProperty("html").GetString()); - Assert.True(message.GetProperty("auto_text").GetBoolean()); + // A text part of our own (HtmlToText) instead of the provider's derived one, and the + // RFC 3834 header on every automated mail. + Assert.False(message.GetProperty("auto_text").GetBoolean()); + Assert.Contains("contoso.invalid", message.GetProperty("text").GetString()); + Assert.DoesNotContain("<", message.GetProperty("text").GetString()); + Assert.Equal("auto-generated", message.GetProperty("headers").GetProperty("Auto-Submitted").GetString()); Assert.False(message.GetProperty("track_opens").GetBoolean()); Assert.False(message.GetProperty("track_clicks").GetBoolean()); Assert.Equal("welcome", message.GetProperty("tags")[0].GetString()); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/HtmlToTextTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/HtmlToTextTests.cs new file mode 100644 index 00000000..8748dd6a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/HtmlToTextTests.cs @@ -0,0 +1,54 @@ +using AutopilotMonitor.Functions.Services; + +namespace AutopilotMonitor.Functions.Tests; + +/// The text/plain twin of the HTML mails (HtmlToText): what it keeps, what it drops, how it breaks lines. +public class HtmlToTextTests +{ + [Fact] + public void Drops_head_style_script_and_comments_and_keeps_the_prose() + { + const string html = "t" + + "

Hello world

"; + Assert.Equal("Hello world", HtmlToText.Convert(html)); + } + + [Fact] + public void Turns_blocks_lists_and_breaks_into_lines_and_decodes_entities() + { + const string html = "

Welcome

Great – access for contoso.invalid is ready.
Second line.

" + + "
ab
"; + var text = HtmlToText.Convert(html); + Assert.Equal("Welcome\nGreat – access for contoso.invalid is ready.\nSecond line.\n\n- One & two\n- Three\na b", text); + } + + [Fact] + public void Keeps_link_targets_and_collapses_whitespace() + { + const string html = "

You can now sign in and start.

" + + "

https://docs.example.invalid

" + + "

write us or this

"; + var text = HtmlToText.Convert(html); + Assert.Equal("You can now sign in (https://portal.example.invalid/) and start.\nhttps://docs.example.invalid\nwrite us or this (https://x.invalid/a?b=1&c=2)", text); + } + + [Fact] + public void Survives_empty_and_broken_markup() + { + Assert.Equal(string.Empty, HtmlToText.Convert(null)); + Assert.Equal(string.Empty, HtmlToText.Convert(" ")); + Assert.Equal("unterminated bold", HtmlToText.Convert("

unterminated bold")); + Assert.Equal("x", HtmlToText.Convert("x")); + } + + [Fact] + public void Renders_the_default_welcome_template_as_readable_text() + { + var text = HtmlToText.Convert(EmailTemplates.GetPreviewApprovedHtml("contoso.invalid")); + Assert.Contains("Welcome to Autopilot Monitor", text); + Assert.Contains("contoso.invalid", text); + Assert.DoesNotContain("<", text); + Assert.DoesNotContain("style=", text); + Assert.Contains("https://", text); // the links survive as URLs + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs b/src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs index 658f9c4d..223a9b6d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs @@ -42,6 +42,16 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender public const string DefaultFromAddress = "noreply@autopilotmonitor.com"; public const string DefaultFromName = "Autopilot Monitor"; + ///

+ /// RFC 3834: every mail the platform sends is machine-generated — auto-responders stay quiet, + /// and filters read the message as a notification rather than as someone pretending to write. + /// + public static readonly IReadOnlyDictionary AutomatedHeaders = new Dictionary + { + ["Auto-Submitted"] = "auto-generated", + ["X-Auto-Response-Suppress"] = "All", + }; + private static readonly JsonSerializerOptions JsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, @@ -85,12 +95,9 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender return false; } - var sent = (await SendViaMandrillAsync( - toEmail, - EmailTemplates.PreviewApprovedSubject, - await _templates.GetHtmlAsync(EmailTemplateKind.Welcome, domainName, ct), - tag: "welcome", - ct)).Accepted; + var html = await _templates.GetHtmlAsync(EmailTemplateKind.Welcome, domainName, ct); + var sent = (await SendViaMandrillAsync(toEmail, EmailTemplates.PreviewApprovedSubject, html, "welcome", ct, + HtmlToText.Convert(html), AutomatedHeaders)).Accepted; if (sent) { @@ -129,12 +136,9 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender return false; } - var sent = (await SendViaMandrillAsync( - toEmail, - EmailTemplates.OffboardingFarewellSubject, - await _templates.GetHtmlAsync(EmailTemplateKind.Farewell, domainName, ct), - tag: "offboarding-farewell", - ct)).Accepted; + var html = await _templates.GetHtmlAsync(EmailTemplateKind.Farewell, domainName, ct); + var sent = (await SendViaMandrillAsync(toEmail, EmailTemplates.OffboardingFarewellSubject, html, "offboarding-farewell", ct, + HtmlToText.Convert(html), AutomatedHeaders)).Accepted; if (sent) { @@ -159,7 +163,8 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender ? await _templates.GetHtmlAsync(kind, domainName, ct) : EmailTemplateService.Render(draftHtml, domainName); - var sent = (await SendViaMandrillAsync(toEmail, EmailTemplateService.Subject(kind), html, tag: "test", ct)).Accepted; + var sent = (await SendViaMandrillAsync(toEmail, EmailTemplateService.Subject(kind), html, "test", ct, + HtmlToText.Convert(html), AutomatedHeaders)).Accepted; if (sent) _logger.LogInformation("{Kind} test email sent to {ToEmail} (draft={IsDraft})", kind, toEmail, draftHtml is not null); return sent; @@ -208,8 +213,8 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender To = new[] { new MandrillRecipient { Email = toEmail, Type = "to" } }, Subject = subject, Html = html, - // A text part of our own (the alert mails) beats the provider's derived one; the - // templates still let the provider derive it. + // A text part of our own beats the provider's derived one (a balanced multipart is + // a content signal filters weigh); auto_text stays as the fallback for callers without one. Text = text, AutoText = text is null, Headers = headers is { Count: > 0 } ? new Dictionary(headers) : null, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/HtmlToText.cs b/src/Backend/AutopilotMonitor.Functions/Services/HtmlToText.cs new file mode 100644 index 00000000..2890c3f7 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/HtmlToText.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace AutopilotMonitor.Functions.Services; + +/// +/// The plain-text twin of an HTML mail, good enough for the text/plain part of a multipart +/// message: a balanced multipart with a real text part is one of the strongest content signals +/// a mail filter has, and a derived one by the provider is weaker than our own. Conservative on +/// purpose — head, style, script and comments go, block boundaries become line breaks, list +/// items get a dash, links keep their URL in parentheses, entities are decoded, whitespace is +/// collapsed. Never throws on odd markup; the worst case is slightly ugly text. +/// +public static class HtmlToText +{ + private const RegexOptions Opts = RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled; + + private static readonly Regex Dropped = new(@"|||", Opts); + private static readonly Regex Anchor = new(@"]*?href\s*=\s*([""'])(?.*?)\1[^>]*>(?.*?)", Opts); + private static readonly Regex LineBreak = new(@"", Opts); + private static readonly Regex ListItem = new(@"]*>", Opts); + // A list item ends where the next one starts ("\n- "), so adds no line of its own. + private static readonly Regex BlockEnd = new(@"", Opts); + private static readonly Regex CellEnd = new(@"", Opts); + private static readonly Regex AnyTag = new(@"<[^>]+>", Opts); + private static readonly Regex Spaces = new(@"[ \t ]+", RegexOptions.Compiled); + private static readonly Regex BlankLines = new(@"\n{3,}", RegexOptions.Compiled); + + public static string Convert(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + return string.Empty; + + var text = Dropped.Replace(html, string.Empty); + text = Anchor.Replace(text, m => + { + var url = WebUtility.HtmlDecode(m.Groups["url"].Value).Trim(); + var label = Spaces.Replace(WebUtility.HtmlDecode(AnyTag.Replace(m.Groups["text"].Value, string.Empty)), " ").Trim(); + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + return label; + if (label.Length == 0 || string.Equals(label, url, StringComparison.OrdinalIgnoreCase)) + return url; + return $"{label} ({url})"; + }); + text = LineBreak.Replace(text, "\n"); + text = ListItem.Replace(text, "\n- "); + text = BlockEnd.Replace(text, "\n"); + text = CellEnd.Replace(text, " "); + text = AnyTag.Replace(text, string.Empty); + text = WebUtility.HtmlDecode(text); + text = text.Replace("\r\n", "\n").Replace('\r', '\n'); + + var lines = text.Split('\n').Select(l => Spaces.Replace(l, " ").Trim()); + var joined = string.Join("\n", lines).Trim(); + return BlankLines.Replace(joined, "\n\n"); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/EmailNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/EmailNotificationService.cs index fc725571..93fdd0a8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/EmailNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/EmailNotificationService.cs @@ -18,15 +18,8 @@ public class EmailNotificationService : IEmailChannelSender public const int MaxRecipients = 5; public const string Tag = "alert"; - /// - /// 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. - /// - public static readonly IReadOnlyDictionary Headers = new Dictionary - { - ["Auto-Submitted"] = "auto-generated", - ["X-Auto-Response-Suppress"] = "All", - }; + /// The platform's automated-mail headers (RFC 3834), shared with the template mails. + public static IReadOnlyDictionary Headers => EmailService.AutomatedHeaders; private readonly EmailService _email; private readonly ILogger _logger;