Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
fbff96e
fix(mailing): send real HTML with a text alternative, not bare text
marcelo-maciel Aug 6, 2026
bb89a24
fix(deps): pin System.Security.Cryptography.Xml to 10.0.10
marcelo-maciel Jul 21, 2026
2359257
Merge remote-tracking branch 'origin/main' into fix/mailing-html-bodies
marcelo-maciel Aug 17, 2026
bada7ce
build: pin SSH.NET to 2026.0.0 so restore passes while #1333 is open
marcelo-maciel Aug 17, 2026
2f638ab
refactor(mailing): one HTML shell and one encoder for every module
marcelo-maciel Aug 17, 2026
12bbb3e
fix(notifications): encode the invoice amount, currency included, in …
marcelo-maciel Aug 17, 2026
fd9225c
build(deps): bump Testcontainers to 4.14.0 and SourceLink past their …
marcelo-maciel Sep 14, 2026
f461831
fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub
marcelo-maciel Sep 14, 2026
50ef8b7
fix(infra): pull minio/mc from quay.io too, not just minio/minio
marcelo-maciel Sep 18, 2026
c23f9ce
build(deps): drop the dead SSH.NET pin
marcelo-maciel Sep 18, 2026
f7a4643
fix(mailing): keep a text/plain part when only Body is supplied
marcelo-maciel Sep 18, 2026
a60c403
refactor(mailing): fix a misleading test name and two dead lines
marcelo-maciel Sep 18, 2026
bc6d1da
docs(mailing): say plainly which half of the claim is true
marcelo-maciel Sep 18, 2026
4824dca
Merge remote-tracking branch 'origin/main' into pr/1385
iammukeshm Sep 25, 2026
5bdbbd3
refactor(identity): drop EmailBodies, superseded by the shared HtmlEmail
iammukeshm Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/BuildingBlocks/Mailing/HtmlEmail.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System.Net;

namespace FSH.Framework.Mailing;

/// <summary>
/// The shared HTML shell and the single encoder for outbound mail. Bodies are sent as
/// <c>text/html</c>, 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: <c>UserRegistrationService.BuildConfirmationEmailHtml</c> 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.
/// </summary>
/// <remarks>
/// Pair every HTML body with a <c>text/plain</c> alternative on <see cref="MailRequest.TextBody"/>:
/// HTML-only mail leaves text-only clients with nothing and scores worse with spam filters.
/// </remarks>
public static class HtmlEmail
{
/// <summary>
/// HTML-encodes a value for insertion into markup. Covers quotes and apostrophes as well as
/// <c>&amp;</c>, <c>&lt;</c> and <c>&gt;</c>, so the same call is safe in an attribute and in
/// element content.
/// </summary>
public static string Encode(string value)
{
ArgumentNullException.ThrowIfNull(value);

return WebUtility.HtmlEncode(value);
}

/// <summary>
/// Wraps already-built markup in the shared document: doctype, charset, viewport, and the
/// centred card every e-mail from the kit renders in.
/// </summary>
/// <param name="heading">Plain text. Encoded here, and also used as the document title.</param>
/// <param name="innerHtml">
/// TRUSTED markup, inserted verbatim and NOT encoded. Build it from literals plus
/// <see cref="Encode(string)"/>d values; never pass user input straight through.
/// </param>
public static string Shell(string heading, string innerHtml)
{
ArgumentNullException.ThrowIfNull(heading);
ArgumentNullException.ThrowIfNull(innerHtml);

string safeHeading = Encode(heading);

return $"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{safeHeading}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f8fafc;">
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<tr>
<td align="center" style="padding: 40px 0;">
<table role="presentation" style="width: 100%; max-width: 600px; border-collapse: collapse; background-color: #ffffff; border-radius: 8px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
<tr>
<td style="padding: 40px;">
<h1 style="margin: 0 0 16px 0; font-size: 22px; color: #0f172a;">{safeHeading}</h1>
{innerHtml}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
""";
}

/// <summary>
/// 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.
/// </summary>
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, $"""
<p style="margin: 0 0 24px 0; font-size: 15px; line-height: 1.6; color: #334155;">{safeIntro}</p>
<p style="margin: 0 0 24px 0;">
<a href="{safeUrl}" style="display: inline-block; padding: 12px 24px; background-color: #0f172a; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px;">{safeLabel}</a>
</p>
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #64748b;">
If the button does not work, copy this address into your browser:<br>
<a href="{safeUrl}" style="color: #2563eb; word-break: break-all;">{safeUrl}</a>
</p>
""");
}

/// <summary>
/// A short informational message with no action link.
/// </summary>
public static string Notice(string heading, string message)
{
ArgumentNullException.ThrowIfNull(message);

return Shell(heading, $"""<p style="margin: 0; font-size: 15px; line-height: 1.6; color: #334155;">{Encode(message)}</p>""");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,7 +40,7 @@ public async Task HandleAsync(UserRegisteredIntegrationEvent @event, Cancellatio
var mail = new MailRequest(
to: new System.Collections.ObjectModel.Collection<string> { @event.Email },
subject: "Welcome!",
body: EmailBodies.NoticeHtml("Welcome!", greeting),
body: HtmlEmail.Notice("Welcome!", greeting),
textBody: greeting);

await _mailService.SendAsync(mail, ct).ConfigureAwait(false);
Expand Down
92 changes: 0 additions & 92 deletions src/Modules/Identity/Modules.Identity/Services/EmailBodies.cs

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public async Task ForgotPasswordAsync(string email, string origin, CancellationT
var mailRequest = new MailRequest(
new Collection<string> { user.Email },
"Reset Password",
EmailBodies.LinkActionHtml(
HtmlEmail.LinkAction(
heading: "Reset your password",
intro: "Use the link below to choose a new password.",
actionUrl: resetPasswordUri,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
using System.Globalization;
using FSH.Framework.Mailing;

namespace FSH.Modules.Notifications.IntegrationEventHandlers;

/// <summary>
/// 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
/// <see cref="HtmlEmail"/> so every module escapes the same way.
/// </summary>
internal static class BillingEmailBodies
{
Expand All @@ -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,
$"<p>Hi {Escape(tenantName)},</p>" +
$"<p>Your <strong>{Escape(plan)}</strong> subscription is valid until " +
$"<p>Hi {HtmlEmail.Encode(tenantName)},</p>" +
$"<p>Your <strong>{HtmlEmail.Encode(plan)}</strong> subscription is valid until " +
$"<strong>{Date(validUpto)}</strong> ({daysRemaining} day(s) remaining).</p>" +
"<p>Please contact your account operator to renew and avoid any interruption to your service.</p>");
var text = Text(subject,
Expand All @@ -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,
$"<p>Hi {Escape(tenantName)},</p>" +
$"<p>Your <strong>{Escape(plan)}</strong> subscription expired on " +
$"<p>Hi {HtmlEmail.Encode(tenantName)},</p>" +
$"<p>Your <strong>{HtmlEmail.Encode(plan)}</strong> subscription expired on " +
$"<strong>{Date(validUpto)}</strong>. Your service continues during a grace period that ends on " +
$"<strong>{Date(graceEnds)}</strong>.</p>" +
"<p>Please renew before the grace period ends to keep your access uninterrupted.</p>");
Expand All @@ -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,
$"<p>Hi {Escape(tenantName)},</p>" +
$"<p>Your <strong>{Escape(plan)}</strong> subscription expired on " +
$"<p>Hi {HtmlEmail.Encode(tenantName)},</p>" +
$"<p>Your <strong>{HtmlEmail.Encode(plan)}</strong> subscription expired on " +
$"<strong>{Date(validUpto)}</strong> and the grace period has ended, so access is now suspended.</p>" +
"<p>Contact your account operator to renew and restore access.</p>");
var text = Text(subject,
Expand All @@ -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 : $"<p>Due by <strong>{Date(dueAtUtc.Value)}</strong>.</p>";
var body = Wrap(subject,
$"<p>A new invoice <strong>{Escape(invoiceNumber)}</strong> for <strong>{amountText}</strong> has been issued.</p>" +
// 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.
$"<p>A new invoice <strong>{HtmlEmail.Encode(invoiceNumber)}</strong> for <strong>{HtmlEmail.Encode(amountText)}</strong> has been issued.</p>" +
due +
"<p>You can view and download this invoice from your dashboard.</p>");
var text = Text(subject,
Expand All @@ -79,28 +83,29 @@ public static (string Subject, string Body, string TextBody) InvoiceIssued(strin
return (subject, body, text);
}

/// <summary>
/// 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 <see cref="HtmlEmail.Shell"/>.
/// </summary>
private static string Wrap(string heading, string innerHtml) =>
"<div style=\"font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#1a1a1a;line-height:1.5\">" +
$"<h2 style=\"font-size:18px;margin:0 0 12px\">{Escape(heading)}</h2>" +
innerHtml +
"<p style=\"margin-top:24px;color:#6b7280;font-size:12px\">This is an automated message.</p>" +
"</div>";
HtmlEmail.Shell(
heading,
"<div style=\"font-size: 15px; line-height: 1.6; color: #334155;\">" +
innerHtml +
"<p style=\"margin-top:24px;color:#6b7280;font-size:12px\">This is an automated message.</p>" +
"</div>");

/// <summary>
/// The text/plain twin of <see cref="Wrap"/>: same copy, no markup, empty paragraphs dropped so an
/// optional line (an absent due date) does not leave a blank gap.
/// </summary>
private static string Text(string heading, params string[] paragraphs)
{
var lines = new List<string> { 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<string> { 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("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);
}
Loading
Loading