feat(ui): also preview HTML email templates (#3526)

Signed-off-by: gabriel miranda <gabriel@resend.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Gabriel Miranda
2026-05-21 14:56:47 -03:00
committed by GitHub
parent 6c4af8c25e
commit 99cadf324e
14 changed files with 255 additions and 43 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@react-email/ui": minor
---
support previewing HTML email templates
@@ -6,7 +6,7 @@ import { cache } from 'react';
import { emailsDirectoryAbsolutePath } from '../app/env';
export const getEmailPathFromSlug = cache(async (slug: string) => {
if (['.tsx', '.jsx', '.ts', '.js'].includes(path.extname(slug)))
if (['.tsx', '.jsx', '.ts', '.js', '.html'].includes(path.extname(slug)))
return path.join(emailsDirectoryAbsolutePath, slug);
const pathWithoutExtension = path.join(emailsDirectoryAbsolutePath, slug);
@@ -23,6 +23,9 @@ export const getEmailPathFromSlug = cache(async (slug: string) => {
if (fs.existsSync(`${pathWithoutExtension}.js`)) {
return `${pathWithoutExtension}.js`;
}
if (fs.existsSync(`${pathWithoutExtension}.html`)) {
return `${pathWithoutExtension}.html`;
}
return undefined;
});
@@ -0,0 +1,36 @@
import path from 'node:path';
import { renderEmailByPath } from './render-email-by-path';
describe('renderEmailByPath() with raw .html templates', () => {
const htmlPath = path.resolve(
__dirname,
'../utils/testing/raw-html-email.html',
);
it('renders raw HTML without bundling a React component', async () => {
const result = await renderEmailByPath(htmlPath, true);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.basename).toBe('raw-html-email');
expect(result.extname).toBe('html');
expect(result.markup).toContain('<h1>Hello from a raw HTML email</h1>');
expect(result.reactMarkup).toContain(
'<h1>Hello from a raw HTML email</h1>',
);
// The fixture ships as a single minified line; prettyMarkup should be
// formatted across multiple lines so the source view is readable.
expect(result.prettyMarkup).toContain('Hello from a raw HTML email');
expect(result.prettyMarkup.split('\n').length).toBeGreaterThan(
result.markup.split('\n').length,
);
// html-to-text uppercases <h1> content by default
expect(result.plainText.toLowerCase()).toContain(
'hello from a raw html email',
);
expect(result.plainText).toContain(
'This template has no JavaScript exports.',
);
});
});
@@ -4,6 +4,7 @@ import fs from 'node:fs';
import path from 'node:path';
import logSymbols from 'log-symbols';
import type React from 'react';
import { pretty, toPlainText } from 'react-email';
import {
isBuilding,
isPreviewDevelopment,
@@ -118,6 +119,31 @@ export const renderEmailByPath = async (
registerSpinnerAutostopping(spinner);
}
if (path.extname(emailPath) === '.html') {
const renderingResult = await renderRawHtmlEmailByPath(emailPath);
if ('error' in renderingResult) {
stopSpinnerAndPersist(spinner, {
symbol: logSymbols.error,
text: `Failed while rendering ${emailFilename}`,
});
} else {
stopSpinnerAndPersist(spinner, {
symbol: logSymbols.success,
text: `Successfully rendered ${emailFilename}`,
});
}
logBufferer.flush();
errorBufferer.flush();
infoBufferer.flush();
warnBufferer.flush();
if (!('error' in renderingResult)) {
cache.set(emailPath, renderingResult);
}
return renderingResult;
}
const originalJsxRuntimePath = path.resolve(
previewServerLocation,
'jsx-runtime',
@@ -312,3 +338,44 @@ export const renderEmailByPath = async (
};
}
};
const renderRawHtmlEmailByPath = async (
emailPath: string,
): Promise<EmailRenderingResult> => {
try {
const source = await fs.promises.readFile(emailPath, 'utf-8');
const markup = source.replaceAll('\0', '');
let prettyMarkup = markup;
try {
prettyMarkup = await pretty(markup);
} catch (_) {
// Fall back to the raw markup if prettier cannot parse the HTML so the
// preview still renders something the user can iterate on.
}
const plainText = toPlainText(markup);
return {
prettyMarkup,
markup,
plainText,
reactMarkup: source,
basename: path.basename(emailPath, path.extname(emailPath)),
extname: path.extname(emailPath).slice(1),
};
} catch (exception) {
const error = exception as Error;
return {
error: {
name: error.name,
message: error.message,
stack: error.stack,
cause: error.cause
? JSON.parse(JSON.stringify(error.cause))
: undefined,
},
};
}
};
@@ -325,6 +325,13 @@ export function EmailFrame({
srcDoc={markup}
width={width}
height={height}
// `srcDoc` content inherits the parent's origin, so a `<script>` in a
// template (especially raw `.html` files read from disk) would execute
// with same-origin access to the preview app. Sandboxing disables
// scripts, forms, popups, and top-level navigation while keeping
// `allow-same-origin` so the parent can still inspect/modify the
// iframe document for color inversion and event bubbling.
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
onLoad={(event) => {
const iframe = event.currentTarget;
if (darkMode) {
+10 -7
View File
@@ -89,13 +89,16 @@ This is most likely not an issue with the preview server. Maybe there was a typo
return 0;
});
compatibilityCheckingResults = [];
for await (const result of loadStream(
await checkCompatibility(
serverEmailRenderingResult.reactMarkup,
emailPath,
),
)) {
compatibilityCheckingResults.push(result);
// Compatibility checks parse JSX/TS — they don't apply to raw .html emails.
if (serverEmailRenderingResult.extname !== 'html') {
for await (const result of loadStream(
await checkCompatibility(
serverEmailRenderingResult.reactMarkup,
emailPath,
),
)) {
compatibilityCheckingResults.push(result);
}
}
const response = await fetch('https://react.email/api/check-spam', {
@@ -36,7 +36,16 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
const isDarkModeEnabled = searchParams.get('dark') !== null;
const activeView = searchParams.get('view') ?? 'preview';
const activeLang = searchParams.get('lang') ?? 'tsx';
const isRawHtmlEmail = renderedEmailMetadata?.extname === 'html';
const requestedLang = searchParams.get('lang');
const defaultLang = isRawHtmlEmail ? 'html' : 'tsx';
// Raw HTML templates only expose `html` and `markdown` tabs, so coerce any
// lingering `tsx` selection from URL state to the HTML tab to avoid the
// "No markup found for the active language!" error in CodeContainer.
const activeLang =
requestedLang === null || (isRawHtmlEmail && requestedLang === 'tsx')
? defaultLang
: requestedLang;
const handleDarkModeChange = (enabled: boolean) => {
const params = new URLSearchParams(searchParams);
@@ -208,22 +217,37 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
<CodeContainer
activeLang={activeLang}
basename={renderedEmailMetadata.basename}
markups={[
{
language: 'tsx',
extension: renderedEmailMetadata.extname,
content: renderedEmailMetadata.reactMarkup,
},
{
language: 'html',
content: renderedEmailMetadata.prettyMarkup,
},
{
language: 'markdown',
extension: 'md',
content: renderedEmailMetadata.plainText,
},
]}
markups={
isRawHtmlEmail
? [
{
language: 'html',
extension: 'html',
content: renderedEmailMetadata.prettyMarkup,
},
{
language: 'markdown',
extension: 'md',
content: renderedEmailMetadata.plainText,
},
]
: [
{
language: 'tsx',
extension: renderedEmailMetadata.extname,
content: renderedEmailMetadata.reactMarkup,
},
{
language: 'html',
content: renderedEmailMetadata.prettyMarkup,
},
{
language: 'markdown',
extension: 'md',
content: renderedEmailMetadata.plainText,
},
]
}
setActiveLang={handleLangChange}
/>
</Tooltip.Provider>
@@ -57,10 +57,13 @@ export const FileTreeDirectoryChildren = (props: {
: `${props.emailsDirectoryMetadata.relativePath}/${emailFilename}`;
const removeExtensionFrom = (path: string) => {
const ext = path.split('.').pop();
if (
path.split('.').pop() === 'tsx' ||
path.split('.').pop() === 'jsx' ||
path.split('.').pop() === 'js'
ext === 'tsx' ||
ext === 'jsx' ||
ext === 'ts' ||
ext === 'js' ||
ext === 'html'
) {
return path.split('.').slice(0, -1).join('.');
}
@@ -72,15 +75,31 @@ export const FileTreeDirectoryChildren = (props: {
emailSlug
: false;
// Raw .html templates don't expose a Compatibility tab, so
// dropping the param prevents the toolbar from opening on
// a hidden tab when navigating from a .tsx email.
const isHtmlTarget = emailFilename.endsWith('.html');
const targetSearchParams = new URLSearchParams(
searchParams,
);
if (
isHtmlTarget &&
targetSearchParams.get('toolbar-panel') ===
'compatibility'
) {
targetSearchParams.set('toolbar-panel', 'linter');
}
const targetSearch = targetSearchParams.toString();
return (
<Link
href={{
pathname: `/preview/${emailSlug}`,
search: searchParams.toString(),
search: targetSearch,
}}
onMouseOver={() => {
router.prefetch(
`/preview/${emailSlug}?${searchParams.toString()}`,
`/preview/${emailSlug}${targetSearch ? `?${targetSearch}` : ''}`,
);
}}
key={emailSlug}
+32 -10
View File
@@ -57,12 +57,14 @@ const ToolbarInner = ({
plainText,
emailPath,
emailSlug,
isRawHtmlEmail,
}: ToolbarProps & {
prettyMarkup: string;
reactMarkup: string;
plainText: string;
emailSlug: string;
emailPath: string;
isRawHtmlEmail: boolean;
}) => {
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -125,8 +127,12 @@ const ToolbarInner = ({
const spamCheckingResult = await loadSpamChecking();
setCachedSpamCheckingResult(spamCheckingResult);
const compatibilityCheckingResults = await loadCompatibility();
setCachedCompatibilityResults(compatibilityCheckingResults);
// Compatibility checks rely on parsing JSX/TS, so they don't apply to
// raw .html templates and would only produce noise.
if (!isRawHtmlEmail) {
const compatibilityCheckingResults = await loadCompatibility();
setCachedCompatibilityResults(compatibilityCheckingResults);
}
})();
}, []);
}
@@ -157,11 +163,13 @@ const ToolbarInner = ({
Linter
</ToolbarButton>
</Tabs.Trigger>
<Tabs.Trigger asChild value="compatibility">
<ToolbarButton active={activeTab === 'compatibility'}>
Compatibility
</ToolbarButton>
</Tabs.Trigger>
{isRawHtmlEmail ? null : (
<Tabs.Trigger asChild value="compatibility">
<ToolbarButton active={activeTab === 'compatibility'}>
Compatibility
</ToolbarButton>
</Tabs.Trigger>
)}
<Tabs.Trigger asChild value="spam-assassin">
<ToolbarButton active={activeTab === 'spam-assassin'}>
Spam
@@ -179,6 +187,7 @@ const ToolbarInner = ({
compatibilityResults={compatibilityCheckingResults}
spamResult={spamCheckingResult}
reactMarkup={reactMarkup}
isRawHtmlEmail={isRawHtmlEmail}
activeTab={activeTab}
/>
<ToolbarButton
@@ -209,7 +218,10 @@ const ToolbarInner = ({
await loadSpamChecking();
} else if (activeTab === 'linter') {
await loadLinting();
} else if (activeTab === 'compatibility') {
} else if (
activeTab === 'compatibility' &&
!isRawHtmlEmail
) {
await loadCompatibility();
}
}}
@@ -258,7 +270,15 @@ const ToolbarInner = ({
)}
</Tabs.Content>
<Tabs.Content value="compatibility">
{compatibilityLoading ? (
{isRawHtmlEmail ? (
<SuccessWrapper>
<SuccessTitle>Compatibility unavailable</SuccessTitle>
<SuccessDescription>
Compatibility checks rely on the React Email source and are
skipped for raw HTML templates.
</SuccessDescription>
</SuccessWrapper>
) : compatibilityLoading ? (
<LoadingState message="Checking email compatibility..." />
) : compatibilityCheckingResults?.length === 0 ? (
<SuccessWrapper>
@@ -398,7 +418,8 @@ export function Toolbar({
const { emailPath, emailSlug, renderedEmailMetadata } = usePreviewContext();
if (renderedEmailMetadata === undefined) return null;
const { prettyMarkup, plainText, reactMarkup } = renderedEmailMetadata;
const { prettyMarkup, plainText, reactMarkup, extname } =
renderedEmailMetadata;
return (
<ToolbarInner
@@ -407,6 +428,7 @@ export function Toolbar({
prettyMarkup={prettyMarkup}
reactMarkup={reactMarkup}
plainText={plainText}
isRawHtmlEmail={extname === 'html'}
serverLintingRows={serverLintingRows}
serverSpamCheckingResult={serverSpamCheckingResult}
serverCompatibilityResults={serverCompatibilityResults}
@@ -24,6 +24,7 @@ interface CopyForAIProps {
compatibilityResults: CompatibilityCheckingResult[] | undefined;
spamResult: SpamCheckingResult | undefined;
reactMarkup: string;
isRawHtmlEmail: boolean;
activeTab: ActiveTab;
}
@@ -168,6 +169,7 @@ function getPromptForTab(
compatibilityResults: CompatibilityCheckingResult[] | undefined,
spamResult: SpamCheckingResult | undefined,
reactMarkup: string,
isRawHtmlEmail: boolean,
): string {
let issuePrompt = '';
@@ -194,11 +196,14 @@ function getPromptForTab(
issuePrompt = parts.join('\n\n---\n\n');
}
const sourceLang = isRawHtmlEmail ? 'html' : 'tsx';
const templateLabel = isRawHtmlEmail ? 'HTML email' : 'React Email';
if (!issuePrompt) {
return `Here is the source code of my React Email template:\n\n\`\`\`tsx\n${reactMarkup}\n\`\`\`\n\nHelp me review and improve this email template.`;
return `Here is the source code of my ${templateLabel} template:\n\n\`\`\`${sourceLang}\n${reactMarkup}\n\`\`\`\n\nHelp me review and improve this email template.`;
}
return `${issuePrompt}\n\nHere is the source code of my email template:\n\n\`\`\`tsx\n${reactMarkup}\n\`\`\``;
return `${issuePrompt}\n\nHere is the source code of my email template:\n\n\`\`\`${sourceLang}\n${reactMarkup}\n\`\`\``;
}
function getLinkDescription(activeTab: ActiveTab): string {
@@ -233,6 +238,7 @@ export const CopyForAI = ({
compatibilityResults,
spamResult,
reactMarkup,
isRawHtmlEmail,
activeTab,
}: CopyForAIProps) => {
const markdown = React.useMemo(
@@ -243,8 +249,16 @@ export const CopyForAI = ({
compatibilityResults,
spamResult,
reactMarkup,
isRawHtmlEmail,
),
[activeTab, lintingRows, compatibilityResults, spamResult, reactMarkup],
[
activeTab,
lintingRows,
compatibilityResults,
spamResult,
reactMarkup,
isRawHtmlEmail,
],
);
const linkDescription = getLinkDescription(activeTab);
@@ -18,6 +18,11 @@ const isFileAnEmail = async (fullPath: string): Promise<boolean> => {
const { ext } = path.parse(fullPath);
if (ext === '.html') {
await fileHandle.close();
return true;
}
if (!['.js', '.tsx', '.jsx'].includes(ext)) {
await fileHandle.close();
return false;
@@ -21,4 +21,9 @@ describe('JavaScript Email Detection', async () => {
expect(emailsMetadata).toBeDefined();
expect(emailsMetadata?.emailFilenames).toContain('mdx-email-test.js');
});
it('detects raw .html templates without requiring any exports', async () => {
expect(emailsMetadata).toBeDefined();
expect(emailsMetadata?.emailFilenames).toContain('raw-html-email.html');
});
});
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8" /><title>Raw HTML Email</title></head><body><h1>Hello from a raw HTML email</h1><p>This template has no JavaScript exports.</p></body></html>
+1
View File
@@ -1,2 +1,3 @@
**/*.tsx
**/*.html
!example.tsx