email-lint: catch Outlook breakage before your users do

The worst email bug is the one that ships to production because nobody tested it in Outlook. cursor: pointer gets silently dropped. A background-image disappears. A <picture> element renders as nothing. The email still sends, the preview still looks fine, and a chunk of your users see a broken layout.

The data to catch this already exists. caniemail.com has compatibility information for ~30 email clients across every HTML element and CSS property. There's even an npm package that wraps the data.

The problem is the data is unusable as-is.

Why raw caniemail data isn't enough

Raw caniemail output gives you one diagnostic per client variant. A single unsupported property produces 26+ results — one for each client/platform combination. There's no severity. No grouping. No context about whether the issue is cosmetic or load-bearing. No filtering for framework-generated markup that always trips false positives.

You get a firehose. Nobody pipes a firehose into CI.

email-lint

email-lint is a linter that makes caniemail data useful.

$ npx @email-lint/core check welcome.html

welcome.html
  12:5   error    cursor not supported (4/4 variants)  [gmail]
  18:3   warning  background-image not supported (2/6 variants: windows, windows-mail)  [outlook]
  24:10  warning  <picture> not supported (4/4 variants)  [gmail]

✖ 1 error, 2 warnings

Four things happen between the raw data and that output.

1. Collapse by family

Gmail has four variants (web, iOS, Android, mobile web). Raw caniemail emits one diagnostic per variant. email-lint collapses them: (4/4 variants) [gmail]. One line, one client. If only a subset of variants are affected, it names them inline: (2/6 variants: windows, windows-mail) [outlook].

2. Smart severity

Not every unsupported property is a bug. cursor: pointer is cosmetic — an interactive hint that doesn't render. It's an info, not an error. Gmail forces target="_blank" on every link regardless of what you write; if your link already has target="_blank", there's nothing to warn about.

email-lint has severity rules baked in that know this. You're not staring at 40 red lines about cursor every run.

3. React Email awareness

React Email generates markup that always trips caniemail false positives: preview blocks that use unsupported CSS for preheader rendering, preload images, forced target="_blank" on all links. These aren't bugs in your email — they're framework internals that are supposed to look that way.

email-lint detects React Email output and suppresses these automatically.

# Auto-detects .tsx and renders before linting
npx @email-lint/core check src/emails/welcome.tsx

4. CI-ready output

Exit code 1 on errors. GitHub Actions formatter for inline PR annotations:

- name: Lint emails
  run: npx @email-lint/core check 'src/emails/**/*.html' --format github

JSON output for custom pipelines. Preset filters like --preset gmail to check compatibility against a single client.

Use in tests

The React Email package exports a lintComponent helper for vitest-style assertions:

import { expect, test } from 'vitest';
import { lintComponent } from '@email-lint/react-email';
import { Welcome } from './emails/welcome';
 
test('Welcome email has no compatibility errors', async () => {
  const result = await lintComponent(<Welcome name="Jane" />);
  expect(result.errorCount).toBe(0);
});
 
test('Welcome email passes Gmail checks', async () => {
  const result = await lintComponent(<Welcome name="Jane" />, { preset: 'gmail' });
  expect(result.success).toBe(true);
});

This is the pattern I actually use. Every email component gets a test that lints its rendered output. Compatibility regressions fail the test suite before they reach the git push.

What it doesn't do

email-lint is not a rendering preview. It doesn't screenshot your email in 30 clients — that's what Litmus and Email on Acid are for, and they cost money. What email-lint does is tell you which properties won't work before you bother spinning up a preview service.

Think of it as the ESLint layer for email. It won't catch everything. It will catch the mechanical stuff — the unsupported properties, the forgotten fallbacks, the Gmail-incompatible selectors — which is most of what breaks in practice.

Install

npm install @email-lint/core
# optional
npm install @email-lint/react-email

Two packages:

  • @email-lint/core — the linter engine and CLI
  • @email-lint/react-email — the React Email integration with framework-aware filtering

MIT licensed.

GitHub: stewartjarod/email-lint