· Tutorials · By Simon Chiu

HTML Email to Image: Previews, Archives and Dark Mode

Render an HTML email to PNG for previews and archives: why 640 px beats 600, scripts and tracking pixels off, dark mode set on purpose, Outlook caveats.

To turn an HTML email into an image, load the email's HTML into a headless browser at a width a little larger than the template (640 px for a 600 px email), turn off scripts and tracking pixels, set the colour scheme explicitly, and take a full-page screenshot. That gives you a faithful preview for a campaign list, a send archive or a Slack notification. It does not show you Outlook for Windows, and this post explains why and what to do about it.

It belongs to the HTML to image API cluster. The general rules for rendering your own HTML (base URLs, fonts, escaping) are in the HTML to image guide. This page covers what is different about email.

Tested on 2026-10-18 with Node 22.18.0 on macOS 27.0.1 (Apple M1 Pro): [email protected] (Chrome for Testing 155.0.8059.79), against a 3,086-byte test newsletter written for this post.

What you can and cannot get from a browser render

Use Browser render is right? Why
Thumbnail in a campaign list Yes Shows the layout and colours at a glance
"View this email" archive page as an image Yes Stable, cannot run code, cannot change later
Preview in a Slack or Teams approval message Yes One image, no HTML sandbox needed
Proof that a template looks right in Outlook for Windows No Outlook uses Word's renderer and reads VML
Proof of what Gmail shows Partly Gmail rewrites and removes some CSS before display

Chrome renders email HTML the way a modern WebKit or Blink client does, close to Apple Mail, iOS Mail and most webmail. For pixel proof of Outlook desktop and Gmail's CSS filtering, you need those clients or a service that runs them.

The test email

The test newsletter is a typical hand-coded template: nested tables, a 600 px card, two columns that stack on phones, a dark mode block, and a bulletproof button with a VML version for Outlook.

<style>
  .card { width: 600px; background: #ffffff; }
  .col { width: 50%; vertical-align: top; }
  @media (max-width: 600px) {
    .card { width: 100% !important; }
    .col { display: block !important; width: 100% !important; }
  }
  @media (prefers-color-scheme: dark) {
    body, .wrap { background: #0b1513 !important; }
    .card { background: #13211e !important; }
  }
</style>
...
<!--[if mso]>
<v:roundrect href="https://example.com/notes" style="height:46px;v-text-anchor:middle;width:200px;"
  arcsize="13%" fillcolor="#0f766e" stroke="f">
  <center style="color:#ffffff;font:bold 16px Arial;">Read the notes</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-- --><a class="btn" href="https://example.com/notes">Read the notes</a><!--<![endif]-->
...
<img src="https://example.com/open.gif?u=8812" width="1" height="1" alt="" style="display:block">

That excerpt holds the three traps the rest of this post deals with: the max-width: 600px breakpoint, the dark mode block and the open-tracking pixel.

The renderer

// render-local.mjs: node render-local.mjs <email.html> <out.png> [--dark] [--width=640]
import { readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';

const args = process.argv.slice(2);
const flags = args.filter((a) => a.startsWith('--'));
const [file, out = 'email.png'] = args.filter((a) => !a.startsWith('--'));
if (!file) {
  console.error('usage: node render-local.mjs <email.html> <out.png> [--dark] [--width=640]');
  process.exit(2);
}
const width = Number(flags.find((f) => f.startsWith('--width='))?.split('=')[1] ?? 640);
const scheme = flags.includes('--dark') ? 'dark' : 'light';

const html = await readFile(file, 'utf8');
const kb = Buffer.byteLength(html) / 1024;
if (kb > 100) console.warn(`warning: ${kb.toFixed(0)} KB of HTML; Gmail may clip it`);

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setJavaScriptEnabled(false); // mail clients do not run scripts
  await page.setRequestInterception(true);
  page.on('request', (req) => {
    const url = req.url();
    const type = req.resourceType();
    if (type === 'document' || url.startsWith('data:')) return req.continue();
    // Images over HTTPS load, except 1 x 1 open-tracking pixels.
    if (type === 'image' && url.startsWith('https://') && !/open\.gif|\/track|pixel/i.test(url)) {
      return req.continue();
    }
    console.log('blocked', type, url);
    return req.abort();
  });
  // Set the scheme every time. Otherwise headless Chrome follows the OS setting.
  await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: scheme }]);
  await page.setViewport({ width, height: 100, deviceScaleFactor: 2 });
  await page.setContent(html, { waitUntil: 'load' });
  await writeFile(out, await page.screenshot({ type: 'png', fullPage: true }));
  const h = await page.evaluate(() => document.documentElement.scrollHeight);
  console.log(`${out}: ${width} x ${h} CSS px, ${scheme}`);
} finally {
  await browser.close();
}

Every line here is a decision about email, not about screenshots in general.

Scripts off

Mail clients do not run JavaScript. If your preview does, a stray script (from a template tool, or from a hostile sender if you archive incoming mail) can change what you capture. setJavaScriptEnabled(false) makes the render match the inbox.

The tracking pixel blocked

The request filter lets through data: URIs and HTTPS images, and blocks everything else. On our test email, it printed:

blocked image https://example.com/open.gif?u=8812

If the preview loaded that pixel, your email platform would count an open for user 8812 every time you rendered the archive. Adjust the pattern to your provider's pixel URL. Plain http:// images are blocked too, on purpose: an archive image should not depend on an insecure host.

The colour scheme set on purpose

Without emulateMediaFeatures, headless Chrome follows the operating system. A designer's Mac in dark mode and a Linux server render the same email differently, and the archive images change depending on which machine made them. Pass the scheme for every render, and render both if your template has a dark block.

Height 100, full page

The viewport height is a starting value. fullPage: true grows the capture to the document height, so short and long emails both come out without blank space. Our test email measured 468 CSS px tall at 640 wide.

Why 640 px and not 600

The obvious width for a 600 px email is 600. We rendered the test email at three widths:

Viewport width Layout Document height
640 px Desktop: two columns side by side, grey margin around the card 468 px
600 px Mobile: columns stacked, card touches both edges 483 px
375 px Mobile: columns stacked, text wraps more 572 px

At exactly 600 px, @media (max-width: 600px) matches, so the template switches to its phone layout. Nothing warns you. The image just looks a little odd and is the wrong layout. 640 px leaves a margin like a real reading pane, and stays clear of the most common breakpoints. Check your own template's media queries; some use 620 or 640 as the breakpoint, and then you need more width.

Measured: size and time

The test email at each scale and format, on a warm page, median of 7 captures:

Scale Format Bytes Capture median
1x PNG 33,509 60 ms
1x JPEG, quality 85 28,064 47 ms
1x WebP, quality 85 16,092 61 ms
2x PNG 74,381 67 ms
2x JPEG, quality 85 68,770 52 ms
2x WebP, quality 85 35,992 93 ms

Load plus a 2x PNG capture took a median of 70 ms. Email layouts are mostly flat colour and text, so PNG compresses well; WebP 85 is still about half the size. For thumbnails, render at 1x. For an archive that people zoom into, use 2x.

What Chrome will not show you about Outlook

Outlook for Windows desktop renders HTML with Microsoft Word's engine. Our test email shows the consequences:

  • The VML button is invisible to Chrome. The <!--[if mso]> block is a comment for every client except Outlook. Chrome draws the HTML <a class="btn"> instead. If the VML version has a typo, no browser render will show it.
  • Some CSS is ignored by Word's engine, for example border-radius on many elements and much of max-width. Chrome applies it, so the preview is more polished than the Outlook reality.
  • Dark mode is different. Outlook and some webmail clients invert colours themselves rather than using your prefers-color-scheme rules. Your dark render shows your design, not their inversion.

So label browser renders as "preview", and keep a separate Outlook check in your release process for templates that matter.

The same render through an API

The HTML to image API takes the same HTML and the same decisions as request parameters: viewport width, full-page capture, colour scheme and blocking. This POST uses Node's built-in fetch:

// render-api.mjs: node render-api.mjs newsletter.html [dark]
import { readFile, writeFile } from 'node:fs/promises';

const [file, mode] = process.argv.slice(2);
const html = await readFile(file, 'utf8');

const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RS_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    html,
    viewport: { width: 640, height: 100, scale: 2 },
    capture: { mode: 'full_page' },
    browser: { dark_mode: mode === 'dark' }, // false forces light, true forces dark
    block: {
      resources: ['script'],          // external scripts
      requests: ['*open.gif*', '*/track*'], // tracking pixels
    },
    output: { format: 'webp', quality: 85 },
  }),
});

if (!res.ok) {
  const { error } = await res.json();
  console.error(`${res.status} ${error.code}: ${error.message}`);
  process.exit(1);
}
await writeFile(mode === 'dark' ? 'email-dark.webp' : 'email.webp', Buffer.from(await res.arrayBuffer()));

Two differences from the local script are worth knowing. block.resources: ['script'] blocks script files, but not inline <script> elements in your HTML. Strip those before you send the email, which is what a mail client effectively does. And browser.dark_mode accepts false as well as true, so you can force the light scheme instead of inheriting a default; see the dark mode guide. The parameter names follow the blocking and browser references. This listing was not run against the live API for this post.

Troubleshooting

Symptom Cause Fix
Columns stacked in the "desktop" image Viewport width matches the template's max-width breakpoint Render wider than the breakpoint, for example 640 px
Preview is dark on one machine only Colour scheme follows the OS Set prefers-color-scheme (or browser.dark_mode) for every render
Open rate jumps after an archive job The tracking pixel loaded during rendering Block the pixel URL pattern
Broken image icons Images on http://, or behind a login Serve images over HTTPS from a public host
Image shows the HTML button, not the VML one Chrome ignores <!--[if mso]> blocks Expected; test Outlook separately
Large blank area at the bottom Fixed viewport height without full page Full-page capture with a small starting height
Web font missing in the preview Email uses a font link that loads late, or is blocked Keep the fallback stack sensible; most clients show it anyway
Content shifts between runs A script or remote content changed Scripts off, and archive the HTML next to the image

Summary

  • Render at 640 px for desktop and 375 px for phones, never at the template's breakpoint.
  • Turn scripts off, block tracking pixels and set the colour scheme on every render.
  • Use full-page capture with a small starting height.
  • Treat the image as a preview of modern clients, and test Outlook for Windows separately.

To try it without running Chrome, create a free account and send one of your own templates with the request above. For dark mode in general, see dark mode screenshots.

Related in this topic