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.tsx4. 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 githubJSON 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-emailTwo 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