Skip to content

Commit 87a2486

Browse files
Ouranos27claudegabrielmfern
authored
fix(tailwind): downlevel CSS for email client compatibility (#3086)
Signed-off-by: gabriel miranda <gabriel@resend.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Gabriel Miranda <gabrielmfern@outlook.com> Co-authored-by: gabriel miranda <gabriel@resend.com>
1 parent 96691f5 commit 87a2486

5 files changed

Lines changed: 382 additions & 12 deletions

File tree

‎.changeset/downlevel-email-css.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-email/tailwind": patch
3+
---
4+
5+
downlevel at rules for email client compatibility

‎packages/react-email/src/components/tailwind/tailwind.spec.tsx‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe('Tailwind component', () => {
5050
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
5151
<meta name="x-apple-disable-message-reformatting" />
5252
<style>
53-
.md_p-4{@media (width>=48rem){padding:1rem!important}}
53+
@media (min-width:48rem){.md_p-4{padding:1rem!important}}
5454
</style>
5555
</head>
5656
<body>
@@ -353,7 +353,7 @@ describe('Tailwind component', () => {
353353
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
354354
<meta name="x-apple-disable-message-reformatting" />
355355
<style>
356-
.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}}
356+
@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}}
357357
</style></head
358358
><!--$--><!--html--><!--head--><span
359359
><!--[if mso]><i style="letter-spacing: 10px;mso-font-width:-100%;" hidden>&nbsp;</i><![endif]--></span
@@ -391,7 +391,7 @@ describe('Tailwind component', () => {
391391
);
392392

393393
expect(actualOutput).toMatchInlineSnapshot(
394-
`"<!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"><!--$--><!--html--><!--head--><!--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>"`,
394+
`"<!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"><!--$--><!--html--><!--head--><!--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>"`,
395395
);
396396
});
397397

@@ -425,7 +425,7 @@ describe('Tailwind component', () => {
425425
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
426426
<meta name="x-apple-disable-message-reformatting" />
427427
<style>
428-
.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}}
428+
@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}}
429429
</style></head
430430
><!--$--><!--html--><!--head-->
431431
<div class="xl_bg-green-500" style="background-color:rgb(255,226,226)">
@@ -452,7 +452,7 @@ describe('Tailwind component', () => {
452452
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
453453
<head>
454454
<style>
455-
.lg_max-h-calc50pxplus5rem{@media (width>=64rem){max-height:calc(50px + 5rem)!important}}
455+
@media (min-width:64rem){.lg_max-h-calc50pxplus5rem{max-height:calc(50px + 5rem)!important}}
456456
</style></head
457457
><!--$--><!--head-->
458458
<div
@@ -494,7 +494,7 @@ describe('Tailwind component', () => {
494494
<html lang="en">
495495
<head>
496496
<style>
497-
.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}}
497+
@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}}
498498
</style>
499499
</head>
500500
<body>
@@ -524,7 +524,7 @@ describe('Tailwind component', () => {
524524
</Tailwind>,
525525
),
526526
).toMatchInlineSnapshot(
527-
`"<!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><!--$--><!--html--><!--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>"`,
527+
`"<!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><!--$--><!--html--><!--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>"`,
528528
);
529529
});
530530

@@ -558,7 +558,7 @@ describe('Tailwind component', () => {
558558
<html lang="en">
559559
<head>
560560
<style>
561-
.text-body{@media (width>=40rem){color:darkgreen!important}}
561+
@media (min-width:40rem){.text-body{color:darkgreen!important}}
562562
</style>
563563
</head>
564564
<body>
@@ -588,7 +588,7 @@ describe('Tailwind component', () => {
588588
<html lang="en">
589589
<head>
590590
<style>
591-
.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}}
591+
.hover_bg-red-600{@media (hover: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{@media (hover: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}}
592592
</style>
593593
</head>
594594
<body>
@@ -659,7 +659,7 @@ describe('Tailwind component', () => {
659659
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
660660
<meta name="x-apple-disable-message-reformatting" />
661661
<style>
662-
.max-sm_text-red-600{@media (width<40rem){color:rgb(231,0,11)!important}}
662+
@media (max-width:40rem){.max-sm_text-red-600{color:rgb(231,0,11)!important}}
663663
</style></head
664664
><!--$--><!--head-->
665665
<p class="max-sm_text-red-600" style="color:rgb(20,71,230)">I am some text</p>
@@ -702,7 +702,7 @@ describe('Tailwind component', () => {
702702
<html lang="en">
703703
<head>
704704
<style>
705-
.sm_bg-red-500{@media (width>=40rem){background-color:rgb(251,44,54)!important}}
705+
@media (min-width:40rem){.sm_bg-red-500{background-color:rgb(251,44,54)!important}}
706706
</style>
707707
<style></style>
708708
<link />
@@ -923,7 +923,7 @@ describe('Tailwind component', () => {
923923
<html lang="en">
924924
<head>
925925
<style>
926-
.sm_border-custom{@media (width>=40rem){border:2px solid!important}}
926+
@media (min-width:40rem){.sm_border-custom{border:2px solid!important}}
927927
</style>
928928
</head>
929929
<body>

‎packages/react-email/src/components/tailwind/tailwind.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as React from 'react';
33
import type { Config } from 'tailwindcss';
44
import { useSuspensedPromise } from './hooks/use-suspended-promise.js';
55
import { sanitizeStyleSheet } from './sanitize-stylesheet.js';
6+
import { downlevelForEmailClients } from './utils/css/downlevel-for-email-clients.js';
67
import { extractRulesPerClass } from './utils/css/extract-rules-per-class.js';
78
import { getCustomProperties } from './utils/css/get-custom-properties.js';
89
import { sanitizeNonInlinableRules } from './utils/css/sanitize-non-inlinable-rules.js';
@@ -118,6 +119,7 @@ export function Tailwind({ children, config }: TailwindProps) {
118119
),
119120
};
120121
sanitizeNonInlinableRules(nonInlineStyles);
122+
downlevelForEmailClients(nonInlineStyles);
121123

122124
const hasNonInlineStylesToApply = nonInlinableRules.size > 0;
123125
let appliedNonInlineStyles = false as boolean;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { generate, parse, type StyleSheet } from 'css-tree';
2+
import { downlevelForEmailClients } from './downlevel-for-email-clients.js';
3+
4+
function transform(css: string): string {
5+
const ast = parse(css) as StyleSheet;
6+
downlevelForEmailClients(ast);
7+
return generate(ast);
8+
}
9+
10+
describe('downlevelForEmailClients', () => {
11+
describe('range syntax', () => {
12+
it('converts width>= to min-width', () => {
13+
expect(transform('@media (width>=40rem){.sm_p-4{padding:1rem}}')).toBe(
14+
'@media (min-width:40rem){.sm_p-4{padding:1rem}}',
15+
);
16+
});
17+
18+
it('converts width<= to max-width', () => {
19+
expect(
20+
transform('@media (width<=40rem){.max-sm_p-4{padding:1rem}}'),
21+
).toBe('@media (max-width:40rem){.max-sm_p-4{padding:1rem}}');
22+
});
23+
24+
it('converts width< to max-width', () => {
25+
expect(
26+
transform('@media (width<40rem){.max-sm_text-red{color:red}}'),
27+
).toBe('@media (max-width:40rem){.max-sm_text-red{color:red}}');
28+
});
29+
30+
it('converts width> to min-width', () => {
31+
expect(transform('@media (width>40rem){.sm_p-4{padding:1rem}}')).toBe(
32+
'@media (min-width:40rem){.sm_p-4{padding:1rem}}',
33+
);
34+
});
35+
36+
it('does not affect non-range media queries', () => {
37+
expect(
38+
transform('@media (prefers-color-scheme:dark){.dark{color:white}}'),
39+
).toBe('@media (prefers-color-scheme:dark){.dark{color:white}}');
40+
});
41+
});
42+
43+
describe('unnesting', () => {
44+
it('unnests @media from inside a selector', () => {
45+
expect(
46+
transform(
47+
'.sm_bg-red{@media (min-width:40rem){background-color:red!important}}',
48+
),
49+
).toBe(
50+
'@media (min-width:40rem){.sm_bg-red{background-color:red!important}}',
51+
);
52+
});
53+
54+
it('handles combined range syntax + nesting', () => {
55+
expect(
56+
transform(
57+
'.sm_bg-red{@media (width>=40rem){background-color:rgb(255,162,162)!important}}',
58+
),
59+
).toBe(
60+
'@media (min-width:40rem){.sm_bg-red{background-color:rgb(255,162,162)!important}}',
61+
);
62+
});
63+
64+
it('handles multiple concatenated rules', () => {
65+
const input =
66+
'.sm_bg-red{@media (width>=40rem){background-color:red!important}}' +
67+
'.md_bg-blue{@media (width>=48rem){background-color:blue!important}}';
68+
69+
const result = transform(input);
70+
71+
expect(result).toContain(
72+
'@media (min-width:40rem){.sm_bg-red{background-color:red!important}}',
73+
);
74+
expect(result).toContain(
75+
'@media (min-width:48rem){.md_bg-blue{background-color:blue!important}}',
76+
);
77+
});
78+
79+
it('unnests dark mode media queries', () => {
80+
expect(
81+
transform(
82+
'.dark_text-white{@media (prefers-color-scheme:dark){color:rgb(255,255,255)!important}}',
83+
),
84+
).toBe(
85+
'@media (prefers-color-scheme:dark){.dark_text-white{color:rgb(255,255,255)!important}}',
86+
);
87+
});
88+
89+
it('preserves rules without nested @media', () => {
90+
expect(transform('.bg-red{background-color:red}')).toBe(
91+
'.bg-red{background-color:red}',
92+
);
93+
});
94+
95+
it('preserves already top-level @media rules', () => {
96+
expect(
97+
transform('@media (min-width:40rem){.sm_p-4{padding:1rem!important}}'),
98+
).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem!important}}');
99+
});
100+
101+
it('handles multiple @media nested in one selector', () => {
102+
const input =
103+
'.multi{@media (width>=40rem){color:red!important}@media (width>=48rem){color:blue!important}}';
104+
105+
const result = transform(input);
106+
107+
expect(result).toContain(
108+
'@media (min-width:40rem){.multi{color:red!important}}',
109+
);
110+
expect(result).toContain(
111+
'@media (min-width:48rem){.multi{color:blue!important}}',
112+
);
113+
});
114+
115+
it('handles empty stylesheet', () => {
116+
expect(transform('')).toBe('');
117+
});
118+
});
119+
});

0 commit comments

Comments
 (0)