From b717543038aabb14132a197a5db0fb3608e744c0 Mon Sep 17 00:00:00 2001 From: Suhaib Date: Sat, 31 Jan 2026 10:30:28 +0000 Subject: [PATCH 1/2] fix(render): decode HTML entities in href and style tags React's SSR escapes special characters to HTML entities which breaks: - URLs with query parameters (&) causing issues with click tracking - CSS media queries in style tags (>) breaking Tailwind responsive utilities This adds post-processing to decode entities in: - href attribute values - '; + const expected = ''; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('should decode < in style tags', () => { + const input = ''; + const expected = ''; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('should decode & in style tags', () => { + const input = ''; + const expected = ''; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('should decode multiple entities in style tags', () => { + const input = + ''; + const expected = + ''; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('should handle style tag with attributes', () => { + const input = + ''; + const expected = + ''; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('should handle multiple style tags', () => { + const input = ` + + + `; + const expected = ` + + + `; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('should not affect style tags without entities', () => { + const input = ''; + expect(decodeHtmlEntities(input)).toBe(input); + }); + }); + + describe('combined', () => { + it('should decode both href and style in same document', () => { + const input = ` + + + + + + Link + + + `; + const expected = ` + + + + + + Link + + + `; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + }); +}); diff --git a/packages/render/src/shared/utils/decode-html-entities.ts b/packages/render/src/shared/utils/decode-html-entities.ts new file mode 100644 index 0000000000..451b302adc --- /dev/null +++ b/packages/render/src/shared/utils/decode-html-entities.ts @@ -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: + * - `&` in href attributes (breaks click tracking services) + * - `>`, `<`, `&` 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(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/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&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(/&/g, '&'); + // Preserve the original quote style by checking the match + const quote = _match.charAt(5); + return `href=${quote}${decoded}${quote}`; + }, + ); +}; + +/** + * Decodes HTML entities inside `; + }, + ); +}; + +/** + * 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; +}; From 5026fb7d2cb7b1a40b1efd0beb752ab39e835426 Mon Sep 17 00:00:00 2001 From: Suhaib Date: Sat, 31 Jan 2026 10:55:34 +0000 Subject: [PATCH 2/2] fix: use declarative test descriptions --- .../shared/utils/decode-html-entities.spec.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/render/src/shared/utils/decode-html-entities.spec.ts b/packages/render/src/shared/utils/decode-html-entities.spec.ts index 7c9d5de6fb..3e4ce981da 100644 --- a/packages/render/src/shared/utils/decode-html-entities.spec.ts +++ b/packages/render/src/shared/utils/decode-html-entities.spec.ts @@ -3,26 +3,26 @@ import { decodeHtmlEntities } from './decode-html-entities'; describe('decodeHtmlEntities', () => { describe('href attributes', () => { - it('should decode & in href attributes', () => { + it('decodes & in href attributes', () => { const input = 'Link'; const expected = 'Link'; expect(decodeHtmlEntities(input)).toBe(expected); }); - it('should decode multiple & in href', () => { + it('decodes multiple & in href', () => { const input = 'Link'; const expected = 'Link'; expect(decodeHtmlEntities(input)).toBe(expected); }); - it('should handle single quoted href', () => { + it('handles single quoted href', () => { const input = "Link"; const expected = "Link"; expect(decodeHtmlEntities(input)).toBe(expected); }); - it('should handle multiple href attributes in document', () => { + it('handles multiple href attributes in document', () => { const input = ` Link 1 Link 2 @@ -34,37 +34,37 @@ describe('decodeHtmlEntities', () => { expect(decodeHtmlEntities(input)).toBe(expected); }); - it('should not affect href without entities', () => { + it('preserves href without entities', () => { const input = 'Link'; expect(decodeHtmlEntities(input)).toBe(input); }); - it('should not decode entities in text content', () => { + it('preserves entities in text content', () => { const input = '

Tom & Jerry

'; expect(decodeHtmlEntities(input)).toBe(input); }); }); describe('style tags', () => { - it('should decode > in style tags', () => { + it('decodes > in style tags', () => { const input = ''; const expected = ''; expect(decodeHtmlEntities(input)).toBe(expected); }); - it('should decode < in style tags', () => { + it('decodes < in style tags', () => { const input = ''; const expected = ''; expect(decodeHtmlEntities(input)).toBe(expected); }); - it('should decode & in style tags', () => { + it('decodes & in style tags', () => { const input = ''; const expected = ''; expect(decodeHtmlEntities(input)).toBe(expected); }); - it('should decode multiple entities in style tags', () => { + it('decodes multiple entities in style tags', () => { const input = ''; const expected = @@ -72,7 +72,7 @@ describe('decodeHtmlEntities', () => { expect(decodeHtmlEntities(input)).toBe(expected); }); - it('should handle style tag with attributes', () => { + it('handles style tag with attributes', () => { const input = ''; const expected = @@ -80,7 +80,7 @@ describe('decodeHtmlEntities', () => { expect(decodeHtmlEntities(input)).toBe(expected); }); - it('should handle multiple style tags', () => { + it('handles multiple style tags', () => { const input = ` @@ -92,14 +92,14 @@ describe('decodeHtmlEntities', () => { expect(decodeHtmlEntities(input)).toBe(expected); }); - it('should not affect style tags without entities', () => { + it('preserves style tags without entities', () => { const input = ''; expect(decodeHtmlEntities(input)).toBe(input); }); }); describe('combined', () => { - it('should decode both href and style in same document', () => { + it('decodes both href and style in same document', () => { const input = `