Skip to content
Closed
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
128 changes: 128 additions & 0 deletions packages/jsx-email/src/renderer/raw.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Root } from 'hast';

const START_TAG = '__COMMENT_START';
const END_TAG = '__COMMENT_END';
export function escapeForRawComponent(input: string): string {
Expand All @@ -10,3 +12,129 @@ export function unescapeForRawComponent(input: string): string {
.replace(new RegExp(START_TAG, 'g'), '<!--')
.replace(new RegExp(END_TAG, 'g'), '/-->');
}

// Normalize corrupted MSO conditional closers that can occur when nested
// comment boundaries abut the `<![endif]-->` sequence (e.g. `<!--[endif]---->`).
export function normalizeMsoConditionalClosers(input: string): string {
return input.replace(/<!--\[endif\]-+-->/g, '<![endif]-->');
}

// Shared pattern for a `<jsx-email-raw><!-- … --></jsx-email-raw>` wrapper,
// also matching an encoded comment opener and a corrupted `</jsx-email-raw-->` closer.
export const RAW_WRAPPER_RE =
/<jsx-email-raw[^>]*?>\s*(?:<!--|&#x3C;!--)([\s\S]*?)-->\s*<\/jsx-email-raw(?:--)?\s*>/g;

// Return a rehype plugin that replaces <jsx-email-raw><!-- ... --></jsx-email-raw>
// with a HAST "raw" node containing the unescaped payload. This avoids nesting
// HTML comments when <Raw> appears inside Outlook conditional comments.
export const getRawPlugin = async () => {
const { visit } = await import('unist-util-visit');

return function rawPlugin() {
return function transform(tree: Root) {
visit(tree, 'element', (node: any, index: number | null, parent: any) => {
if (!parent || index == null) return;

// 1) Lift <jsx-email-raw> comment payloads into literal HTML
if (node.tagName === 'jsx-email-raw') {
const child = node.children && node.children[0];
if (!child) return;

if (child.type === 'comment') {
const payload = String(child.value ?? '');
const html = unescapeForRawComponent(payload);
parent.children.splice(index, 1, { type: 'raw', value: html });
}
return;
}

// 2) Replace <jsx-email-cond> wrapper with raw nodes that
// represent the actual Outlook conditional bytes. Handle
// both the single-comment form (<!--[if mso]>...<![endif]-->)
// and the downlevel-revealed form (<!--[if !mso]><!-->...<!--<![endif]-->).
if (node.tagName === 'jsx-email-cond') {
// NOTE: keep transformation logic local to this plugin.
const children = Array.isArray(node.children) ? (node.children as any[]) : [];
if (!children.length) return;

// Prefer transforming AST nodes directly over string parsing.

// Replace the wrapper element with: opening comment, transformed
// children (with <jsx-email-raw> lifted), and closing comment.
const transformRaw = (nodes: any[]): any[] =>
nodes.flatMap((n) => {
if (n.type === 'element' && n.tagName === 'jsx-email-raw') {
const first = n.children && n.children[0];
const payload = typeof first?.value === 'string' ? first.value : '';
return [{ type: 'raw', value: unescapeForRawComponent(payload) }];
}
if (n.children && Array.isArray(n.children)) {
n.children = transformRaw(n.children);
}
return [n];
});

const first = children[0];
const last = children[children.length - 1];
// Downlevel-revealed form produces 2 comment siblings around real
// element/text children: `<!--[if !mso]><!-->` … `<!--<![endif]-->`.
// Preserve the exact serializer bytes by keeping the original
// comment nodes and only transforming any nested <jsx-email-raw>
// elements found between them.
if (first?.type === 'comment' && last?.type === 'comment' && children.length > 1) {
const middle = transformRaw(children.slice(1, -1));
parent.children.splice(index, 1, first, ...middle, last);
return;
}

// Single-comment form packs everything into a single comment node:
// `<!--[if mso]>…<![endif]-->`. Split it into raw nodes so that
// nested <jsx-email-raw> wrappers can be safely inlined.
if (first?.type === 'comment') {
const value = String(first.value ?? '');
const openIdx = value.indexOf(']>');
const closeIdx = value.lastIndexOf('<![endif]');

// If not a valid MSO single-comment wrapper, fall back to generic behavior.
if (!(openIdx >= 0 && closeIdx > openIdx)) {
parent.children.splice(index, 1, ...transformRaw(children));
return;
}

// Construct opening marker comment bytes
const openRaw = openIdx >= 0 ? `<!--${value.slice(0, openIdx + 2)}` : `<!--${value}`;

// Extract inner HTML strictly between the open and close markers when both exist
let innerHtml = '';
if (openIdx >= 0 && closeIdx > openIdx) {
innerHtml = value.slice(openIdx + 2, closeIdx);
} else if (openIdx >= 0) {
innerHtml = value.slice(openIdx + 2);
}

// Inline any nested <jsx-email-raw><!-- … --></jsx-email-raw> occurrences inside the
// single-comment payload, being tolerant of a corrupted element closer like
// "</jsx-email-raw-->" that some serializers may emit when adjacent to comment ends.
innerHtml = innerHtml.replace(RAW_WRAPPER_RE, (_m: string, p1: string) =>
unescapeForRawComponent(p1)
);

// Include any middle child nodes (downlevel-revealed form produces real nodes)
parent.children.splice(
index,
1,
{ type: 'raw', value: openRaw },
{ type: 'raw', value: innerHtml },
...transformRaw(children.slice(1, -1)),
{ type: 'raw', value: '<![endif]-->' }
);
return;
}

// Fallback: drop the wrapper and keep transformed children as-is.
parent.children.splice(index, 1, ...transformRaw(children));
}
});
};
};
};
41 changes: 32 additions & 9 deletions packages/jsx-email/src/renderer/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import type { PlainTextOptions, RenderOptions } from '../types.js';

import { jsxToString } from './jsx-to-string.js';
import { getMovePlugin } from './move-style.js';
import { unescapeForRawComponent } from './raw.js';
import {
RAW_WRAPPER_RE,
getRawPlugin,
normalizeMsoConditionalClosers,
unescapeForRawComponent
} from './raw.js';

export const jsxEmailTags = ['jsx-email-cond'];

Expand Down Expand Up @@ -74,13 +79,14 @@ const processHtml = async (config: JsxEmailConfig, html: string) => {
const docType =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const movePlugin = await getMovePlugin();
const rawPlugin = await getRawPlugin();
const settings = { emitParseErrors: true };
const reJsxTags = new RegExp(`<[/]?(${jsxEmailTags.join('|')})>`, 'g');

// @ts-ignore: This is perfectly valid, see here: https://www.npmjs.com/package/rehype#examples
const processor = rehype().data('settings', settings);

processor.use(movePlugin);
processor.use(rawPlugin);
await callProcessHook({ config, processor });

const doc = await processor
Expand All @@ -92,12 +98,29 @@ const processHtml = async (config: JsxEmailConfig, html: string) => {
})
.process(html);

let result = docType + String(doc).replace('<!doctype html>', '').replace('<head></head>', '');

result = result.replace(reJsxTags, '');
result = result.replace(/<jsx-email-raw.*?><!--(.*?)--><\/jsx-email-raw>/gs, (_, p1) =>
unescapeForRawComponent(p1)
const result = docType + String(doc).replace('<!doctype html>', '').replace('<head></head>', '');
try {
if (process.env.DEBUG_REHYPE && result.includes('<jsx-email-raw')) {
const { writeFileSync } = await import('node:fs');
const { join } = await import('node:path');
const { tmpdir } = await import('node:os');
const filePath = join(tmpdir(), 'jsx-email-debug.html');
writeFileSync(filePath, result);
}
} catch {
// noop: best-effort debug write when DEBUG_REHYPE is set
}
const reJsxTags = new RegExp(`<[/]?(${jsxEmailTags.join('|')})>`, 'g');
const reRawWrapper = RAW_WRAPPER_RE;
// Handle a corrupted closer where the </jsx-email-raw> tag is eaten and the
// comment terminator abuts the MSO closer: `<jsx-email-raw><!--…--><![endif]-->`.
const reRawBeforeEndif =
/<jsx-email-raw[^>]*?>\s*(?:<!--|&#x3C;!--)([\s\S]*?)-->[\s\r\n]*(?=(?:<!\[endif\]|<!--\[endif\])-+-->)/g;
const unwrapped = normalizeMsoConditionalClosers(
result
.replace(reJsxTags, '')
.replace(reRawWrapper, (_m, p1) => unescapeForRawComponent(p1))
.replace(reRawBeforeEndif, (_m, p1) => unescapeForRawComponent(p1))
);

return result;
return unwrapped;
};
73 changes: 73 additions & 0 deletions packages/jsx-email/test/conditional-closing.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @ts-ignore
import React from 'react';

import { render } from '../src/renderer/render.js';
import { Conditional } from '../src/index.js';

/**
* Repro for issue #316: MSO conditional closer is malformed in body content.
*
* This test mirrors the structure reported by the user:
* - a modern-clients block wrapped in <Conditional mso={false}>
* - followed by an MSO-only block wrapped in <Conditional mso>
* both as siblings within table rows/cells.
*
* Expected: the MSO block closes with `<![endif]-->` and no extra hyphens.
*/
describe('<Conditional> closer integrity', async () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});

it('emits a well-formed <![endif]--> for mso blocks placed in table cells', async () => {
const fragment = (
<table role="presentation" cellPadding={0} cellSpacing={0} width={300}>
<tbody>
<tr>
<td>
<Conditional mso={false}>
<table data-test="modern" role="presentation" width={300}>
<tbody>
<tr>
<td>modern</td>
</tr>
</tbody>
</table>
</Conditional>
</td>
</tr>
<tr>
<td>
<Conditional mso>
<table data-test="mso" role="presentation" width={300}>
<tbody>
<tr>
<td>mso</td>
</tr>
</tbody>
</table>
</Conditional>
</td>
</tr>
</tbody>
</table>
);

const html = await render(fragment, { pretty: true });

// Guard against the known-bad tail observed in .eml outputs:
expect(html).not.toContain('<!--[endif]---->');

// Verify that our MSO block uses a valid closer.
// We limit the search to our block by anchoring on a unique attribute.
const start = html.indexOf('<!--[if mso]');
const msoIdx = html.indexOf('data-test="mso"', start);
expect(start).toBeGreaterThan(-1);
expect(msoIdx).toBeGreaterThan(-1);

// small window around our block
const tail = html.slice(msoIdx, msoIdx + 400);
expect(tail).toContain('<![endif]-->');
});
});
58 changes: 58 additions & 0 deletions packages/jsx-email/test/conditional-mso-raw-closing.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// @ts-ignore
import React from 'react';

import { render } from '../src/renderer/render.js';
import { Conditional, Raw } from '../src/index.js';

describe('<Conditional> + <Raw> interaction', async () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});

it('does not corrupt the <![endif]--> closer when Raw is present inside the MSO block', async () => {
const fragment = (
<table role="presentation" cellPadding={0} cellSpacing={0} width={300}>
<tbody>
<tr>
<td>
<Conditional mso>
<table role="presentation" width={300}>
<tbody>
<tr>
<td style={{ fontSize: 0, height: 16, width: 16 }}>
{/* Minimal VML corner via Raw (no comments inside) */}
<Raw
content={
'<v:shape style="display:block;position:relative;width:16px;height:16px" coordorigin="0 0" coordsize="2 2" fillcolor="#def2f4" fill="true" stroke="f" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"><v:path v="m 0,2 c 0,1,1,0,2,0 l 2,2 x"/></v:shape>'
}
/>
</td>
<td style={{ fontSize: 0, height: 16 }} />
<td style={{ fontSize: 0, height: 16, width: 16 }}>
<Raw
content={
'<v:shape style="display:block;position:relative;width:16px;height:16px" coordorigin="0 0" coordsize="2 2" fillcolor="#def2f4" fill="true" stroke="f" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"><v:path v="m 0,0 c 1,0,2,1,2,2 l 0,2 x"/></v:shape>'
}
/>
</td>
</tr>
</tbody>
</table>
</Conditional>
</td>
</tr>
</tbody>
</table>
);

const html = await render(fragment, { pretty: true });

// Known-bad form observed in issue reports
expect(html).not.toContain('<!--[endif]---->');

// Valid closer must exist for the MSO block
const idx = html.indexOf('<![endif]-->', html.indexOf('<!--[if mso]'));
expect(idx).toBeGreaterThan(-1);
});
});
35 changes: 35 additions & 0 deletions packages/jsx-email/test/conditional-plugin-msoraw.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// @ts-ignore
import React from 'react';

import { render } from '../src/renderer/render.js';
import { Conditional, Raw } from '../src/index.js';

describe('rehype raw plugin → <Conditional mso> + <Raw>', async () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});

it('lifts <jsx-email-raw> and drops <jsx-email-cond> wrapper with a valid <![endif]--> closer', async () => {
const fragment = (
<Conditional mso>
<Raw
content={
'<v:shape style="width:1px;height:1px" xmlns:v="urn:schemas-microsoft-com:vml" />'
}
/>
</Conditional>
);

const html = await render(fragment, { minify: false, pretty: false });

// Should render Outlook conditional markers
expect(html).toContain('<!--[if mso]');
expect(html).toContain('<![endif]-->');

// Raw payload should be inlined as literal HTML (no synthetic wrappers left)
expect(html).toContain('<v:shape');
expect(html).not.toContain('<jsx-email-raw');
expect(html).not.toContain('<jsx-email-cond');
});
});
Loading
Loading