diff --git a/.changeset/fix-html-entities.md b/.changeset/fix-html-entities.md new file mode 100644 index 0000000000..628978abc1 --- /dev/null +++ b/.changeset/fix-html-entities.md @@ -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 (`&` → `&`) +- `'; + const expected = ''; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('decodes < in style tags', () => { + const input = ''; + const expected = ''; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('decodes & in style tags', () => { + const input = ''; + const expected = ''; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('decodes multiple entities in style tags', () => { + const input = + ''; + const expected = + ''; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('handles style tag with attributes', () => { + const input = + ''; + const expected = + ''; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('handles multiple style tags', () => { + const input = ` + + + `; + const expected = ` + + + `; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + it('preserves style tags without entities', () => { + const input = ''; + expect(decodeHtmlEntities(input)).toBe(input); + }); + }); + + describe('combined', () => { + it('decodes 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; +};