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.
" +
+ "";
+ 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(@"||