Skip to main content

Litmus & Email on Acid Workflows: API Integration, Rendering Tests, and CI/CD Pipelines

Integrate Litmus and Email on Acid into your CI/CD pipeline for automated cross-client rendering tests and QA reporting.

Modern transactional and marketing email systems demand rigorous validation before deployment. As rendering engines diverge across clients, relying on manual checks introduces unacceptable latency and regression risk. Establishing robust Email Testing & QA Workflows requires shifting from ad-hoc browser checks to programmatic validation frameworks. This guide details how to architect scalable pipelines using Litmus and Email on Acid, focusing on API-driven execution, rendering constraint analysis, and continuous integration patterns.

Litmus client-render coverage A single HTML source fans out through Litmus into isolated client render environments, each returning a pass or fail capture. Client-Render Coverage via Litmus HTML Source POST /v3/tests Litmus render farm Gmail (Blink) Outlook 365 (Word) Apple Mail (WebKit) Samsung Email Captures pass / fail per client One payload, many real client binaries, deterministic per-client results.
A single HTML payload fans out through Litmus to isolated client render environments, returning a pass or fail capture per client.

API Architecture and Authentication Patterns

Both platforms expose RESTful endpoints that enable programmatic test execution, report retrieval, and webhook notifications. Implementation begins with secure credential management using environment variables or secret managers. API tokens must be scoped appropriately to prevent unauthorized report generation. When orchestrating parallel test runs, developers should implement exponential backoff and request queuing to respect platform rate limits. For teams adopting infrastructure-as-code, Integrating Litmus API into GitHub Actions provides a foundational blueprint for automating test triggers on pull requests and deployment gates.

Provider-Specific Authentication & Payloads

Litmus uses Bearer token authentication. The API expects a POST to /v3/tests with a JSON payload containing the HTML source, optional client list, and test metadata.

{
  "test_name": "PR-142-transactional-welcome",
  "html_source": "<!DOCTYPE html><html>...</html>",
  "test_type": "preview",
  "clients": ["gmail_app_android", "outlook_2021", "apple_mail_16"],
  "webhook_url": "https://api.yourdomain.com/webhooks/litmus-complete"
}

Email on Acid uses an API key passed as a query parameter or HTTP Basic Auth header depending on the endpoint. Check the current Email on Acid API documentation for your account's authentication scheme, as it has evolved across platform versions.

Rate Limit Handling & Debugging

Both platforms enforce strict concurrency caps (typically 5–10 concurrent renders per account tier). Implement exponential backoff in your CI runner:

// Node.js rate-limit wrapper
async function executeWithBackoff(apiCall, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const res = await apiCall();
      if (res.status === 429) {
        const wait = Math.pow(2, i) * 1000 + Math.random() * 1000;
        console.warn(`Rate limited. Retrying in ${wait}ms`);
        await new Promise(r => setTimeout(r, wait));
        continue;
      }
      return res;
    } catch (err) {
      if (i === retries - 1) throw err;
    }
  }
}

Debugging Tip: If you receive 401 Unauthorized, verify token scope and ensure no trailing whitespace in environment variables. For 422 Unprocessable Entity, validate that your HTML payload is UTF-8 encoded and does not contain unescaped & or < in attribute values.

Rendering Engine Constraints and Client-Specific Quirks

Email rendering relies on a fragmented ecosystem of layout engines, including WebKit (Apple Mail), Blink (Gmail), and legacy MSHTML/Word (Outlook). Litmus and Email on Acid abstract this complexity by provisioning isolated virtualized environments that execute HTML/CSS payloads against real client binaries. Developers must account for inline CSS transformation, media query stripping, and table-based layout fallbacks. While cloud-based rendering provides comprehensive coverage, local iteration remains critical for rapid debugging. Pairing cloud validation with Local Email Preview Servers accelerates the feedback loop, allowing engineers to verify structural integrity before committing to remote rendering queues.

Engine-Specific Fallback Patterns

Client/Engine Constraint Production Workaround
Gmail (Blink) Strips <style> in <head>, ignores @media in some contexts Inline all critical CSS via MJML/PostCSS. Use !important sparingly; prefer specificity.
Apple Mail (WebKit) Supports modern CSS; older macOS builds may not support display: flex Use display: -webkit-box with -webkit-box-orient: vertical fallbacks for older versions.
Outlook (MSHTML/Word) No background-image on <div>, ignores margin on block elements Use VML for backgrounds: <!--[if mso]><v:rect ...><![endif]-->. Use padding instead of margin.

Debugging Rendering Failures

  1. Isolate the Engine: Run a minimal test with only the failing component. If it breaks in Outlook but passes in Apple Mail, the issue is almost certainly MSHTML table parsing or VML syntax.
  2. Inspect Computed Styles: Download the raw HTML from the cloud provider's "Source View". Compare it against your pre-processed template to identify where your inliner stripped or modified selectors.
  3. Asset Loading: Ensure all images use absolute HTTPS URLs. Relative paths or HTTP will trigger mixed-content blocks in secure clients, causing broken layouts that appear as rendering failures.

Automated Regression and Snapshot Validation

Visual regression testing forms the backbone of reliable email deployment. By capturing baseline screenshots across target clients, engineering teams can detect unintended layout shifts, broken typography, or missing assets. Modern pipelines implement pixel-diff algorithms with configurable tolerance thresholds to filter out anti-aliasing noise. When combined with Automated Snapshot Testing, these workflows enable deterministic validation of dynamic content blocks, personalization tokens, and dark mode adaptations. Failed snapshots should trigger automated Slack alerts and block CI merges until visual parity is restored.

CI Pipeline Implementation

# .github/workflows/email-visual-regression.yml
name: Email Visual Regression
on: [pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Build email templates
        run: npm run email:build
      - name: Trigger Litmus Test
        id: litmus
        run: |
          RESPONSE=$(curl -s -w "\n%{http_code}" -X POST https://api.litmus.com/v3/tests \
            -H "Authorization: Bearer ${{ secrets.LITMUS_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d @payload.json)
          HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
          BODY=$(echo "$RESPONSE" | sed '$d')
          if [ "$HTTP_CODE" -ne 201 ]; then
            echo "::error::Litmus API error: HTTP $HTTP_CODE"
            exit 1
          fi
          echo "test_id=$(echo "$BODY" | jq -r '.id')" >> $GITHUB_OUTPUT
      - name: Poll for completion
        run: node scripts/poll-litmus.js "${{ steps.litmus.outputs.test_id }}"

Handling Dynamic Content & Anti-Aliasing

  • Threshold Tuning: Set pixel diff threshold to 0.030.08 depending on font rendering. Lower thresholds catch subtle shifts but increase false positives from subpixel anti-aliasing.
  • Token Masking: Replace dynamic variables ({{user.first_name}}) with deterministic placeholders (TEST_USER) before sending to the API. This prevents snapshot drift caused by varying string lengths.
  • Dark Mode Validation: Inject @media (prefers-color-scheme: dark) overrides in your test payload. Capture both light and dark baselines separately to avoid cross-mode diff contamination.

Implementation Best Practices and Optimization

Scaling email testing requires strategic resource allocation. Cache rendered outputs for unchanged templates to reduce API consumption and execution time. Implement conditional test matrices that prioritize high-traffic clients while running full suites on nightly schedules. Utilize webhooks to asynchronously process test results, storing metadata in structured databases for trend analysis. Finally, integrate accessibility linting and performance budgeting into the same pipeline to ensure deliverability, compliance, and user experience standards are met simultaneously. Deliverability checks belong in the same gate as visual checks: wiring up automated Litmus spam testing in CI catches authentication and content-filter regressions before a broken template reaches a real inbox.

Webhook Processing & Caching Strategy

// Express webhook handler for async result processing
const crypto = require('crypto');

app.post('/webhooks/email-test-complete', async (req, res) => {
  const { test_id, status, html_source } = req.body;
  if (status !== 'complete') return res.sendStatus(200);

  // Cache check: if template hash matches previous run, skip diff
  const templateHash = crypto.createHash('sha256').update(html_source || '').digest('hex');
  const cached = await redis.get(`render:${templateHash}`);
  if (cached) {
    await db.log('cache_hit', { test_id, templateHash });
    return res.sendStatus(200);
  }

  // Store & trigger CI status update
  await db.insert('test_results', { test_id, timestamp: Date.now() });
  await redis.set(`render:${templateHash}`, '1', 'EX', 86400);
  res.sendStatus(200);
});

Conditional Test Matrices

  • PR Validation: Run only top 5 clients by open rate (e.g., Gmail iOS, Apple Mail, Outlook 365, Yahoo, Samsung Mail).
  • Nightly Full Suite: Execute 30+ clients including legacy Outlook and regional providers.
  • Cost Optimization: Use client group configurations to request aggregated reports instead of individual client renders when granular debugging isn't required.

Production Debugging Checklist

  1. Verify Payload Size: Keep HTML under 102KB to avoid Gmail clipping. If exceeded, inline critical CSS only and defer non-essential styles.
  2. Monitor API Latency: Track rate limit headers in API responses (both platforms return rate limit metadata). Log request durations to identify platform degradation before it impacts CI gates.
  3. Fallback Routing: If a provider's API is down, route to a secondary rendering service or fail open with a warning status that requires manual QA approval before deployment.

Litmus vs Email on Acid: choosing a platform

Both platforms render against real client binaries and expose a REST API, but they differ in ways that matter once you commit to one inside an automated pipeline. The decision is rarely about which produces a "better" screenshot — the captures are comparable — and almost always about API ergonomics, the shape of the results JSON, and how the platform models a "test" versus a "test set."

Dimension Litmus Email on Acid
Auth model Bearer token (Authorization: Bearer <token>) on every endpoint API key, historically via Basic auth or query parameter — confirm against your account's current scheme
Test creation POST /v3/tests with html_source, optional clients[], webhook_url Create a test, receive a test id; client list configured per request or per account profile
Result model One test fans out to many client captures; poll /v3/tests/{id} for status Test contains per-client results; results endpoint returns capture URLs plus pass/fail flags
Spam testing Separate spam_tests resource returning SpamAssassin score + SPF/DKIM/DMARC Equivalent spam/deliverability test with its own JSON field names for score and auth
Webhooks webhook_url on test creation posts back on completion Supported; payload shape and signing differ — verify before trusting it in a gate
Best fit Teams wanting a single token and a clean async test resource Teams needing granular per-client configuration and aggregated deliverability reporting

The practical takeaway: wrap whichever platform you pick behind a thin adapter that exposes two methods — submitTest(html, clients) and getResult(id) — and normalize the response into your own shape ({ status, captures: [{ client, verdict, imageUrl }] }). That adapter is the only code that knows the provider's field names, so swapping platforms, or running both in parallel during a migration, never touches your gating logic. Treat the provider as replaceable infrastructure, not as the center of your pipeline.

API-driven test creation, end to end

The full lifecycle is: compile the template, submit it, capture the returned test id, poll until the render farm reports completion, then read per-client verdicts. The script below implements that lifecycle against Litmus with provider-named comments, and is structured so the Email on Acid path slots in behind the same three functions.

// scripts/run-render-test.mjs — submit, poll, and evaluate a cross-client render test.
const LITMUS = 'https://api.litmus.com/v3';
const AUTH = { Authorization: `Bearer ${process.env.LITMUS_API_KEY}` }; // Litmus: Bearer on every call
const JSON_HEADERS = { ...AUTH, 'Content-Type': 'application/json', Accept: 'application/json' };

// The client matrix MUST name real Litmus client slugs, not friendly names.
const PR_CLIENTS = [
  'gmail_app_android',  // Gmail Android: Blink engine, strips <head><style>
  'gmail_new',          // Gmail web: clips messages over ~102KB
  'outlook_2021',       // Outlook 2021 Windows: Word engine, no max-width on <div>
  'outlook_365',        // Outlook 365 Windows: Word engine, ignores margin on block elements
  'apple_mail_16',      // Apple Mail macOS: WebKit, full modern CSS
  'iphone_15_pro',      // iOS Mail: WebKit, respects prefers-color-scheme
  'samsung_email'       // Samsung Email: known to force its own dark-mode color inversion
];

async function submitTest(html) {
  const res = await fetch(`${LITMUS}/tests`, {
    method: 'POST',
    headers: JSON_HEADERS,
    body: JSON.stringify({
      test_name: `render-${process.env.GITHUB_SHA?.slice(0, 7) ?? 'local'}`,
      html_source: html,        // raw HTML string; Litmus stores and renders it verbatim
      test_type: 'preview',
      clients: PR_CLIENTS
    })
  });
  if (res.status !== 201) throw new Error(`Litmus create failed: HTTP ${res.status}`);
  return (await res.json()).id; // Litmus: 201 Created returns { id, ... }
}

async function getResult(id, { tries = 30, delayMs = 10000 } = {}) {
  for (let i = 0; i < tries; i++) {
    const res = await fetch(`${LITMUS}/tests/${id}`, { headers: AUTH });
    const test = await res.json();
    if (test.status === 'completed') return test;   // Litmus: terminal success state
    if (test.status === 'failed') throw new Error('Litmus reported test failure');
    await new Promise(r => setTimeout(r, delayMs)); // back off; render farm is async
  }
  throw new Error('Render test did not complete before timeout');
}

const html = await import('node:fs').then(fs => fs.readFileSync('dist/email.html', 'utf8'));
const id = await submitTest(html);
const test = await getResult(id);

// Per-client verdicts. Litmus exposes results under test.results keyed by client slug.
const broken = (test.results ?? []).filter(r => r.status !== 'passed');
if (broken.length) {
  console.error('Render failures:\n - ' + broken.map(r => r.client).join('\n - '));
  process.exit(1); // non-zero blocks the merge
}
console.log(`All ${PR_CLIENTS.length} client renders passed.`);

Note the two design choices that keep this reliable in CI: client slugs are declared once as data, and the poll has a hard ceiling so a stuck render farm fails the job instead of hanging it. The same submitTest/getResult pair is what your snapshot pipeline calls — see automated snapshot testing for how the captures feed a pixel-diff baseline.

Spam, SpamAssassin, and authentication checks

Cross-client rendering proves the email looks right; it says nothing about whether it will be delivered. A template can render perfectly in all seven clients above and still land in spam because a newly added tracking domain sits on a blocklist or a sending change broke DKIM alignment. Both platforms expose a separate spam-test resource that runs the message through real spam filters and returns a numeric SpamAssassin score alongside per-mechanism authentication verdicts.

// SpamAssassin + auth gate (Litmus). Higher score = spammier; lower is better.
const MAX_SCORE = Number(process.env.MAX_SPAM_SCORE ?? '3.0');

const result = await getResult(spamTestId); // same poll loop, spam_tests resource
const score = result.results.spamassassin.score;            // e.g. 1.8
const { spf, dkim, dmarc } = result.results.authentication; // "pass" | "fail" | "neutral"

const failures = [];
if (score > MAX_SCORE) failures.push(`SpamAssassin ${score} > ${MAX_SCORE}`);
// SPF: envelope-from must align; DKIM: body must be signed and unmodified in transit;
// DMARC: From-domain policy (p=quarantine/reject) must be satisfied by SPF or DKIM alignment.
for (const [name, verdict] of Object.entries({ spf, dkim, dmarc })) {
  if (verdict !== 'pass') failures.push(`${name.toUpperCase()} = ${verdict}`);
}
if (failures.length) { console.error(failures.join('\n')); process.exit(1); }

The authentication verdicts only mean something if the test email travels your real signing path. A spam test that submits raw HTML without sending through your relay reports SPF/DKIM/DMARC as neutral or fail because nothing signed it — which is a false alarm, not a regression. Send the built email to the provider's seed addresses over the same transport production uses, so the verdict reflects the actual records from your SPF, DKIM, and DMARC setup. The deeper mechanics of wiring this into a build live in the automated Litmus spam testing in CI deep-dive.

Polling versus webhooks for test results

The render farm is asynchronous: a test takes from tens of seconds to several minutes depending on the client matrix and queue depth. You have two ways to learn when it finishes, and the right choice depends on whether your gate runs inside a single job or across an event-driven pipeline.

Approach How it works Use when
Polling Loop GET /v3/tests/{id} with backoff until status = completed A synchronous CI job that must return one pass/fail; simplest, self-contained
Webhook Pass webhook_url at creation; provider POSTs the result on completion Event-driven pipelines; the runner shouldn't sit open for minutes

Polling keeps everything in one job and is the default for a pull-request check — the job submits, waits, and reports. Its cost is a runner held open for the full render duration, which on a busy queue can be five minutes of billable CI time per run. Webhooks decouple submission from evaluation: the CI step submits and exits, the provider posts back to your handler, and a separate deploy gate reads the stored verdict. That removes the idle runner but adds a moving part you must secure and make idempotent — verify the callback signature and dedupe on test id so a redelivered webhook doesn't double-process. For most teams, poll on pull requests and reserve webhooks for the deploy pipeline where minutes of idle runner time actually accumulate.

Gating CI on results

A render or spam test only protects production if a failing verdict stops the merge. The gate has three responsibilities: translate the provider's per-client results into a single exit code, surface which client broke so the author can act, and never let an infrastructure error (a 5xx, a timeout) silently pass as success.

  1. Compile the template to its final bytes — the exact HTML you will send, after inlining and MSO conditional injection.
  2. Submit the payload and capture the returned test id as a step output.
  3. Poll with backoff and a hard timeout; a timeout is a failure, not a pass.
  4. Read per-client verdicts (rendering) and the SpamAssassin score plus auth results (deliverability).
  5. Gate: exit non-zero on any failed client, any score over threshold, or any auth mechanism not pass.
  6. Report the specific failing clients to the PR via the Checks API so the fix is targeted.

Make the deploy job declare needs: [email-qa] so the gate is a true blocker rather than an advisory step. The full workflow YAML, secret handling, and polling loop are in the GitHub Actions integration guide.

Provider and client constraint reference

Keep this table next to your client matrix — it explains why a capture fails and what the fix touches, so a red check turns into an action rather than a guess.

Client / Provider Constraint surfaced by the render farm Fix the test is verifying
Gmail (web/app) Clips messages over ~102KB; strips <head><style> Inline critical CSS; keep compiled HTML under the clip limit
Outlook 2016/2019 Word engine: no max-width on <div>, ignores margin Fixed-width tables, padding on <td>, VML for backgrounds
Outlook 365 (Win) Word engine; background-image on <div> dropped <!--[if mso]><v:rect> VML fallback verified in the capture
Apple Mail (macOS) WebKit; older builds lack display:flex -webkit-box fallback present and rendering
iOS Mail WebKit; honors prefers-color-scheme: dark Dark-mode overrides captured in a separate baseline
Samsung Email Forces its own dark-mode color inversion Logo/background colors survive forced inversion
SES / SendGrid / Postmark Sending path, not rendering: dictates SPF/DKIM alignment Spam test confirms the seed send authenticates as expected

Debugging named symptoms

  • Symptom: render passes locally but fails only in Outlook 2016/2019. Cause: the Word engine dropped max-width or margin on a block element. Fix: convert the offending <div> to a fixed-width <table> and move spacing to padding on the <td>; re-run the test scoped to Outlook clients only to confirm.
  • Symptom: capture is blank or shows a broken-image icon across every client. Cause: relative or HTTP image URLs blocked as mixed content. Fix: rewrite all src to absolute HTTPS before submitting; this is a payload bug, not a client bug.
  • Symptom: 422 Unprocessable Entity on test creation. Cause: non-UTF-8 bytes or unescaped &/< inside attribute values in html_source. Fix: serialize the HTML through JSON.stringify so it is valid JSON, and ensure the file is read as UTF-8.
  • Symptom: SpamAssassin score jumps after an unrelated template edit. Cause: a newly introduced link domain landed on a URIBL/SURBL blocklist, or the text-to-image ratio crossed a rule threshold. Fix: diff the rule breakdown in the spam result; remove or replace the offending domain, restore a text alternative.
  • Symptom: DKIM = fail while SPF and DMARC pass. Cause: the message body was modified after signing (a relay rewrote links or re-encoded the body). Fix: ensure DKIM signs the final bytes and that no downstream hop alters the body; re-send to the seed list and re-poll.
  • Symptom: polling never reaches completed. Cause: the render queue is degraded, or you are polling a stale/incorrect test id. Fix: confirm the id captured from the create response, check provider status, and let the hard timeout fail the job rather than looping forever.

FAQ

How often should the full client matrix run versus the PR subset? Run the top five clients by open rate on every pull request for fast feedback, and the full 30+ matrix on a nightly schedule. Reserve render minutes for the clients that actually receive your mail.

Can I cache results to avoid re-rendering unchanged templates? Yes. Hash the compiled HTML and skip submission when the hash matches a prior passing run. The webhook handler shown earlier keys its cache on a SHA-256 of html_source.

Why does the spam test report neutral for authentication? Almost always because the email was not sent through your real signing relay — raw payload submission has nothing to sign. Route the seed send through the same transport production uses.

Should a provider API outage block deploys? Decide deliberately. Failing closed is safest for high-volume senders; failing open with a required manual-QA approval is acceptable for lower-risk flows. Encode the choice in the workflow rather than leaving it to chance.

Validation checklist

  • Client slugs are declared once as data and named with real provider identifiers.
  • Test submission captures the returned id and the poll has a hard timeout.
  • A timeout or 5xx fails the job; it never passes silently as success.
  • Per-client failures are surfaced individually to the PR check.
  • The spam test sends through the real signing relay so SPF/DKIM/DMARC are exercised.
  • SpamAssassin threshold is documented and reviewed as deliverability headroom shifts.
  • The deploy job declares needs on the QA gate so failures block release.
  • Credentials live in repository secrets, never in workflow YAML.

← Back to Email Testing & QA Workflows