Published on

Email Is Not a Web Page: When the Rendering Engine Is Out of Your Control, Which Layer Should the Visual Contract Live In

Authors
  • avatar
    Name
    Jack Qin
    Twitter

The most natural mental model for writing a "good-looking" HTML email is to bring over the same CSS you use for web pages. That model almost never lets you down on Apple Mail and Gmail — until the email lands in Outlook for desktop: gradients gone, inline SVGs stripped wholesale, image widths misaligned, max-width ignored.

This is not a string of isolated compatibility bugs. They all stem from the same first-principles fact, and once you accept that fact, the question "which layer should the visual contract live in" has a definite answer. This post is not about how to assemble one specific email; it wants to start from that root fact and derive a rendering contract: which visual elements cannot trust client CSS, why they must be rasterized server-side, and where you'll still hit traps at the mechanism intersections after rasterizing. The worked example throughout is an environmental-monitoring platform's scheduled customer pushes — heatmap reports, tank-level alerts, flow-meter weekly reports (stack: .NET 10, MassTransit, SkiaSharp, Playwright, Microsoft Graph for sending, PostgreSQL).

The root fact: Outlook renders Word, not HTML

The root of every phenomenon is a single sentence: Outlook Desktop's HTML rendering goes through Word's layout engine, not WebKit/Blink/Gecko. The Word engine's CSS support is stuck at a very ancient subset, and its failure mode is silent — no error, it just quietly does nothing.

The table below is a "silent failure" list verified one by one through real testing. But more important than the list itself is the dividing line it reveals:

FeatureOutlook supportAlternative
background: linear-gradient(...)Not renderedRasterize into a PNG, or pair with background-color as a fallback
Inline <svg> (including gradients)StrippedRasterize into a PNG with SkiaSharp, embed as a CID attachment
border-radius on <img>UnreliableBake the rounded corners directly into the PNG's alpha channel
max-width: Npx on <img>May be ignored, falling back to the image's natural widthUse the HTML width="N" attribute, or derive width from a shared <table> container
box-sizing: border-boxUnreliableDon't use it; align widths by removing differential borders instead

The dividing line is this: anything whose visual depends on "modern CSS computation" to appear — gradients, vectors, rounded-corner clipping, box-model widths — is untrustworthy in the Word engine. Conversely, purely structural HTML (tables, the width attribute, <img> references) is the trustworthy greatest common divisor.

From this follows the first-principles conclusion of the whole contract: any "visible" gradient or inline SVG in the email body cannot rely on CSS. Any visual element that pure HTML+CSS cannot draw yet must be visible (gradients, charts, diagrams, tank silhouettes) must be rendered into a PNG server-side and then dropped in as an inline CID (Content-ID) attachment. This is not to dodge some bug; it is because you do not control the rendering engine of the single most important client — pushing the visual contract down from "the client will interpret my CSS correctly" to "I'll fix the pixels on the server" is the only layer that does not depend on the runtime environment's goodwill.

The design space: which layer should the contract live in

Having accepted "server-side rasterize the visible visuals," the next question is: how should the rendering pipeline be layered so that this contract lands stably on every email? The platform's approach is to split email production into three layers, each doing exactly one thing:

  • Builders (*EmailBuilder.cs): assemble the final HTML string for each kind of email.
  • Renderers (*Renderer.cs): fetch data, call the Builder, generate attachments, return RenderedEmail.
  • Consumers / Senders: the generic path goes through SendEmailConsumer, the heatmap path through SendHeatmapReportConsumer, ultimately handed to GraphEmailSender to send via Microsoft Graph.

Visual elements follow a uniform three steps: render to a PNG byte stream server-side (vector content with SkiaSharp, DOM content with Playwright) → attach it as an inline attachment (with a non-empty ContentId) → reference it in HTML with <img src="cid:{contentId}" />. GraphEmailSender supports both paths: as long as an attachment's ContentId is non-empty, it marks the attachment as isInline = true in the Graph payload. The CID naming convention is {kind}-{identifier} (e.g. heatmap-{siteId}-{type}, legend-{variant}, tank-image-{assetId}); when the same CID is referenced by multiple <img> tags, deduplicate so you don't attach the same image twice.

The point of this layering is: the judgment of "which visuals go through CSS and which go through raster" is converged into the Builder and Renderer layers, so the caller doesn't have to remake that judgment each time. Once the contract sinks into structure, it no longer depends on every email author remembering Outlook's quirks.

After rasterizing: a few traps at the mechanism intersections

Baking visuals into PNGs solves "can it display at all," but it brings along a set of new mechanisms, each hiding a trap at its intersection.

HiDPI: render at 2×. An image rendered directly at logical pixels will be blurry on a Retina screen. The approach is to create the canvas at 2× logical pixels and canvas.Scale(2, 2) before drawing, while the HTML width/height attributes still hold the logical dimensions — so the image is 1:1 crisp on Retina and cleanly downsampled on a regular screen.

private const int CanvasWidth = 140;   // logical pixels
private const int CanvasHeight = 130;
private const int RenderScale = 2;

using var surface = SKSurface.Create(
    new SKImageInfo(CanvasWidth * RenderScale, CanvasHeight * RenderScale));
var canvas = surface.Canvas;
canvas.Clear(SKColors.Transparent);
canvas.Scale(RenderScale, RenderScale);
// ... everything afterward drawn in logical coordinates ...

Fonts: don't trust the default. The Worker runs in a slimmed-down Docker image. When SkiaSharp draws text and falls back to SKTypeface.Default while the container has no default font installed at all, Skia can only draw empty boxes (tofu). This is essentially the implicit environmental assumption "surely it'll have a default" breaking in the production container — the dev machine has a full set of fonts, the slimmed-down image has none. The fix is to give an explicit fallback chain and never leave the outcome to a default:

private static readonly SKTypeface LabelTypeface =
    SKFontManager.Default.MatchFamily("DejaVu Sans")
    ?? SKFontManager.Default.MatchFamily("FreeSans")
    ?? SKFontManager.Default.MatchFamily("Arial")
    ?? SKTypeface.Default;

Width consistency between sibling images. The heatmap and the legend bar below it must be the same width. Don't rely on max-width (Outlook ignores it); the two images must derive width from the same HTML mechanism: either both use the HTML width="N" attribute and sit in the same kind of container, or both use width="100%" inside the same outer <td> / <table>. There's also a subtle trap — don't give sibling images asymmetric 1px borders: under the default box-sizing: content-box, a 1px border adds 2px to the total rendered width, and the alignment immediately breaks. To align, remove the differential border rather than relying on box-sizing: border-box (which is also unreliable in Outlook).

Small gradients: progressive enhancement rather than a full attachment. A small decorative gradient like a status indicator bar is not worth a dedicated PNG attachment. Just layer the gradient over a solid-color fallback — Outlook ignores background-image: linear-gradient(...) and renders the solid color, while modern clients layer the gradient over the solid color, and both sides look good:

<div
  style="background-color: #ef4444;
            background-image: linear-gradient(90deg, #ef4444, #b91c1c);"
></div>

This reveals a useful design lens: rasterization is not a black-or-white switch. "Is a visual worth the cost of one attachment" is a cost question — core readability elements (heatmaps, legends) are worth it, a purely decorative small gradient is not, and the latter settles the bill more cheaply via progressive enhancement.

The two implicit contracts most worth singling out

Beyond the rasterization pipeline, there are two more traps that belong not to rendering itself but to implicit contracts that cross a boundary — exactly the kind of place that rots most easily at a mechanism intersection.

Cross-layer palette synchronization. The heatmap legend's gradient must match the palette of the frontend canvas renderer — the legend is essentially the ruler the reader uses to "decode" the heatmap's colors, and if the two sides don't match, the whole image is read wrong. The synchronization points are the backend's ColorSchemeImageProvider.GetGradientStops (SKColor[]) and the frontend's CANVAS_COLOR_SCHEMES in apps/web/src/features/map-core/constants.ts; change one side and you must change the other. This is a cross-repository implicit contract that a comment cannot hold — its best home is documentation plus a test.

A database constraint that "silently swallows." This is the production trap most worth being wary of in the whole post. EmailLog.SentVia is limited by the CHECK constraint email_logs_sent_via_check to exactly two values: 'manual' or 'cron'. Writing any other value (e.g. "transactional", "system") triggers Postgres 23514 and the insert fails.

What makes it fatal is that it fails silently in production: SendEmailConsumer wraps the EmailLog write in try/catch and deliberately swallows the DB failure. Why deliberately? Because letting the exception propagate would trigger a MassTransit retry, which would in turn resend the email — resending an email is far worse than losing one audit record. So the result is: the email sends normally, the audit row never lands in the DB, and there's no exception the whole way through. You have no idea the audit trail broke.

The assignment rule: human-triggered (UI button, password reset, manual resend) → "manual"; schedule-triggered (Quartz job, cron) → "cron"; a new transactional flow → "manual". The constraint is defined in EmailDbContext.OnModelCreating and in migration 20260408103141_CodifyRemainingCheckConstraints; existing producers assign with SentVia = cmd.TriggerSource, so TriggerSource must be constrained to these two literals at the dispatch site. The only way to guard against this trap is a test — write a consumer-level integration test asserting that the EmailLog row actually exists. The human eye can't catch it, because it doesn't error in production.

These two traps share the same structure: a contract that lives in only one place in code/comments will reliably rot as new call sites accumulate. The palette relies on the tacit "remember to change the other side when you change one"; SentVia relies on the self-discipline of "don't typo the literal" — and tacit understanding and self-discipline are both unenforceable. What can block the regression is nailing the contract into a test.

Test strategy: test only the stable, never the fragile pixels

The existing test shape is worth borrowing — it deliberately tests only stable things and avoids fragile pixels:

  • Builder tests: pure string input/output.
  • Renderer / Provider tests: assert the PNG magic number [0x89, 0x50, 0x4E, 0x47], not Skia's pixel fidelity.

Should assert: the HTML contains the expected cid:{...} references; the HTML contains no <svg and no "unpaired" linear-gradient (paired means a background-color fallback is also present); the PNG bytes start with the magic number; the shape of attachment ContentId is stable across calls. Should not assert: the exact rendered pixel values; the exact byte length of the PNG (varies with compression); Outlook-specific rendering — automation can't cover it, so mark "needs manual verification" in the PR description.

This trade-off is itself a transferable principle: assert the shape of the contract (whether there's a CID reference, whether there's a fallback color), not the result of the rendering (what the pixels look like). Shape is stable, pixels are fragile; testing shape can block regressions, while testing pixels only feeds a pile of brittle snapshots.

The transferable layer

Set aside the specific SkiaSharp and Outlook APIs, and the real transferable insight from this case is:

When the final rendering of a piece of visual/logic depends on a rendering environment you don't control, push the contract down to the layer you genuinely do control. You can't manage how Outlook interprets CSS, but you can manage what bytes the server emits — so baking the visible visuals into PNGs is taking "right or wrong" back from the client's goodwill into your own hands. This line of thinking isn't limited to email: in any scenario where "the target environment is a subset from over a decade ago and can't be upgraded" (legacy browsers, embedded WebViews, PDF renderers), the most stable answer is don't compute in the uncontrollable layer; fix the result in the controllable layer.

And the one line most worth taking away: email is not a web page. It runs on a crowd of rendering engines you can't control, the most important of which is still stuck on a CSS subset from over a decade ago. For any visual element in doubt, baking it into a server-side PNG is almost always the most reliable answer.