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-radiuson many elements and much ofmax-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-schemerules. 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
- HTML to Image API: Render HTML and CSS to PNG Send HTML and CSS, get a PNG, JPEG, WebP or PDF back from real Chrome. Base URLs for assets, web fonts, transparent backgrounds and signed URLs.
- Best HTML to Image APIs (2026): 9 Options Compared The best HTML to image APIs compared: rendering engine, ready signals, fonts, delivery, public links and prices at three volumes, from each vendor's own docs.
- html2canvas Alternative: Render the DOM in Real Chromium We tested html2canvas 1.4.1 against real Chromium on 14 CSS features. See what it drops, when it is still the right tool, and how to move capture server-side.
- Convert a Webpage to an Image: 5 Methods, Measured Convert a webpage to a PNG, JPEG or WebP five ways, from a DevTools command to an API. Effort, cost and timings for each, plus the settings that matter.
- HTML to Image in Node.js: 4 Ways, Same 6 Templates HTML to image in Node.js with Puppeteer, node-html-to-image, satori and an API, run on the same six templates. Measured times, what broke, and code that works.
- HTML to Image in Python: imgkit, html2image, Playwright Convert HTML to an image in Python with html2image, Playwright or an API, compared on five templates with timings and renders, and why imgkit is a dead end.