Flows · 4 of 4

Inline CSS for email — ship HTML that survives Gmail & Outlook

Email clients strip `<head><style>`. One server call promotes every `<style>` rule onto each element's inline `style=""`, so your rendered page lands in the inbox looking exactly like the editor — no broken buttons, no naked text. Click once in the demo below; the whole transform is ~300 lines of dependency-free PHP you own.

Walkthrough

  1. The problem — `getHtml()` ships two style layers, and some targets keep only one

    BuilderJS's getHtml() already emits two layers of styling:

    (1) Element-level style="" — the Formatter writes an inline style attribute for every visual property the author set (padding, colour, border, font). These are already inline and survive everywhere.

    (2) A <head><style> block — the theme/template CSS: class rules, container-width constraints ([builder-element="BlockElement"]), positional rules (:first-child, :not(:last-child)), :hover, and @media responsiveness. This layer lives only inside the <style> tag.

    Layer 2 is the problem whenever the <head> doesn't travel with the markup — a CMS rich-text field, a third-party embed, an archived snippet, and most famously email (Gmail, Outlook desktop, Yahoo and most corporate webmail strip the entire <head> before rendering). A rule that exists only as .btn{background:#0d6efd} vanishes — the button renders unstyled. The fix is inlining: copy every matched declaration onto the element itself, producing one self-contained file that renders the same anywhere.

    HTML
    <!-- What getHtml() emits today (abridged) -->
    <head>
      <style>
        .btn { background:#0d6efd; color:#fff; border-radius:6px; }  /* layer 2 — DROPPED by Gmail */
        [builder-element="BlockElement"] { max-width:600px; }       /* layer 2 — DROPPED */
        .btn:hover { background:#0a58ca; }                          /* can't inline — stays */
        @media (max-width:600px){ .btn{ width:100% } }              /* can't inline — stays */
      </style>
    </head>
    <body>
      <a class="btn" style="padding:8px 16px;">Buy now</a>   <!-- layer 1 — survives, but loses bg+color -->
    </body>
    
    <!-- After Inline CSS export -->
    <body>
      <a class="btn" style="background:#0d6efd; color:#fff; border-radius:6px; padding:8px 16px;">Buy now</a>
      <!-- @media + :hover kept in a slimmed <style>; everything else baked inline -->
    </body>
  2. The fix — one server endpoint, one helper class

    The transform is pure string-in → string-out: hand it the rendered HTML, get back HTML with the <style> rules promoted onto each element. demo/backend/export-inline.php validates the payload, calls the inliner, and either streams an .inlined.html download or returns {html, stats} JSON for a live preview.

    The work lives in demo/backend/_lib/CssInliner.php — zero dependencies, built on PHP's native DOMDocument. It parses each <style> block, matches every inlinable selector (type · .class · #id · [attr="val"] · descendant · child >), merges declarations onto the element in specificity order, and — critically — lets a pre-existing inline style="" always win (true inline specificity, so the Formatter's per-element styling is never clobbered).

    PHP
    // demo/backend/export-inline.php — the whole handler.
    require_once __DIR__ . '/_lib/Validator.php';
    require_once __DIR__ . '/_lib/JsonResponse.php';
    require_once __DIR__ . '/_lib/CssInliner.php';
    
    use DemoBuilder\Validator;
    use DemoBuilder\CssInliner;
    
    $validated = Validator::make($_POST)
        ->required('html')->maxLength(10 * 1024 * 1024)
        ->optional('format')->regex('/^(download|json)$/')
        ->validateOrFail();
    
    $result = CssInliner::inline((string) $validated['html']);
    // $result = [
    //   'html'                => '…inlined HTML…',
    //   'inlinedDeclarations' => 47,   // props promoted onto elements
    //   'inlinedRules'        => 12,   // <style> rules that matched something
    //   'keptRules'           => 3,    // @media / :hover / unsupported — kept
    //   'styleBlocks'         => 1,
    // ];
    
    if (($validated['format'] ?? 'download') === 'json') {
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode(['ok' => true] + $result);
        exit;
    }
    
    header('Content-Type: text/html; charset=utf-8');
    header('Content-Disposition: attachment; filename="builder-export.inlined.html"');
    echo $result['html'];
  3. Wire the button — fetch → stats → download

    The host owns the trigger. The pattern mirrors C.15: build a FormData body, POST it, and act on the response. Here we ask for format=json so we can show a stats pill ("12 rules inlined · 47 declarations · 3 kept") AND download the result from the same round-trip — instant, honest feedback that the transform actually did something.

    Try it in the live demo above: click Inline CSS. Watch the pill report exactly how many rules were promoted vs. kept, then check your Downloads folder for email-inlined.html — open it and view-source to see the <style> block shrink to just the @media + :hover rules.

    JavaScript
    const btn  = document.getElementById('inlineBtn');
    const pill = document.getElementById('inlineStatus');
    
    btn.addEventListener('click', async () => {
      pill.textContent = 'Inlining…';
      pill.dataset.tone = 'busy';
      btn.disabled = true;
    
      try {
        const body = new FormData();
        body.append('html', builder.getHtml());   // absolute media URLs — email-safe
        body.append('format', 'json');            // {html, stats} instead of a raw download
    
        const res = await fetch('/backend/export-inline.php', { method: 'POST', body });
        if (!res.ok) throw new Error('HTTP ' + res.status);
        const { html, inlinedRules, inlinedDeclarations, keptRules } = await res.json();
    
        pill.textContent =
          `\u2713 ${inlinedRules} rules inlined \u00b7 ${inlinedDeclarations} decls \u00b7 ${keptRules} kept`;
        pill.dataset.tone = 'success';
    
        // Download the inlined HTML from the same response.
        const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
        const a = Object.assign(document.createElement('a'), { href: url, download: 'email-inlined.html' });
        document.body.appendChild(a); a.click(); a.remove();
        setTimeout(() => URL.revokeObjectURL(url), 0);
      } catch (err) {
        pill.textContent = 'Failed: ' + err.message;
        pill.dataset.tone = 'error';
      } finally {
        btn.disabled = false;
      }
    });
  4. What gets inlined, what stays — and where to call it in production

    Inlined (promoted onto style=""): ordinary rules whose selector is any mix of type, .class, #id, [attr] / [attr="val"], descendant (space), child (>) combinators, AND structural pseudo-classes:first-child, :last-child, :only-child, :nth-child(n), :not(…) — which are resolved against the static DOM exactly like juice / emogrifier do. Specificity decides who wins when two rules touch the same property.

    Kept verbatim in a slimmed <style> block (never dropped): @media, @font-face, @supports, @keyframes; any selector with a dynamic pseudo (:hover, :focus, ::before — these have no static answer, so they cannot be expressed as an inline attribute); and any selector the inliner doesn't fully model (substring attribute operators ^= $= *=, sibling combinators + ~). The rule is honest: if we can't inline it correctly, we keep it rather than guess and corrupt it. So a fully-inlined export typically leaves a <style> containing only @media + :hover — everything statically resolvable is already on the elements.

    Where to call it. Inline server-side, right before you hand the HTML to your mail transport — that's the single point every send path crosses, regardless of whether the HTML came from a live builder, a saved snapshot, or an API call. Don't inline at save time: you want to store the compact <style> form (smaller, re-editable) and inline only at send time.

    Swapping the engine. The inliner is tuned for the selectors BuilderJS themes emit and is deliberately dependency-free so it ships in the buyer ZIP with nothing to composer install. For arbitrary third-party CSS with exotic selectors, drop in pelago/emogrifier or tijsverkoyen/css-to-inline-styles — the contract is identical (HTML string in → HTML string out), so only the one require + call line in export-inline.php changes.

    PHP
    // Drop-in swap for a production-grade inliner — same I/O contract,
    // so export-inline.php is the ONLY file that changes.
    //
    //   composer require pelago/emogrifier
    //
    use Pelago\Emogrifier\CssInliner as Emogrifier;
    
    function inline_for_email(string $html): string
    {
        return Emogrifier::fromHtml($html)
            ->inlineCss()                 // promote <style> → style=""
            ->render();
    }
    
    // In your real send path:
    //
    //   $html       = $builderSnapshot->html;        // stored compact (with <style>)
    //   $emailReady = inline_for_email($html);        // inline ONLY at send time
    //   $mailer->send($to, $subject, $emailReady);
    //
    // Keep save-time HTML and send-time HTML decoupled: store the small
    // re-editable form, inline the email-safe form on the way out.

Live demo

demo-mini-builder--rich-3col-header
Inline CSS — email-ready exportIdle

The whole snippet

Click Copy on any file to grab it byte-identical, or Expand to read inline (capped at ~520 px so the page stays scannable). Multi-file snippets surface a side-by-side grid — copy only the files you need.

<?php
/**
 * snippet.php — inline-CSS email export, end to end.
 *
 * Drop alongside `dist/` + `themes/default/` + `backend/`, run a PHP
 * server, open this page. Click "Inline CSS" → the rendered HTML is
 * POSTed to /backend/export-inline.php, every <head><style> rule is
 * promoted onto each element's inline style="", and the email-ready
 * file downloads as email-inlined.html.
 *
 * The single moving part is /backend/export-inline.php +
 * /backend/_lib/CssInliner.php (zero dependencies). In a buyer app the
 * four THEME_* globals come from the same ThemeRegistry::resolveBundle()
 * round-trip the demo uses (or wherever your app stores the saved page).
 *
 * Matches the live demo on /examples/flows/4-inline-css-email/ (IDs
 * inlineBtn / inlineStatus) and the topnav Export ▸ Inline CSS action.
 */
declare(strict_types=1);

require_once __DIR__ . '/../../../backend/_lib/ThemeRegistry.php';

$bundle = (new \DemoBuilder\ThemeRegistry(__DIR__ . '/../../../themes'))
    ->resolveBundle('default', 'master/sample/email/Minimal');
?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Inline CSS for email</title>
    <link rel="stylesheet" href="/dist/builder.css">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block">
    <style>
        body { margin: 0; font-family: system-ui, sans-serif; display: grid; grid-template-rows: 56px 1fr; height: 100vh; }
        .my-header  { display: flex; align-items: center; gap: 8px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; }
        .my-header__title { font-weight: 600; font-size: 14px; margin-right: auto; }
        .my-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; background: #4338ca; border: 1px solid #4338ca; color: #fff; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12px; font-weight: 600; }
        .my-btn:hover { filter: brightness(1.08); }
        .my-btn:disabled { opacity: 0.5; cursor: not-allowed; }

        #inlineStatus { font-size: 11px; padding: 4px 10px; border-radius: 999px; background: #f4f4f5; color: #71717a; font-weight: 600; max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        #inlineStatus[data-tone="busy"]    { background: #e0e7ff; color: #4338ca; }
        #inlineStatus[data-tone="success"] { background: #dcfce7; color: #15803d; }
        #inlineStatus[data-tone="error"]   { background: #fee2e2; color: #b91c1c; }

        .my-builder-host { display: grid; grid-template-columns: 220px 1fr 280px; min-height: 0; }
        #MyWidgets, #MySettings { background: #f8f9fa; padding: 12px; overflow: auto; }
        #MyCanvas { overflow: auto; }
    </style>
</head>
<body>

<header class="my-header">
    <span class="my-header__title">Inline CSS — email-ready export</span>
    <button type="button" id="inlineBtn" class="my-btn">
        <span class="material-symbols-rounded" aria-hidden="true" style="font-size:16px;">mail</span>
        Inline CSS
    </button>
    <span id="inlineStatus" data-tone="" aria-live="polite">Idle</span>
</header>

<div class="my-builder-host">
    <div id="MyWidgets"></div>
    <div id="MyCanvas"></div>
    <div id="MySettings"></div>
</div>

<script>
    window.THEME_JSON        = <?= json_encode($bundle->themeJson,      JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_TEMPLATES   = <?= json_encode($bundle->themeTemplates, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_CONFIG_DATA = <?= json_encode($bundle->configData,     JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.MEDIA_URL         = <?= json_encode($bundle->mediaUrl,       JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
</script>

<script src="/dist/builder.js"></script>

<script>
    const builder = new Builder({
        mainContainer:     '#MyCanvas',
        widgetsContainer:  '#MyWidgets',
        settingsContainer: '#MySettings',
    });

    const btn  = document.getElementById('inlineBtn');
    const pill = document.getElementById('inlineStatus');

    function setStatus(text, tone) {
        pill.textContent = text;
        pill.dataset.tone = tone || '';
    }

    btn.addEventListener('click', async () => {
        setStatus('Inlining…', 'busy');
        btn.disabled = true;

        try {
            const body = new FormData();
            // Absolute media URLs — email clients fetch images from wherever
            // they're hosted, so the plain getHtml() flavour is what you want.
            body.append('html', builder.getHtml());
            body.append('format', 'json');   // {html, stats} so we can show numbers + download

            const res = await fetch('/backend/export-inline.php', { method: 'POST', body });
            if (!res.ok) throw new Error('HTTP ' + res.status);
            const data = await res.json();

            setStatus(
                `✓ ${data.inlinedRules} rules inlined · ` +
                `${data.inlinedDeclarations} decls · ${data.keptRules} kept (media/hover)`,
                'success'
            );

            // Download the inlined HTML from the same response.
            const url = URL.createObjectURL(new Blob([data.html], { type: 'text/html' }));
            const a = document.createElement('a');
            a.href = url;
            a.download = 'email-inlined.html';
            document.body.appendChild(a);
            a.click();
            a.remove();
            setTimeout(() => URL.revokeObjectURL(url), 0);
        } catch (err) {
            setStatus('Failed: ' + err.message, 'error');
        } finally {
            btn.disabled = false;
        }
    });

    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
</script>

</body>
</html>

Notes

Is inlining already "researched" in BuilderJS? Yes — for email artefacts, client-side. When a sample declares artefact_type: "email", getHtml() runs it through src/includes/EmailExportPipeline.js, which inlines every <style> rule via the browser's native CSSOM (the same approach as juice) as ONE step of a larger email transform — it also converts flex grids to <table>, swaps SVG social icons for PNGs, injects a mobile-stack @media query, and wraps the body in Outlook MSO conditional comments. So the email-specific path already ships a battle-tested inliner.

Why this server-side endpoint then? Three reasons the client pipeline doesn't cover: (1) it's artefact-agnostic — it inlines a page, form, or ad export too, not just email; (2) it runs without a browser — cron jobs, queue workers, and API callers that never mount a Builder can still get inlined HTML; (3) it's the one chokepoint every send path crosses. Use the client pipeline when you're exporting an email from a live editor; use this endpoint when you inline at send time on the server. They share the same contract (existing inline style="" always wins).

Pre-existing inline styles always win. The Formatter's per-element style="" is treated as the highest-specificity layer (true inline specificity), so promoting a low-specificity class rule never overrides what the author set directly on an element. This is the single most important correctness property — get it wrong and every author override silently breaks.

What it deliberately does NOT do. No <table> conversion, no MSO comments, no image-fluid defense — those are email-layout concerns owned by EmailExportPipeline. This endpoint does exactly one job (promote <style> → inline) and does it for any artefact. Compose the two if you need both.

Topnav parity. The same transform is one click away in the live builder: Export ▸ Inline CSS (the top, accent-tinted item, flagged email-ready). It POSTs builder.getHtml() to this exact endpoint with format=download. Wiring lives in demo/assets/js/builder-export-menu.js; the menu item in demo/_partials/builder/topnav.php.

Flows tier closes here at 4. C.15 export modes → C.16 import bundle → C.17 history shortcuts → C.18 inline CSS. Together they cover the full round-trip: design → export (3 artefact shapes + inline) → re-import → keep editing.