refactor(mailing): one HTML shell and one encoder for every module - #1385
Open
marcelo-maciel wants to merge 13 commits into
Open
marcelo-maciel wants to merge 13 commits into
marcelo-maciel wants to merge 13 commits into
Conversation
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.
# Conflicts: # src/Directory.Packages.props
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Takes you up on the non-blocking nit from your approval of #1351:
EmailBodies(Identity) andBillingEmailBodies.Wrap(Notifications) as two independent shells with two escapers, the hand-rolled one being the weaker.src/BuildingBlocks(Golden Rule #4, requesting sign-off)One file, and it is an addition:
Mailing/HtmlEmail.cs. Nothing existing underBuildingBlocksis modified — no signature, no behaviour, no registration. It holds the document shell (doctype, charset, viewport, card) andEncode.What changes
EncodeisWebUtility.HtmlEncodeeverywhere. The four-Replacechain covered only&,<and>. That is safe in element content and not in an attribute, which is exactly the drift you predicted.EmailBodiesis deleted rather than left as a pass-through. Its two callers useHtmlEmaildirectly. Both modules already referenced the Mailing building block, so there is no new project reference.BillingEmailBodieskeeps its own copy and itsText()twin, and loses onlyWrapandEscape. 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ç), becauseWebUtility.HtmlEncodeentitises 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)insertsinnerHtmlverbatim. That is the point — callers compose markup from literals plusEncoded 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, verbatiminnerHtml, a real anchor,&in a query string, and a quote inside the URL that would otherwise close thehref.Verified on the pushed tree with the NuGet audit on:
dotnet restore: exit 0.dotnet build -warnaserror: exit 0.UserPasswordServiceTestsorUserRegisteredEmailHandlerTestsrequired a change, the refactor would have altered identity behaviour and stopped being a refactor.Encodeto the four-Replacechain, makingShellencodeinnerHtml, dropping the doctype, and leaving the URL raw in thehrefeach 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.InvoiceIssuedinterpolatedamountText— which embedscurrency, a data-driven value — into the markup unencoded. It was the only value in that file reaching markup raw;invoiceNumber,tenantNameandplanwere 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, whereCreatePlanCommandValidatorenforcesNotEmpty().Length(3)behindBillingPermissions.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 USDreturns it unchanged, so no delivered e-mail changes.Stated rather than glossed: this line is not pinned by a test.
BillingEmailBodiesisinternalto Notifications, which has no test project — the module'sAssemblyInfo.csalready declaresInternalsVisibleTo("Notifications.Tests")for one that was never created. Adding it here would mean a new project plus asrc/FSH.Starter.slnxentry, 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:
minio/minioto quay.io.minio/mcis gone from Docker Hub too(
hub.docker.com/v2/repositories/minio/mc/answers 404) and it is whatminio-initruns, so bothdotnet run --project src/Host/FSH.Starter.AppHostanddocker compose updied on the pull and thefshbucket was never created. Now pinned to the same quay tag #1388 uses.SSH.NETpin is gone: it pinned nothing. Its own comment claimed bumping Testcontainersdoes 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 --forcereports 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.ymlandsrc/Directory.Packages.propsare nowgenuinely byte-identical to #1388 (
git diff --exit-code, checked today), which the earlier claimwas 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.BuildConfirmationEmailHtmlstill builds its owncomplete document — its own doctype, its own header, its own inline
WebUtility.HtmlEncodecalls —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
HtmlEmailnow 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 theJoinfiltered straight back out.