Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/downlevel-email-css.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@react-email/tailwind": patch
---

fix(tailwind): downlevel CSS for email client compatibility

Rewrites modern CSS features that email clients don't support using css-tree AST:
- Unnests `@media` rules from inside selectors (CSS Nesting)
- Converts Media Queries Level 4 range syntax to legacy `min-width`/`max-width`
24 changes: 12 additions & 12 deletions packages/tailwind/src/tailwind.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('Tailwind component', () => {
<meta name="x-apple-disable-message-reformatting" />
<!--$-->
<style>
.md_p-4{@media (width>=48rem){padding:1rem!important}}
@media (min-width:48rem){.md_p-4{padding:1rem!important}}
</style>
</head>
<body>
Expand Down Expand Up @@ -352,7 +352,7 @@ describe('Tailwind component', () => {
<meta name="x-apple-disable-message-reformatting" />
<!--$-->
<style>
.sm_bg-red-50{@media (width>=40rem){background-color:rgb(254,242,242)!important}}.sm_text-sm{@media (width>=40rem){font-size:0.875rem!important;line-height:1.4285714285714286!important}}.md_text-lg{@media (width>=48rem){font-size:1.125rem!important;line-height:1.5555555555555556!important}}
@media (min-width:40rem){.sm_bg-red-50{background-color:rgb(254,242,242)!important}}@media (min-width:40rem){.sm_text-sm{font-size:0.875rem!important;line-height:1.4285714285714286!important}}@media (min-width:48rem){.md_text-lg{font-size:1.125rem!important;line-height:1.5555555555555556!important}}
</style></head
><span
><!--[if mso]><i style="letter-spacing: 10px;mso-font-width:-100%;" hidden>&nbsp;</i><![endif]--></span
Expand Down Expand Up @@ -390,7 +390,7 @@ describe('Tailwind component', () => {
);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><!--$--><style>.text-body{@media (prefers-color-scheme:dark){color:orange!important}}</style></head><body class="text-body"><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="color:green">this is the body</td></tr></tbody></table><!--/$--></body></html>"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><!--$--><style>@media (prefers-color-scheme:dark){.text-body{color:orange!important}}</style></head><body class="text-body"><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="color:green">this is the body</td></tr></tbody></table><!--/$--></body></html>"`,
);
});

Expand Down Expand Up @@ -425,7 +425,7 @@ describe('Tailwind component', () => {
<meta name="x-apple-disable-message-reformatting" />
<!--$-->
<style>
.xl_bg-green-500{@media (width>=1280px){background-color:rgb(0,201,80)!important}}.twoxl_bg-blue-500{@media (width>=1536px){background-color:rgb(43,127,255)!important}}
@media (min-width:1280px){.xl_bg-green-500{background-color:rgb(0,201,80)!important}}@media (min-width:1536px){.twoxl_bg-blue-500{background-color:rgb(43,127,255)!important}}
</style>
</head>
<div class="xl_bg-green-500" style="background-color:rgb(255,226,226)">
Expand Down Expand Up @@ -453,7 +453,7 @@ describe('Tailwind component', () => {
<head>
<!--$-->
<style>
.lg_max-h-calc50pxplus5rem{@media (width>=64rem){max-height:calc(50px + 5rem)!important}}
@media (min-width:64rem){.lg_max-h-calc50pxplus5rem{max-height:calc(50px + 5rem)!important}}
</style>
</head>
<div
Expand Down Expand Up @@ -496,7 +496,7 @@ describe('Tailwind component', () => {
<head>
<!--$-->
<style>
.sm_bg-red-300{@media (width>=40rem){background-color:rgb(255,162,162)!important}}.md_bg-red-400{@media (width>=48rem){background-color:rgb(255,100,103)!important}}.lg_bg-red-500{@media (width>=64rem){background-color:rgb(251,44,54)!important}}
@media (min-width:40rem){.sm_bg-red-300{background-color:rgb(255,162,162)!important}}@media (min-width:48rem){.md_bg-red-400{background-color:rgb(255,100,103)!important}}@media (min-width:64rem){.lg_bg-red-500{background-color:rgb(251,44,54)!important}}
</style>
</head>
<body>
Expand Down Expand Up @@ -525,7 +525,7 @@ describe('Tailwind component', () => {
</Tailwind>,
),
).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><!--$--><style>.sm_bg-red-300{@media (width>=40rem){background-color:rgb(255,162,162)!important}}.md_bg-red-400{@media (width>=48rem){background-color:rgb(255,100,103)!important}}.lg_bg-red-500{@media (width>=64rem){background-color:rgb(251,44,54)!important}}</style></head><body><div class="sm_bg-red-300 md_bg-red-400 lg_bg-red-500" style="background-color:rgb(255,201,201)"></div><!--/$--></body></html>"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><!--$--><style>@media (min-width:40rem){.sm_bg-red-300{background-color:rgb(255,162,162)!important}}@media (min-width:48rem){.md_bg-red-400{background-color:rgb(255,100,103)!important}}@media (min-width:64rem){.lg_bg-red-500{background-color:rgb(251,44,54)!important}}</style></head><body><div class="sm_bg-red-300 md_bg-red-400 lg_bg-red-500" style="background-color:rgb(255,201,201)"></div><!--/$--></body></html>"`,
);
});

Expand Down Expand Up @@ -560,7 +560,7 @@ describe('Tailwind component', () => {
<head>
<!--$-->
<style>
.text-body{@media (width>=40rem){color:darkgreen!important}}
@media (min-width:40rem){.text-body{color:darkgreen!important}}
</style>
</head>
<body>
Expand Down Expand Up @@ -590,7 +590,7 @@ describe('Tailwind component', () => {
<head>
<!--$-->
<style>
.hover_bg-red-600{&:hover{@media (hover:hover){background-color:rgb(231,0,11)!important}}}.focus_bg-red-700{&:focus{background-color:rgb(193,0,7)!important}}.sm_bg-red-300{@media (width>=40rem){background-color:rgb(255,162,162)!important}}.sm_hover_bg-red-200{@media (width>=40rem){&:hover{@media (hover:hover){background-color:rgb(255,201,201)!important}}}}.md_bg-red-400{@media (width>=48rem){background-color:rgb(255,100,103)!important}}.lg_bg-red-500{@media (width>=64rem){background-color:rgb(251,44,54)!important}}
.hover_bg-red-600{&:hover{@media (hover:hover){background-color:rgb(231,0,11)!important}}}.focus_bg-red-700{&:focus{background-color:rgb(193,0,7)!important}}@media (min-width:40rem){.sm_bg-red-300{background-color:rgb(255,162,162)!important}}@media (min-width:40rem){.sm_hover_bg-red-200{&:hover{@media (hover:hover){background-color:rgb(255,201,201)!important}}}}@media (min-width:48rem){.md_bg-red-400{background-color:rgb(255,100,103)!important}}@media (min-width:64rem){.lg_bg-red-500{background-color:rgb(251,44,54)!important}}
</style>
</head>
<body>
Expand Down Expand Up @@ -661,7 +661,7 @@ describe('Tailwind component', () => {
<meta name="x-apple-disable-message-reformatting" />
<!--$-->
<style>
.max-sm_text-red-600{@media (width<40rem){color:rgb(231,0,11)!important}}
@media (max-width:40rem){.max-sm_text-red-600{color:rgb(231,0,11)!important}}
</style>
</head>
<p class="max-sm_text-red-600" style="color:rgb(20,71,230)">I am some text</p>
Expand Down Expand Up @@ -705,7 +705,7 @@ describe('Tailwind component', () => {
<head>
<!--$-->
<style>
.sm_bg-red-500{@media (width>=40rem){background-color:rgb(251,44,54)!important}}
@media (min-width:40rem){.sm_bg-red-500{background-color:rgb(251,44,54)!important}}
</style>
<style></style>
<link />
Expand Down Expand Up @@ -926,7 +926,7 @@ describe('Tailwind component', () => {
<head>
<!--$-->
<style>
.sm_border-custom{@media (width>=40rem){border:2px solid!important}}
@media (min-width:40rem){.sm_border-custom{border:2px solid!important}}
</style>
</head>
<body>
Expand Down
2 changes: 2 additions & 0 deletions packages/tailwind/src/tailwind.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from 'react';
import type { Config } from 'tailwindcss';
import { useSuspensedPromise } from './hooks/use-suspended-promise';
import { sanitizeStyleSheet } from './sanitize-stylesheet';
import { downlevelForEmailClients } from './utils/css/downlevel-for-email-clients';
import { extractRulesPerClass } from './utils/css/extract-rules-per-class';
import { getCustomProperties } from './utils/css/get-custom-properties';
import { sanitizeNonInlinableRules } from './utils/css/sanitize-non-inlinable-rules';
Expand Down Expand Up @@ -118,6 +119,7 @@ export function Tailwind({ children, config }: TailwindProps) {
),
};
sanitizeNonInlinableRules(nonInlineStyles);
downlevelForEmailClients(nonInlineStyles);

const hasNonInlineStylesToApply = nonInlinableRules.size > 0;
let appliedNonInlineStyles = false as boolean;
Expand Down
125 changes: 125 additions & 0 deletions packages/tailwind/src/utils/css/downlevel-for-email-clients.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { generate, parse, type StyleSheet } from 'css-tree';
import { downlevelForEmailClients } from './downlevel-for-email-clients';

function transform(css: string): string {
const ast = parse(css) as StyleSheet;
downlevelForEmailClients(ast);
return generate(ast);
}

describe('downlevelForEmailClients', () => {
describe('range syntax', () => {
it('converts width>= to min-width', () => {
expect(
transform('@media (width>=40rem){.sm_p-4{padding:1rem}}'),
).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem}}');
});

it('converts width<= to max-width', () => {
expect(
transform('@media (width<=40rem){.max-sm_p-4{padding:1rem}}'),
).toBe('@media (max-width:40rem){.max-sm_p-4{padding:1rem}}');
});

it('converts width< to max-width', () => {
expect(
transform('@media (width<40rem){.max-sm_text-red{color:red}}'),
).toBe('@media (max-width:40rem){.max-sm_text-red{color:red}}');
});

it('converts width> to min-width', () => {
expect(
transform('@media (width>40rem){.sm_p-4{padding:1rem}}'),
).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem}}');
});

it('does not affect non-range media queries', () => {
expect(
transform(
'@media (prefers-color-scheme:dark){.dark{color:white}}',
),
).toBe('@media (prefers-color-scheme:dark){.dark{color:white}}');
});
});

describe('unnesting', () => {
it('unnests @media from inside a selector', () => {
expect(
transform(
'.sm_bg-red{@media (min-width:40rem){background-color:red!important}}',
),
).toBe(
'@media (min-width:40rem){.sm_bg-red{background-color:red!important}}',
);
});

it('handles combined range syntax + nesting', () => {
expect(
transform(
'.sm_bg-red{@media (width>=40rem){background-color:rgb(255,162,162)!important}}',
),
).toBe(
'@media (min-width:40rem){.sm_bg-red{background-color:rgb(255,162,162)!important}}',
);
});

it('handles multiple concatenated rules', () => {
const input =
'.sm_bg-red{@media (width>=40rem){background-color:red!important}}' +
'.md_bg-blue{@media (width>=48rem){background-color:blue!important}}';

const result = transform(input);

expect(result).toContain(
'@media (min-width:40rem){.sm_bg-red{background-color:red!important}}',
);
expect(result).toContain(
'@media (min-width:48rem){.md_bg-blue{background-color:blue!important}}',
);
});

it('unnests dark mode media queries', () => {
expect(
transform(
'.dark_text-white{@media (prefers-color-scheme:dark){color:rgb(255,255,255)!important}}',
),
).toBe(
'@media (prefers-color-scheme:dark){.dark_text-white{color:rgb(255,255,255)!important}}',
);
});

it('preserves rules without nested @media', () => {
expect(transform('.bg-red{background-color:red}')).toBe(
'.bg-red{background-color:red}',
);
});

it('preserves already top-level @media rules', () => {
expect(
transform(
'@media (min-width:40rem){.sm_p-4{padding:1rem!important}}',
),
).toBe(
'@media (min-width:40rem){.sm_p-4{padding:1rem!important}}',
);
});

it('handles multiple @media nested in one selector', () => {
const input =
'.multi{@media (width>=40rem){color:red!important}@media (width>=48rem){color:blue!important}}';

const result = transform(input);

expect(result).toContain(
'@media (min-width:40rem){.multi{color:red!important}}',
);
expect(result).toContain(
'@media (min-width:48rem){.multi{color:blue!important}}',
);
});

it('handles empty stylesheet', () => {
expect(transform('')).toBe('');
});
});
});
Loading
Loading