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
16 changes: 16 additions & 0 deletions .changeset/fix-html-entities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@react-email/render": patch
---

fix: decode HTML entities in href attributes and style tags

React's SSR escapes special characters to HTML entities which breaks:
- URLs with query parameters (`&` → `&`) causing issues with email click tracking services
- CSS media queries in style tags (`>` → `>`) breaking Tailwind responsive utilities

This fix adds post-processing to decode entities in:
- `href` attribute values (`&` → `&`)
- `<style>` tag contents (`&gt;`, `&lt;`, `&amp;`)

Fixes #1767
Fixes #2841
5 changes: 4 additions & 1 deletion packages/render/src/browser/render.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Suspense } from 'react';
import { pretty, toPlainText } from '../node';
import { decodeHtmlEntities } from '../shared/utils/decode-html-entities';
import { createErrorBoundary } from '../shared/error-boundary';
import type { Options } from '../shared/options';
import { readStream } from '../shared/read-stream.browser';
Expand Down Expand Up @@ -38,7 +39,9 @@ export const render = async (node: React.ReactNode, options?: Options) => {
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
const document = decodeHtmlEntities(
`${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`,
);

if (options?.pretty) {
return pretty(document);
Expand Down
5 changes: 4 additions & 1 deletion packages/render/src/edge/render.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Suspense } from 'react';
import { pretty } from '../node';
import { decodeHtmlEntities } from '../shared/utils/decode-html-entities';
import { createErrorBoundary } from '../shared/error-boundary';
import type { Options } from '../shared/options';
import { readStream } from '../shared/read-stream.browser';
Expand Down Expand Up @@ -44,7 +45,9 @@ export const render = async (
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
const document = decodeHtmlEntities(
`${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`,
);

if (options?.pretty) {
return pretty(document);
Expand Down
5 changes: 4 additions & 1 deletion packages/render/src/node/render.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Suspense } from 'react';
import { decodeHtmlEntities } from '../shared/utils/decode-html-entities';
import { createErrorBoundary } from '../shared/error-boundary';
import type { Options } from '../shared/options';
import { pretty } from '../shared/utils/pretty';
Expand Down Expand Up @@ -66,7 +67,9 @@ export const render = async (node: React.ReactNode, options?: Options) => {
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
const document = decodeHtmlEntities(
`${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`,
);

if (options?.pretty) {
return pretty(document);
Expand Down
126 changes: 126 additions & 0 deletions packages/render/src/shared/utils/decode-html-entities.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import { decodeHtmlEntities } from './decode-html-entities';

describe('decodeHtmlEntities', () => {
describe('href attributes', () => {
it('decodes &amp; in href attributes', () => {
const input = '<a href="https://example.com?a=1&amp;b=2">Link</a>';
const expected = '<a href="https://example.com?a=1&b=2">Link</a>';
expect(decodeHtmlEntities(input)).toBe(expected);
});

it('decodes multiple &amp; in href', () => {
const input =
'<a href="https://example.com?a=1&amp;b=2&amp;c=3">Link</a>';
const expected = '<a href="https://example.com?a=1&b=2&c=3">Link</a>';
expect(decodeHtmlEntities(input)).toBe(expected);
});

it('handles single quoted href', () => {
const input = "<a href='https://example.com?a=1&amp;b=2'>Link</a>";
const expected = "<a href='https://example.com?a=1&b=2'>Link</a>";
expect(decodeHtmlEntities(input)).toBe(expected);
});

it('handles multiple href attributes in document', () => {
const input = `
<a href="https://example.com?a=1&amp;b=2">Link 1</a>
<a href="https://other.com?x=1&amp;y=2">Link 2</a>
`;
const expected = `
<a href="https://example.com?a=1&b=2">Link 1</a>
<a href="https://other.com?x=1&y=2">Link 2</a>
`;
expect(decodeHtmlEntities(input)).toBe(expected);
});

it('preserves href without entities', () => {
const input = '<a href="https://example.com/page">Link</a>';
expect(decodeHtmlEntities(input)).toBe(input);
});

it('preserves entities in text content', () => {
const input = '<p>Tom &amp; Jerry</p>';
expect(decodeHtmlEntities(input)).toBe(input);
});
});

describe('style tags', () => {
it('decodes &gt; in style tags', () => {
const input = '<style>.foo{@media (width&gt;=48rem){display:block}}</style>';
const expected = '<style>.foo{@media (width>=48rem){display:block}}</style>';
expect(decodeHtmlEntities(input)).toBe(expected);
});

it('decodes &lt; in style tags', () => {
const input = '<style>.foo{@media (width&lt;=48rem){display:block}}</style>';
const expected = '<style>.foo{@media (width<=48rem){display:block}}</style>';
expect(decodeHtmlEntities(input)).toBe(expected);
});

it('decodes &amp; in style tags', () => {
const input = '<style>.a &amp; .b { color: red }</style>';
const expected = '<style>.a & .b { color: red }</style>';
expect(decodeHtmlEntities(input)).toBe(expected);
});

it('decodes multiple entities in style tags', () => {
const input =
'<style>.sm_block{@media (width&gt;=40rem){display:block}}.md_block{@media (width&gt;=48rem){display:block}}</style>';
const expected =
'<style>.sm_block{@media (width>=40rem){display:block}}.md_block{@media (width>=48rem){display:block}}</style>';
expect(decodeHtmlEntities(input)).toBe(expected);
});

it('handles style tag with attributes', () => {
const input =
'<style type="text/css">.foo{@media (width&gt;=48rem){display:block}}</style>';
const expected =
'<style type="text/css">.foo{@media (width>=48rem){display:block}}</style>';
expect(decodeHtmlEntities(input)).toBe(expected);
});

it('handles multiple style tags', () => {
const input = `
<style>.a{@media (width&gt;=40rem){color:red}}</style>
<style>.b{@media (width&gt;=48rem){color:blue}}</style>
`;
const expected = `
<style>.a{@media (width>=40rem){color:red}}</style>
<style>.b{@media (width>=48rem){color:blue}}</style>
`;
expect(decodeHtmlEntities(input)).toBe(expected);
});

it('preserves style tags without entities', () => {
const input = '<style>.foo { color: red; }</style>';
expect(decodeHtmlEntities(input)).toBe(input);
});
});

describe('combined', () => {
it('decodes both href and style in same document', () => {
const input = `
<html>
<head>
<style>.foo{@media (width&gt;=48rem){display:block}}</style>
</head>
<body>
<a href="https://example.com?a=1&amp;b=2">Link</a>
</body>
</html>
`;
const expected = `
<html>
<head>
<style>.foo{@media (width>=48rem){display:block}}</style>
</head>
<body>
<a href="https://example.com?a=1&b=2">Link</a>
</body>
</html>
`;
expect(decodeHtmlEntities(input)).toBe(expected);
});
});
});
84 changes: 84 additions & 0 deletions packages/render/src/shared/utils/decode-html-entities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Decodes HTML entities in specific contexts where React's SSR escaping
* causes issues with email clients or CSS processing.
*
* This fixes:
* - `&amp;` in href attributes (breaks click tracking services)
* - `&gt;`, `&lt;`, `&amp;` in style tags (breaks CSS media queries)
*
* @see https://github.com/resend/react-email/issues/1767
* @see https://github.com/resend/react-email/issues/2841
*/

/**
* Decodes common HTML entities back to their original characters.
*/
const decodeEntities = (str: string): string => {
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#x27;/g, "'")
.replace(/&#39;/g, "'");
};

/**
* Decodes HTML entities inside href attribute values.
* This fixes URLs with query parameters that get escaped by React SSR.
*
* Example:
* href="https://example.com?a=1&amp;b=2"
* becomes
* href="https://example.com?a=1&b=2"
*/
const decodeHrefAttributes = (html: string): string => {
// Match href="..." or href='...'
return html.replace(
/href=["']([^"']*)["']/gi,
(_match, hrefValue: string) => {
const decoded = hrefValue.replace(/&amp;/g, '&');
// Preserve the original quote style by checking the match
const quote = _match.charAt(5);
return `href=${quote}${decoded}${quote}`;
},
);
};

/**
* Decodes HTML entities inside <style> tags.
* This fixes CSS media queries that use comparison operators.
*
* Example:
* @media (width&gt;=48rem)
* becomes
* @media (width>=48rem)
*/
const decodeStyleTags = (html: string): string => {
return html.replace(
/<style([^>]*)>([\s\S]*?)<\/style>/gi,
(_match, attributes: string, cssContent: string) => {
const decoded = decodeEntities(cssContent);
return `<style${attributes}>${decoded}</style>`;
},
);
};

/**
* Post-processes HTML output from React SSR to decode HTML entities
* in contexts where they cause issues with email clients.
*
* @param html - The HTML string from React SSR
* @returns The HTML with entities decoded in href attributes and style tags
*/
export const decodeHtmlEntities = (html: string): string => {
let result = html;

// Decode entities in href attributes
result = decodeHrefAttributes(result);

// Decode entities in style tags
result = decodeStyleTags(result);

return result;
};
Loading