Skip to content

EmailRealizer

Edgar Mesquita edited this page Aug 25, 2026 · 2 revisions

Email Rendering (Track M)

🌐 This page in: English · Português

Status: the core is BUILT (merged 2026-08-25; ships with the next release). EmailRealizer and EmailRenderer exist in eQuantic.UI.Email with the vocabulary below. Still ahead: the real-client matrix (a generated sample against Gmail/Outlook/Apple Mail) and HTML pins once the output settles.

Write the email in C# with the same components a page is written with, and get back HTML an email client will actually render.

Why this fits the architecture rather than fighting it

A component here is a VisualNode tree that knows nothing about its target. The web realizer turns it into DOM and CSS; Photon turns it into GPU paint. Email is a third realizer — a target whose rendering engine happens to be very restrictive, which is architecturally the same shape as a target with no DOM at all.

Nothing about the authoring layer changes. The same Column, Text, Image and theme tokens, the same Build(context).

What already exists

Two pieces are in the tree today, verified by reading them:

  • WebRealizer.Lower(node, theme, typeScale, styles: null) — without a style sink, styles stay inline on the element instead of becoming deduplicated atomic classes. Inline style is email's hardest constraint, and this path already produces it.
  • HtmlRenderer.RenderNode(...) turns the lowered tree into an HTML string.

So "C# component → HTML with inline styles" is two existing calls. What is missing is everything about the medium.

What email actually demands

Demand Why Where the web realizer differs
Nested <table> layout Outlook on Windows renders with Word's engine: no flexbox, no grid Row/Column lower to FlexDirection
Inline style="" only <style> blocks and external CSS are stripped or ignored by several clients Atomic classes + a stylesheet (the sink path)
Literal colors CSS custom properties are not resolved Tokens emit var(--eq-color-…, fallback)
Absolute image URLs or CID data: URIs are dropped by Gmail Assets assume a served origin
A constrained width ~600px is the convention every client tolerates Pages are fluid
No interaction No JS, no hover in most clients, no scrolling containers Pressable, ScrollView, hover states exist

Design

A new package, eQuantic.UI.Email, depending only on Primitives and Core — never on eQuantic.UI.Web, so the two realizers cannot drift into each other.

// Authored exactly like a page.
public sealed class WelcomeEmail(string name) : StatelessComponent
{
    public override VisualNode Build(ComponentContext context) =>
        Column(gap: Space.S4, children: [
            Image(Assets.Logo, width: 132),
            Text($"Welcome, {name}", TypeRole.Heading),
            Button("Confirm your address", href: "https://…"),
        ]);
}

// Rendered where it is sent.
EmailMessage message = EmailRenderer.Render(new WelcomeEmail("Edgar"), theme);
// message.Html          — table-lowered, fully inline
// message.PlainText     — generated from the SAME tree, not written twice

EmailMessage carries both parts because a multipart message needs both, and generating the text alternative from the tree is the only way it stays in step with the HTML.

Slices

M0 — Measure before building. Done — and it changed the plan. Besides the two expected findings (flex layout, light-dark() colors), a third the plan did not have: typography lives in eq-type-* classes, and an email has no stylesheet, so every text rendered at one size. M2 grew to inline the theme's ramp onto every text. The remaining half of M0 is human: the generated sample against real mailboxes.

M1 — Table lowering. Done. Box, Row, Column and Gap become nested <table>/<tr>/<td> with cell padding and spacer rows. Alignment maps to align/valign. This is the bulk of the track.

M2 — Inline styles and literal theming. Done (plus the inlined type ramp). Reuse the no-sink path; resolve every token to a literal color and length so nothing depends on custom properties. Density and type scale resolve once, at render.

M3 — Document shell. Done. EmailDocument: doctype, <meta> set, the 600px container, the invisible preheader line, and the dark-mode meta clients honour.

M4 — Vocabulary fence. Done. Diagnostics naming what an email cannot do — ScrollView, Pressable, Draggable, hover and focus styling, position: absolute. A Link becomes <a>; a Button becomes a bulletproof <a> styled as a button. An Image without an absolute URL is an error, not a broken picture in someone's inbox.

M5 — Plain-text alternative. Done. Walk the same tree for the text/plain part.

What the review taught the contract

The slice went through five review rounds (32 findings) and the survivors are rules worth stating:

  • A property the medium cannot express is fenced, not approximated: gradient ink and MaxLines throw naming the way out — a solid stand-in is a different ink nobody chose, and showing more text than the author bounded is a content divergence.
  • A property the medium expresses differently is honoured: container padding (a one-cell wrapper table), Cross and per-child AlignSelf (cell alignment), per-corner radii, borders, heading elements, aria-label from Link.Label.
  • MainAlign is vacuous here, documented rather than fenced: an email table sizes to its content, so there is no free space to distribute — every value renders identically, which is faithful.
  • A broken component fails the SEND. Components expand via Build, not the web's BuildContained: a describe-box is right on a live page a developer is watching and wrong in a message about to reach a reader's inbox dressed as content.
  • Addresses are parsed, not prefix-checked; attribute values are attribute-encoded; a Link around linked runs refuses to nest anchors, naming the choice.

Fences

  • No <style> block, ever, even where a client supports it. One rule beats a matrix of exceptions.
  • No interactivity. An email is a printed page that happens to have links.
  • No client-specific hacks in components. Anything Outlook needs lives in the realizer, never in something an author writes.
  • Attachments and delivery are not ours. The output is HTML plus text; sending is the app's job (MailKit, SES, whatever it already uses).

How it is tested

The same two nets the rest of the framework uses:

  • HTML pins, regenerated on purpose like the transpiled fixtures, so a layout change is visible as a diff rather than a surprise.
  • A client matrix as conformance, run against real clients and recorded — the browser-is-the- arbiter rule applies here even harder, because email clients are further from a spec than browsers are.

Clone this wiki locally