ScreenshotNeo

BlogHow-to

How to Capture a Web Application Screenshot and Email It on Errors

Capture failed browser tests, attach the screenshot to an email, preserve CI artifacts, and troubleshoot delivery and security issues.

By the ScreenshotNeo team1 October 20269 min read

Capture the screenshot before closing the browser, save it with a unique test and run identifier, attach that file to an SMTP message, and retain it as a CI artifact. Playwright requires an explicit screenshot call in a failure hook or reporter. Cypress can capture screenshots automatically during cypress run, or manually with cy.screenshot(). Nodemailer can attach the resulting PNG by file path, Buffer, or stream.

What the failure-notification flow must do

  1. Run the scenario in your browser test framework.
  2. Detect the failure while the page and browser context are still open.
  3. Capture the viewport, a full page, or the relevant element.
  4. Write the image to a unique temporary path or keep its bytes in memory.
  5. Send an email containing the test name, URL, timestamp, browser, environment, exception, and artifact link.
  6. Upload or retain the screenshot before deleting temporary files.

Capture is asynchronous. A page can change between the assertion failure and the completed write, so take the screenshot immediately in the failure hook and do not close the page first.

Playwright: capture and email a failed test

Playwright supports viewport, element, and full-scrollable-page screenshots in PNG, JPEG, or WebP. The following example uses a custom reporter so the screenshot is taken while the failed test’s page is available. It sends one message per failed test.

Install dependencies

npm install -D @playwright/test
npm install nodemailer
npx playwright install

Test and reporter code

// tests/checkout.spec.js
const { test, expect } = require('@playwright/test');

test('checkout shows a confirmation', async ({ page }) => {
  await page.goto('https://example.com/checkout');
  await expect(page.getByRole('heading', { name: 'Confirmation' })).toBeVisible();
});
// failure-email-reporter.js
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const nodemailer = require('nodemailer');

class FailureEmailReporter {
  constructor() {
    this.outDir = path.join(os.tmpdir(), 'browser-failure-shots');
    this.transporter = nodemailer.createTransport({
      host: process.env.SMTP_HOST,
      port: Number(process.env.SMTP_PORT || 587),
      secure: process.env.SMTP_SECURE === 'true',
      auth: {
        user: process.env.SMTP_USER,
        pass: process.env.SMTP_PASS
      }
    });
  }

  async onBegin() {
    await fs.mkdir(this.outDir, { recursive: true });
  }

  async onTestEnd(test, result) {
    if (result.status === 'passed' || result.status === 'skipped') return;

    const page = result.attachments?.find(a => a.name === 'page');
    const safeTitle = test.title.replace(/[^a-z0-9_-]+/gi, '-').slice(0, 80);
    const file = path.join(this.outDir, `${safeTitle}-${Date.now()}.png`);

    // The test fixture should attach a screenshot named failed-page.
    const shot = result.attachments?.find(a => a.name === 'failed-page');
    if (!shot?.path) {
      console.error('No failed-page attachment was produced');
      return;
    }

    await fs.copyFile(shot.path, file);
    const body = [
      `Test: ${test.title}`,
      `Project: ${test.parent?.title || 'unknown'}`,
      `Browser: ${test.parent?.title || 'configured Playwright project'}`,
      `Status: ${result.status}`,
      `Error: ${result.error?.message || 'see attached screenshot'}`,
      `Duration: ${result.duration} ms`,
      `Screenshot: ${file}`
    ].join('\n');

    await this.transporter.sendMail({
      from: process.env.ALERT_FROM,
      to: process.env.ALERT_TO,
      subject: `[E2E failure] ${test.title}`,
      text: body,
      attachments: [{ filename: path.basename(file), path: file }]
    });
  }
}

module.exports = FailureEmailReporter;

A simpler and usually more reliable pattern is to create the screenshot in a fixture and attach it to the test result:

// playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  reporter: [['list'], ['./failure-email-reporter.js']],
  use: { trace: 'retain-on-failure' }
});
// tests/fixtures.js
const base = require('@playwright/test');
const test = base.test.extend({
  page: async ({ page }, use, testInfo) => {
    await use(page);
    if (testInfo.status !== testInfo.expectedStatus) {
      const file = testInfo.outputPath('failed-page.png');
      await page.screenshot({ path: file, fullPage: true });
      testInfo.attachments.push({ name: 'failed-page', path: file, contentType: 'image/png' });
    }
  }
});
module.exports = { test, expect: base.expect };

Use fullPage: false for the current viewport, fullPage: true for the full scrollable document, or capture a locator with await page.locator('[data-testid="checkout"]').screenshot({ path: file }). Give parallel runs unique output paths with testInfo.outputPath() or a run identifier.

Cypress: automatic and manual failure screenshots

Cypress documents automatic screenshots on failure during cypress run; they are not automatic in cypress open. The screenshotOnRunFailure setting controls this behavior. See the Cypress screenshot documentation for capture modes, clipping, selector blackout, and onAfterScreenshot metadata.

// cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    screenshotOnRunFailure: true,
    screenshotsFolder: 'cypress/screenshots',
    setupNodeEvents(on) {
      on('after:screenshot', (details) => {
        console.log(`Screenshot saved: ${details.path}`);
        return details;
      });
    }
  }
});

For a manual capture:

cy.screenshot('checkout-failure', {
  capture: 'fullPage',
  blackout: ['[data-sensitive]', 'input[type="password"]']
});

Cypress supports capture: 'viewport', 'fullPage', and 'runner'. Runner capture includes the Cypress command log. Full-page capture scrolls and stitches the application, so fixed or sticky elements can appear more than once. Cypress also warns that capture is asynchronous and may not represent a page that changed immediately before the image was written.

Sending the attachment with Nodemailer

Nodemailer attachments accept a path, URL, string, Buffer, or readable stream. A file path is streamed from disk, which avoids loading a large image into application memory. The Nodemailer attachments documentation describes these forms and inline images.

const nodemailer = require('nodemailer');

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: Number(process.env.SMTP_PORT || 587),
  secure: process.env.SMTP_SECURE === 'true',
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
});

async function emailScreenshot({ file, testName, url, error, artifactUrl }) {
  await transporter.sendMail({
    from: process.env.ALERT_FROM,
    to: process.env.ALERT_TO,
    subject: `[Browser failure] ${testName}`,
    text: [
      `Test: ${testName}`,
      `URL: ${url}`,
      `Time: ${new Date().toISOString()}`,
      `Error: ${error}`,
      artifactUrl ? `CI artifact: ${artifactUrl}` : ''
    ].filter(Boolean).join('\n'),
    attachments: [{ filename: 'failure.png', path: file }]
  });
}

module.exports = { emailScreenshot };

To embed the image in HTML mail, assign a unique cid to the attachment and reference cid:that-id in the HTML body. Keep the attachment as well because some clients block remote or inline images.

Python SMTP alternative

If your test runner is Python based, save the screenshot first and attach it with the standard library.

import os
import smtplib
from email.message import EmailMessage
from pathlib import Path


def send_failure_email(path: str, test_name: str, error: str) -> None:
    message = EmailMessage()
    message['Subject'] = f'[Browser failure] {test_name}'
    message['From'] = os.environ['ALERT_FROM']
    message['To'] = os.environ['ALERT_TO']
    message.set_content(f'Test: {test_name}\nError: {error}')

    image = Path(path).read_bytes()
    message.add_attachment(image, maintype='image', subtype='png', filename=Path(path).name)

    with smtplib.SMTP(os.environ['SMTP_HOST'], int(os.environ.get('SMTP_PORT', '587'))) as smtp:
        smtp.starttls()
        smtp.login(os.environ['SMTP_USER'], os.environ['SMTP_PASS'])
        smtp.send_message(message)

CI workflow and artifact retention

GitHub Actions can notify users when workflows fail, but that setting does not attach a browser screenshot. Upload the image as a CI artifact or send it through the SMTP step. Keep both when an investigation may outlast mailbox retention.

name: e2e
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
        env:
          SMTP_HOST: ${{ secrets.SMTP_HOST }}
          SMTP_PORT: ${{ secrets.SMTP_PORT }}
          SMTP_USER: ${{ secrets.SMTP_USER }}
          SMTP_PASS: ${{ secrets.SMTP_PASS }}
          ALERT_FROM: ${{ secrets.ALERT_FROM }}
          ALERT_TO: ${{ secrets.ALERT_TO }}
      - name: Upload screenshots and reports
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: browser-failure-artifacts
          path: |
            test-results/
            playwright-report/
            cypress/screenshots/

Security and privacy checklist

  • Store SMTP credentials in environment variables or CI secret storage.
  • Mask passwords, tokens, payment data, and personal information before capture. Use Cypress blackout selectors or an equivalent masking step.
  • Restrict recipients and avoid putting sensitive values in subject lines.
  • Treat screenshots as sensitive logs: they can contain user data, tokens, and internal URLs.
  • If message inputs can be influenced by untrusted data, review Nodemailer’s disableFileAccess and disableUrlAccess controls to prevent arbitrary local reads or URL fetches.
  • Delete temporary files only after email delivery and artifact upload have completed.

Performance, reliability, and cost

  • Viewport screenshots are generally smaller and faster than full-page captures. Capture an element when the failure is localized.
  • Use deterministic filenames containing the test, shard, retry, and run ID so parallel workers cannot overwrite one another.
  • Do not block the test process forever on email. Apply a bounded SMTP timeout, log the delivery error, and preserve the screenshot as an artifact even if mail fails.
  • Retry transient SMTP connection or response errors, but avoid duplicate alerts by recording a run ID and attempt number.
  • Large attachments can exceed mailbox or SMTP limits. Compress or resize images, or send a short email with a CI artifact link when the image is too large.
  • Screenshot capture itself has no universal fixed duration: page size, fonts, images, animations, and network activity affect it. Wait for the state you need rather than adding an unnecessarily long delay.
  • CI artifact retention and mailbox retention are separate policies. Configure both according to how long failures need to remain reviewable.

Common errors and fixes

Error Cause Fix
Screenshot file is missing The browser or context was closed before the asynchronous capture finished. Await the screenshot in the failure hook and capture before teardown.
Only Cypress works locally Automatic Cypress failure screenshots run during cypress run, not generally during cypress open. Run headlessly or call cy.screenshot() manually.
Sticky header appears repeatedly Cypress full-page capture scrolls and stitches the document. Use viewport or element capture, or hide the fixed selector before capture.
Nodemailer cannot find attachment The path is relative to a different working directory, or cleanup ran too early. Use an absolute path, verify it exists, and delete it only after sendMail resolves.
SMTP authentication failed Wrong credentials, port, encryption mode, or provider policy. Check secret values, use TLS settings required by the provider, and inspect the SMTP response code.
Email is rejected or times out Recipient, attachment size, rate limit, or network policy. Log the Nodemailer error, reduce attachment size, retry transient failures, and keep the CI artifact.
Screenshot contains secrets Sensitive fields were captured without masking. Blackout selectors or mask data before taking the image; limit recipients.
Parallel tests overwrite images Every worker uses the same filename. Include worker, retry, test, and run identifiers in the path.

Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns a PNG, JPEG, WebP, or PDF, so an error handler can capture a URL without managing a browser in the notification service. See the ScreenshotNeo API documentation for the complete option list.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are never billed, and response headers report the page verdict and billing result. Its MCP server lets Claude, Cursor, and other MCP clients use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

FAQ

Should I email every failed retry?

Usually send one alert for the final failed attempt and retain intermediate screenshots as artifacts. Include the retry number when diagnosing flaky tests.

Should the image be inline or attached?

Attach it for consistent access. Inline CID images can make triage faster, but mail clients may block them, so keep an attachment or artifact link too.

What should happen if email delivery fails?

Log the SMTP error, preserve the screenshot and test report as CI artifacts, and retry only errors that are likely transient.

When is a full-page screenshot useful?

Use it when layout or content below the fold matters. Use a viewport or element capture when you need a smaller, faster image focused on the failure.