Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
107 changes: 107 additions & 0 deletions src/BuildingBlocks/Mailing/HtmlEmail.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Net;

namespace FSH.Framework.Mailing;

/// <summary>
/// The single 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 both here means a module cannot ship its own weaker escaping.
/// </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>""");
}
}
14 changes: 13 additions & 1 deletion src/BuildingBlocks/Mailing/MailRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,26 @@

namespace FSH.Framework.Mailing;

public class MailRequest(Collection<string> to, string subject, string? body = null, string? from = null, string? displayName = null, string? replyTo = null, string? replyToName = null, Collection<string>? bcc = null, Collection<string>? cc = null, IDictionary<string, byte[]>? attachmentData = null, IDictionary<string, string>? headers = null)
public class MailRequest(Collection<string> to, string subject, string? body = null, string? from = null, string? displayName = null, string? replyTo = null, string? replyToName = null, Collection<string>? bcc = null, Collection<string>? cc = null, IDictionary<string, byte[]>? attachmentData = null, IDictionary<string, string>? headers = null, string? textBody = null)
{
public Collection<string> To { get; } = to;

public string Subject { get; } = subject;

/// <summary>
/// The HTML body. Every provider sends this as <c>text/html</c>, so a caller that passes bare text
/// gets a message whose URLs are not anchors — most clients do not auto-link inside HTML — and whose
/// interpolated values are parsed as markup. Build real HTML here and put the fallback in
/// <see cref="TextBody"/>.
/// </summary>
public string? Body { get; } = body;

/// <summary>
/// Optional <c>text/plain</c> alternative, sent alongside <see cref="Body"/> as multipart/alternative.
/// Clients that cannot render HTML (and spam filters, which score HTML-only mail worse) fall back to it.
/// </summary>
public string? TextBody { get; } = textBody;

public string? From { get; } = from;

public string? DisplayName { get; } = displayName;
Expand Down
4 changes: 3 additions & 1 deletion src/BuildingBlocks/Mailing/Services/SendGridMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ public async Task SendAsync(MailRequest request, CancellationToken ct)
}

var from = CreateFromAddress(request);
// plainTextContent and htmlContent are distinct parts: passing Body to both shipped the HTML
// template as the text alternative, so a text-only client rendered raw markup.
var msg = MailHelper.CreateSingleEmail(
from,
new EmailAddress(request.To[0]),
request.Subject,
request.Body,
request.TextBody,
request.Body);

ConfigureRecipients(msg, request);
Expand Down
4 changes: 3 additions & 1 deletion src/BuildingBlocks/Mailing/Services/SmtpMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ private static void ConfigureContent(MimeMessage email, MailRequest request)

private static async Task AddAttachmentsAsync(MimeMessage email, MailRequest request, CancellationToken ct)
{
var builder = new BodyBuilder { HtmlBody = request.Body };
// Both parts when the caller supplies them: MailKit emits multipart/alternative and the client
// picks. HtmlBody alone leaves text-only clients with nothing.
var builder = new BodyBuilder { HtmlBody = request.Body, TextBody = request.TextBody };

if (request.AttachmentData is not null)
{
Expand Down
7 changes: 7 additions & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -143,5 +143,12 @@
AccessViolation). Transitive pinning is enabled, so this entry alone bumps it.
Remove once the SignalR backplane package depends on a patched version itself. -->
<PackageVersion Include="MessagePack" Version="2.5.301" />
<!-- Pulled transitively by the Testcontainers packages; versions up to 2025.1.0 fail
NuGet audit (NU1903, GHSA-q939-rpr3-3284 / CVE-2026-48798: ScpClient recursive
download writes outside the target directory), which breaks restore for the whole
solution under TreatWarningsAsErrors. Testcontainers 4.11.0 and 4.13.0 both depend
on 2025.1.0, so bumping Testcontainers does not help; 2026.0.0 is the first patched
release. Remove once Testcontainers depends on a patched version itself. -->
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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 @@ -34,10 +35,14 @@ public async Task HandleAsync(UserRegisteredIntegrationEvent @event, Cancellatio

try
{
// The body is sent as text/html, so the name — user-supplied — has to be encoded or a
// first name containing '<' is parsed as markup instead of shown.
var greeting = $"Hi {@event.FirstName}, thanks for registering.";
var mail = new MailRequest(
to: new System.Collections.ObjectModel.Collection<string> { @event.Email },
subject: "Welcome!",
body: $"Hi {@event.FirstName}, thanks for registering.");
body: HtmlEmail.Notice("Welcome!", greeting),
textBody: greeting);

await _mailService.SendAsync(mail, ct).ConfigureAwait(false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,17 @@ public async Task ForgotPasswordAsync(string email, string origin, CancellationT
["email"] = email,
["tenant"] = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id,
});
// The body is sent as text/html, so the link has to be an anchor: a bare URL in an HTML part is
// not auto-linked by most clients, which is how the reset link reached users as dead text.
var mailRequest = new MailRequest(
new Collection<string> { user.Email },
"Reset Password",
$"Please reset your password using the following link: {resetPasswordUri}");
HtmlEmail.LinkAction(
heading: "Reset your password",
intro: "Use the link below to choose a new password.",
actionUrl: resetPasswordUri,
actionLabel: "Reset password"),
textBody: $"Please reset your password using the following link: {resetPasswordUri}");

jobService.Enqueue(() => mailService.SendAsync(mailRequest, CancellationToken.None));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,8 @@ private async Task SendConfirmationEmailAsync(FshUser user, string origin, Cance
var mailRequest = new MailRequest(
new Collection<string> { user.Email },
"Confirm Your Email Address",
emailBody);
emailBody,
textBody: $"Please confirm your email address using the following link: {emailVerificationUri}");

jobService.Enqueue("email", () => mailService.SendAsync(mailRequest, cancellationToken));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,72 +1,110 @@
using System.Globalization;
using FSH.Framework.Mailing;

namespace FSH.Modules.Notifications.IntegrationEventHandlers;

/// <summary>
/// Builds the subject + HTML body 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.
/// 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. The document shell and the encoder come from
/// <see cref="HtmlEmail"/> so every module escapes the same way.
/// </summary>
internal static class BillingEmailBodies
{
private static string Date(DateTime utc) => utc.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture);

public static (string Subject, string Body) NearingExpiry(string tenantName, string? planKey, DateTime validUpto, int daysRemaining)
public static (string Subject, string Body, string TextBody) NearingExpiry(string tenantName, string? planKey, DateTime validUpto, int daysRemaining)
{
var subject = daysRemaining <= 1
? "Your subscription expires tomorrow"
: $"Your subscription expires in {daysRemaining} days";
var plan = planKey ?? "current";
var body = Wrap(subject,
$"<p>Hi {Escape(tenantName)},</p>" +
$"<p>Your <strong>{Escape(planKey ?? "current")}</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>");
return (subject, body);
var text = Text(subject,
$"Hi {tenantName},",
$"Your {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.");
return (subject, body, text);
}

public static (string Subject, string Body) EnteredGrace(string tenantName, string? planKey, DateTime validUpto, DateTime graceEnds)
public static (string Subject, string Body, string TextBody) EnteredGrace(string tenantName, string? planKey, DateTime validUpto, DateTime graceEnds)
{
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(planKey ?? "current")}</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>");
return (subject, body);
var text = Text(subject,
$"Hi {tenantName},",
$"Your {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.");
return (subject, body, text);
}

public static (string Subject, string Body) Expired(string tenantName, string? planKey, DateTime validUpto)
public static (string Subject, string Body, string TextBody) Expired(string tenantName, string? planKey, DateTime validUpto)
{
const string subject = "Your subscription has expired";
var plan = planKey ?? "current";
var body = Wrap(subject,
$"<p>Hi {Escape(tenantName)},</p>" +
$"<p>Your <strong>{Escape(planKey ?? "current")}</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>");
return (subject, body);
var text = Text(subject,
$"Hi {tenantName},",
$"Your {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.");
return (subject, body, text);
}

public static (string Subject, string Body) InvoiceIssued(string invoiceNumber, decimal amount, string currency, DateTime? dueAtUtc)
public static (string Subject, string Body, string TextBody) InvoiceIssued(string invoiceNumber, decimal amount, string currency, DateTime? dueAtUtc)
{
var subject = $"Invoice {invoiceNumber} issued";
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>");
return (subject, body);
var text = Text(subject,
$"A new invoice {invoiceNumber} for {amountText} has been issued.",
dueAtUtc is null ? string.Empty : $"Due by {Date(dueAtUtc.Value)}.",
"You can view and download this invoice from your dashboard.");
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>");

private static string Escape(string value) =>
value.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);
/// <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 };
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));
}
}
Loading
Loading