From 46a27740926d95b7ecb5619fe7d74407c17ba6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Melo?= Date: Mon, 3 Nov 2025 14:30:28 -0300 Subject: [PATCH 01/32] feat: initial templates api integration --- benchmarks/preview-server/src/utils/sleep.ts | 3 + packages/preview-server/next-env.d.ts | 2 +- packages/preview-server/package.json | 7 +- .../preview-server/scripts/setup-resend.mts | 57 +++++ .../src/actions/bulk-import-templates.ts | 115 +++++++++ .../preview-server/src/actions/safe-action.ts | 15 ++ .../src/app/api/has-resend-api-key/route.ts | 21 ++ .../src/components/icons/icon-cloud-alert.tsx | 23 ++ .../src/components/icons/icon-cloud-check.tsx | 22 ++ .../src/components/icons/icon-loader.tsx | 18 ++ .../preview-server/src/components/toolbar.tsx | 62 ++++- .../src/components/toolbar/resend.tsx | 223 ++++++++++++++++++ packages/preview-server/src/lib/resend.ts | 3 + packages/preview-server/src/utils/sleep.ts | 3 + pnpm-lock.yaml | 97 +++++++- 15 files changed, 661 insertions(+), 10 deletions(-) create mode 100644 benchmarks/preview-server/src/utils/sleep.ts create mode 100644 packages/preview-server/scripts/setup-resend.mts create mode 100644 packages/preview-server/src/actions/bulk-import-templates.ts create mode 100644 packages/preview-server/src/actions/safe-action.ts create mode 100644 packages/preview-server/src/app/api/has-resend-api-key/route.ts create mode 100644 packages/preview-server/src/components/icons/icon-cloud-alert.tsx create mode 100644 packages/preview-server/src/components/icons/icon-cloud-check.tsx create mode 100644 packages/preview-server/src/components/icons/icon-loader.tsx create mode 100644 packages/preview-server/src/components/toolbar/resend.tsx create mode 100644 packages/preview-server/src/lib/resend.ts create mode 100644 packages/preview-server/src/utils/sleep.ts 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/next-env.d.ts b/packages/preview-server/next-env.d.ts index 9edff1c7ca..c4b7818fbb 100644 --- a/packages/preview-server/next-env.d.ts +++ b/packages/preview-server/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/preview-server/package.json b/packages/preview-server/package.json index fcb6cf0385..443713a1e2 100644 --- a/packages/preview-server/package.json +++ b/packages/preview-server/package.json @@ -8,6 +8,7 @@ "clean": "rm -rf dist", "dev": "tsx ./scripts/dev.mts", "dev:seed": "tsx ./scripts/seed.mts", + "setup:resend": "tsx ./scripts/setup-resend.mts", "test": "vitest run", "test:watch": "vitest" }, @@ -41,12 +42,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 +59,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:*", @@ -83,4 +86,4 @@ "publishConfig": { "access": "public" } -} +} \ No newline at end of file diff --git a/packages/preview-server/scripts/setup-resend.mts b/packages/preview-server/scripts/setup-resend.mts new file mode 100644 index 0000000000..fcafe92336 --- /dev/null +++ b/packages/preview-server/scripts/setup-resend.mts @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import logSymbols from 'log-symbols'; + +const setupResend = async () => { + const apiKey = process.argv[2]; + const cwd = process.cwd(); + const envPath = path.join(cwd, '.env'); + const envContent = `RESEND_API_KEY=${apiKey || ''}\n`; + + try { + if (fs.existsSync(envPath)) { + const currentContent = fs.readFileSync(envPath, 'utf-8'); + + if (currentContent.includes('RESEND_API_KEY=')) { + const newContent = currentContent.replace( + /RESEND_API_KEY=.*/, + `RESEND_API_KEY=${apiKey || ''}`, + ); + fs.writeFileSync(envPath, newContent); + console.log(logSymbols.success, 'Updated RESEND_API_KEY in .env file'); + } else { + fs.appendFileSync(envPath, `\n${envContent}`); + console.log( + logSymbols.success, + 'Added RESEND_API_KEY to existing .env file', + ); + } + } else { + fs.writeFileSync(envPath, envContent); + console.log(logSymbols.success, 'Created .env file with RESEND_API_KEY'); + } + + if (!apiKey) { + console.log( + '\nšŸ”‘ Add your Resend API key by updating the RESEND_API_KEY value in .env', + ); + console.log('šŸ’” You can also run: pnpm setup:resend '); + } + + const now = new Date(); + fs.utimesSync(envPath, now, now); + console.log( + logSymbols.success, + 'Server will automatically refresh with new API key', + ); + } catch (error) { + console.error( + logSymbols.error, + 'Error creating or updating .env file:', + error, + ); + process.exit(1); + } +}; + +setupResend(); \ No newline at end of file diff --git a/packages/preview-server/src/actions/bulk-import-templates.ts b/packages/preview-server/src/actions/bulk-import-templates.ts new file mode 100644 index 0000000000..776a3e827e --- /dev/null +++ b/packages/preview-server/src/actions/bulk-import-templates.ts @@ -0,0 +1,115 @@ +'use server'; + +import path from 'node:path'; +import { z } from 'zod'; +import { emailsDirectoryAbsolutePath } from '../app/env'; +import { resend } from '../lib/resend'; +import { sleep } from '../utils/sleep'; +import { getEmailsDirectoryMetadataAction } from './get-emails-directory-metadata-action'; +import { renderEmailByPath } from './render-email-by-path'; +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 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, + }, + ]; + }); + +export const bulkExportTemplates = baseActionClient + .metadata({ actionName: 'bulkExportTemplates' }) + .action(async () => { + try { + const emailsDirectory = await getEmailsDirectoryMetadataAction( + path.dirname(`${emailsDirectoryAbsolutePath}/emails`), + true, + ); + + if (!emailsDirectory) { + throw new Error('No emails directory found'); + } + + const results: { + name: string; + status: 'succeeded' | 'failed'; + id?: string; + error?: string; + }[] = []; + + const allDirectories = [ + emailsDirectory, + ...emailsDirectory.subDirectories, + ]; + + for (const directory of allDirectories) { + for (const filename of directory.emailFilenames) { + try { + const templateName = path.parse(filename).name; + const renderingResult = await renderEmailByPath( + path.join(directory.absolutePath, filename), + ); + + if ('error' in renderingResult) { + throw new Error(renderingResult.error.message); + } + + const resendResponse = await resend.templates.create({ + name: templateName, + html: renderingResult.markup, + }); + + if (resendResponse.error) { + results.push({ + name: templateName, + status: 'failed' as const, + error: resendResponse.error.message, + }); + } else { + results.push({ + name: templateName, + status: 'succeeded' as const, + id: resendResponse.data.id, + }); + } + + await sleep(200); + } catch (error) { + results.push({ + name: filename, + status: 'failed' as const, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + } + + return results; + } catch (error) { + throw error instanceof Error + ? error + : new Error('Failed to bulk import templates'); + } + }); 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/api/has-resend-api-key/route.ts b/packages/preview-server/src/app/api/has-resend-api-key/route.ts new file mode 100644 index 0000000000..1de506e4ba --- /dev/null +++ b/packages/preview-server/src/app/api/has-resend-api-key/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + try { + const apiKey = process.env.RESEND_API_KEY || ''; + + return NextResponse.json( + { + ok: !!apiKey, + error: null, + }, + { status: 200 }, + ); + } catch (error) { + console.error('Error checking Resend API key', error); + return NextResponse.json( + { ok: false, error: 'Internal Server Error' }, + { status: 500 }, + ); + } +} 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..a6bbebb845 --- /dev/null +++ b/packages/preview-server/src/components/icons/icon-cloud-alert.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import type { IconElement, IconProps } from './icon-base'; +import { IconBase } from './icon-base'; + +export const IconCloudAlert = React.forwardRef< + IconElement, + Readonly +>(({ ...props }, forwardedRef) => ( + + + + + +)); + +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..e264427d4b --- /dev/null +++ b/packages/preview-server/src/components/icons/icon-cloud-check.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import type { IconElement, IconProps } from './icon-base'; +import { IconBase } from './icon-base'; + +export const IconCloudCheck = React.forwardRef< + IconElement, + Readonly +>(({ ...props }, forwardedRef) => ( + + + + +)); + +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..2c4bee708d --- /dev/null +++ b/packages/preview-server/src/components/icons/icon-loader.tsx @@ -0,0 +1,18 @@ +import { forwardRef } from 'react'; +import type { IconElement, IconProps } from './icon-base'; +import { IconBase } from './icon-base'; + +export const IconLoader = forwardRef((props, ref) => ( + + + +)); + +IconLoader.displayName = 'IconLoader'; diff --git a/packages/preview-server/src/components/toolbar.tsx b/packages/preview-server/src/components/toolbar.tsx index b35e2a5a0f..cccf03fc89 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'; @@ -7,12 +8,14 @@ import type { CompatibilityCheckingResult } from '../actions/email-validation/ch import { isBuilding } from '../app/env'; import { usePreviewContext } from '../contexts/preview'; 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 { Resend, useResend } from './toolbar/resend'; import { SpamAssassin, type SpamCheckingResult, @@ -21,10 +24,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; @@ -46,12 +54,14 @@ const ToolbarInner = ({ plainText, emailPath, emailSlug, + htmlMarkup, }: ToolbarProps & { prettyMarkup: string; reactMarkup: string; plainText: string; emailSlug: string; emailPath: string; + htmlMarkup: string; }) => { const pathname = usePathname(); const searchParams = useSearchParams(); @@ -103,6 +113,9 @@ const ToolbarInner = ({ initialResults: serverCompatibilityResults ?? cachedCompatibilityResults, }); + const [resendStatus, { load: loadResend, loading: resendLoading }] = + useResend(); + if (!isBuilding) { // biome-ignore lint/correctness/useHookAtTopLevel: This is fine since isBuilding does not change at runtime // biome-ignore lint/correctness/useExhaustiveDependencies: Setters don't need dependencies @@ -116,6 +129,8 @@ const ToolbarInner = ({ const compatibilityCheckingResults = await loadCompatibility(); setCachedCompatibilityResults(compatibilityCheckingResults); + + await loadResend(); })(); }, []); } @@ -156,6 +171,11 @@ const ToolbarInner = ({ Spam + + + Resend + +
@@ -175,7 +197,12 @@ const ToolbarInner = ({ {isBuilding ? null : ( { if (activeTab === undefined) { setActivePanelValue('linter'); @@ -186,6 +213,8 @@ const ToolbarInner = ({ await loadLinting(); } else if (activeTab === 'compatibility') { await loadCompatibility(); + } else if (activeTab === 'resend') { + await loadResend(); } }} > @@ -193,7 +222,10 @@ const ToolbarInner = ({ size={24} className={cn({ 'opacity-60 animate-spin-fast': - lintLoading || spamLoading, + lintLoading || + spamLoading || + compatibilityLoading || + resendLoading, })} /> @@ -262,6 +294,22 @@ const ToolbarInner = ({ )} + + {resendLoading ? ( + + ) : resendStatus?.hasApiKey ? ( + + ) : ( + + Connect to Resend + + Run{' '} + pnpm setup:resend YOUR_API_KEY to + connect your Resend account. + + + )} +
@@ -335,7 +383,12 @@ export const Toolbar = ({ const { emailPath, emailSlug, renderedEmailMetadata } = usePreviewContext(); if (renderedEmailMetadata === undefined) return null; - const { prettyMarkup, plainText, reactMarkup } = renderedEmailMetadata; + const { + prettyMarkup, + plainText, + reactMarkup, + markup: htmlMarkup, + } = renderedEmailMetadata; return ( { + const [status, setStatus] = useState(initialStatus); + const [loading, setLoading] = useState(false); + const isLoadingRef = useRef(false); + + const load = async () => { + if (isLoadingRef.current) return; + isLoadingRef.current = true; + setLoading(true); + + try { + const response = await fetch('/api/has-resend-api-key', { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + const body = (await response.json().catch(() => ({}))) as + | { ok: boolean; error: string | null } + | undefined; + + if (response.ok && body?.ok) { + const result = { hasApiKey: true, error: null }; + setStatus(result); + return result; + } + + const result = { + hasApiKey: false, + error: body?.error ?? 'Unknown error', + }; + setStatus(result); + return result; + } catch (exception) { + console.error('Error checking Resend API key', exception); + } finally { + setLoading(false); + isLoadingRef.current = false; + } + }; + + return [status, { loading, load }] as const; +}; + +interface ResendItem { + status: 'uploading' | 'failed' | 'succeeded'; + name: string; + id?: string; +} + +export const Resend = ({ + emailSlug, + htmlMarkup, +}: { + emailSlug: string; + htmlMarkup: string; +}) => { + const { emailsDirectoryMetadata } = useEmails(); + const [items, setItems] = useState([]); + + const { execute: exportSingle, isPending: isExportSinglePending } = useAction( + exportSingleTemplate, + { + onSuccess: ({ data }) => { + setItems(data); + }, + }, + ); + + const { execute: exportBulk, isPending: isExportBulkPending } = useAction( + bulkExportTemplates, + { + onSuccess: ({ data }) => { + setItems(data); + }, + }, + ); + + const loading = isExportSinglePending || isExportBulkPending; + + 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 ↗ + + )} + + + ))} + + ); +}; + +const SuccessWrapper = ({ children }: { children: React.ReactNode }) => { + return ( +
+ {children} +
+ ); +}; + +const SuccessTitle = ({ children }) => { + return ( +

{children}

+ ); +}; + +const SuccessDescription = ({ children }) => { + return ( +

+ {children} +

+ ); +}; diff --git a/packages/preview-server/src/lib/resend.ts b/packages/preview-server/src/lib/resend.ts new file mode 100644 index 0000000000..4b9d870d84 --- /dev/null +++ b/packages/preview-server/src/lib/resend.ts @@ -0,0 +1,3 @@ +import { Resend } from 'resend'; + +export const resend = new Resend(process.env.RESEND_API_KEY); diff --git a/packages/preview-server/src/utils/sleep.ts b/packages/preview-server/src/utils/sleep.ts new file mode 100644 index 0000000000..0d7f188e17 --- /dev/null +++ b/packages/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/pnpm-lock.yaml b/pnpm-lock.yaml index 1fda3058aa..5684ac4452 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -710,6 +710,9 @@ importers: next: specifier: 16.0.1 version: 16.0.1(@babel/core@7.26.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next-safe-action: + specifier: 8.0.11 + version: 8.0.11(next@16.0.1(@babel/core@7.26.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) node-html-parser: specifier: 7.0.1 version: 7.0.1 @@ -728,6 +731,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) + resend: + specifier: 6.4.0 + version: 6.4.0(@react-email/render@1.0.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) sharp: specifier: 0.34.4 version: 0.34.4 @@ -756,8 +762,8 @@ importers: specifier: 10.0.4 version: 10.0.4(react@19.0.0) zod: - specifier: 3.24.3 - version: 3.24.3 + specifier: 4.1.12 + version: 4.1.12 devDependencies: '@react-email/components': specifier: workspace:* @@ -3957,6 +3963,9 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@stoplight/better-ajv-errors@1.0.3': resolution: {integrity: sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA==} engines: {node: ^12.20 || >= 14.13} @@ -5484,6 +5493,9 @@ packages: es-toolkit@1.41.0: resolution: {integrity: sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA==} + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -5684,6 +5696,9 @@ packages: fast-memoize@2.5.2: resolution: {integrity: sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.0.3: resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==} @@ -7065,6 +7080,14 @@ packages: react: ^19.0.0 react-dom: ^19.0.0 + next-safe-action@8.0.11: + resolution: {integrity: sha512-gqJLmnQLAoFCq1kRBopN46New+vx1n9J9Y/qDQLXpv/VqU40AWxDakvshwwnWAt8R0kLvlakNYNLX5PqlXWSMg==} + engines: {node: '>=18.17'} + peerDependencies: + next: '>= 14.0.0' + react: ^19.0.0 + react-dom: ^19.0.0 + next@14.2.3: resolution: {integrity: sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==} engines: {node: '>=18.17.0'} @@ -7578,7 +7601,7 @@ packages: puppeteer@22.15.0: resolution: {integrity: sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q==} engines: {node: '>=18'} - deprecated: < 24.15.0 is no longer supported + deprecated: < 24.10.2 is no longer supported hasBin: true qs@6.13.0: @@ -7599,6 +7622,9 @@ packages: resolution: {integrity: sha512-MWkCOVIcJP9QSKU52Ngow6bsAWAPlPK2MludXvcrS2bGZSl+T1qX9MZvRIkqUIkGLJquMJHWfsT6eRqUpp4aWg==} engines: {node: '>=18'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -7830,10 +7856,22 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resend@4.3.0: resolution: {integrity: sha512-4OBHeusMVSl0vcba2J3AaGzdZ1SXAAhX/Wkcwobe16AHmlW9h3li8wG62Fhvlsc61e+wlQoxcwJZP6WrBTbghQ==} engines: {node: '>=18'} + resend@6.4.0: + resolution: {integrity: sha512-CTr4ix4RI5M/ucL58Wqr+LE8eI4JHtJEFaBAx6yUVNOI3eaPVtJjpNL0G/BdRSWMbwv6CtpprVOY8Xvpp6UJlA==} + engines: {node: '>=18'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -8357,6 +8395,9 @@ packages: peerDependencies: react: ^19.0.0 + svix@1.76.1: + resolution: {integrity: sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -8802,6 +8843,9 @@ packages: resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + urlpattern-polyfill@10.0.0: resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==} @@ -8847,6 +8891,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -9194,6 +9242,9 @@ packages: zod@3.24.3: resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -12400,6 +12451,8 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@stablelib/base64@1.0.1': {} + '@stoplight/better-ajv-errors@1.0.3(ajv@8.17.1)': dependencies: ajv: 8.17.1 @@ -14111,6 +14164,8 @@ snapshots: es-toolkit@1.41.0: {} + es6-promise@4.2.8: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -14447,6 +14502,8 @@ snapshots: fast-memoize@2.5.2: {} + fast-sha256@1.3.0: {} + fast-uri@3.0.3: {} fastq@1.17.1: @@ -16203,6 +16260,12 @@ snapshots: - acorn - supports-color + next-safe-action@8.0.11(next@16.0.1(@babel/core@7.26.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + next: 16.0.1(@babel/core@7.26.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + next@14.2.3(@babel/core@7.24.5)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 14.2.3 @@ -16787,6 +16850,8 @@ snapshots: filter-obj: 5.1.0 split-on-first: 3.0.0 + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -17188,6 +17253,8 @@ snapshots: require-from-string@2.0.2: {} + requires-port@1.0.0: {} + resend@4.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@react-email/render': 1.0.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -17195,6 +17262,12 @@ snapshots: - react - react-dom + resend@6.4.0(@react-email/render@1.0.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)): + dependencies: + svix: 1.76.1 + optionalDependencies: + '@react-email/render': 1.0.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -17881,6 +17954,15 @@ snapshots: dependencies: react: 19.0.0 + svix@1.76.1: + dependencies: + '@stablelib/base64': 1.0.1 + '@types/node': 22.14.1 + es6-promise: 4.2.8 + fast-sha256: 1.3.0 + url-parse: 1.5.10 + uuid: 10.0.0 + symbol-tree@3.2.4: {} tailwind-merge@2.2.0: @@ -18459,6 +18541,11 @@ snapshots: url-join@5.0.0: {} + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + urlpattern-polyfill@10.0.0: {} use-callback-ref@1.3.3(@types/react@19.0.1)(react@19.0.0): @@ -18505,6 +18592,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@10.0.0: {} + uuid@11.1.0: {} validate-npm-package-name@5.0.1: {} @@ -18948,6 +19037,8 @@ snapshots: zod@3.24.3: {} + zod@4.1.12: {} + zustand@4.5.7(@types/react@19.0.1)(immer@9.0.21)(react@19.0.0): dependencies: use-sync-external-store: 1.6.0(react@19.0.0) From aa75ef670b8363f2bbe45fd5f882a58ee466979d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Melo?= Date: Mon, 3 Nov 2025 14:42:00 -0300 Subject: [PATCH 02/32] fix: ts error due to the zod upgrade --- packages/preview-server/src/utils/get-email-component.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/preview-server/src/utils/get-email-component.ts b/packages/preview-server/src/utils/get-email-component.ts index 61ee46bdad..5f0a012237 100644 --- a/packages/preview-server/src/utils/get-email-component.ts +++ b/packages/preview-server/src/utils/get-email-component.ts @@ -135,15 +135,17 @@ export const getEmailComponent = async ( const { data: componentModule } = parseResult; + const typedRender = componentModule.render as typeof render; + return { emailComponent: componentModule.default as EmailComponent, - renderWithReferences: (async (...args) => { + renderWithReferences: (async (...args: Parameters) => { context.shouldIncludeSourceReference = true; - const renderingResult = await componentModule.render(...args); + const renderingResult = await typedRender(...args); context.shouldIncludeSourceReference = false; return renderingResult; }) as typeof render, - render: componentModule.render as typeof render, + render: typedRender, createElement: componentModule.reactEmailCreateReactElement as typeof React.createElement, From b2960f3aaa3c82f94c17ff97d17fedb2e002ba10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Melo?= Date: Mon, 3 Nov 2025 14:44:30 -0300 Subject: [PATCH 03/32] fix: add key to element within a loop --- .../preview-server/src/components/topbar/view-size-controls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) => ( - +
- + ); } @@ -222,24 +222,3 @@ export function ResendIntegration({ ); } -const SuccessWrapper = ({ children }: { children: React.ReactNode }) => { - return ( -
- {children} -
- ); -}; - -const SuccessTitle = ({ children }) => { - return ( -

{children}

- ); -}; - -const SuccessDescription = ({ children }) => { - return ( -

- {children} -

- ); -}; From dcd97ea846de10838ddffee5a7e2e875272993b1 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Thu, 6 Nov 2025 10:54:46 -0300 Subject: [PATCH 32/32] lint --- .../src/components/toolbar/resend.tsx | 13 +++++++------ packages/preview-server/src/contexts/toolbar.tsx | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/preview-server/src/components/toolbar/resend.tsx b/packages/preview-server/src/components/toolbar/resend.tsx index bff02c2574..de78f3599c 100644 --- a/packages/preview-server/src/components/toolbar/resend.tsx +++ b/packages/preview-server/src/components/toolbar/resend.tsx @@ -58,7 +58,9 @@ export function ResendIntegration({ if (items.length === 0 && !loading) { return (
-

Upload to Resend

+

+ Upload to Resend +

Import your email using the Templates API.

@@ -133,10 +135,10 @@ export function ResendIntegration({ prevItems.map((item, index) => index === i ? { - ...item, - status: 'succeeded', - id: exportResult.data!.id, - } + ...item, + status: 'succeeded', + id: exportResult.data!.id, + } : item, ), ); @@ -221,4 +223,3 @@ export function ResendIntegration({ ); } - diff --git a/packages/preview-server/src/contexts/toolbar.tsx b/packages/preview-server/src/contexts/toolbar.tsx index d13992ea88..af54e4a780 100644 --- a/packages/preview-server/src/contexts/toolbar.tsx +++ b/packages/preview-server/src/contexts/toolbar.tsx @@ -4,8 +4,8 @@ import { createContext, use } from 'react'; const ToolbarContext = createContext< | { - hasSetupResendIntegration: boolean; - } + hasSetupResendIntegration: boolean; + } | undefined >(undefined);