fix(tailwind): downlevel CSS for email client compatibility#3086
fix(tailwind): downlevel CSS for email client compatibility#3086Ouranos27 wants to merge 1 commit intoresend:canaryfrom
Conversation
Tailwind CSS v4 generates modern CSS features that most email clients
don't support:
1. Media Queries Level 4 range syntax: `@media (width>=40rem)` —
Gmail, Outlook, Yahoo strip these entirely.
2. CSS Nesting: `.class{@media (cond){decls}}` — email clients don't
parse nested at-rules inside selectors.
This adds a `downlevelForEmailClients()` transform that runs on the
generated non-inlinable CSS before it's injected into the <style> tag:
- Converts range syntax to legacy min-width/max-width
- Unnests @media rules from inside selectors to top-level
Fixes resend#2712
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
|
@Ouranos27 is attempting to deploy a commit to the resend Team on Vercel. A member of the Team first needs to authorize it. |
commit: |
There was a problem hiding this comment.
2 issues found across 3 files
Confidence score: 3/5
- There is meaningful regression risk in
packages/tailwind/src/utils/css/downlevel-for-email-clients.ts:parseBlockSegmentsnot splitting declarations before nested@mediacan leave unsupported nested media untransformed for email clients. - Brace matching in the same file currently ignores CSS strings/comments, so quoted braces may break block boundary detection and produce malformed transformed CSS, which is user-facing output risk.
- Given both findings are medium severity (6/10) with high confidence, this is likely fixable but not quite low-risk yet.
- Pay close attention to
packages/tailwind/src/utils/css/downlevel-for-email-clients.ts- parsing and brace-boundary logic can silently generate incorrect CSS output.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/tailwind/src/utils/css/downlevel-for-email-clients.ts">
<violation number="1" location="packages/tailwind/src/utils/css/downlevel-for-email-clients.ts:45">
P2: Brace matching ignores CSS strings/comments, so quoted braces can cause incorrect block boundaries and malformed transformed CSS.</violation>
<violation number="2" location="packages/tailwind/src/utils/css/downlevel-for-email-clients.ts:70">
P2: `parseBlockSegments` fails to split declarations before nested `@media`, causing `unnestMediaQueries` to miss and preserve unsupported nested media.</violation>
</file>
Since this is your first cubic review, here's how it works:
- cubic automatically reviews your code and comments on bugs and improvements
- Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
- Add one-off context when rerunning by tagging
@cubic-dev-aiwith guidance or docs links (includingllms.txt) - Ask questions if you need clarification on any suggestion
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| const segStart = i; | ||
|
|
||
| // Read until we find a '{' (start of a block) or run out | ||
| while (i < blockContent.length && blockContent[i] !== '{') i++; |
There was a problem hiding this comment.
P2: parseBlockSegments fails to split declarations before nested @media, causing unnestMediaQueries to miss and preserve unsupported nested media.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/tailwind/src/utils/css/downlevel-for-email-clients.ts, line 70:
<comment>`parseBlockSegments` fails to split declarations before nested `@media`, causing `unnestMediaQueries` to miss and preserve unsupported nested media.</comment>
<file context>
@@ -0,0 +1,184 @@
+ const segStart = i;
+
+ // Read until we find a '{' (start of a block) or run out
+ while (i < blockContent.length && blockContent[i] !== '{') i++;
+
+ if (i >= blockContent.length) {
</file context>
| function findClosingBrace(css: string, start: number): number { | ||
| let depth = 0; | ||
| for (let i = start; i < css.length; i++) { | ||
| if (css[i] === '{') depth++; |
There was a problem hiding this comment.
P2: Brace matching ignores CSS strings/comments, so quoted braces can cause incorrect block boundaries and malformed transformed CSS.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/tailwind/src/utils/css/downlevel-for-email-clients.ts, line 45:
<comment>Brace matching ignores CSS strings/comments, so quoted braces can cause incorrect block boundaries and malformed transformed CSS.</comment>
<file context>
@@ -0,0 +1,184 @@
+function findClosingBrace(css: string, start: number): number {
+ let depth = 0;
+ for (let i = start; i < css.length; i++) {
+ if (css[i] === '{') depth++;
+ else if (css[i] === '}') {
+ depth--;
</file context>
|
Both findings are about edge cases that can't occur in this context: Brace matching (strings/comments): The input is always the output of css-tree's Declarations before nested @media: React Email's pipeline separates inlinable declarations (inlined into elements) from non-inlinable ones (kept in |
Problem
Tailwind CSS v4's
compile().build()generates two modern CSS features that most email clients don't support:1. Media Queries Level 4 range syntax — Gmail, Outlook, Yahoo strip these entirely:
2. CSS Nesting — email clients don't parse nested at-rules inside selectors:
When React Email uses the Tailwind compiler API directly, the PostCSS/Lightning CSS pipeline that normally downlevels these features doesn't run. The result: responsive breakpoints (
sm:,md:,lg:,max-sm:) are silently stripped by email clients.Solution
Adds a
downlevelForEmailClients()string transform that runs on the generated non-inlinable CSS (the<style>tag content) before injection:(width>=40rem)→(min-width:40rem), handles>=,<=,>,<for bothwidthandheight@mediarules to the top level, wrapping the parent selector insideThe transform is applied in
tailwind.tsxaftergenerate(nonInlineStyles), so it only affects the CSS that goes into the<style>tag — inlined styles are not affected.What's not covered
&:hovernesting (e.g..hover_bg-red{&:hover{@media (hover:hover){...}}}) — this is preserved as-is. Pseudo-class support in email clients is limited regardless, and resolving&to the parent selector is a separate concern.</>are approximated as<=/>=(sub-pixel difference, irrelevant for email).Testing
&:hoverpass-through, dark mode, and edge casestailwind.spec.tsxwill need updating (the<style>output changes from nested/range to legacy/unnested) — I couldn't run them locally since the workspace packages need a full build. Happy to update once CI runs.Fixes #2712
References
Summary by cubic
Downlevels Tailwind v4 CSS for email clients by converting range media queries and unnesting @media rules before injecting the stylesheet. This restores responsive styles in Gmail, Outlook, and Yahoo.
(width|height >=|<=|>|<)to legacymin-/max-media features.@mediablocks to top-level; applied only to the non-inlinable<style>CSS (inlined styles are unchanged).Written for commit 2216625. Summary will update on new commits.