Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ jobs:

- name: Run unit tests with coverage
run: |
for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Webhooks; do
for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Notifications Webhooks; do
echo "::group::${proj}.Tests"
dotnet test "src/Tests/${proj}.Tests" -c Release --no-build \
--collect:"XPlat Code Coverage" --settings coverage.runsettings \
Expand Down
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: 4 additions & 0 deletions src/BuildingBlocks/Mailing/Mailing.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Framework.Tests" />
</ItemGroup>

</Project>
9 changes: 8 additions & 1 deletion src/BuildingBlocks/Mailing/Services/SendGridMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,18 @@ 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.
//
// Falling back to Body when TextBody is absent is deliberate. CreateSingleEmail drops the
// text/plain part entirely for a null or empty string, so a caller outside this repo that
// still passes only Body would silently go from "two parts" to "HTML only" — a regression
// for them, in a template other people build on, with no compiler error to warn them.
var msg = MailHelper.CreateSingleEmail(
from,
new EmailAddress(request.To[0]),
request.Subject,
request.Body,
request.TextBody ?? request.Body,
request.Body);

ConfigureRecipients(msg, request);
Expand Down
8 changes: 6 additions & 2 deletions src/BuildingBlocks/Mailing/Services/SmtpMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,13 @@ private static void ConfigureContent(MimeMessage email, MailRequest request)
email.Subject = request.Subject;
}

private static async Task AddAttachmentsAsync(MimeMessage email, MailRequest request, CancellationToken ct)
// internal so the suite can assert the MIME shape without an SMTP server: the transport is the
// one thing a test cannot reach here, and it is not where the bodies get mapped.
internal 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
1 change: 1 addition & 0 deletions src/FSH.Starter.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
<Project Path="Tests/Identity.Tests/Identity.Tests.csproj" />
<Project Path="Tests/Integration.Tests/Integration.Tests.csproj" />
<Project Path="Tests/Integration.Middleware.Tests/Integration.Middleware.Tests.csproj" />
<Project Path="Tests/Notifications.Tests/Notifications.Tests.csproj" />
<Project Path="Tests/Multitenancy.Tests/Multitenancy.Tests.csproj" Id="985345a2-edb4-4ef9-9a1b-59b704f523b6" />
</Folder>
<!--#if (includeTools) -->
Expand Down
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: EmailBodies.NoticeHtml("Welcome!", greeting),
textBody: greeting);

await _mailService.SendAsync(mail, ct).ConfigureAwait(false);
}
Expand Down
92 changes: 92 additions & 0 deletions src/Modules/Identity/Modules.Identity/Services/EmailBodies.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Net;

namespace FSH.Modules.Identity.Services;

/// <summary>
/// Builds the HTML bodies for identity e-mails. Every value that reaches the markup is HTML-encoded:
/// bodies are sent as <c>text/html</c>, so an unescaped name or URL is parsed as markup rather than shown.
/// </summary>
internal static class EmailBodies
{
/// <summary>
/// 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.
/// </summary>
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 $"""
<!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>
<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>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
""";
}

/// <summary>
/// A short informational message with no action link.
/// </summary>
internal static string NoticeHtml(string heading, string message)
{
string safeHeading = WebUtility.HtmlEncode(heading);
string safeMessage = WebUtility.HtmlEncode(message);

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>
<p style="margin: 0; font-size: 15px; line-height: 1.6; color: #334155;">{safeMessage}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
""";
}
}
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}");
EmailBodies.LinkActionHtml(
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
Expand Up @@ -3,50 +3,67 @@
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.
/// </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>Your <strong>{Escape(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>Your <strong>{Escape(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>Your <strong>{Escape(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}";
Expand All @@ -55,7 +72,11 @@ public static (string Subject, string Body) InvoiceIssued(string invoiceNumber,
$"<p>A new invoice <strong>{Escape(invoiceNumber)}</strong> for <strong>{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);
}

private static string Wrap(string heading, string innerHtml) =>
Expand All @@ -65,6 +86,19 @@ private static string Wrap(string heading, string 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 };
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));
}

private static string Escape(string value) =>
value.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
Expand Down
Loading
Loading