Skip to content

fix(mailing): send real HTML with a text alternative, not bare text - #1351

Closed
marcelo-maciel wants to merge 4 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/mailing-html-bodies
Closed

marcelo-maciel wants to merge 4 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/mailing-html-bodies

Conversation

@marcelo-maciel

@marcelo-maciel marcelo-maciel commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Every mail provider in the kit puts MailRequest.Body in the HTML slot:

// SmtpMailService
var builder = new BodyBuilder { HtmlBody = request.Body };

// SendGridMailService
MailHelper.CreateSingleEmail(from, to, request.Subject, request.Body, request.Body);
//                                                      ^ plainText   ^ html — the same string

But two callers pass plain text into it:

  • UserPasswordService.ForgotPasswordAsync — $"Please reset your password using the following link: {resetPasswordUri}"
  • UserRegisteredEmailHandler — $"Hi {@event.FirstName}, thanks for registering."

A bare URL inside a text/html part is not auto-linked by most clients (auto-linking is text/plain behaviour), so the password-reset link arrives as dead text and the user cannot complete the flow. I hit this on a real deployment: the reset mail landed with the URL unclickable.

Two more consequences of the same root cause:

  • The welcome mail interpolates FirstName — user-supplied — straight into markup. A name containing < breaks the message; it is an HTML injection into the rendered mail.
  • SendGrid received Body as both parts, so the confirmation and billing templates were shipped as the text/plain alternative too: a text-only client rendered raw markup.

The confirmation mail (BuildConfirmationEmailHtml) and the billing bodies were already correct HTML with escaping — the defect is the inconsistency, not the templates.

Solution

MailRequest gains an optional TextBody (appended last, so existing positional calls keep compiling):

  • SmtpMailService sets both HtmlBody and TextBody → MailKit emits multipart/alternative.
  • SendGridMailService maps TextBody → plainTextContent and Body → htmlContent, instead of sending the HTML as both.
  • New EmailBodies helper in Identity (LinkActionHtml, NoticeHtml) renders the action link as a real <a href> and HTML-encodes every interpolated value.
  • Reset, confirmation, welcome and the four billing mails now all carry a text/plain twin, so nothing goes out HTML-only.

Note for reviewers: inside HTML the query separator is &amp;, so the reset URL in the HTML part reads ...?token=…&amp;email=…&amp;tenant=…. That is correct per the HTML spec — the browser hands & to the server. The verbatim URL lives in TextBody, which is where the existing link-shape test now asserts.

Changes to src/BuildingBlocks (Golden Rule #4, requesting sign-off)

  • Mailing/MailRequest.cs — new optional TextBody property + XML docs stating that Body is HTML.
  • Mailing/Services/SmtpMailService.cs — one line: TextBody on the BodyBuilder.
  • Mailing/Services/SendGridMailService.cs — plainTextContent now comes from TextBody.

No behaviour change for a caller that does not set TextBody, except on SendGrid, where the text part becomes absent instead of being a copy of the markup.

Tests

  • UserPasswordServiceTests — the reset link is a real anchor; &amp; in the HTML part; a text alternative exists, carries the verbatim URL and no markup. The pre-existing link-shape test (single slash, tenant, %2B encoding) now asserts on TextBody.
  • UserRegisteredEmailHandlerTests (new) — a first name of <script>alert(1)</script> comes out encoded; the text alternative is present; nothing is sent when the event carries no e-mail.
  • SendGridMailServiceTests — the two bodies land in their own MIME parts (text/html / text/plain).
  • MailRequestTests — TextBody round-trips and defaults to null.

Verified locally on the pushed tree, with the NuGet audit on rather than disabled:

  • dotnet restore src/FSH.Starter.slnx: exit 0, zero NU1903.
  • dotnet build -warnaserror: exit 0.
  • Full suite: 14 assemblies, 1806 passed / 0 failed / 1 skipped, including Integration at 746 passed / 1 skipped against a real Postgres (Testcontainers).

That closes the gap left in the earlier description, which said the integration suite would not run here and leaned on CI for it. The fault was local and is cleared; the suite ran end to end this time. It remains true that no integration test exercises the mail path itself — the harness does not run enqueued mail jobs — so the mail assertions are the unit tests listed above.

Rebased on main, and the SSH.NET pin

This branch was CONFLICTING. main has since added the System.Security.Cryptography.Xml 10.0.10 pin that this PR was carrying, which was the only conflict. Resolved by keeping main's version, so this PR no longer touches that pin at all — one less shared-config edit to review.

The remaining red was a different advisory: NU1903 / GHSA-q939-rpr3-3284 on SSH.NET 2025.1.0, pulled transitively by Testcontainers, which fails restore for the whole solution under TreatWarningsAsErrors — on main too, re-verified today at 3f2959e6. The fix belongs to #1333, still open. Rather than leave an approved PR red on someone else's advisory, the pin is carried here byte-identical to #1333's version of the file, comment included (same blob), so both stay mergeable in either order and this copy can be dropped once #1333 lands.

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).
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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.
@marcelo-maciel

Copy link
Copy Markdown
Contributor Author

Pushed bb89a24f: cherry-picked the System.Security.Cryptography.Xml 10.0.10 pin so this branch can actually be evaluated. Without it every backend job dies in restore on NU1903, which is failing main today rather than anything in this diff.

The same pin is in #1323, so whichever lands first leaves the other with a one-hunk conflict in Directory.Packages.props — happy to drop it here the moment #1323 (or your own pin) merges.

Beyond unblocking CI, this matters for review confidence: I could not run the integration suite locally (dotnet test returns instantly with no output in that worktree — an environment fault on my machine), so the CI run is the only place it executes.

@marcelo-maciel

Copy link
Copy Markdown
Contributor Author

Docs + changelog for this change (Golden Rule #10): fullstackhero/docs#239 — the Mailing page gets the TextBody signature, a correction to the SendGrid sentence (it no longer receives Body as both parts) and a caution about bare text in Body, plus a changelog entry.

CI here is green now that the NU1903 pin is on the branch: Backend CI, Frontend CI, Unit, Integration (6m26s), Coverage Gate, DbMigrator Smoke, both Scaffold jobs and CodeQL all pass.

@iammukeshm iammukeshm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Good catch on a bug that had been sitting in the kit since the mail services were written.

The diagnosis is the valuable part: the defect isn't in either template, it's that MailRequest.Body had no documented contract, so two callers reasonably passed text into what every provider treats as the HTML slot. Adding TextBody and the XML doc stating Body is HTML fixes the instance and the cause. SendGrid receiving the markup as both parts is a real second bug that would never have been noticed without pulling this thread.

The &amp; note in the description is correct and worth keeping in the history — entity-encoding the query separator inside an href is right per spec, and putting the verbatim URL in TextBody (where the link-shape test now asserts) is the correct place for it.

The <script>alert(1)</script> first-name test is the one I'd have asked for if it weren't already there.

One required change before merge — but not the one you flagged

Drop the System.Security.Cryptography.Xml pin from this branch. Your heads-up about main being red on NU1903 is stale — the pin has since landed on main (src/Directory.Packages.props, currently at 10.0.10 with a superset of the advisories you list, five rather than four). Re-adding it here produces a duplicate PackageVersion item on rebase. Rebase onto main and drop the entry; nothing else in this PR depends on it, and it means this branch is not blocked by #1323 after all.

nit (non-blocking, follow-up welcome)

EmailBodies (Identity) and BillingEmailBodies.Wrap (Notifications) are now two independent HTML shells with their own Escape implementations — WebUtility.HtmlEncode in one, a hand-rolled four-Replace chain in the other. They'll drift, and the hand-rolled one is the weaker of the two. Not blocking this PR, but the shell and the encoder belong in BuildingBlocks/Mailing so every module gets the same escaping. Happy to take that as a separate PR if you want it.

BuildingBlocks sign-off (Golden Rule #4)

Granted for the three files listed. They're minimal and additive: one optional property appended last so positional call sites keep compiling, and one line in each provider. The only behaviour change for an unmodified caller is on SendGrid, where the text part becomes absent instead of being a copy of the markup — which is the bug being fixed, and strictly better than shipping raw HTML to a text client.

Noted that the integration suite didn't run locally and that no integration test covers the mail path today (the harness doesn't drain enqueued mail jobs). The unit coverage here is the right level for this change; CI will cover the rest.

`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 #1333, which is still open.

Carried byte-identical to #1333's version of the file, comment included, so both
stay mergeable in either order and this copy can simply be dropped once #1333
lands.
@marcelo-maciel

marcelo-maciel commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Unblocked. Pushed bada7cee: main merged in, plus the SSH.NET pin. No behaviour change since your approval.

Your one required change is done. You asked to drop the System.Security.Cryptography.Xml pin and rebase, since it had landed on main with a superset of the advisories and would produce a duplicate PackageVersion. That entry was in fact the only conflict, and it is resolved by keeping main's version — this PR no longer carries that pin. Confirmed there is no duplicate PackageVersion Include in the resolved file. You were right that #1323 was never a blocker here.

One line does come back into that file, for a different advisory. After the merge, restore still failed on NU1903 / GHSA-q939-rpr3-3284 — SSH.NET 2025.1.0, transitive via Testcontainers. That fails main too: dotnet restore src/FSH.Starter.slnx at 3f2959e6 exits 1 with it. The fix belongs to #1333, which is still open, so the pin is carried here byte-identical to that PR's version of the file (same blob), keeping both mergeable in either order. It comes back out once #1333 lands. Say the word if you would rather this PR sit red and wait for #1333 instead.

The description's claim that the integration suite would not run locally is no longer true, and I have replaced it with real numbers. That fault was on my machine and is cleared. Full run on the pushed tree, with the NuGet audit on rather than disabled:

  • dotnet restore: exit 0, zero NU1903.
  • dotnet build -warnaserror: exit 0.
  • 14 assemblies, 1806 passed / 0 failed / 1 skipped, including Integration at 746 passed / 1 skipped against a real Postgres.

Worth flagging given what landed on main in between: the outbox rework touches the same Notifications integration-event handlers this PR edits. The merge was textually clean there, but that is not proof of semantic compatibility, so the integration run above is the evidence rather than the merge itself. Your note still holds that no integration test exercises the mail path itself — the harness does not drain enqueued mail jobs — so the mail assertions remain the unit tests.

On the non-blocking nit: agreed that EmailBodies and BillingEmailBodies.Wrap should not be two shells with two different escapers, and that WebUtility.HtmlEncode is the one to keep. Deliberately not doing it here so this PR stays the diff you approved. I will follow up separately.

Docs companion fullstackhero/docs#239 is MERGEABLE / CLEAN.

@marcelo-maciel

Copy link
Copy Markdown
Contributor Author

Reopened as #1380.

This PR was closed automatically on 2026-09-14, when the head fork was deleted.
The branch and the commits are unchanged; the head commit is still bada7ceeea0a11097ab9deab93374e92d5317650.

iammukeshm added a commit that referenced this pull request Sep 25, 2026
…1385)

* fix(mailing): send real HTML with a text alternative, not bare text

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).

* fix(deps): pin System.Security.Cryptography.Xml to 10.0.10

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.

* build: pin SSH.NET to 2026.0.0 so restore passes while #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 #1333, which is still open.

Carried byte-identical to #1333's version of the file, comment included, so both
stay mergeable in either order and this copy can simply be dropped once #1333
lands.

* refactor(mailing): one HTML shell and one encoder for every module

Follow-up to the nit on #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.

* fix(notifications): encode the invoice amount, currency included, in 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.

* build(deps): bump Testcontainers to 4.14.0 and SourceLink past their 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 #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.

* fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub

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`.

* fix(infra): pull minio/mc from quay.io too, not just minio/minio

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 #1388, which owns the fix, so the copy stays byte-identical
to it and can be dropped once that lands.

* build(deps): drop the dead SSH.NET pin

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).

* fix(mailing): keep a text/plain part when only Body is supplied

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.

* refactor(mailing): fix a misleading test name and two dead lines

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.

* docs(mailing): say plainly which half of the claim is true

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.

* refactor(identity): drop EmailBodies, superseded by the shared HtmlEmail

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: iammukeshm <iammukeshm@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

2 participants