MailSlurp logo

Automate device renders in CI

Documentation navigation
Search documentation

Render a captured email, wait with a deadline, retain screenshot artifacts and fail incomplete required runs.

Run device rendering after the application has produced and delivered its email. Keep the emailId from the same test so your screenshots show the message your application actually sent.

Set up the test

Choose the JavaScript SDK example or the Python example, configure the account's API key on the test runner and select native target IDs from the catalog. Give the job enough time for message delivery, rendering and artifact downloads. Set the HTTP timeout above the requested long-wait duration.

Create a private artifact directory before the run. In Node.js, pass an async saveArtifact(name, data) function that writes into that directory using node:fs/promises.writeFile. Playwright users can attach these files to testInfo; other runners can publish the directory after the test, including on failure.

Render, save evidence and assert completion

Use Node.js 18 or later and mailslurp-client@17.4.0 for this SDK example. Initialize mailslurp with new MailSlurp({ apiKey: process.env.MAILSLURP_API_KEY }), importing MailSlurp from mailslurp-client. This SDK version calls the target field nativeTargets; the API still accepts that compatibility field. The Python HTTP example below uses the current REST field, targets.

async function renderEmailInCi(mailslurp, emailId, targets, saveArtifact) {
  const requiredTargets = new Set(targets);
  if (!requiredTargets.size) throw new Error('Select at least one target');
  const controller = mailslurp.devicePreviewsController;
  const created = await controller.createDevicePreviewRun({
    emailId,
    createDevicePreviewOptions: { nativeTargets: [...requiredTargets] },
  });
  const deadline = Date.now() + 10 * 60 * 1000;
  while (Date.now() < deadline) {
    const remaining = deadline - Date.now();
    if (remaining < 1000) break;
    const waited = await controller.waitForDevicePreviewRun(
      {
        runId: created.run.runId,
        timeoutMillis: Math.min(60_000, remaining),
        pollIntervalMillis: 1_000,
      },
      { signal: AbortSignal.timeout(70_000) },
    );
    if (!waited.timedOut) break;
  }
  const results = await controller.getDevicePreviewRunResults({
    runId: created.run.runId,
  });

  await saveArtifact(
    'render-results.json',
    Buffer.from(JSON.stringify(results, null, 2)),
  );
  const downloaded = new Set();
  const downloadErrors = [];
  for (const screenshot of results.screenshots) {
    if (!screenshot.accessUrl) continue;
    try {
      const response = await fetch(screenshot.accessUrl, { signal: AbortSignal.timeout(60_000) });
      if (!response.ok) throw new Error(`Screenshot download failed: ${response.status}`);
      await saveArtifact(
        `${screenshot.screenshotId}.png`,
        new Uint8Array(await response.arrayBuffer()),
      );
      downloaded.add(screenshot.screenshotId);
    } catch (error) {
      downloadErrors.push(String(error));
    }
  }

  const ready = new Set(results.targets
    .filter((target) => target.status === 'READY' && downloaded.has(target.primaryScreenshotId))
    .map((target) => target.clientProfile));
  const missing = [...requiredTargets].filter((target) => !ready.has(target));
  if (results.run.status !== 'COMPLETE' || missing.length || downloadErrors.length) {
    throw new Error(`Device preview ${results.run.status}; incomplete targets: ${missing.join(', ')}; ${downloadErrors.join('; ')}`);
  }
  return results;
}

Call renderEmailInCi(mailslurp, email.id, targets, saveArtifact). This example treats every requested target as required. A PARTIAL_COMPLETE result saves available screenshots and fails the check. It repeats waits of at most one minute, up to a ten-minute deadline, then checks the final results.

GET /emails/device-previews/{runId}/wait

Wait for device preview run to complete

Request, parameters, and responses

Path parameters

NameTypeRequiredDescription
runIdstring:uuidYes

Query parameters

NameTypeRequiredDescription
timeoutMillisinteger:int64No
pollIntervalMillisinteger:int64No

Responses

StatusSchemaDescription
200DevicePreviewRunWaitResultOK
HTTP examples

HTTP

HTTP
GET /emails/device-previews/00000000-0000-4000-8000-000000000000/wait HTTP/1.1
Host: api.mailslurp.com
x-api-key: YOUR_API_KEY
Accept: */*

cURL

Requires cURL 7.76+ and a POSIX shell. Tested with cURL 8.12.1.

cURL
(
set -eu
response_file=$(mktemp)
trap 'rm -f "$response_file"' EXIT
status=$(curl --silent --show-error \
  --request GET \
  --url 'https://api.mailslurp.com/emails/device-previews/00000000-0000-4000-8000-000000000000/wait' \
  --header 'x-api-key: YOUR_API_KEY' \
  --header 'Accept: */*' \
  --output "$response_file" \
  --write-out '%{http_code}')
case "$status" in
  2??) mv "$response_file" 'response.bin' ;;
  *) printf 'HTTP %s\n' "$status" >&2; cat "$response_file" >&2; exit 1 ;;
esac
)

JavaScript (HTTP)

Requires Node.js 22+. Tested with Node.js 22.14.0.

JavaScript (HTTP)
import { writeFile } from "node:fs/promises";

const response = await fetch("https://api.mailslurp.com/emails/device-previews/00000000-0000-4000-8000-000000000000/wait", {
  method: "GET",
  headers: {
    "x-api-key": "YOUR_API_KEY",
    "Accept": "*/*"
  },
  redirect: "manual",
  signal: AbortSignal.timeout(60000),
});
const responseBytes = Buffer.from(await response.arrayBuffer());
if (!response.ok) {
  throw new Error(`HTTP ${response.status}: ${responseBytes.toString("utf8")}`);
}
await writeFile("response.bin", responseBytes);

Python (HTTP)

Requires Python 3.9+. Tested with Python 3.9.21.

Python (HTTP)
import http.client
from pathlib import Path
from urllib.parse import urlsplit

url = urlsplit("https://api.mailslurp.com/emails/device-previews/00000000-0000-4000-8000-000000000000/wait")
headers = {
    "x-api-key": "YOUR_API_KEY",
    "Accept": "*/*"
}
connection_class = http.client.HTTPSConnection if url.scheme == "https" else http.client.HTTPConnection
connection = connection_class(url.hostname, url.port, timeout=60)
try:
    path = (url.path or "/") + ("?" + url.query if url.query else "")
    connection.request("GET", path, headers=headers)
    response = connection.getresponse()
    response_bytes = response.read()
    if not 200 <= response.status < 300:
        raise RuntimeError(f"HTTP {response.status}: {response_bytes.decode('utf-8', errors='replace')}")
    Path("response.bin").write_bytes(response_bytes)
finally:
    connection.close()
GET /emails/device-previews/{runId}/results

Get device preview run results

Request, parameters, and responses

Path parameters

NameTypeRequiredDescription
runIdstring:uuidYes

Responses

StatusSchemaDescription
200DevicePreviewRunResultsDtoOK
HTTP examples

HTTP

HTTP
GET /emails/device-previews/00000000-0000-4000-8000-000000000000/results HTTP/1.1
Host: api.mailslurp.com
x-api-key: YOUR_API_KEY
Accept: */*

cURL

Requires cURL 7.76+ and a POSIX shell. Tested with cURL 8.12.1.

cURL
(
set -eu
response_file=$(mktemp)
trap 'rm -f "$response_file"' EXIT
status=$(curl --silent --show-error \
  --request GET \
  --url 'https://api.mailslurp.com/emails/device-previews/00000000-0000-4000-8000-000000000000/results' \
  --header 'x-api-key: YOUR_API_KEY' \
  --header 'Accept: */*' \
  --output "$response_file" \
  --write-out '%{http_code}')
case "$status" in
  2??) mv "$response_file" 'response.bin' ;;
  *) printf 'HTTP %s\n' "$status" >&2; cat "$response_file" >&2; exit 1 ;;
esac
)

JavaScript (HTTP)

Requires Node.js 22+. Tested with Node.js 22.14.0.

JavaScript (HTTP)
import { writeFile } from "node:fs/promises";

const response = await fetch("https://api.mailslurp.com/emails/device-previews/00000000-0000-4000-8000-000000000000/results", {
  method: "GET",
  headers: {
    "x-api-key": "YOUR_API_KEY",
    "Accept": "*/*"
  },
  redirect: "manual",
  signal: AbortSignal.timeout(60000),
});
const responseBytes = Buffer.from(await response.arrayBuffer());
if (!response.ok) {
  throw new Error(`HTTP ${response.status}: ${responseBytes.toString("utf8")}`);
}
await writeFile("response.bin", responseBytes);

Python (HTTP)

Requires Python 3.9+. Tested with Python 3.9.21.

Python (HTTP)
import http.client
from pathlib import Path
from urllib.parse import urlsplit

url = urlsplit("https://api.mailslurp.com/emails/device-previews/00000000-0000-4000-8000-000000000000/results")
headers = {
    "x-api-key": "YOUR_API_KEY",
    "Accept": "*/*"
}
connection_class = http.client.HTTPSConnection if url.scheme == "https" else http.client.HTTPConnection
connection = connection_class(url.hostname, url.port, timeout=60)
try:
    path = (url.path or "/") + ("?" + url.query if url.query else "")
    connection.request("GET", path, headers=headers)
    response = connection.getresponse()
    response_bytes = response.read()
    if not 200 <= response.status < 300:
        raise RuntimeError(f"HTTP {response.status}: {response_bytes.decode('utf-8', errors='replace')}")
    Path("response.bin").write_bytes(response_bytes)
finally:
    connection.close()

Python

Use the Python device-rendering project to submit an HTML file or a stored email, wait for the job, and view screenshots in a local gallery. It uses Python 3.9 or later and the standard library. Its HTTP calls use the current REST fields independently of the device-preview methods in your installed SDK.

Clone the examples repository and list the supported client targets:

git clone https://github.com/mailslurp/examples.git
cd examples/python-device-render
python3 render_email.py targets

Set MAILSLURP_API_KEY in your environment to the key for your rendering account. Run the included sample on explicit catalog targets, or replace sample.html with your email:

python3 render_email.py render --html sample.html \
  --target gmail_chromium_web --target outlook_android_emulator_dark

For an email already received by MailSlurp, use --email-id "$MAILSLURP_EMAIL_ID" instead of --html sample.html. The Python email-testing guide shows how to create an inbox and wait for that email.

The script prints the run ID and repeats waits of at most one minute, up to a ten-minute polling deadline. Open the printed gallery URL to view each client's screenshot and status. Available screenshots, the original target selection and results JSON are saved under render-artifacts/<run-id>/.

To continue waiting or download the current results without creating another run, set MAILSLURP_RENDER_RUN_ID to the printed ID:

python3 render_email.py wait "$MAILSLURP_RENDER_RUN_ID" --timeout-seconds 600
python3 render_email.py results "$MAILSLURP_RENDER_RUN_ID"

Both commands reuse the saved target selection. On a different machine, pass the original --target options to assert the same coverage. The project README explains the output files, target checks and timeout options; the complete script contains the API calls.

Publish the private artifact directory from CI even when the command fails. A partial or unfinished run, a missing required target, or a failed screenshot download exits with code 1 after available evidence is saved. A complete run with all required screenshots exits with code 0. The polling deadline does not cancel the remote job. For a subscription error, check demo-account access; for a capture failure, inspect the target's failureCode.

Run make test in the project directory for local checks that need no account and submit no live render jobs.

Diagnose a failed target

Inspect target status, failure code and run warnings in the saved results JSON. Differentiate a layout defect in a completed screenshot from a client capture that never completed. Rerun selected failed targets when appropriate and keep the original result with the retry evidence.

POST /emails/device-previews/{runId}/targets/rerun

Rerun selected device preview targets in the same run

Set failedOnly=false to rerun a successful target. Successful targets consume their configured credit cost again.

Request, parameters, and responses

Path parameters

NameTypeRequiredDescription
runIdstring:uuidYes

Request body (required)

FieldTypeRequiredDescription
targetIdsstring:uuid[]YesDevice preview target IDs to rerun within the existing run. Use target IDs from DevicePreviewRunResultsDto.targets.
failedOnlybooleanYesWhen true, only selected targets in failed terminal states are rerun. Set false to rerun any selected target; a target that already completed successfully uses its credit cost again. Defaults to true.
Request example
{
  "targetIds": [
    "00000000-0000-4000-8000-000000000000"
  ],
  "failedOnly": true
}

Responses

StatusSchemaDescription
200RerunDevicePreviewTargetsResultOK
HTTP examples

HTTP

HTTP
POST /emails/device-previews/00000000-0000-4000-8000-000000000000/targets/rerun HTTP/1.1
Host: api.mailslurp.com
x-api-key: YOUR_API_KEY
Content-Type: application/json
Accept: */*

{
  "targetIds": [
    "00000000-0000-4000-8000-000000000000"
  ],
  "failedOnly": false
}

cURL

Requires cURL 7.76+ and a POSIX shell. Tested with cURL 8.12.1.

cURL
(
set -eu
response_file=$(mktemp)
request_body=$(mktemp)
trap 'rm -f "$response_file" "$request_body"' EXIT
{
  printf '%b' '{\0012  "targetIds": [\0012    "00000000-0000-4000-8000-000000000000"\0012  ],\0012  "failedOnly": false\0012}'
} > "$request_body"
status=$(curl --silent --show-error \
  --request POST \
  --url 'https://api.mailslurp.com/emails/device-previews/00000000-0000-4000-8000-000000000000/targets/rerun' \
  --header 'x-api-key: YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --header 'Accept: */*' \
  --output "$response_file" \
  --write-out '%{http_code}' \
  --data-binary "@$request_body")
case "$status" in
  2??) mv "$response_file" 'response.bin' ;;
  *) printf 'HTTP %s\n' "$status" >&2; cat "$response_file" >&2; exit 1 ;;
esac
)

JavaScript (HTTP)

Requires Node.js 22+. Tested with Node.js 22.14.0.

JavaScript (HTTP)
import { writeFile } from "node:fs/promises";

const body = Buffer.concat([
  Buffer.from("{\n  \"targetIds\": [\n    \"00000000-0000-4000-8000-000000000000\"\n  ],\n  \"failedOnly\": false\n}", "utf8")
]);

const response = await fetch("https://api.mailslurp.com/emails/device-previews/00000000-0000-4000-8000-000000000000/targets/rerun", {
  method: "POST",
  headers: {
    "x-api-key": "YOUR_API_KEY",
    "Content-Type": "application/json",
    "Accept": "*/*"
  },
  body,
  redirect: "manual",
  signal: AbortSignal.timeout(60000),
});
const responseBytes = Buffer.from(await response.arrayBuffer());
if (!response.ok) {
  throw new Error(`HTTP ${response.status}: ${responseBytes.toString("utf8")}`);
}
await writeFile("response.bin", responseBytes);

Python (HTTP)

Requires Python 3.9+. Tested with Python 3.9.21.

Python (HTTP)
import http.client
from pathlib import Path
from urllib.parse import urlsplit

url = urlsplit("https://api.mailslurp.com/emails/device-previews/00000000-0000-4000-8000-000000000000/targets/rerun")
headers = {
    "x-api-key": "YOUR_API_KEY",
    "Content-Type": "application/json",
    "Accept": "*/*"
}
body = b"".join([
    "{\n  \"targetIds\": [\n    \"00000000-0000-4000-8000-000000000000\"\n  ],\n  \"failedOnly\": false\n}".encode("utf-8")
])
connection_class = http.client.HTTPSConnection if url.scheme == "https" else http.client.HTTPConnection
connection = connection_class(url.hostname, url.port, timeout=60)
try:
    path = (url.path or "/") + ("?" + url.query if url.query else "")
    connection.request("POST", path, body=body, headers=headers)
    response = connection.getresponse()
    response_bytes = response.read()
    if not 200 <= response.status < 300:
        raise RuntimeError(f"HTTP {response.status}: {response_bytes.decode('utf-8', errors='replace')}")
    Path("response.bin").write_bytes(response_bytes)
finally:
    connection.close()

If your job is cancelled, decide whether to cancel the remote run too. A test-runner timeout does not itself prove that remote rendering stopped.

Compare an approved baseline

Store the approved run and client configuration in your test setup. Download baseline and current screenshots, match the same client/platform/color-scheme context, and use your image comparison tool to review the difference. Run-specific target UUIDs identify screenshots within a run; do not assume they are stable across separate runs.

Mask or normalize intentional dynamic content in your comparison process. Require a person to approve a new baseline rather than replacing it automatically whenever the check fails. Use share links and comments for that review, and add Email Audit for links, content and accessibility findings.