Skip to content

feat: render email templates with Razor components - #2525

Merged
ejsmith merged 18 commits into
mainfrom
feature/razor-email-templates
Aug 28, 2026
Merged

feat: render email templates with Razor components#2525
ejsmith merged 18 commits into
mainfrom
feature/razor-email-templates

Conversation

@ejsmith

@ejsmith ejsmith commented Aug 25, 2026

Copy link
Copy Markdown
Member

Summary

Replaces the Foundation for Emails/Gulp source project and the generated, embedded Handlebars HTML with a .NET 10 Razor class library used directly by Exceptionless.Core.

  • migrates all 9 production email templates to strongly typed Razor components and record models
  • centralizes the email-safe layout, buttons, sections, callouts, metrics, stack lists, and footers
  • renders templates in process with ASP.NET Core HtmlRenderer
  • removes the npm/Gulp/Foundation source pipeline, generated HTML, embedded template resources, and the Handlebars.Net dependency
  • adds no PackageReference to the email-template project; it uses the shared Microsoft.AspNetCore.App framework
  • adds a file-based .NET preview tool that renders 13 representative variants to a static gallery and can also send them over SMTP to local Mailpit
  • centralizes all 20 existing application destinations in EmailAppUrlBuilder
  • preserves the email security and rendering fixes from test(mail): characterize production email contracts #2526 while retaining that PR's characterization coverage

Behavior

Mailer creates a strongly typed record for each email, renders it through IEmailTemplateRenderer, and then enqueues the existing MailMessage payload. Renderer dispatch is centralized, and generic component constraints verify each component-to-model pairing at compile time. Razor HTML-encodes interpolated content by default; application URLs and mailto: values are constructed and escaped explicitly. Mailbox addresses are parsed and canonicalized before their local and domain components are escaped separately, preserving valid mailbox syntax without allowing message-body query injection.

Email action metadata is emitted as JSON-LD with source-generated System.Text.Json metadata, so subjects containing quotes cannot alter the structured-data document. The queued message shape and delivery pipeline are unchanged. Mailer has one renderer-aware constructor; all construction sites are controlled in this repository and use DI or explicitly provide the renderer.

Application links preserve the existing templates' exact BaseURL-plus-route behavior, including the existing stack actions, organization views, project reports, account settings, and authentication pages. With the production BaseURL, these remain clean paths such as /event/{id}; if a configured BaseURL already contains a hash fragment, that fragment is preserved.

Critical presentation styles are inline in the Razor markup. The remaining <style> block contains client resets and responsive media queries that cannot be represented as inline declarations, so there is no CSS-inlining build step.

Rendering parity audit

The current main base (5cb6b5910, including #2526) and this branch were rendered with identical deterministic models. All 13 representative variants were captured in Chromium at 800 px desktop and 375 px mobile: 52 source screenshots and 26 labeled side-by-side comparisons.

Automated comparison of normalized visible text, decoded href values, and parsed JSON-LD found:

  • all links are identical for all 13 variants
  • JSON-LD is structurally identical for all templates that emit it
  • visible text is identical except for deliberate copy corrections in organization notices and payment failure
  • the contact-request rendering is pixel-identical in Chromium at both widths

Text differences

  • organization monthly/hourly notice: removes duplicate to, changes being counting to being counted, and fixes conserns to concerns
  • organization payment failed: changes the verb login to log in

Visual and markup differences

  • the 580 px desktop container, 95% mobile width, palette, typography scale, button padding, borders, and primary content hierarchy are preserved
  • shared buttons change from approximately margin: 0 0 16px to margin: 16px auto 21px; callouts similarly gain top/bottom spacing, so event bodies are about 10-13 px taller in the representative renders
  • the new markup does not apply Foundation's automatic hyphenation; long words wrap at word boundaries instead, most visibly in organization headings on mobile
  • the organization-added and organization-invited social footer keeps the same text, links, colors, desktop two-column layout, and mobile stacked layout, but the simplified tables add mobile vertical spacing and shift wrapping
  • the throttled daily summary intentionally changes four mobile metrics from a cramped four-across row (which clips 98765) to a readable 2x2 grid; this makes that representative mobile message 66 px taller
  • normal daily-summary mobile content is 48 px shorter because of the simplified row/list markup and spacing
  • most actions now use target="_blank"; the logo has explicit dimensions; light-only color-scheme metadata and presentation roles are added
  • the old XHTML Transitional wrapper, empty preheader, Gmail iOS spacer, Margin fallbacks, automatic hyphenation, and extensive Foundation/Outlook compatibility declarations are removed. Chromium parity does not prove Outlook/Gmail-client parity, so those clients remain the main residual rendering risk

Any subsequent change to a template or shared email component in this PR must regenerate the affected desktop and mobile comparison images and update this audit.

Template audit matrix

Template / variants Content and links Rendering result
Contact request Exact Pixel-identical in Chromium
Event notice: new critical, regression Exact Added button/callout spacing; same fields, user block, actions, and metadata
Organization added Exact Shared social-footer spacing/wrapping differences
Organization invited Exact Shared social-footer spacing/wrapping differences
Organization notice: monthly, hourly Copy fixes only; links exact Copy reflows slightly; shared spacing changes
Organization payment failed login -> log in; links exact Copy reflows slightly; shared spacing changes
Project daily summary: normal, throttled/free, unconfigured Exact Shared spacing/list differences; intentional mobile 2x2 throttled metrics
Email verification Exact Shared button spacing only
Password reset Exact Shared button spacing and word wrapping only

Rendering performance benchmark

Compared pre-merge main 5cfd90d0fbd8 with the merged PR head 4a73ce00bda6 on Linux/.NET 10 Release using the same 13 deterministic email variants. Steady-state results are medians from 3 fresh processes × 7 samples × 1,000 renders per template (21,000 renders per row). Managed allocations use GC.GetTotalAllocatedBytes(precise: true) with a full GC before each sample.

Aggregate results

  • Steady-state rendering: one pass over all 13 variants took 115.46 µs / 391.9 KiB with cached Handlebars versus 302.79 µs / 492.6 KiB with Razor: Razor is 2.62× slower and allocates 25.7% more per representative gallery pass. The average per-message difference is 8.88 µs → 23.29 µs (about 14.4 µs additional CPU time).
  • First use: across 10 fresh processes, initializing each rendering engine and rendering all 13 variants—including Handlebars' lazy template compilation—took 75.23 ms / 5.79 MiB with Handlebars versus 43.67 ms / 1.33 MiB with precompiled Razor components. Razor reduced first-use time by 41.9% and allocations by 77.1%.
  • Rendered payload: the 13 HTML bodies total 194,367 characters with the generated Foundation templates and 72,806 characters with Razor, a 62.5% reduction. This reduces queue serialization and SMTP transfer volume.
  • Delivery path: MailMessageJob, IMailSender, and the infrastructure mail sender are unchanged between the compared SHAs. SMTP/network latency is therefore not changed by code; only rendering cost and payload size differ. Network delivery normally dominates the additional ~14 µs average render time.

The steady-state cost comes primarily from creating and disposing a new ASP.NET Core HtmlRenderer for each email. Handlebars pays a larger one-time lazy compilation cost, then reuses cached compiled delegates. Daily summaries are the exception: Razor is 7-18% faster because the much smaller markup offsets renderer setup cost.

Per-template steady-state results

Variant Handlebars time Razor time Time delta Handlebars alloc Razor alloc Alloc delta HTML size delta
Contact request 3.30 µs 5.53 µs +68% 2.0 KiB 7.8 KiB +284% -10%
Event: new critical 9.70 µs 31.75 µs +227% 34.6 KiB 44.0 KiB +27% -58%
Event: regression 7.95 µs 34.56 µs +335% 27.6 KiB 38.6 KiB +40% -61%
Organization added 6.77 µs 38.91 µs +475% 37.1 KiB 38.9 KiB +5% -68%
Organization hourly limit 7.15 µs 33.92 µs +375% 27.6 KiB 37.1 KiB +35% -62%
Organization invited 6.61 µs 40.12 µs +507% 37.6 KiB 39.3 KiB +5% -68%
Organization monthly limit 6.58 µs 33.95 µs +416% 27.5 KiB 36.9 KiB +34% -62%
Organization payment failed 5.34 µs 13.83 µs +159% 24.9 KiB 36.8 KiB +48% -62%
Daily summary 24.11 µs 22.38 µs -7% 42.0 KiB 47.0 KiB +12% -61%
Daily summary: throttled 26.30 µs 21.61 µs -18% 53.3 KiB 69.6 KiB +30% -62%
Project not configured 5.12 µs 10.09 µs +97% 28.2 KiB 35.9 KiB +27% -67%
Email verification 3.08 µs 8.06 µs +161% 19.8 KiB 24.5 KiB +24% -61%
Password reset 3.45 µs 8.05 µs +133% 20.3 KiB 24.7 KiB +22% -60%

Previewing

From the repository root, render the static gallery:

dotnet run --no-cache --file build/EmailTemplatePreviews.cs

This writes the ignored gallery and individual messages to artifacts/email-previews. With the Aspire AppHost running, render the gallery and also send all 13 messages to Mailpit:

dotnet run --no-cache --file build/EmailTemplatePreviews.cs -- --send

By default, Aspire exposes Mailpit SMTP on localhost:1026 and its web UI at http://localhost:8026.

Verification

  • dotnet build --no-restore --disable-build-servers -m:1 — 0 warnings, 0 errors
  • dotnet test --project tests/Exceptionless.Tests/Exceptionless.Tests.csproj --no-restore -- --filter-class Exceptionless.Tests.Mail.MailerTests — 35 passed
  • dotnet run --no-cache --file build/EmailTemplatePreviews.cs — 13 variants rendered
  • deterministic DOM audit — decoded links and parsed JSON-LD preserved across all 13 variants; only the documented copy fixes changed visible text
  • Chromium rendering audit — all 13 variants captured at 800 px and 375 px against current main
  • mailer tests cover all 20 existing application destinations, every production template path, concurrent rendering, HTML encoding, quoted-subject JSON-LD validity, hostile mailto: input, valid mailbox syntax, regressed/non-regressed behavior, absence of unresolved Handlebars/Blazor markers, and strict URL/action contracts

Breaking changes

  • Mailer now has one constructor that requires IEmailTemplateRenderer; the old overload was removed because all construction sites are controlled in this repository and have been updated. No HTTP API, queued message, WebSocket, configuration, or storage contract changes.

@ejsmith
ejsmith marked this pull request as ready for review August 25, 2026 04:04

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71cd0a0585

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Exceptionless.Core/Mail/Mailer.cs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 700762c032

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Exceptionless.Core/Mail/Mailer.cs Outdated
Comment thread src/Exceptionless.Core/Mail/Mailer.cs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: da692f94c5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Exceptionless.Core/Mail/Mailer.cs
@ejsmith
ejsmith force-pushed the feature/razor-email-templates branch from 3e84201 to 054a6fd Compare August 27, 2026 15:58

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c824cef6fa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

private readonly ILogger _logger;

public Mailer(IQueue<MailMessage> queue, FormattingPluginManager pluginManager, ITextSerializer serializer, AppOptions appOptions, TimeProvider timeProvider, ILogger<Mailer> logger)
public Mailer(IQueue<MailMessage> queue, IEmailTemplateRenderer templateRenderer, FormattingPluginManager pluginManager, ITextSerializer serializer, AppOptions appOptions, TimeProvider timeProvider, ILogger<Mailer> logger)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore the public six-parameter Mailer constructor

BLOCKER: Replacing the existing public constructor with this renderer-aware signature breaks source and binary compatibility for consumers that directly instantiate Mailer; rebuilt clients no longer compile, and previously compiled clients can fail with MissingMethodException. Fresh evidence after the earlier review thread is that commit c824cef6 removed the restored overload and its compatibility test, so the final change again lacks the original signature; retain it as a delegating overload.

AGENTS.md reference: AGENTS.md:L67-L67

Useful? React with 👍 / 👎.

@ejsmith
ejsmith force-pushed the feature/razor-email-templates branch from a50de13 to 3dbbc93 Compare August 27, 2026 20:47
@ejsmith
ejsmith merged commit a115e6f into main Aug 28, 2026
9 checks passed
@ejsmith
ejsmith deleted the feature/razor-email-templates branch August 28, 2026 02:17
@github-actions

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Complexity Health
Exceptionless.Insulation 37% 35% 286
Exceptionless.Core 76% 68% 10337
Exceptionless.Web 85% 70% 8001
Exceptionless.AppHost 38% 41% 147
Summary 79% (25785 / 32734) 68% (12047 / 17775) 18771

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