diff --git a/.changeset/cute-pigs-lay.md b/.changeset/cute-pigs-lay.md new file mode 100644 index 0000000000..4e5ec661a6 --- /dev/null +++ b/.changeset/cute-pigs-lay.md @@ -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 diff --git a/benchmarks/preview-server/src/utils/sleep.ts b/benchmarks/preview-server/src/utils/sleep.ts new file mode 100644 index 0000000000..0d7f188e17 --- /dev/null +++ b/benchmarks/preview-server/src/utils/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/preview-server/package.json b/packages/preview-server/package.json index db43c2784a..6ec1182f90 100644 --- a/packages/preview-server/package.json +++ b/packages/preview-server/package.json @@ -41,12 +41,14 @@ "log-symbols": "4.1.0", "module-punycode": "npm:punycode@2.3.1", "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", @@ -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:*", diff --git a/packages/preview-server/src/actions/export-single-template.ts b/packages/preview-server/src/actions/export-single-template.ts new file mode 100644 index 0000000000..eb6f0ccd10 --- /dev/null +++ b/packages/preview-server/src/actions/export-single-template.ts @@ -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, + }; + }); diff --git a/packages/preview-server/src/actions/safe-action.ts b/packages/preview-server/src/actions/safe-action.ts new file mode 100644 index 0000000000..25457803dd --- /dev/null +++ b/packages/preview-server/src/actions/safe-action.ts @@ -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; + }, +}); diff --git a/packages/preview-server/src/app/env.ts b/packages/preview-server/src/app/env.ts index ef3f9d5437..d16b317581 100644 --- a/packages/preview-server/src/app/env.ts +++ b/packages/preview-server/src/app/env.ts @@ -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 = diff --git a/packages/preview-server/src/app/preview/[...slug]/page.tsx b/packages/preview-server/src/app/preview/[...slug]/page.tsx index f0d6456c1c..36f0674da5 100644 --- a/packages/preview-server/src/app/preview/[...slug]/page.tsx +++ b/packages/preview-server/src/app/preview/[...slug]/page.tsx @@ -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; @@ -132,11 +137,13 @@ This is most likely not an issue with the preview server. Maybe there was a typo - + 0}> + + diff --git a/packages/preview-server/src/components/icons/icon-cloud-alert.tsx b/packages/preview-server/src/components/icons/icon-cloud-alert.tsx new file mode 100644 index 0000000000..6843ab45c7 --- /dev/null +++ b/packages/preview-server/src/components/icons/icon-cloud-alert.tsx @@ -0,0 +1,18 @@ +import type { IconProps } from './icon-base'; +import { IconBase } from './icon-base'; + +export const IconCloudAlert = (props: IconProps) => ( + + + + + +); + +IconCloudAlert.displayName = 'IconCloudAlert'; diff --git a/packages/preview-server/src/components/icons/icon-cloud-check.tsx b/packages/preview-server/src/components/icons/icon-cloud-check.tsx new file mode 100644 index 0000000000..7a57e0dbc2 --- /dev/null +++ b/packages/preview-server/src/components/icons/icon-cloud-check.tsx @@ -0,0 +1,17 @@ +import type { IconProps } from './icon-base'; +import { IconBase } from './icon-base'; + +export const IconCloudCheck = (props: IconProps) => ( + + + + +); + +IconCloudCheck.displayName = 'IconCloudCheck'; diff --git a/packages/preview-server/src/components/icons/icon-loader.tsx b/packages/preview-server/src/components/icons/icon-loader.tsx new file mode 100644 index 0000000000..5cc5f79e83 --- /dev/null +++ b/packages/preview-server/src/components/icons/icon-loader.tsx @@ -0,0 +1,16 @@ +import type { IconProps } from './icon-base'; +import { IconBase } from './icon-base'; + +export const IconLoader = (props: IconProps) => ( + + + +); + +IconLoader.displayName = 'IconLoader'; diff --git a/packages/preview-server/src/components/toolbar.tsx b/packages/preview-server/src/components/toolbar.tsx index 3cd7e1d1fc..9d6c5971df 100644 --- a/packages/preview-server/src/components/toolbar.tsx +++ b/packages/preview-server/src/components/toolbar.tsx @@ -1,4 +1,5 @@ 'use client'; + import * as Tabs from '@radix-ui/react-tabs'; import { LayoutGroup } from 'framer-motion'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; @@ -6,13 +7,16 @@ 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, @@ -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; @@ -57,6 +66,8 @@ const ToolbarInner = ({ const searchParams = useSearchParams(); const router = useRouter(); + const { hasSetupResendIntegration } = useToolbarContext(); + const { activeTab, toggled } = useToolbarState(); const setActivePanelValue = (newValue: ToolbarTabValue | undefined) => { @@ -155,6 +166,11 @@ const ToolbarInner = ({ Spam + + + Resend + +
- {isBuilding ? null : ( + {isBuilding || activeTab === 'resend' ? null : ( { if (activeTab === undefined) { setActivePanelValue('linter'); @@ -192,7 +210,7 @@ const ToolbarInner = ({ size={24} className={cn({ 'opacity-60 animate-spin-fast': - lintLoading || spamLoading, + lintLoading || spamLoading || compatibilityLoading, })} /> @@ -261,6 +279,22 @@ const ToolbarInner = ({ )} + + {hasSetupResendIntegration ? ( + + ) : ( + + Connect to Resend + + Run email resend setup re_xxxxxx{' '} + to connect your Resend account and refresh. + + + )} +
@@ -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; @@ -348,4 +382,4 @@ export const Toolbar = ({ serverCompatibilityResults={serverCompatibilityResults} /> ); -}; +} diff --git a/packages/preview-server/src/components/toolbar/resend.tsx b/packages/preview-server/src/components/toolbar/resend.tsx new file mode 100644 index 0000000000..de78f3599c --- /dev/null +++ b/packages/preview-server/src/components/toolbar/resend.tsx @@ -0,0 +1,225 @@ +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { exportSingleTemplate } from '../../actions/export-single-template'; +import { getEmailPathFromSlug } from '../../actions/get-email-path-from-slug'; +import { renderEmailByPath } from '../../actions/render-email-by-path'; +import { useEmails } from '../../contexts/emails'; +import type { EmailsDirectory } from '../../utils/get-emails-directory-metadata'; +import { sleep } from '../../utils/sleep'; +import { Button } from '../button'; +import { IconCloudAlert } from '../icons/icon-cloud-alert'; +import { IconCloudCheck } from '../icons/icon-cloud-check'; +import { IconLoader } from '../icons/icon-loader'; +import { Results } from './results'; + +export interface ResendStatus { + hasApiKey: boolean; + error: string | null; +} + +interface ResendItem { + status: 'uploading' | 'failed' | 'succeeded'; + name: string; + id?: string; +} + +type ResendIntegrationProps = { + emailSlug: string; + htmlMarkup: string; +}; + +export function ResendIntegration({ + emailSlug, + htmlMarkup, +}: ResendIntegrationProps) { + const { emailsDirectoryMetadata } = useEmails(); + const [items, setItems] = useState([]); + const [isBulkProcessing, setIsBulkProcessing] = useState(false); + + const { execute: exportSingle, isPending: isExportSinglePending } = useAction( + exportSingleTemplate, + { + onSuccess: ({ data }) => setItems([data]), + }, + ); + + const { executeAsync: exportSingleAsync } = useAction(exportSingleTemplate); + + const getAllDirectories = (metadata: EmailsDirectory): EmailsDirectory[] => { + const result = [metadata]; + for (const subDir of metadata.subDirectories) { + result.push(...getAllDirectories(subDir)); + } + return result; + }; + + const loading = isExportSinglePending || isBulkProcessing; + + if (items.length === 0 && !loading) { + return ( +
+

+ Upload to Resend +

+

+ Import your email using the Templates API. +

+
+ + +
+
+ ); + } + + return ( + + {items.map((item, index) => ( + + + {item.status === 'uploading' && ( + + + {item.name} + + )} + {item.status === 'failed' && ( + + + {item.name} + + )} + {item.status === 'succeeded' && ( + + + {item.name} + + )} + + + {item.status === 'uploading' + ? 'Uploading...' + : item.status === 'failed' + ? 'Failed to upload. Try again.' + : 'Template uploaded successfully.'} + + + {item.status === 'succeeded' && ( + + Open in Resend ↗ + + )} + + + ))} + + ); +} diff --git a/packages/preview-server/src/components/topbar/view-size-controls.tsx b/packages/preview-server/src/components/topbar/view-size-controls.tsx index e620e85901..cd647aa34a 100644 --- a/packages/preview-server/src/components/topbar/view-size-controls.tsx +++ b/packages/preview-server/src/components/topbar/view-size-controls.tsx @@ -80,7 +80,7 @@ export const ViewSizeControls = ({ return (
{VIEW_PRESETS.map((preset) => ( - +