How to Screenshot Your Email Campaigns Before Sending
Litmus and Email on Acid charge $100–$400/month to preview emails across clients. For most teams, the real requirement is simpler: see what the email looks like on desktop, mobile, and in dark mode before sending it to 50,000 subscribers.
Here's how to screenshot your HTML email template directly — rendered in a real browser viewport — for a fraction of the cost.
Screenshot an HTML email template
async function previewEmail(htmlTemplate) {
const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
html: htmlTemplate,
fullPage: true,
blockBanners: true,
}),
});
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
Full preview matrix: desktop + mobile + dark mode
import fs from "fs/promises";
async function generateEmailPreviews(htmlTemplate, campaignSlug) {
const PREVIEWS = [
{ label: "desktop-light", opts: { fullPage: true, darkMode: false } },
{ label: "desktop-dark", opts: { fullPage: true, darkMode: true } },
{ label: "mobile-light", opts: { fullPage: true, darkMode: false, viewportDevice: "iphone_14_pro" } },
{ label: "mobile-dark", opts: { fullPage: true, darkMode: true, viewportDevice: "iphone_14_pro" } },
{ label: "tablet", opts: { fullPage: true, darkMode: false, viewportDevice: "ipad_pro_12_9" } },
];
await fs.mkdir(`previews/${campaignSlug}`, { recursive: true });
const results = await Promise.allSettled(
PREVIEWS.map(async ({ label, opts }) => {
const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ html: htmlTemplate, blockBanners: true, ...opts }),
});
const image = Buffer.from(await res.arrayBuffer());
await fs.writeFile(`previews/${campaignSlug}/${label}.png`, image);
console.log(`✓ ${label}`);
return label;
})
);
return results.filter((r) => r.status === "fulfilled").map((r) => r.value);
}
// Usage
const template = await fs.readFile("emails/newsletter-march.html", "utf8");
await generateEmailPreviews(template, "newsletter-march-2026");
// → previews/newsletter-march-2026/desktop-light.png
// → previews/newsletter-march-2026/desktop-dark.png
// → previews/newsletter-march-2026/mobile-light.png
// → ...
Inject test data before screenshotting
Email templates use merge tags ({{first_name}}, {{{unsubscribe_url}}}). Replace them with realistic test data before capturing:
function injectTestData(template, data = {}) {
const defaults = {
first_name: "Alex",
company_name: "Acme Corp",
product_name: "Pro Plan",
amount: "$149.00",
unsubscribe_url: "https://yourapp.com/unsubscribe",
view_in_browser_url: "https://yourapp.com/email/view",
...data,
};
return Object.entries(defaults).reduce(
(html, [key, value]) =>
html.replace(new RegExp(`\\{\\{\\{?${key}\\}?\\}\\}`, "g"), value),
template
);
}
const template = await fs.readFile("emails/welcome.html", "utf8");
const rendered = injectTestData(template, { first_name: "Jordan" });
await generateEmailPreviews(rendered, "welcome-email");
CI check — screenshot on template change
# .github/workflows/email-preview.yml
name: Email template preview
on:
pull_request:
paths:
- "emails/**/*.html"
- "emails/**/*.mjml"
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get changed email templates
id: changed
run: |
CHANGED=$(git diff --name-only origin/main HEAD -- 'emails/*.html' | tr '\n' ' ')
echo "files=$CHANGED" >> $GITHUB_OUTPUT
- name: Generate previews
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
run: |
for file in ${{ steps.changed.outputs.files }}; do
slug=$(basename "$file" .html)
node scripts/preview-email.js "$file" "$slug"
done
- name: Upload previews
uses: actions/upload-artifact@v4
with:
name: email-previews-${{ github.run_id }}
path: previews/
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `## Email previews\nDesktop light/dark + mobile light/dark + tablet previews generated. [Download artifacts](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`
});
Express endpoint — preview on demand
Useful for internal tooling or a marketing team dashboard:
import express from "express";
import multer from "multer";
const app = express();
const upload = multer();
// POST /preview-email (multipart: html file or JSON body)
app.post("/preview-email", upload.single("html"), async (req, res) => {
const html = req.file
? req.file.buffer.toString("utf8")
: req.body.html;
if (!html) return res.status(400).json({ error: "No HTML provided" });
const mode = req.query.mode || "desktop-light";
const opts = {
"desktop-light": { fullPage: true, darkMode: false },
"desktop-dark": { fullPage: true, darkMode: true },
"mobile-light": { fullPage: true, darkMode: false, viewportDevice: "iphone_14_pro" },
"mobile-dark": { fullPage: true, darkMode: true, viewportDevice: "iphone_14_pro" },
}[mode] ?? { fullPage: true };
const screenshotRes = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: { "x-api-key": process.env.PAGEBOLT_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ html, blockBanners: true, ...opts }),
});
const image = Buffer.from(await screenshotRes.arrayBuffer());
res.setHeader("Content-Type", "image/png");
res.send(image);
});
# Preview your email from the CLI
curl -X POST http://localhost:3000/preview-email \
-F "html=@emails/newsletter.html" \
-o preview.png && open preview.png
Check subject line rendering with a wrapper
Wrap the email in a mock inbox chrome to see how the subject line and preheader look:
function wrapInInboxChrome(html, { subject, from, preheader }) {
return `<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
body { margin: 0; background: #f5f5f5; font-family: -apple-system, sans-serif; }
.inbox-chrome { background: white; border-bottom: 1px solid #e5e5e5; padding: 16px 20px; margin-bottom: 0; }
.inbox-from { font-weight: 600; font-size: 13px; }
.inbox-subject { font-size: 14px; font-weight: 700; margin-top: 2px; }
.inbox-preheader { font-size: 12px; color: #888; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.email-body { background: white; }
</style>
</head>
<body>
<div class="inbox-chrome">
<div class="inbox-from">${from}</div>
<div class="inbox-subject">${subject}</div>
<div class="inbox-preheader">${preheader}</div>
</div>
<div class="email-body">${html}</div>
</body>
</html>`;
}
Five previews per campaign (desktop light/dark, mobile light/dark, tablet) completes in under 10 seconds. No Litmus account, no BrowserStack, no email test send.
Try it free — 100 requests/month, no credit card. → Get started in 2 minutes
Top comments (0)