How We Caught a Layout Bug in Our Own Emails Using the Screenshot API
Two weeks. That's how long we'd been sending a broken welcome email to new signups before someone mentioned it in a support ticket.
The bug: our welcome email had a two-column layout that collapsed correctly on mobile — or so we thought. On Gmail Android dark mode, the right column disappeared entirely. Just gone. The CTA button was in that column.
We'd tested it in our own email client (Apple Mail, macOS). It looked fine. We'd sent a test to ourselves. Fine. We shipped it.
Here's how we found it, fixed it, and set up the check that caught the next one before it shipped.
How we found it
When the support ticket came in, we wrote a quick script to screenshot the template across the matrix we should have checked before sending:
import fs from "fs/promises";
const template = await fs.readFile("emails/welcome.html", "utf8");
const MATRIX = [
{ label: "desktop-light", opts: {} },
{ label: "desktop-dark", opts: { darkMode: true } },
{ label: "gmail-mobile", opts: { viewportDevice: "iphone_14_pro" } },
{ label: "gmail-mobile-dark", opts: { viewportDevice: "iphone_14_pro", darkMode: true } },
{ label: "outlook-width", opts: { viewport: { width: 600, height: 900 } } },
];
await fs.mkdir("email-debug", { recursive: true });
await Promise.all(MATRIX.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: template, fullPage: true, blockBanners: true, ...opts }),
});
const image = Buffer.from(await res.arrayBuffer());
await fs.writeFile(`email-debug/${label}.png`, image);
console.log(`✓ ${label}`);
}));
One run. Thirty seconds. The gmail-mobile-dark.png showed exactly what our user had reported — the right column was white text on a white background. Invisible.
What the bug actually was
The template used a <table> layout with bgcolor="white" hardcoded on the right cell. In dark mode, the text color inverted automatically (CSS color-scheme), but the background stayed white. White text, white background.
<!-- Before — wrong -->
<td bgcolor="#ffffff" style="color: #333333;">
<a href="...">Get started →</a>
</td>
<!-- After — correct -->
<td style="background-color: #ffffff; color: #333333;">
<a href="..." style="color: #333333 !important;">Get started →</a>
</td>
The fix was adding !important to the text color and removing the bgcolor attribute in favor of an inline style that dark mode CSS couldn't override. Classic email client issue — bgcolor attributes are parsed differently than inline background-color.
The check we set up afterward
We added a CI step to every PR that touches emails/:
# .github/workflows/email-check.yml
name: Email template preview
on:
pull_request:
paths:
- "emails/**"
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Screenshot changed templates
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
run: node scripts/email-preview.js
- uses: actions/upload-artifact@v4
with:
name: email-previews-${{ github.run_id }}
path: email-previews/
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const previews = fs.readdirSync('email-previews').filter(f => f.endsWith('.png'));
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `## Email previews\n\n${previews.length} screenshots generated (desktop light/dark, mobile light/dark, 600px width).\n\n[Download to review](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
});
// scripts/email-preview.js
import fs from "fs/promises";
import path from "path";
// Find changed email templates (or all if running manually)
const emailsDir = "emails";
const files = (await fs.readdir(emailsDir)).filter((f) => f.endsWith(".html"));
for (const file of files) {
const template = await fs.readFile(path.join(emailsDir, file), "utf8");
const slug = path.basename(file, ".html");
const outDir = `email-previews/${slug}`;
await fs.mkdir(outDir, { recursive: true });
const MATRIX = [
{ label: "desktop-light", darkMode: false, device: null },
{ label: "desktop-dark", darkMode: true, device: null },
{ label: "mobile-light", darkMode: false, device: "iphone_14_pro" },
{ label: "mobile-dark", darkMode: true, device: "iphone_14_pro" },
{ label: "narrow-600", darkMode: false, device: null, viewport: { width: 600, height: 900 } },
];
await Promise.all(MATRIX.map(async ({ label, darkMode, device, viewport }) => {
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: template,
fullPage: true,
darkMode,
blockBanners: true,
...(device && { viewportDevice: device }),
...(viewport && { viewport }),
}),
});
const image = Buffer.from(await res.arrayBuffer());
await fs.writeFile(path.join(outDir, `${label}.png`), image);
}));
console.log(`✓ ${slug} — ${MATRIX.length} previews`);
}
What we'd do differently
The two mistakes that let this ship:
We only tested in our own email client, which handles dark mode correctly. Testing in the tool you use every day is not testing — it's confirming your own assumptions.
We had no automated check. Email templates changed infrequently enough that there was no PR process for them. Someone edited the file, committed it, and deployed.
The fix for both: make it impossible to merge an email template change without seeing what it looks like in dark mode on mobile. The CI check does that now. It costs one API request per template per preview variant — roughly half a cent per PR.
The welcome email is the most important email we send. The CTA in it drives the majority of our trial conversions. We were sending it broken for two weeks.
Don't be us.
Try it free — 100 requests/month, no credit card. → Get started in 2 minutes
Top comments (0)