diff --git a/.changeset/major-queens-talk.md b/.changeset/major-queens-talk.md
new file mode 100644
index 000000000..34a2009ca
--- /dev/null
+++ b/.changeset/major-queens-talk.md
@@ -0,0 +1,5 @@
+---
+"@react-email/ui": minor
+---
+
+support previewing HTML email templates
diff --git a/packages/ui/src/actions/get-email-path-from-slug.ts b/packages/ui/src/actions/get-email-path-from-slug.ts
index abc6744aa..e7b86157b 100644
--- a/packages/ui/src/actions/get-email-path-from-slug.ts
+++ b/packages/ui/src/actions/get-email-path-from-slug.ts
@@ -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;
});
diff --git a/packages/ui/src/actions/render-email-by-path.spec.ts b/packages/ui/src/actions/render-email-by-path.spec.ts
new file mode 100644
index 000000000..f14fba23a
--- /dev/null
+++ b/packages/ui/src/actions/render-email-by-path.spec.ts
@@ -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('
Hello from a raw HTML email
');
+ expect(result.reactMarkup).toContain(
+ 'Hello from a raw HTML email
',
+ );
+ // 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 content by default
+ expect(result.plainText.toLowerCase()).toContain(
+ 'hello from a raw html email',
+ );
+ expect(result.plainText).toContain(
+ 'This template has no JavaScript exports.',
+ );
+ });
+});
diff --git a/packages/ui/src/actions/render-email-by-path.tsx b/packages/ui/src/actions/render-email-by-path.tsx
index 401999f06..80aefbb4c 100644
--- a/packages/ui/src/actions/render-email-by-path.tsx
+++ b/packages/ui/src/actions/render-email-by-path.tsx
@@ -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 => {
+ 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,
+ },
+ };
+ }
+};
diff --git a/packages/ui/src/app/preview/[...slug]/email-frame.tsx b/packages/ui/src/app/preview/[...slug]/email-frame.tsx
index 00aa4094b..03ac0c167 100644
--- a/packages/ui/src/app/preview/[...slug]/email-frame.tsx
+++ b/packages/ui/src/app/preview/[...slug]/email-frame.tsx
@@ -325,6 +325,13 @@ export function EmailFrame({
srcDoc={markup}
width={width}
height={height}
+ // `srcDoc` content inherits the parent's origin, so a `