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
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
6 changes: 3 additions & 3 deletions deploy/docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ This brings up the full stack on a single host:
| `admin` | `fsh/admin:local` | `FSH_ADMIN_PORT` (default 8081) | Operator console (nginx + React) |
| `dashboard` | `fsh/dashboard:local` | `FSH_DASHBOARD_PORT` (default 8082) | Tenant dashboard (nginx + React) |
| `migrator` | `fsh/dbmigrator:local` | — | One-shot: applies EF migrations + seeds the root tenant + creates the default admin user |
| `postgres` | `postgres:17-alpine` | (internal) | Identity, tenant catalog, module schemas |
| `redis` | `redis:7-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store |
| `minio` | `minio/minio:latest` | (internal) | S3-compatible blob store for the Files module |
| `postgres` | `postgres:18-alpine` | (internal) | Identity, tenant catalog, module schemas |
| `redis` | `valkey/valkey:9.1.0-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store |
| `minio` | `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` | (internal) | S3-compatible blob store for the Files module |

The compose file does **not** include a reverse proxy or TLS terminator. You bring your own edge — Cloudflare Tunnel, AWS ALB, Tailscale Funnel, your existing nginx, anything that can route a TLS subdomain to a host:port on this machine.

Expand Down
6 changes: 4 additions & 2 deletions deploy/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ services:
# - "6379:6379"

minio:
image: minio/minio:latest
# quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest.
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: fsh-minio
restart: unless-stopped
command: ["server", "/data", "--console-address", ":9001"]
Expand All @@ -79,7 +80,8 @@ services:
# policy is set — objects are served via the API / presigned URLs, not a
# public bucket.
minio-init:
image: minio/mc:latest
# quay.io: minio/mc is gone from Docker Hub too. Tag pinned; quay stopped moving :latest.
image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z
container_name: fsh-minio-init
restart: "no"
depends_on:
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
10 changes: 6 additions & 4 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
<!-- Pack default lives in Directory.Build.props (false; source-ownership model). -->
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<!-- 10.x: the 8.x line pulls Microsoft.Build.Tasks.Git 8.0.0, unpatched for NU1902. -->
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.401" />
</ItemGroup>
<ItemGroup Label="Aspire">
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.4.0" />
Expand Down Expand Up @@ -122,9 +123,10 @@
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.11.0" />
<PackageVersion Include="Testcontainers.Redis" Version="4.11.0" />
<PackageVersion Include="Testcontainers.Minio" Version="4.11.0" />
<!-- 4.14.0+: earlier versions pull SSH.NET 2025.1.0, NU1903. -->
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.14.0" />
<PackageVersion Include="Testcontainers.Redis" Version="4.14.0" />
<PackageVersion Include="Testcontainers.Minio" Version="4.14.0" />
</ItemGroup>
<ItemGroup Label="AWS">
<PackageVersion Include="AWSSDK.S3" Version="4.0.23.4" />
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
5 changes: 5 additions & 0 deletions src/Host/FSH.Starter.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@
var minioUser = builder.AddParameter("minio-user", "minioadmin");
var minioPassword = builder.AddParameter("minio-password", "minioadmin", secret: true);

// quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest.
var minio = builder.AddContainer("minio", "minio/minio")
.WithImageRegistry("quay.io")
.WithImageTag("RELEASE.2025-09-07T16-13-09Z")
.WithArgs("server", "/data", "--console-address", ":9001")
.WithHttpEndpoint(port: 9000, targetPort: 9000, name: "api")
.WithHttpEndpoint(port: 9001, targetPort: 9001, name: "console")
Expand All @@ -73,6 +76,8 @@
""").ReplaceLineEndings("\n");

var minioInit = builder.AddContainer("minio-init", "minio/mc")
.WithImageRegistry("quay.io")
.WithImageTag("RELEASE.2025-08-13T08-35-41Z")
.WithEntrypoint("/bin/sh")
.WithArgs("-c", minioInitScript)
.WithEnvironment("MC_USER", minioUser)
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
Loading
Loading