Skip to content

refactor(mailing): one HTML shell and one encoder for every module - #1385

Open
marcelo-maciel wants to merge 13 commits into
fullstackhero:mainfrom
marcelo-maciel:refactor/mailing-shared-html-shell
Open

marcelo-maciel wants to merge 13 commits into
fullstackhero:mainfrom
marcelo-maciel:refactor/mailing-shared-html-shell

Conversation

@marcelo-maciel

@marcelo-maciel marcelo-maciel commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Carries two infrastructure fixes that are not this PR's topic. Without them CI cannot even reach this PR's code.

  • Dependency bump: Microsoft.SourceLink.GitHub to 10.0.401 and Testcontainers to 4.14.0, clearing NU1902/NU1903 so restore succeeds. Those are the versions #1375 (SourceLink) and #1369 (Testcontainers) carry: this hunk is the union of the two, plus one comment per pin naming the advisory it answers.
  • MinIO (f4618319): minio/minio is gone from Docker Hub, so every Testcontainers-backed integration test dies on the image pull. Pulls from quay.io on a pinned tag instead. Same fix as #1388.

The MinIO hunk is byte-identical to #1388. The dependency hunk is not byte-identical to #1375 or #1369, which each carry half of it without the comments, but it is identical across all twelve PRs in this series: src/Directory.Packages.props resolves to the same blob (854deb95) at every head. Either way they merge in any order, and these copies can be dropped once the PRs that own them land.

Reopened from #1364. That PR was closed automatically on 2026-09-14, when the head fork
was deleted. It reopened at 12bbb3ec, and review has added commits on top since then (the
commit list above is the current one). The earlier review history stays on #1364.


Takes you up on the non-blocking nit from your approval of #1351: EmailBodies (Identity) and BillingEmailBodies.Wrap (Notifications) as two independent shells with two escapers, the hand-rolled one being the weaker.

Read the six files below, not the diff. This branch is cut from the branch behind #1351, whose files this refactors, and a fork PR cannot be based on another PR's branch — so the diff here also shows that PR's files. #1351 is closed: it was reopened as #1380 after the fork was deleted, and #1380 is the one that has to merge first. That PR has moved since this branch was cut and now also carries SmtpMailServiceTests and a Notifications.Tests project; neither is here, and both arrive when this rebases onto a merged #1380. Measured rather than assumed: cherry-picking that test project onto this branch and running it gives 13/13, so the shared HtmlEmail shell and encoder do not change what those tests pin. Mine are:

  • src/BuildingBlocks/Mailing/HtmlEmail.cs (new)
  • src/Modules/Identity/Modules.Identity/Services/EmailBodies.cs (deleted)
  • src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs
  • src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs
  • src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs
  • src/Tests/Framework.Tests/Mailing/HtmlEmailTests.cs (new)

Rebasing onto main after #1351 lands leaves exactly those six, with no content change.

⚠️ Touches protected src/BuildingBlocks (Golden Rule #4, requesting sign-off)

One file, and it is an addition: Mailing/HtmlEmail.cs. Nothing existing under BuildingBlocks is modified — no signature, no behaviour, no registration. It holds the document shell (doctype, charset, viewport, card) and Encode.

What changes

  • Encode is WebUtility.HtmlEncode everywhere. The four-Replace chain covered only &, < and >. That is safe in element content and not in an attribute, which is exactly the drift you predicted.
  • EmailBodies is deleted rather than left as a pass-through. Its two callers use HtmlEmail directly. Both modules already referenced the Mailing building block, so there is no new project reference.
  • BillingEmailBodies keeps its own copy and its Text() twin, and loses only Wrap and Escape. The automated-message footer stays with the billing copy, since it is the one part genuinely specific to those e-mails.

Behaviour change, stated rather than buried

Billing mail now renders in the same document as identity mail, so it gains a doctype, a <meta charset> and the card. Previously it was a bare <div> fragment with no charset declared. Visible to the recipient.

Accented text in billing mail becomes numeric entities (ç becomes &#231;), because WebUtility.HtmlEncode entitises the Latin-1 supplement while the old escaper left it raw. Renders identically and is more robust to a mis-declared charset. Identity mail already behaved this way. Worth knowing that the encoder is asymmetric here: characters above 255 (CJK, for instance) stay verbatim and rely on the declared utf-8. Pinned by a test so it is documented rather than discovered.

The one contract that needs care

Shell(heading, innerHtml) inserts innerHtml verbatim. That is the point — callers compose markup from literals plus Encoded values — but it means a future contributor "hardening" it by encoding would render every e-mail as visible tags. The doc comment says so and a test pins it.

Tests

15 new cases in HtmlEmailTests: script tags, quotes and apostrophes, already-escaped & encoded exactly once, the Latin-1 vs higher-plane asymmetry, empty and null input, the complete document, verbatim innerHtml, a real anchor, & in a query string, and a quote inside the URL that would otherwise close the href.

Verified on the pushed tree with the NuGet audit on:

  • dotnet restore: exit 0. dotnet build -warnaserror: exit 0.
  • Full suite: 14 assemblies, 1821 passed / 0 failed / 1 skipped, including Integration at 746 passed / 1 skipped against a real Postgres. That is fix(mailing): send real HTML with a text alternative, not bare text #1351's 1806 plus these 15.
  • No existing test needed editing. That was the tripwire: had UserPasswordServiceTests or UserRegisteredEmailHandlerTests required a change, the refactor would have altered identity behaviour and stopped being a refactor.
  • Each new test is a real gate, not just green: reverting Encode to the four-Replace chain, making Shell encode innerHtml, dropping the doctype, and leaving the URL raw in the href each turn the matching test red, with the file restored byte-exact (sha256 checked).

Also folded in: the unescaped amount

The earlier version of this description offered to send this separately. It is one line in the same file this PR is already unifying, and the same defect class the nit is about, so it rides here instead.

BillingEmailBodies.InvoiceIssued interpolated amountText — which embeds currency, a data-driven value — into the markup unencoded. It was the only value in that file reaching markup raw; invoiceNumber, tenantName and plan were all escaped.

Not a vulnerability today, and I would rather say that than dress it up. I traced both paths that reach this e-mail: the top-up invoice passes a hardcoded "USD", and the subscription invoice takes the currency from the plan, where CreatePlanCommandValidator enforces NotEmpty().Length(3) behind BillingPermissions.Manage. Three characters, in element content, written by an operator, cannot form a working payload. What makes it worth fixing is that the only thing standing between that value and the markup is a length rule in a different module, which nobody would think to check before relaxing.

It is a no-op for every real currency code: encoding 100.00 USD returns it unchanged, so no delivered e-mail changes.

Stated rather than glossed: this line is not pinned by a test. BillingEmailBodies is internal to Notifications, which has no test project — the module's AssemblyInfo.cs already declares InternalsVisibleTo("Notifications.Tests") for one that was never created. Adding it here would mean a new project plus a src/FSH.Starter.slnx entry, turning a six-file refactor into a solution-structure change for a one-line fix. Happy to add the project if you would rather have the coverage than the smaller diff.


Infra carve-outs, corrected after review. Two things in the out-of-topic hunks were wrong and
are fixed on the branch:

  • The MinIO carve-out only moved minio/minio to quay.io. minio/mc is gone from Docker Hub too
    (hub.docker.com/v2/repositories/minio/mc/ answers 404) and it is what minio-init runs, so both
    dotnet run --project src/Host/FSH.Starter.AppHost and docker compose up died on the pull and the
    fsh bucket was never created. Now pinned to the same quay tag #1388 uses.
  • The SSH.NET pin is gone: it pinned nothing. Its own comment claimed bumping Testcontainers
    does not help, but 4.14.0 — which this branch also carries — declares SSH.NET >= 2026.0.0.
    Measured rather than argued: with the pin removed, dotnet restore src/FSH.Starter.slnx --force
    reports zero NU1902/NU1903 and exits 0. (The MessagePack pin next to it stays; removing that one
    does bring its advisory straight back.)

With both applied, deploy/docker/docker-compose.yml and src/Directory.Packages.props are now
genuinely byte-identical to #1388 (git diff --exit-code, checked today), which the earlier claim
was not.

Review follow-up: the shell is not universal yet

The title says "one HTML shell and one encoder for every module". The encoder half is true. The
shell half is not: UserRegistrationService.BuildConfirmationEmailHtml still builds its own
complete document — its own doctype, its own header, its own inline WebUtility.HtmlEncode calls —
and it is the e-mail users see most. Migrating it changes copy and layout, which is a different
review from this six-file refactor, so it stays for a follow-up PR and the type doc on HtmlEmail
now says so instead of claiming coverage it does not have.

Also from review, in the branch: the encoder test that was named and commented as guarding against
double-encoding while asserting exactly that (behaviour correct, documentation inverted), an orphan
using, and two blank list entries the Join filtered straight back out.

Every provider puts MailRequest.Body in the HTML slot — MailKit's
BodyBuilder.HtmlBody, SendGrid's htmlContent — but the password-reset and
welcome mails passed plain text. A bare URL inside an HTML part is not
auto-linked by most clients, so the reset link arrived as dead text and the
user had no way to complete the flow. The welcome mail additionally
interpolated the user-supplied first name straight into that HTML.

MailRequest gains an optional TextBody carrying the text/plain alternative.
SmtpMailService emits both parts as multipart/alternative; SendGridMailService
stops passing Body as plainTextContent, which had been shipping raw markup to
text-only clients. Identity builds its bodies through EmailBodies, which
HTML-encodes every interpolated value, and billing bodies gained their plain
twin so no message goes out HTML-only.

Verified: build -warnaserror 0/0; unit suites green (Identity 317,
Framework 122, Billing 123, and the rest).
The test hosts pull 10.0.8 transitively, which carries HIGH-severity
advisories (GHSA-23rf-6693-g89p, GHSA-8q5v-6pqq-x66h, GHSA-cvvh-rhrc-wg4q,
GHSA-g8r8-53c2-pm3f) and trips NuGetAudit under TreatWarningsAsErrors,
breaking the build of every test project. 10.0.10 is the patched
servicing release. Mirrors the existing Microsoft.OpenApi transitive pin.
…1333 is open

`NU1903` / `GHSA-q939-rpr3-3284` on `SSH.NET` 2025.1.0, pulled transitively by
Testcontainers, fails `restore` for the whole solution under
`TreatWarningsAsErrors` — on `main` too. It is not introduced here and the fix
belongs to fullstackhero#1333, which is still open.

Carried byte-identical to fullstackhero#1333's version of the file, comment included, so both
stay mergeable in either order and this copy can simply be dropped once fullstackhero#1333
lands.
Follow-up to the nit on fullstackhero#1351: `EmailBodies` (Identity) and
`BillingEmailBodies.Wrap` (Notifications) had grown into two independent HTML
shells with two different escapers, and they would have drifted.

- New `FSH.Framework.Mailing.HtmlEmail` holds the document shell (doctype,
  charset, viewport, card) and the encoder. Both modules already referenced the
  Mailing building block, so no new project reference.
- `Encode` is `WebUtility.HtmlEncode` everywhere. The hand-rolled four-`Replace`
  chain in Notifications covered only `&`, `<` and `>` — safe in element content,
  not in an attribute — and is gone.
- `EmailBodies` is deleted; its two callers use `HtmlEmail` directly rather than
  a pass-through.
- Billing mail now renders in the same document as identity mail, so it gains a
  doctype and a `<meta charset>` it did not have.

`Shell` takes trusted markup and does not encode it; the doc comment says so and
a test pins it, because "hardening" that would render every e-mail as visible
tags.
…the HTML part

`amountText` embeds `currency`, which is data rather than a literal, and was the
only value in this file reaching the markup unencoded — `invoiceNumber`,
`tenantName` and `plan` were all escaped already.

Not a vulnerability today, and the description says so: the only writer is
`CreatePlanCommand`, capped at three characters by its validator and gated by
`BillingPermissions.Manage`, while the top-up path passes a hardcoded "USD".
Three characters in element content cannot form a working payload. This is
consistency and defence in depth: the only thing standing between the value and
the markup is a length rule in another module.

No-op for every real currency code: encoding "100.00 USD" returns it unchanged.
…advisories

`dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on
`main` and on every open PR alike. Advisory-database drift, not a regression from
any change: a commit green on 2026-08-10 is red today with no edits.

- `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903,
  GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already
  depends on the patched 2026.0.0, so the advisory clears with no transitive pin
  to remember to remove later. Same fix as fullstackhero#1369, so the two do not conflict.
- `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902,
  GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the
  8.x line has no patched release, so a transitive pin cannot fix it; the package
  itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401,
  past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced
  only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is
  excluded from the template, so the scaffold never sees it.

Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and
`dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings
and 0 errors.
MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers
`object not found` for the repository, and a pull fails with:

    pull access denied for minio/minio, repository does not exist or may
    require 'docker login'

That takes down every Testcontainers-backed integration test (the harness boots
a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at
container start), the Aspire AppHost, and the Docker Compose deployment. The
image is still published at `quay.io/minio/minio`:

- `Integration.Tests` and `Integration.Middleware.Tests` harnesses
- `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag`
- `deploy/docker/docker-compose.yml` and the image table in its README

The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay
has not moved `:latest` since 2025-09-07, so the two resolve to the same digest
today; pinning only removes the surprise of a silent move later, and keeps the
test harness off a floating tag. Whether to track a newer release, or a different
S3-compatible image, is a separate call.

While in the README's image table: `postgres` and `redis` rows had drifted from
what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`).

Verified: `docker pull minio/minio:latest` fails with the error above;
`docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds
(`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the
same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release`
passes against the pinned image, and the Aspire manifest renders the container
as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`.
The MinIO carve-out this branch carries only moved `minio/minio`. `minio/mc` is
gone from Docker Hub as well (`hub.docker.com/v2/repositories/minio/mc/` answers
404), and it is what `minio-init` runs: without it `dotnet run --project
src/Host/FSH.Starter.AppHost` and `docker compose up` both die on the image pull,
and the `fsh` bucket is never created, so the first upload fails with
NoSuchBucket.

Same pinned tag as fullstackhero#1388, which owns the fix, so the copy stays byte-identical
to it and can be dropped once that lands.
The pin's own comment says "Testcontainers 4.11.0 and 4.13.0 both depend on
2025.1.0, so bumping Testcontainers does not help", but the branch also bumps
Testcontainers to 4.14.0, whose nuspec declares `SSH.NET >= 2026.0.0`. The two
statements cannot both be true, and the bump is the one that is: with the pin
removed, `dotnet restore src/FSH.Starter.slnx --force` reports zero NU1902/NU1903
and exits 0. It was carrying a transitive pin that no longer pins anything.

The MessagePack pin above it stays: that one is still load-bearing (removing it
brings GHSA-hv8m-jj95-wg3x straight back, verified in the same probe).
Moving `plainTextContent` from `Body` to `TextBody` made the text part vanish
rather than become empty: `MailHelper.CreateSingleEmail` only adds it when the
string is non-null and non-empty. Every caller inside this repo was migrated, so
the tree is fine — but this is a template, and a consumer who still writes
`new MailRequest(to, subject, "Your code is 123456")` silently went from a
two-part message to HTML-only, with no compiler error and no warning.

`TextBody ?? Body` restores the old behaviour for them and changes nothing for a
caller that supplies both. Covered by a test that would have caught the drop.
The encoder test was named Encode_Should_EncodeOnce_When_ValueIsAlreadyEscaped
and commented as guarding against double-encoding, while asserting exactly that
double-encoding. The behaviour is right — the input is text, so a literal
ampersand must be escaped even when it spells an entity — but the name invited
someone to 'fix' the encoder into leaving raw ampersands in markup.

Also drops an orphan using (EmailBodies is deleted) and the two blank entries in
the text builder that the Join filtered straight back out.
The type doc said HtmlEmail was the single shell for outbound mail. It is the
single encoder; UserRegistrationService.BuildConfirmationEmailHtml still builds
its own document, and that is the most-seen e-mail in the product. Migrating it
changes layout and copy, so it belongs in its own PR — but the doc should not
claim coverage that does not exist in the meantime.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant