Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
46a2774
feat: initial templates api integration
joaopcm Nov 3, 2025
aa75ef6
fix: ts error due to the zod upgrade
joaopcm Nov 3, 2025
b2960f3
fix: add key to element within a loop
joaopcm Nov 3, 2025
06be0e8
fix: make bulk operations atomic
joaopcm Nov 3, 2025
84bce5b
fix: properly log errors
joaopcm Nov 3, 2025
d1b7760
fix: spread icon props at the first level
joaopcm Nov 3, 2025
3245eef
fix: remove toctou vulnerability
joaopcm Nov 3, 2025
46c2dfa
fix: only override existing api key when a valid value is provided
joaopcm Nov 3, 2025
a197438
fix: traverse deeper folder structures
joaopcm Nov 3, 2025
5aaeec3
Merge branch 'canary' into feat/templates-integration
joaopcm Nov 3, 2025
4f3845a
chore: add changeset
joaopcm Nov 3, 2025
678d776
fix: lint
joaopcm Nov 3, 2025
6dfa418
Merge branch 'canary' into feat/templates-integration
gabrielmfern Nov 3, 2025
7c037af
fix: remove forwardRef usage
joaopcm Nov 3, 2025
b9c7bc9
fix: remove forwardRef usage
joaopcm Nov 3, 2025
ec31a28
fix: remove unnecessary property and use prettyMarkup instead
joaopcm Nov 3, 2025
feaf52b
chore: update loading copy
joaopcm Nov 3, 2025
eea72dc
initial version
gabrielmfern Nov 4, 2025
0837765
receive the api key from an argument, fix api key trimming, style the…
gabrielmfern Nov 4, 2025
faa0822
lint
gabrielmfern Nov 4, 2025
250d4d9
use a separate file for conf, different field for api key and improve…
gabrielmfern Nov 4, 2025
34832af
define a type for the conf
gabrielmfern Nov 4, 2025
401108d
Merge branch 'canary' into feat/templates-integration
gabrielmfern Nov 4, 2025
93885e3
pass the resend api key as env to the preview server
gabrielmfern Nov 4, 2025
cf51571
use a server action to check for api key, define it in env.ts
gabrielmfern Nov 4, 2025
ab7e02d
update tree snap
gabrielmfern Nov 4, 2025
e5fc01a
Merge branch 'feat/resend-setup-command' into feat/templates-integration
gabrielmfern Nov 4, 2025
c1ffbd2
update bringing api key message
gabrielmfern Nov 4, 2025
86cef43
remove setup resend script
gabrielmfern Nov 4, 2025
d1acbf5
instantiate Resend at the point of exporting to templates
gabrielmfern Nov 4, 2025
f97f01d
use a different placeholder for the text wrapping to look better
gabrielmfern Nov 4, 2025
a3484b7
lint
gabrielmfern Nov 4, 2025
d47ab37
Merge branch 'canary' into feat/templates-integration
gabrielmfern Nov 5, 2025
a577208
improve code a bit more
gabrielmfern Nov 6, 2025
78a30bb
add details about it only supporting HTML
gabrielmfern Nov 6, 2025
f28d67a
remove copied components, and just use elements directly
gabrielmfern Nov 6, 2025
dcd97ea
lint
gabrielmfern Nov 6, 2025
57a631a
Merge branch 'canary' into feat/templates-integration
gabrielmfern Nov 6, 2025
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
5 changes: 5 additions & 0 deletions .changeset/cute-pigs-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-email/preview-server": minor
---

Integrate with Templates API so users can easily turn React Email templates into actual Resend templates
3 changes: 3 additions & 0 deletions benchmarks/preview-server/src/utils/sleep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
4 changes: 3 additions & 1 deletion packages/preview-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,14 @@
"log-symbols": "4.1.0",
"module-punycode": "npm:[email protected]",
"next": "16.0.1",
"next-safe-action": "8.0.11",
"node-html-parser": "7.0.1",
"ora": "5.4.1",
"pretty-bytes": "6.1.1",
"prism-react-renderer": "2.4.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"resend": "6.4.0",
"sharp": "0.34.4",
"socket.io-client": "4.8.1",
"sonner": "2.0.3",
Expand All @@ -56,7 +58,7 @@
"tailwind-merge": "3.2.0",
"tailwindcss": "3.4.0",
"use-debounce": "10.0.4",
"zod": "3.24.3"
"zod": "4.1.12"
},
"devDependencies": {
"@react-email/components": "workspace:*",
Expand Down
36 changes: 36 additions & 0 deletions packages/preview-server/src/actions/export-single-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use server';

import { Resend } from 'resend';
import { z } from 'zod';
import { resendApiKey } from '../app/env';
import { baseActionClient } from './safe-action';

export const exportSingleTemplate = baseActionClient
.metadata({
actionName: 'exportSingleTemplate',
})
.inputSchema(
z.object({
name: z.string(),
html: z.string(),
}),
)
.action(async ({ parsedInput }) => {
const resend = new Resend(resendApiKey);

const response = await resend.templates.create({
name: parsedInput.name,
html: parsedInput.html,
});

if (response.error) {
console.error('Error creating single template', response.error);
return { name: parsedInput.name, status: 'failed' as const };
}

return {
name: parsedInput.name,
status: 'succeeded' as const,
id: response.data.id,
};
});
15 changes: 15 additions & 0 deletions packages/preview-server/src/actions/safe-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {
createSafeActionClient,
DEFAULT_SERVER_ERROR_MESSAGE,
} from 'next-safe-action';
import { z } from 'zod';

export const baseActionClient = createSafeActionClient({
defineMetadataSchema() {
return z.object({ actionName: z.string() });
},
handleServerError(error, options) {
console.error(`Action error: ${options.metadata.actionName}`, error);
return DEFAULT_SERVER_ERROR_MESSAGE;
},
});
3 changes: 3 additions & 0 deletions packages/preview-server/src/app/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export const previewServerLocation = process.env.PREVIEW_SERVER_LOCATION!;
export const emailsDirectoryAbsolutePath =
process.env.EMAILS_DIR_ABSOLUTE_PATH!;

/** ONLY ACCESSIBLE ON THE SERVER */
export const resendApiKey = process.env.RESEND_API_KEY;

export const isBuilding = process.env.NEXT_PUBLIC_IS_BUILDING === 'true';

export const isPreviewDevelopment =
Expand Down
19 changes: 13 additions & 6 deletions packages/preview-server/src/app/preview/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ import { Toolbar } from '../../../components/toolbar';
import type { LintingRow } from '../../../components/toolbar/linter';
import type { SpamCheckingResult } from '../../../components/toolbar/spam-assassin';
import { PreviewProvider } from '../../../contexts/preview';
import { ToolbarProvider } from '../../../contexts/toolbar';
import { getEmailsDirectoryMetadata } from '../../../utils/get-emails-directory-metadata';
import { getLintingSources, loadLintingRowsFrom } from '../../../utils/linting';
import { loadStream } from '../../../utils/load-stream';
import { emailsDirectoryAbsolutePath, isBuilding } from '../../env';
import {
emailsDirectoryAbsolutePath,
isBuilding,
resendApiKey,
} from '../../env';
import Preview from './preview';

export const dynamicParams = true;
Expand Down Expand Up @@ -132,11 +137,13 @@ This is most likely not an issue with the preview server. Maybe there was a typo
<Suspense>
<Preview emailTitle={path.basename(emailPath)} />

<Toolbar
serverLintingRows={lintingRows}
serverSpamCheckingResult={spamCheckingResult}
serverCompatibilityResults={compatibilityCheckingResults}
/>
<ToolbarProvider hasApiKey={(resendApiKey ?? '').trim().length > 0}>
<Toolbar
serverLintingRows={lintingRows}
serverSpamCheckingResult={spamCheckingResult}
serverCompatibilityResults={compatibilityCheckingResults}
/>
</ToolbarProvider>
</Suspense>
</Shell>
</PreviewProvider>
Expand Down
18 changes: 18 additions & 0 deletions packages/preview-server/src/components/icons/icon-cloud-alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { IconProps } from './icon-base';
import { IconBase } from './icon-base';

export const IconCloudAlert = (props: IconProps) => (
<IconBase
{...props}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 12v4" />
<path d="M12 20h.01" />
<path d="M17 18h.5a1 1 0 0 0 0-9h-1.79A7 7 0 1 0 7 17.708" />
</IconBase>
);

IconCloudAlert.displayName = 'IconCloudAlert';
17 changes: 17 additions & 0 deletions packages/preview-server/src/components/icons/icon-cloud-check.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { IconProps } from './icon-base';
import { IconBase } from './icon-base';

export const IconCloudCheck = (props: IconProps) => (
<IconBase
{...props}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m17 15-5.5 5.5L9 18" />
<path d="M5 17.743A7 7 0 1 1 15.71 10h1.79a4.5 4.5 0 0 1 1.5 8.742" />
</IconBase>
);

IconCloudCheck.displayName = 'IconCloudCheck';
16 changes: 16 additions & 0 deletions packages/preview-server/src/components/icons/icon-loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { IconProps } from './icon-base';
import { IconBase } from './icon-base';

export const IconLoader = (props: IconProps) => (
<IconBase
{...props}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</IconBase>
);

IconLoader.displayName = 'IconLoader';
48 changes: 41 additions & 7 deletions packages/preview-server/src/components/toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
'use client';

import * as Tabs from '@radix-ui/react-tabs';
import { LayoutGroup } from 'framer-motion';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import * as React from 'react';
import type { CompatibilityCheckingResult } from '../actions/email-validation/check-compatibility';
import { isBuilding } from '../app/env';
import { usePreviewContext } from '../contexts/preview';
import { useToolbarContext } from '../contexts/toolbar';
import { cn } from '../utils';
import CodeSnippet from './code-snippet';
import { IconArrowDown } from './icons/icon-arrow-down';
import { IconCheck } from './icons/icon-check';
import { IconInfo } from './icons/icon-info';
import { IconReload } from './icons/icon-reload';
import { Compatibility, useCompatibility } from './toolbar/compatibility';
import { Linter, type LintingRow, useLinter } from './toolbar/linter';
import { ResendIntegration } from './toolbar/resend';
import {
SpamAssassin,
type SpamCheckingResult,
Expand All @@ -21,10 +25,15 @@ import {
import { ToolbarButton } from './toolbar/toolbar-button';
import { useCachedState } from './toolbar/use-cached-state';

export type ToolbarTabValue = 'linter' | 'compatibility' | 'spam-assassin';
export type ToolbarTabValue =
| 'linter'
| 'compatibility'
| 'spam-assassin'
| 'resend';

export const useToolbarState = () => {
const searchParams = useSearchParams();

const activeTab = (searchParams.get('toolbar-panel') ?? undefined) as
| ToolbarTabValue
| undefined;
Expand Down Expand Up @@ -57,6 +66,8 @@ const ToolbarInner = ({
const searchParams = useSearchParams();
const router = useRouter();

const { hasSetupResendIntegration } = useToolbarContext();

const { activeTab, toggled } = useToolbarState();

const setActivePanelValue = (newValue: ToolbarTabValue | undefined) => {
Expand Down Expand Up @@ -155,6 +166,11 @@ const ToolbarInner = ({
Spam
</ToolbarButton>
</Tabs.Trigger>
<Tabs.Trigger asChild value="resend">
<ToolbarButton active={activeTab === 'resend'}>
Resend
</ToolbarButton>
</Tabs.Trigger>
</LayoutGroup>
<div className="flex gap-0.5 ml-auto">
<ToolbarButton
Expand All @@ -166,15 +182,17 @@ const ToolbarInner = ({
'The Spam tab will look at the content and use a robust scoring framework to determine if the email is likely to be spam. Powered by SpamAssassin.') ||
(activeTab === 'compatibility' &&
'The Compatibility tab shows how well the HTML/CSS is supported across mail clients like Outlook, Gmail, etc. Powered by Can I Email.') ||
(activeTab === 'resend' &&
"The Resend tab allows you to upload your React Email's HTML using the Templates API. It does not yet upload with variables.") ||
'Info'
}
>
<IconInfo size={24} />
</ToolbarButton>
{isBuilding ? null : (
{isBuilding || activeTab === 'resend' ? null : (
<ToolbarButton
tooltip="Reload"
disabled={lintLoading || spamLoading}
disabled={lintLoading || spamLoading || compatibilityLoading}
onClick={async () => {
if (activeTab === undefined) {
setActivePanelValue('linter');
Expand All @@ -192,7 +210,7 @@ const ToolbarInner = ({
size={24}
className={cn({
'opacity-60 animate-spin-fast':
lintLoading || spamLoading,
lintLoading || spamLoading || compatibilityLoading,
})}
/>
</ToolbarButton>
Expand Down Expand Up @@ -261,6 +279,22 @@ const ToolbarInner = ({
<SpamAssassin result={spamCheckingResult} />
)}
</Tabs.Content>
<Tabs.Content value="resend">
{hasSetupResendIntegration ? (
<ResendIntegration
emailSlug={emailSlug}
htmlMarkup={prettyMarkup}
/>
) : (
<SuccessWrapper>
<SuccessTitle>Connect to Resend</SuccessTitle>
<SuccessDescription>
Run <CodeSnippet>email resend setup re_xxxxxx</CodeSnippet>{' '}
to connect your Resend account and refresh.
</SuccessDescription>
</SuccessWrapper>
)}
</Tabs.Content>
</div>
</div>
</Tabs.Root>
Expand Down Expand Up @@ -326,11 +360,11 @@ interface ToolbarProps {
serverCompatibilityResults: CompatibilityCheckingResult[] | undefined;
}

export const Toolbar = ({
export function Toolbar({
serverLintingRows,
serverSpamCheckingResult,
serverCompatibilityResults,
}: ToolbarProps) => {
}: ToolbarProps) {
const { emailPath, emailSlug, renderedEmailMetadata } = usePreviewContext();

if (renderedEmailMetadata === undefined) return null;
Expand All @@ -348,4 +382,4 @@ export const Toolbar = ({
serverCompatibilityResults={serverCompatibilityResults}
/>
);
};
}
Loading
Loading