diff --git a/src/BuildingBlocks/Mailing/HtmlEmail.cs b/src/BuildingBlocks/Mailing/HtmlEmail.cs new file mode 100644 index 0000000000..d09621008a --- /dev/null +++ b/src/BuildingBlocks/Mailing/HtmlEmail.cs @@ -0,0 +1,110 @@ +using System.Net; + +namespace FSH.Framework.Mailing; + +/// +/// The shared HTML shell and the single encoder for outbound mail. Bodies are sent as +/// text/html, so any value reaching the markup has to be encoded or it is parsed as markup +/// rather than shown. Keeping the encoder here means a module cannot ship its own weaker escaping. +/// The shell is not yet universal: UserRegistrationService.BuildConfirmationEmailHtml still +/// builds its own document, because migrating it changes the layout of the most-seen e-mail in the +/// product and deserves its own review. +/// +/// +/// Pair every HTML body with a text/plain alternative on : +/// HTML-only mail leaves text-only clients with nothing and scores worse with spam filters. +/// +public static class HtmlEmail +{ + /// + /// HTML-encodes a value for insertion into markup. Covers quotes and apostrophes as well as + /// &, < and >, so the same call is safe in an attribute and in + /// element content. + /// + public static string Encode(string value) + { + ArgumentNullException.ThrowIfNull(value); + + return WebUtility.HtmlEncode(value); + } + + /// + /// Wraps already-built markup in the shared document: doctype, charset, viewport, and the + /// centred card every e-mail from the kit renders in. + /// + /// Plain text. Encoded here, and also used as the document title. + /// + /// TRUSTED markup, inserted verbatim and NOT encoded. Build it from literals plus + /// d values; never pass user input straight through. + /// + public static string Shell(string heading, string innerHtml) + { + ArgumentNullException.ThrowIfNull(heading); + ArgumentNullException.ThrowIfNull(innerHtml); + + string safeHeading = Encode(heading); + + return $""" + + + + + + {safeHeading} + + + + + + +
+ + + + +
+

{safeHeading}

+ {innerHtml} +
+
+ + + """; + } + + /// + /// A message whose point is a single action link, rendered as a real anchor so mail clients make + /// it clickable. The address is repeated as text underneath for clients that strip buttons. + /// + public static string LinkAction(string heading, string intro, string actionUrl, string actionLabel) + { + ArgumentNullException.ThrowIfNull(intro); + ArgumentNullException.ThrowIfNull(actionUrl); + ArgumentNullException.ThrowIfNull(actionLabel); + + string safeIntro = Encode(intro); + string safeUrl = Encode(actionUrl); + string safeLabel = Encode(actionLabel); + + return Shell(heading, $""" +

{safeIntro}

+

+ {safeLabel} +

+

+ If the button does not work, copy this address into your browser:
+ {safeUrl} +

+ """); + } + + /// + /// A short informational message with no action link. + /// + public static string Notice(string heading, string message) + { + ArgumentNullException.ThrowIfNull(message); + + return Shell(heading, $"""

{Encode(message)}

"""); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs index b123831eca..c995d01366 100644 --- a/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs @@ -2,7 +2,6 @@ using FSH.Framework.Mailing; using FSH.Framework.Mailing.Services; using FSH.Modules.Identity.Contracts.Events; -using FSH.Modules.Identity.Services; using Microsoft.Extensions.Logging; namespace FSH.Modules.Identity.Events; @@ -41,7 +40,7 @@ public async Task HandleAsync(UserRegisteredIntegrationEvent @event, Cancellatio var mail = new MailRequest( to: new System.Collections.ObjectModel.Collection { @event.Email }, subject: "Welcome!", - body: EmailBodies.NoticeHtml("Welcome!", greeting), + body: HtmlEmail.Notice("Welcome!", greeting), textBody: greeting); await _mailService.SendAsync(mail, ct).ConfigureAwait(false); diff --git a/src/Modules/Identity/Modules.Identity/Services/EmailBodies.cs b/src/Modules/Identity/Modules.Identity/Services/EmailBodies.cs deleted file mode 100644 index b5dfd4da34..0000000000 --- a/src/Modules/Identity/Modules.Identity/Services/EmailBodies.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Net; - -namespace FSH.Modules.Identity.Services; - -/// -/// Builds the HTML bodies for identity e-mails. Every value that reaches the markup is HTML-encoded: -/// bodies are sent as text/html, so an unescaped name or URL is parsed as markup rather than shown. -/// -internal static class EmailBodies -{ - /// - /// A message whose point is a single action link, rendered as a real anchor so mail clients make it - /// clickable. Callers pair this with a text/plain alternative carrying the same URL. - /// - internal static string LinkActionHtml(string heading, string intro, string actionUrl, string actionLabel) - { - string safeHeading = WebUtility.HtmlEncode(heading); - string safeIntro = WebUtility.HtmlEncode(intro); - string safeUrl = WebUtility.HtmlEncode(actionUrl); - string safeLabel = WebUtility.HtmlEncode(actionLabel); - - return $""" - - - - - - {safeHeading} - - - - - - -
- - - - -
-

{safeHeading}

-

{safeIntro}

-

- {safeLabel} -

-

- If the button does not work, copy this address into your browser:
- {safeUrl} -

-
-
- - - """; - } - - /// - /// A short informational message with no action link. - /// - internal static string NoticeHtml(string heading, string message) - { - string safeHeading = WebUtility.HtmlEncode(heading); - string safeMessage = WebUtility.HtmlEncode(message); - - return $""" - - - - - - {safeHeading} - - - - - - -
- - - - -
-

{safeHeading}

-

{safeMessage}

-
-
- - - """; - } -} diff --git a/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs index 46d1251dba..528da410a4 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs @@ -56,7 +56,7 @@ public async Task ForgotPasswordAsync(string email, string origin, CancellationT var mailRequest = new MailRequest( new Collection { user.Email }, "Reset Password", - EmailBodies.LinkActionHtml( + HtmlEmail.LinkAction( heading: "Reset your password", intro: "Use the link below to choose a new password.", actionUrl: resetPasswordUri, diff --git a/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs b/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs index 1ae5c7b281..c88a0a2ff6 100644 --- a/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs +++ b/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs @@ -1,4 +1,5 @@ using System.Globalization; +using FSH.Framework.Mailing; namespace FSH.Modules.Notifications.IntegrationEventHandlers; @@ -6,7 +7,8 @@ namespace FSH.Modules.Notifications.IntegrationEventHandlers; /// Builds the subject + HTML body + text/plain alternative for tenant billing emails. Plain interpolated /// HTML (the framework has no template engine); kept here so the handlers stay thin and the copy is easy /// to review. Every message carries both parts: HTML-only mail leaves text-only clients with nothing and -/// scores worse with spam filters. +/// scores worse with spam filters. The document shell and the encoder come from +/// so every module escapes the same way. /// internal static class BillingEmailBodies { @@ -19,8 +21,8 @@ public static (string Subject, string Body, string TextBody) NearingExpiry(strin : $"Your subscription expires in {daysRemaining} days"; var plan = planKey ?? "current"; var body = Wrap(subject, - $"

Hi {Escape(tenantName)},

" + - $"

Your {Escape(plan)} subscription is valid until " + + $"

Hi {HtmlEmail.Encode(tenantName)},

" + + $"

Your {HtmlEmail.Encode(plan)} subscription is valid until " + $"{Date(validUpto)} ({daysRemaining} day(s) remaining).

" + "

Please contact your account operator to renew and avoid any interruption to your service.

"); var text = Text(subject, @@ -35,8 +37,8 @@ public static (string Subject, string Body, string TextBody) EnteredGrace(string const string subject = "Your subscription has lapsed — grace period active"; var plan = planKey ?? "current"; var body = Wrap(subject, - $"

Hi {Escape(tenantName)},

" + - $"

Your {Escape(plan)} subscription expired on " + + $"

Hi {HtmlEmail.Encode(tenantName)},

" + + $"

Your {HtmlEmail.Encode(plan)} subscription expired on " + $"{Date(validUpto)}. Your service continues during a grace period that ends on " + $"{Date(graceEnds)}.

" + "

Please renew before the grace period ends to keep your access uninterrupted.

"); @@ -52,8 +54,8 @@ public static (string Subject, string Body, string TextBody) Expired(string tena const string subject = "Your subscription has expired"; var plan = planKey ?? "current"; var body = Wrap(subject, - $"

Hi {Escape(tenantName)},

" + - $"

Your {Escape(plan)} subscription expired on " + + $"

Hi {HtmlEmail.Encode(tenantName)},

" + + $"

Your {HtmlEmail.Encode(plan)} subscription expired on " + $"{Date(validUpto)} and the grace period has ended, so access is now suspended.

" + "

Contact your account operator to renew and restore access.

"); var text = Text(subject, @@ -69,7 +71,9 @@ public static (string Subject, string Body, string TextBody) InvoiceIssued(strin var amountText = $"{amount.ToString("0.00", CultureInfo.InvariantCulture)} {currency}"; var due = dueAtUtc is null ? string.Empty : $"

Due by {Date(dueAtUtc.Value)}.

"; var body = Wrap(subject, - $"

A new invoice {Escape(invoiceNumber)} for {amountText} has been issued.

" + + // amountText embeds the currency, which is data rather than a literal, so it is encoded + // for the HTML part while the text/plain twin below keeps it verbatim. + $"

A new invoice {HtmlEmail.Encode(invoiceNumber)} for {HtmlEmail.Encode(amountText)} has been issued.

" + due + "

You can view and download this invoice from your dashboard.

"); var text = Text(subject, @@ -79,12 +83,17 @@ public static (string Subject, string Body, string TextBody) InvoiceIssued(strin return (subject, body, text); } + /// + /// The billing copy inside the shared document shell. Only the automated-message footer is + /// specific to these e-mails; the doctype, charset and card come from . + /// private static string Wrap(string heading, string innerHtml) => - "
" + - $"

{Escape(heading)}

" + - innerHtml + - "

This is an automated message.

" + - "
"; + HtmlEmail.Shell( + heading, + "
" + + innerHtml + + "

This is an automated message.

" + + "
"); /// /// The text/plain twin of : same copy, no markup, empty paragraphs dropped so an @@ -92,15 +101,11 @@ private static string Wrap(string heading, string innerHtml) => /// private static string Text(string heading, params string[] paragraphs) { - var lines = new List { heading, string.Empty }; + // The blank entries this used to add were filtered straight back out by the Join, which is + // what actually separates the paragraphs. + var lines = new List { heading }; lines.AddRange(paragraphs.Where(p => !string.IsNullOrWhiteSpace(p))); - lines.Add(string.Empty); lines.Add("This is an automated message."); - return string.Join(Environment.NewLine + Environment.NewLine, lines.Where(l => l.Length > 0)); + return string.Join(Environment.NewLine + Environment.NewLine, lines); } - - private static string Escape(string value) => - value.Replace("&", "&", StringComparison.Ordinal) - .Replace("<", "<", StringComparison.Ordinal) - .Replace(">", ">", StringComparison.Ordinal); } diff --git a/src/Tests/Framework.Tests/Mailing/HtmlEmailTests.cs b/src/Tests/Framework.Tests/Mailing/HtmlEmailTests.cs new file mode 100644 index 0000000000..ada59c7cc5 --- /dev/null +++ b/src/Tests/Framework.Tests/Mailing/HtmlEmailTests.cs @@ -0,0 +1,203 @@ +using FSH.Framework.Mailing; + +namespace Framework.Tests.Mailing; + +public sealed class HtmlEmailTests +{ + #region Encoding + + [Fact] + public void Encode_Should_NeutraliseMarkup_When_ValueContainsTags() + { + // Act + var encoded = HtmlEmail.Encode(""); + + // Assert + encoded.ShouldNotContain(", thanks for registering."); + + // Assert + html.ShouldStartWith(""); + html.ShouldNotContain("