React Email
Lint React Email templates against 21 email clients, locally and in CI, by checking the HTML that React Email exports.
React Email turns your components into HTML. Emailens tells you what 21 email clients do to that HTML.
Recommended: one command in your project
npx @emailens/cli react-emailIt finds the emails folder email dev uses (emails/ or src/emails/),
renders every template with your project's own React Email, and checks the
HTML. Nothing to export or configure:
PreviewPropsare applied, so a prop with no default renders.- Local imports, any npm package and tsconfig paths work: the template is
built with your project's own esbuild, the way
email devbuilds it. - Subfolders are included. Folders starting with
_,static/, and files with no default export (shared components) are skipped. - It runs offline: no API key, no quota, no network call to Emailens.
npx @emailens/cli react-email --watch # re-check on save
npx @emailens/cli react-email --email-type transactional # sign-in, receipts, alerts
npx @emailens/cli react-email path/to/emails # another folder--email-type transactional drops the unsubscribe requirement, which
sign-in, receipt and alert emails do not have. Put "emailType": "transactional" in .emailensrc to make it the
project default.
A template that throws while rendering fails the run with its file name and the
error. A /static/... image is reported too: React Email serves that folder
only in its preview server, so the image is broken in every inbox until the
base URL points somewhere public.
In CI
# .github/workflows/email-lint.yml
name: Email lint
on:
pull_request:
paths: ["emails/**"]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx @emailens/cli react-email --reporter github--reporter github puts each finding on the pull request. The exit code is
1 on any error, so the step fails the build.
Regressions, with the gate
EMAILENS_API_KEY=ek_live_... npx @emailens/cli react-email --gate--gate also sends each rendered email to the pre-send gate
under its file path. The gate keeps the last version of each file that passed,
so a change that makes Outlook worse fails even when the score stays above the
threshold. Pro plan.
Other ways to connect them
| Lint the exported HTML | Send JSX directly | |
|---|---|---|
Works with local imports (./components/footer) | Yes | No |
| Works with any npm package | Yes | No, only react, react-email and @react-email/components |
| Extra dependencies | None | sucrase and isolated-vm (CLI only) |
| Runs offline | Yes | CLI yes, API no |
| Best for | Projects already exporting HTML | Single self-contained files, the web app, AI agents |
Lint the exported HTML
If you already run email export, lint its output.
npm install -D @emailens/cli
npx email export --outDir out --pretty
npx emailens lint 'out/*.html'--pretty keeps line breaks in the exported HTML, so the line and column in each finding point at something you can read.
out/welcome.html
error spam missing-unsubscribe No unsubscribe link found; required by CAN-SPAM and GDPR.
warn 37:47 protonmail text-decoration Proton Mail does not support "text-decoration".
warn 37:124 outlook-windows,outlook-windows-legacy border-radius Outlook (New) does not support "border-radius".lint exits 1 when it finds an error, so the same command gates CI.
Give every prop a default
email export renders each template with no props. It does not apply PreviewProps. A template that reads a prop without a default fails the export:
Error: TypeError: Cannot read properties of undefined (reading 'toUpperCase')Default the props in the signature and the export, and the lint after it, both work:
export const Welcome = ({ name = "Ada" }: Partial<Props>) => (
// ...
);Templates in subfolders
emailens lint takes one path or one single-level glob. It does not expand **, and it ignores any argument after the first. If your emails live in subfolders, lint each exported file:
find out -name '*.html' -print0 | xargs -0 -n1 npx emailens lintxargs exits non-zero if any file fails, so this also works as a CI gate.
GitHub Actions, exported HTML
# .github/workflows/email-lint.yml
name: Email lint
on:
pull_request:
paths: ["emails/**"]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx email export --outDir out --pretty
- run: find out -name '*.html' -print0 | xargs -0 -n1 npx emailens lintThis runs entirely on the runner. No API key, no quota, no network call to Emailens.
Tune what fails the build with .emailensrc, for example to skip the spam checks on transactional emails.
Alternative: send JSX directly
The API, the web app, the MCP server, and the CLI can also compile a React Email file themselves. The file runs in a sandbox, which is what makes it safe to accept from anyone, and also what limits it:
- Imports: only
react,react-email(where React Email 6 puts the components) and@react-email/components. A relative import or any other package is rejected. - Props: the default export is rendered with no props.
PreviewPropsis not applied.
So this path fits a single self-contained file, such as one an AI agent just wrote.
API
curl -X POST https://app.emailens.dev/api/preview \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ek_your_api_key" \
-d '{
"format": "jsx",
"html": "import { Html, Head, Body, Container, Text } from \"@react-email/components\";\nexport default function Email() {\n return (\n <Html>\n <Head />\n <Body style={{ fontFamily: \"Arial\" }}>\n <Container>\n <Text>Hello from React Email!</Text>\n </Container>\n </Body>\n </Html>\n );\n}"
}'The response adds a compiledHtml field to the usual transforms, warnings, and scores.
Without an API key the endpoint allows 3 previews a day per IP.
CLI
npm install -D @emailens/cli sucrase isolated-vm
npx emailens lint emails/welcome.tsxsucrase transpiles the JSX and isolated-vm is the sandbox it runs in. react and @react-email/components are already in a React Email project.
Errors
A file that fails to compile returns 422 with the reason and the phase it failed in:
{
"error": "JSX execution error: Import of \"./footer\" is not allowed. Only \"react\" and \"@react-email/components\" can be imported.",
"phase": "execution"
}phase | Meaning |
|---|---|
transpile | The JSX has a syntax error |
execution | The module failed to load, usually a disallowed import |
render | The component threw while rendering, usually a prop with no default |
Fix snippets in React Email syntax
With format: "jsx", fixes are written as React Email components rather than raw HTML tables and VML:
| Problem | Generic HTML fix | React Email fix |
|---|---|---|
| Flexbox in Outlook | <table> with <!--[if mso]> | <Row> + <Column> components |
| CSS Grid | <table> layout | <Row> + <Column> components |
@font-face | Web-safe font stack | <Font> component in <Head> |
<svg> | <img> replacement | <Img> component |
<video> | Linked thumbnail | <Img> + <Link> components |
max-width in Outlook | MSO conditional table wrapper | <Container> component |
border-radius in Outlook | VML <v:roundrect> | dangerouslySetInnerHTML with VML |
gap property | Padding on <td> elements | paddingRight on <Column> |
<style> in Gmail | Inline CSS | style={{ }} props |
<link> stylesheet | Inline <style> block | <Head> component |
To get these for exported HTML, call the engine with the framework set:
import { analyzeEmail, getCodeFix } from "@emailens/engine";
// Warnings carry React Email fix snippets (fix.language === "jsx")
const warnings = analyzeEmail(exportedHtml, "jsx");
// Or look up one fix
const fix = getCodeFix("display:flex", "outlook-windows-legacy", "jsx");
console.log(fix.description);
// Use React Email Row + Column components instead of flexbox (Outlook-safe)