Back to Blog
Guide February 26, 2026 · 5 min read

How to Screenshot Your Email Campaigns Before Sending

Preview exactly how your HTML email renders — across desktop, mobile, and dark mode — before hitting send. No Litmus subscription, no manual client testing. One API call per preview.

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 required. OG images, screenshots, PDFs, and video — one API.

Get API Key — Free