From fc016487fdbaea210d1454b836963e8ed748f1bf Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Mon, 20 Apr 2026 03:28:39 +0400 Subject: [PATCH 01/12] feat: email config --- packages/react-email/package.json | 2 +- .../react-email/src/cli/commands/build.ts | 8 +++- .../react-email/src/cli/commands/export.ts | 11 +++++- .../get-env-variables-for-preview-app.ts | 2 + .../src/config/get-email-config-path.ts | 20 ++++++++++ .../src/config/get-email-config.ts | 37 +++++++++++++++++++ packages/react-email/src/config/index.ts | 11 ++++++ packages/react-email/src/index.ts | 1 + packages/ui/next.config.mjs | 2 +- packages/ui/package.json | 5 ++- packages/ui/src/app/env.ts | 4 ++ packages/ui/src/utils/get-email-component.ts | 8 +++- pnpm-lock.yaml | 14 +++++-- pnpm-workspace.yaml | 1 + 14 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 packages/react-email/src/config/get-email-config-path.ts create mode 100644 packages/react-email/src/config/get-email-config.ts create mode 100644 packages/react-email/src/config/index.ts diff --git a/packages/react-email/package.json b/packages/react-email/package.json index d4eeee6e60..1aeb0d976e 100644 --- a/packages/react-email/package.json +++ b/packages/react-email/package.json @@ -49,7 +49,7 @@ "debounce": "^2.0.0", "esbuild": "catalog:", "glob": "^13.0.6", - "jiti": "2.4.2", + "jiti": "catalog:", "log-symbols": "catalog:", "marked": "^15.0.12", "mime-types": "^3.0.0", diff --git a/packages/react-email/src/cli/commands/build.ts b/packages/react-email/src/cli/commands/build.ts index 709477fc53..77fd0d5bee 100644 --- a/packages/react-email/src/cli/commands/build.ts +++ b/packages/react-email/src/cli/commands/build.ts @@ -5,6 +5,7 @@ import { getPackages } from '@manypkg/get-packages'; import logSymbols from 'log-symbols'; import { installDependencies, type PackageManagerName, runScript } from 'nypm'; import ora from 'ora'; +import { getEmailConfigPath } from '../../config/get-email-config-path.js'; import { type EmailsDirectory, getEmailsDirectoryMetadata, @@ -29,11 +30,13 @@ const setNextEnvironmentVariablesForBuild = async ( if (isInReactEmailMonorepo) { rootDir = `'${await getPackages(usersProjectLocation).then((p) => p.rootDir.replaceAll('\\', '/'))}'`; } + const emailConfigPath = getEmailConfigPath(usersProjectLocation); const nextConfigContents = ` import path from 'path'; const emailsDirRelativePath = path.normalize('${emailsDirRelativePath}'); const userProjectLocation = '${process.cwd().replaceAll('\\', '/')}'; const previewServerLocation = '${builtPreviewAppPath.replaceAll('\\', '/')}'; +const emailConfigPath = ${emailConfigPath ? `'${emailConfigPath.replaceAll('\\', '/')}'` : 'undefined'}; const rootDir = ${rootDir}; /** @type {import('next').NextConfig} */ const nextConfig = { @@ -42,13 +45,14 @@ const nextConfig = { REACT_EMAIL_INTERNAL_EMAILS_DIR_RELATIVE_PATH: emailsDirRelativePath, REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH: path.resolve(userProjectLocation, emailsDirRelativePath), REACT_EMAIL_INTERNAL_PREVIEW_SERVER_LOCATION: previewServerLocation, - REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION: userProjectLocation + REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION: userProjectLocation, + REACT_EMAIL_INTERNAL_EMAIL_CONFIG_PATH: emailConfigPath }, turbopack: { root: rootDir, }, outputFileTracingRoot: rootDir, - serverExternalPackages: ['esbuild'], + serverExternalPackages: ['esbuild', 'jiti'], typescript: { ignoreBuildErrors: true }, diff --git a/packages/react-email/src/cli/commands/export.ts b/packages/react-email/src/cli/commands/export.ts index 260534c780..a1b287fc54 100644 --- a/packages/react-email/src/cli/commands/export.ts +++ b/packages/react-email/src/cli/commands/export.ts @@ -9,6 +9,8 @@ import logSymbols from 'log-symbols'; import normalize from 'normalize-path'; import ora, { type Ora } from 'ora'; import type React from 'react'; +import { getEmailConfig } from '../../config/get-email-config.js'; +import { getEmailConfigPath } from '../../config/get-email-config-path.js'; import { renderingUtilitiesExporter } from '../utils/esbuild/renderring-utilities-exporter.js'; import { type EmailsDirectory, @@ -78,6 +80,10 @@ export const exportTemplates = async ( const allTemplates = getEmailTemplatesFromDirectory(emailsDirectoryMetadata); try { + const emailConfigPath = getEmailConfigPath(process.cwd()); + const emailConfig = await getEmailConfig(emailConfigPath); + const emailConfigPlugins = emailConfig.esbuild?.plugins ?? []; + await build({ bundle: true, entryPoints: allTemplates, @@ -89,7 +95,10 @@ export const exportTemplates = async ( outExtension: { '.js': '.cjs' }, outdir: pathToWhereEmailMarkupShouldBeDumped, platform: 'node', - plugins: [renderingUtilitiesExporter(allTemplates)], + plugins: [ + renderingUtilitiesExporter(allTemplates), + ...emailConfigPlugins, + ], write: true, }); } catch (exception) { diff --git a/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.ts b/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.ts index 6b60cb279e..10a35a43e5 100644 --- a/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.ts +++ b/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { getEmailConfigPath } from '../../../config/get-email-config-path.js'; export const getEnvVariablesForPreviewApp = ( relativePathToEmailsDirectory: string, @@ -15,6 +16,7 @@ export const getEnvVariablesForPreviewApp = ( ), REACT_EMAIL_INTERNAL_PREVIEW_SERVER_LOCATION: previewServerLocation, REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION: cwd, + REACT_EMAIL_INTERNAL_EMAIL_CONFIG_PATH: getEmailConfigPath(cwd), REACT_EMAIL_INTERNAL_RESEND_API_KEY: resendApiKey, } as const; }; diff --git a/packages/react-email/src/config/get-email-config-path.ts b/packages/react-email/src/config/get-email-config-path.ts new file mode 100644 index 0000000000..80278478cf --- /dev/null +++ b/packages/react-email/src/config/get-email-config-path.ts @@ -0,0 +1,20 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const supportedEmailConfigFilenames = [ + 'email.config.ts', + 'email.config.mts', + 'email.config.js', + 'email.config.mjs', + 'email.config.cjs', +]; + +export const getEmailConfigPath = (userProjectLocation: string) => { + for (const filename of supportedEmailConfigFilenames) { + const emailConfigPath = path.join(userProjectLocation, filename); + + if (fs.existsSync(emailConfigPath)) { + return emailConfigPath; + } + } +}; diff --git a/packages/react-email/src/config/get-email-config.ts b/packages/react-email/src/config/get-email-config.ts new file mode 100644 index 0000000000..5f03e90613 --- /dev/null +++ b/packages/react-email/src/config/get-email-config.ts @@ -0,0 +1,37 @@ +import { createJiti } from 'jiti'; +import type { EmailConfig } from './index.js'; + +export const getEmailConfig = async ( + emailConfigPath?: string, +): Promise => { + if (!emailConfigPath) { + return {}; + } + + let emailConfig: EmailConfig; + try { + const jiti = createJiti(emailConfigPath); + emailConfig = await jiti.import(emailConfigPath, { default: true }); + } catch (exception) { + throw new Error( + `Failed to load React Email config at ${emailConfigPath}.`, + { cause: exception }, + ); + } + + if (!emailConfig || typeof emailConfig !== 'object') { + throw new Error( + `Expected React Email config at ${emailConfigPath} to export an object.`, + ); + } + + const plugins = emailConfig.esbuild?.plugins; + + if (typeof plugins !== 'undefined' && !Array.isArray(plugins)) { + throw new Error( + `Expected "esbuild.plugins" in React Email config at ${emailConfigPath} to be an array.`, + ); + } + + return emailConfig; +}; diff --git a/packages/react-email/src/config/index.ts b/packages/react-email/src/config/index.ts new file mode 100644 index 0000000000..ddfaa91bbf --- /dev/null +++ b/packages/react-email/src/config/index.ts @@ -0,0 +1,11 @@ +import type { Plugin } from 'esbuild'; + +export interface EmailConfig { + esbuild?: { + plugins?: Plugin[]; + }; +} + +export const defineConfig = (config: T) => config; + +export * from './get-email-config.js'; diff --git a/packages/react-email/src/index.ts b/packages/react-email/src/index.ts index 671c179e78..e021640572 100644 --- a/packages/react-email/src/index.ts +++ b/packages/react-email/src/index.ts @@ -1,2 +1,3 @@ export * from '@react-email/render'; export * from './components/index.js'; +export * from './config/index.js'; diff --git a/packages/ui/next.config.mjs b/packages/ui/next.config.mjs index 91cc898c4d..2f10db4cb9 100644 --- a/packages/ui/next.config.mjs +++ b/packages/ui/next.config.mjs @@ -2,7 +2,7 @@ * @type {import('next').NextConfig} */ const nextConfig = { - serverExternalPackages: ['esbuild'], + serverExternalPackages: ['esbuild', 'jiti'], // Noticed an issue with typescript transpilation when going from Next 14.1.1 to 14.1.2 // and I narrowed that down into this PR https://github.com/vercel/next.js/pull/62005 // diff --git a/packages/ui/package.json b/packages/ui/package.json index ae4537db72..aecb4e8db8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -27,7 +27,9 @@ ], "dependencies": { "esbuild": "catalog:", - "next": "catalog:" + "jiti": "catalog:", + "next": "catalog:", + "react-email": "workspace:*" }, "devDependencies": { "@babel/core": "7.29.0", @@ -43,7 +45,6 @@ "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-tooltip": "catalog:", - "react-email": "workspace:*", "@tailwindcss/postcss": "catalog:", "@types/babel__core": "catalog:", "@types/babel__traverse": "catalog:", diff --git a/packages/ui/src/app/env.ts b/packages/ui/src/app/env.ts index b0b297aea8..b96f3fc024 100644 --- a/packages/ui/src/app/env.ts +++ b/packages/ui/src/app/env.ts @@ -2,6 +2,10 @@ export const userProjectLocation = process.env.REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION!; +/** ONLY ACCESSIBLE ON THE SERVER */ +export const emailConfigPath = + process.env.REACT_EMAIL_INTERNAL_EMAIL_CONFIG_PATH; + /** ONLY ACCESSIBLE ON THE SERVER */ export const previewServerLocation = process.env.REACT_EMAIL_INTERNAL_PREVIEW_SERVER_LOCATION!; diff --git a/packages/ui/src/utils/get-email-component.ts b/packages/ui/src/utils/get-email-component.ts index 51a67cf5eb..e670ee8a92 100644 --- a/packages/ui/src/utils/get-email-component.ts +++ b/packages/ui/src/utils/get-email-component.ts @@ -1,9 +1,10 @@ import path from 'node:path'; import { type BuildFailure, build, type OutputFile } from 'esbuild'; import type React from 'react'; -import type { render } from 'react-email'; +import { getEmailConfig, type render } from 'react-email'; import type { RawSourceMap } from 'source-map-js'; import { z } from 'zod'; +import { emailConfigPath } from '../app/env'; import { convertStackWithSourceMap } from './convert-stack-with-sourcemap'; import { renderingUtilitiesExporter } from './esbuild/renderring-utilities-exporter'; import { isErr } from './result'; @@ -57,10 +58,13 @@ export const getEmailComponent = async ( > => { let outputFiles: OutputFile[]; try { + const emailConfig = await getEmailConfig(emailConfigPath); + const emailConfigPlugins = emailConfig.esbuild?.plugins ?? []; + const buildData = await build({ bundle: true, entryPoints: [emailPath], - plugins: [renderingUtilitiesExporter([emailPath])], + plugins: [renderingUtilitiesExporter([emailPath]), ...emailConfigPlugins], platform: 'node', write: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09d44aa629..44257f1e77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ catalogs: framer-motion: specifier: 12.38.0 version: 12.38.0 + jiti: + specifier: 2.4.2 + version: 2.4.2 log-symbols: specifier: ^7.0.0 version: 7.0.1 @@ -670,7 +673,7 @@ importers: specifier: ^13.0.6 version: 13.0.6 jiti: - specifier: 2.4.2 + specifier: 'catalog:' version: 2.4.2 log-symbols: specifier: 'catalog:' @@ -792,9 +795,15 @@ importers: esbuild: specifier: 'catalog:' version: 0.28.0 + jiti: + specifier: 'catalog:' + version: 2.4.2 next: specifier: 'catalog:' version: 16.2.3(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-email: + specifier: workspace:* + version: link:../react-email devDependencies: '@babel/core': specifier: 7.29.0 @@ -910,9 +919,6 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) - react-email: - specifier: workspace:* - version: link:../react-email resend: specifier: 'catalog:' version: 6.4.0(@react-email/render@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5826680c26..7ba44f57f4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,6 +28,7 @@ catalog: commander: ^13.0.0 esbuild: 0.28.0 framer-motion: 12.38.0 + jiti: 2.4.2 log-symbols: ^7.0.0 next: 16.2.3 nypm: 0.6.5 From 0ecdc4a3d56018ee95447865b5ef1605a25fa723 Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:08:35 +0400 Subject: [PATCH 02/12] fix: minor --- packages/react-email/src/cli/commands/export.ts | 3 +-- packages/react-email/src/config/get-email-config-path.ts | 3 ++- packages/react-email/src/config/index.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react-email/src/cli/commands/export.ts b/packages/react-email/src/cli/commands/export.ts index a1b287fc54..560dfd0ba6 100644 --- a/packages/react-email/src/cli/commands/export.ts +++ b/packages/react-email/src/cli/commands/export.ts @@ -9,8 +9,7 @@ import logSymbols from 'log-symbols'; import normalize from 'normalize-path'; import ora, { type Ora } from 'ora'; import type React from 'react'; -import { getEmailConfig } from '../../config/get-email-config.js'; -import { getEmailConfigPath } from '../../config/get-email-config-path.js'; +import { getEmailConfig, getEmailConfigPath } from '../../config/index.js'; import { renderingUtilitiesExporter } from '../utils/esbuild/renderring-utilities-exporter.js'; import { type EmailsDirectory, diff --git a/packages/react-email/src/config/get-email-config-path.ts b/packages/react-email/src/config/get-email-config-path.ts index 80278478cf..50e919f4d1 100644 --- a/packages/react-email/src/config/get-email-config-path.ts +++ b/packages/react-email/src/config/get-email-config-path.ts @@ -1,9 +1,10 @@ import fs from 'node:fs'; import path from 'node:path'; -const supportedEmailConfigFilenames = [ +export const supportedEmailConfigFilenames = [ 'email.config.ts', 'email.config.mts', + 'email.config.cts', 'email.config.js', 'email.config.mjs', 'email.config.cjs', diff --git a/packages/react-email/src/config/index.ts b/packages/react-email/src/config/index.ts index ddfaa91bbf..d9dc90b905 100644 --- a/packages/react-email/src/config/index.ts +++ b/packages/react-email/src/config/index.ts @@ -9,3 +9,4 @@ export interface EmailConfig { export const defineConfig = (config: T) => config; export * from './get-email-config.js'; +export * from './get-email-config-path.js'; From 2fa8dd52f32993789bb1b31b9aac20e5677541d8 Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:10:17 +0400 Subject: [PATCH 03/12] feat: tests --- .../src/cli/commands/testing/export.spec.ts | 97 +++++++++++++ .../get-env-variables-for-preview-app.spec.ts | 36 +++++ .../react-email/src/cli/utils/tree.spec.ts | 1 + .../src/config/get-email-config-path.spec.ts | 48 +++++++ .../src/config/get-email-config.spec.ts | 67 +++++++++ .../ui/src/utils/get-email-component.spec.ts | 132 ++++++++++++++++++ 6 files changed, 381 insertions(+) create mode 100644 packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.spec.ts create mode 100644 packages/react-email/src/config/get-email-config-path.spec.ts create mode 100644 packages/react-email/src/config/get-email-config.spec.ts diff --git a/packages/react-email/src/cli/commands/testing/export.spec.ts b/packages/react-email/src/cli/commands/testing/export.spec.ts index 0212f796db..888be7b092 100644 --- a/packages/react-email/src/cli/commands/testing/export.spec.ts +++ b/packages/react-email/src/cli/commands/testing/export.spec.ts @@ -1,7 +1,18 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; +import { build } from 'esbuild'; +import * as config from '../../../config/index.js'; import { exportTemplates } from '../export.js'; +vi.mock('esbuild', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + build: vi.fn(actual.build), + }; +}); + test('email export', { retry: 3 }, async () => { const pathToEmailsDirectory = path.resolve(__dirname, './emails'); const pathToDumpMarkup = path.resolve(__dirname, './out'); @@ -212,3 +223,89 @@ test('email export', { retry: 3 }, async () => { " `); }); + +test('email export uses email config plugins', async () => { + const temporaryProjectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'react-email-export-config-'), + ); + const previousWorkingDirectory = process.cwd(); + + try { + process.chdir(temporaryProjectRoot); + + fs.mkdirSync(path.join(temporaryProjectRoot, 'emails')); + fs.writeFileSync( + path.join(temporaryProjectRoot, 'emails', 'test-email.js'), + ` +export default function Email() { + return null; +} + `, + 'utf8', + ); + fs.writeFileSync( + path.join(temporaryProjectRoot, 'email.config.ts'), + 'export default {};\n', + 'utf8', + ); + + const mockedBuild = vi.mocked(build); + mockedBuild.mockClear(); + mockedBuild.mockImplementation(async (options: any) => { + fs.mkdirSync(options.outdir, { recursive: true }); + fs.writeFileSync( + path.join(options.outdir, 'test-email.cjs'), + ` +module.exports = { + default: function Email() { + return null; + }, + render: async () => 'rendered', + reactEmailCreateReactElement: (type, props) => ({ type, props }), +}; + `, + 'utf8', + ); + + return { outputFiles: [] } as any; + }); + const mockedGetEmailConfig = vi + .spyOn(config, 'getEmailConfig') + .mockResolvedValue({ + esbuild: { + plugins: [{ name: 'email-config-plugin', setup: vi.fn() }], + }, + }); + const mockedGetEmailConfigPath = vi + .spyOn(config, 'getEmailConfigPath') + .mockReturnValue(path.join(temporaryProjectRoot, 'email.config.ts')); + mockedGetEmailConfig.mockClear(); + mockedGetEmailConfigPath.mockClear(); + + const outDir = path.join(temporaryProjectRoot, 'out'); + await exportTemplates(outDir, path.join(temporaryProjectRoot, 'emails'), { + silent: true, + pretty: true, + }); + + const calledConfigPath = mockedGetEmailConfig.mock.calls[0]?.[0]; + expect(calledConfigPath).toContain('react-email-export-config-'); + expect(path.basename(calledConfigPath ?? '')).toBe('email.config.ts'); + const calledEmailConfigPathArg = + mockedGetEmailConfigPath.mock.calls[0]?.[0]; + expect(calledEmailConfigPathArg).toContain('react-email-export-config-'); + expect(path.basename(calledEmailConfigPathArg ?? '')).toBe( + path.basename(temporaryProjectRoot), + ); + expect(mockedBuild).toHaveBeenCalledTimes(1); + expect(mockedBuild.mock.calls[0]?.[0].plugins).toEqual([ + expect.objectContaining({ name: 'rendering-utilities-exporter' }), + expect.objectContaining({ name: 'email-config-plugin' }), + ]); + expect(fs.existsSync(path.join(outDir, 'test-email.html'))).toBe(true); + } finally { + process.chdir(previousWorkingDirectory); + fs.rmSync(temporaryProjectRoot, { recursive: true, force: true }); + vi.restoreAllMocks(); + } +}); diff --git a/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.spec.ts b/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.spec.ts new file mode 100644 index 0000000000..7dd3196a12 --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.spec.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getEnvVariablesForPreviewApp } from './get-env-variables-for-preview-app'; + +describe('getEnvVariablesForPreviewApp()', () => { + let temporaryDirectory = ''; + + beforeEach(() => { + temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'react-email-preview-env-'), + ); + }); + + afterEach(() => { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + it('includes the discovered email config path', () => { + const emailConfigPath = path.join(temporaryDirectory, 'email.config.cts'); + fs.writeFileSync(emailConfigPath, 'export default {};\n', 'utf8'); + + expect( + getEnvVariablesForPreviewApp('emails', '/preview', temporaryDirectory), + ).toMatchObject({ + REACT_EMAIL_INTERNAL_EMAIL_CONFIG_PATH: emailConfigPath, + REACT_EMAIL_INTERNAL_EMAILS_DIR_RELATIVE_PATH: 'emails', + REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH: path.join( + temporaryDirectory, + 'emails', + ), + REACT_EMAIL_INTERNAL_PREVIEW_SERVER_LOCATION: '/preview', + REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION: temporaryDirectory, + }); + }); +}); diff --git a/packages/react-email/src/cli/utils/tree.spec.ts b/packages/react-email/src/cli/utils/tree.spec.ts index 2034af49ae..a5926e8d6d 100644 --- a/packages/react-email/src/cli/utils/tree.spec.ts +++ b/packages/react-email/src/cli/utils/tree.spec.ts @@ -8,6 +8,7 @@ test('tree(__dirname, 2)', async () => { │ └── renderring-utilities-exporter.ts ├── preview │ ├── hot-reloading + │ ├── get-env-variables-for-preview-app.spec.ts │ ├── get-env-variables-for-preview-app.ts │ ├── index.ts │ ├── serve-static-file.ts diff --git a/packages/react-email/src/config/get-email-config-path.spec.ts b/packages/react-email/src/config/get-email-config-path.spec.ts new file mode 100644 index 0000000000..86924e1af4 --- /dev/null +++ b/packages/react-email/src/config/get-email-config-path.spec.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + getEmailConfigPath, + supportedEmailConfigFilenames, +} from './get-email-config-path'; + +describe('getEmailConfigPath()', () => { + let temporaryDirectory = ''; + + beforeEach(() => { + temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'react-email-config-path-'), + ); + }); + + afterEach(() => { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + it('returns undefined when there is no config file', () => { + expect(getEmailConfigPath(temporaryDirectory)).toBeUndefined(); + }); + + it.each( + supportedEmailConfigFilenames, + )('detects a %s config file', (filename) => { + const emailConfigPath = path.join(temporaryDirectory, filename); + fs.writeFileSync(emailConfigPath, 'export default {};\n', 'utf8'); + + expect(getEmailConfigPath(temporaryDirectory)).toBe(emailConfigPath); + }); + + it('prefers the first supported filename when multiple configs exist', () => { + for (const filename of supportedEmailConfigFilenames) { + fs.writeFileSync( + path.join(temporaryDirectory, filename), + 'export default {};\n', + 'utf8', + ); + } + + expect(getEmailConfigPath(temporaryDirectory)).toBe( + path.join(temporaryDirectory, supportedEmailConfigFilenames[0]!), + ); + }); +}); diff --git a/packages/react-email/src/config/get-email-config.spec.ts b/packages/react-email/src/config/get-email-config.spec.ts new file mode 100644 index 0000000000..86686d690a --- /dev/null +++ b/packages/react-email/src/config/get-email-config.spec.ts @@ -0,0 +1,67 @@ +import { createJiti } from 'jiti'; +import { getEmailConfig } from './get-email-config'; + +vi.mock('jiti', () => ({ + createJiti: vi.fn(), +})); + +describe('getEmailConfig()', () => { + const mockedCreateJiti = vi.mocked(createJiti); + + beforeEach(() => { + mockedCreateJiti.mockReset(); + }); + + it('returns an empty config when no path is provided', async () => { + await expect(getEmailConfig()).resolves.toEqual({}); + expect(mockedCreateJiti).not.toHaveBeenCalled(); + }); + + it('loads a config object from disk', async () => { + const importMock = vi.fn().mockResolvedValue({ + esbuild: { + plugins: [{ name: 'test-plugin', setup: vi.fn() }], + }, + }); + mockedCreateJiti.mockReturnValue({ + import: importMock, + } as unknown as ReturnType); + + const config = await getEmailConfig('/tmp/email.config.ts'); + + expect(config).toMatchObject({ + esbuild: { + plugins: [{ name: 'test-plugin' }], + }, + }); + + expect(mockedCreateJiti).toHaveBeenCalledWith('/tmp/email.config.ts'); + expect(importMock).toHaveBeenCalledWith('/tmp/email.config.ts', { + default: true, + }); + }); + + it('rejects configs that do not export an object', async () => { + mockedCreateJiti.mockReturnValue({ + import: vi.fn().mockResolvedValue(null), + } as unknown as ReturnType); + + await expect(getEmailConfig('/tmp/email.config.ts')).rejects.toThrow( + 'Expected React Email config at /tmp/email.config.ts to export an object.', + ); + }); + + it('rejects configs with a non-array esbuild.plugins value', async () => { + mockedCreateJiti.mockReturnValue({ + import: vi.fn().mockResolvedValue({ + esbuild: { + plugins: {}, + }, + }), + } as unknown as ReturnType); + + await expect(getEmailConfig('/tmp/email.config.ts')).rejects.toThrow( + 'Expected "esbuild.plugins" in React Email config at /tmp/email.config.ts to be an array.', + ); + }); +}); diff --git a/packages/ui/src/utils/get-email-component.spec.ts b/packages/ui/src/utils/get-email-component.spec.ts index 834882013d..a9476bd7e3 100644 --- a/packages/ui/src/utils/get-email-component.spec.ts +++ b/packages/ui/src/utils/get-email-component.spec.ts @@ -1,7 +1,33 @@ +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; +import { build } from 'esbuild'; import { getEmailComponent } from './get-email-component'; +let mockedEmailConfigPath: string | undefined; + +vi.mock('../app/env', () => ({ + get emailConfigPath() { + return mockedEmailConfigPath; + }, +})); + +vi.mock('esbuild', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + build: vi.fn(actual.build), + }; +}); + describe('getEmailComponent()', () => { + const mockedBuild = vi.mocked(build); + + beforeEach(() => { + mockedBuild.mockClear(); + mockedEmailConfigPath = undefined; + }); + test('with a demo email template', { timeout: 10_000 }, async () => { const result = await getEmailComponent( path.resolve(__dirname, './testing/vercel-invite-user.tsx'), @@ -232,4 +258,110 @@ describe('getEmailComponent()', () => { `); } }); + + test('uses email config plugins when a config file exists', async () => { + const temporaryProjectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'react-email-ui-config-'), + ); + const previousWorkingDirectory = process.cwd(); + + try { + process.chdir(temporaryProjectRoot); + mockedEmailConfigPath = path.join( + temporaryProjectRoot, + 'email.config.cjs', + ); + + fs.writeFileSync( + path.join(temporaryProjectRoot, 'email.config.cjs'), + ` +module.exports = { + esbuild: { + plugins: [{ + name: 'email-config-plugin', + setup() {}, + }], + }, +}; + `, + 'utf8', + ); + + mockedBuild.mockImplementation(async () => { + fs.writeFileSync( + path.join(temporaryProjectRoot, 'email.js.map'), + JSON.stringify({ + version: 3, + sources: [], + names: [], + mappings: '', + }), + 'utf8', + ); + fs.writeFileSync( + path.join(temporaryProjectRoot, 'email.js'), + ` +export default function Email() { + return null; +} +export async function render() { + return 'rendered'; +} +export function reactEmailCreateReactElement(type, props) { + return { type, props }; +} + `, + 'utf8', + ); + + return { + outputFiles: [ + { + path: path.join(temporaryProjectRoot, 'email.js.map'), + text: JSON.stringify({ + version: 3, + sources: [], + names: [], + mappings: '', + }), + }, + { + path: path.join(temporaryProjectRoot, 'email.js'), + text: ` +export default function Email() { + return null; +} +export async function render() { + return 'rendered'; +} +export function reactEmailCreateReactElement(type, props) { + return { type, props }; +} + `, + }, + ], + } as any; + }); + + const result = await getEmailComponent( + path.resolve(__dirname, './testing/vercel-invite-user.tsx'), + path.resolve(__dirname, '../../jsx-runtime'), + ); + + expect('error' in result).toBe(false); + if ('error' in result) { + return; + } + + expect(mockedBuild).toHaveBeenCalledTimes(1); + expect(mockedBuild.mock.calls[0]?.[0].plugins).toEqual([ + expect.objectContaining({ name: 'rendering-utilities-exporter' }), + expect.objectContaining({ name: 'email-config-plugin' }), + ]); + } finally { + process.chdir(previousWorkingDirectory); + fs.rmSync(temporaryProjectRoot, { recursive: true, force: true }); + mockedEmailConfigPath = undefined; + } + }); }); From 30c0182a30fee046531a59188bfe6b3119e6fcb3 Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:20:23 +0400 Subject: [PATCH 04/12] fix: config export --- packages/react-email/package.json | 24 +++++++++++++++----- packages/react-email/src/index.ts | 1 - packages/react-email/tsdown.config.ts | 6 +++++ packages/ui/src/utils/get-email-component.ts | 3 ++- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/react-email/package.json b/packages/react-email/package.json index 1aeb0d976e..1f4e39ade1 100644 --- a/packages/react-email/package.json +++ b/packages/react-email/package.json @@ -17,13 +17,25 @@ }, "license": "MIT", "exports": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" + "./config": { + "import": { + "types": "./dist/config/index.d.mts", + "default": "./dist/config/index.mjs" + }, + "require": { + "types": "./dist/config/index.d.cts", + "default": "./dist/config/index.cjs" + } } }, "repository": { diff --git a/packages/react-email/src/index.ts b/packages/react-email/src/index.ts index e021640572..671c179e78 100644 --- a/packages/react-email/src/index.ts +++ b/packages/react-email/src/index.ts @@ -1,3 +1,2 @@ export * from '@react-email/render'; export * from './components/index.js'; -export * from './config/index.js'; diff --git a/packages/react-email/tsdown.config.ts b/packages/react-email/tsdown.config.ts index d2db1eec15..91b8d64796 100644 --- a/packages/react-email/tsdown.config.ts +++ b/packages/react-email/tsdown.config.ts @@ -42,6 +42,12 @@ export default defineConfig([ format: ['esm'], outDir: 'dist/cli', }, + { + dts: true, + entry: ['./src/config/index.ts'], + format: ['esm', 'cjs'], + outDir: 'dist/config', + }, { dts: true, entry: ['./src/index.ts'], diff --git a/packages/ui/src/utils/get-email-component.ts b/packages/ui/src/utils/get-email-component.ts index e670ee8a92..acfc36015d 100644 --- a/packages/ui/src/utils/get-email-component.ts +++ b/packages/ui/src/utils/get-email-component.ts @@ -1,7 +1,8 @@ import path from 'node:path'; import { type BuildFailure, build, type OutputFile } from 'esbuild'; import type React from 'react'; -import { getEmailConfig, type render } from 'react-email'; +import type { render } from 'react-email'; +import { getEmailConfig } from 'react-email/config'; import type { RawSourceMap } from 'source-map-js'; import { z } from 'zod'; import { emailConfigPath } from '../app/env'; From e84e8f944ed43a03c15cd996794229d847eac57b Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:23:43 +0400 Subject: [PATCH 05/12] fix: config validation --- packages/react-email/src/config/get-email-config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-email/src/config/get-email-config.ts b/packages/react-email/src/config/get-email-config.ts index 5f03e90613..72a4dc652f 100644 --- a/packages/react-email/src/config/get-email-config.ts +++ b/packages/react-email/src/config/get-email-config.ts @@ -19,7 +19,11 @@ export const getEmailConfig = async ( ); } - if (!emailConfig || typeof emailConfig !== 'object') { + if ( + !emailConfig || + typeof emailConfig !== 'object' || + Array.isArray(emailConfig) + ) { throw new Error( `Expected React Email config at ${emailConfigPath} to export an object.`, ); From a74524b018944dde57c7662725e61c93ba86b651 Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:26:29 +0400 Subject: [PATCH 06/12] fix: emailConfigPath quoting --- packages/react-email/src/cli/commands/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-email/src/cli/commands/build.ts b/packages/react-email/src/cli/commands/build.ts index 77fd0d5bee..288564edcb 100644 --- a/packages/react-email/src/cli/commands/build.ts +++ b/packages/react-email/src/cli/commands/build.ts @@ -36,7 +36,7 @@ import path from 'path'; const emailsDirRelativePath = path.normalize('${emailsDirRelativePath}'); const userProjectLocation = '${process.cwd().replaceAll('\\', '/')}'; const previewServerLocation = '${builtPreviewAppPath.replaceAll('\\', '/')}'; -const emailConfigPath = ${emailConfigPath ? `'${emailConfigPath.replaceAll('\\', '/')}'` : 'undefined'}; +const emailConfigPath = ${emailConfigPath ? JSON.stringify(emailConfigPath.replaceAll('\\', '/')) : 'undefined'}; const rootDir = ${rootDir}; /** @type {import('next').NextConfig} */ const nextConfig = { From a9f3fe3ca4b56df8f64fe6e51e52a61347d32616 Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:42:33 +0400 Subject: [PATCH 07/12] fix: getEmailConfigPath return type --- packages/react-email/src/config/get-email-config-path.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-email/src/config/get-email-config-path.ts b/packages/react-email/src/config/get-email-config-path.ts index 50e919f4d1..2658b681e6 100644 --- a/packages/react-email/src/config/get-email-config-path.ts +++ b/packages/react-email/src/config/get-email-config-path.ts @@ -10,7 +10,9 @@ export const supportedEmailConfigFilenames = [ 'email.config.cjs', ]; -export const getEmailConfigPath = (userProjectLocation: string) => { +export const getEmailConfigPath = ( + userProjectLocation: string, +): string | undefined => { for (const filename of supportedEmailConfigFilenames) { const emailConfigPath = path.join(userProjectLocation, filename); From 9e16701de0c391c4da858fc76f16e67d4dab621f Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:59:10 +0400 Subject: [PATCH 08/12] Create curly-lions-bake.md --- .changeset/curly-lions-bake.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/curly-lions-bake.md diff --git a/.changeset/curly-lions-bake.md b/.changeset/curly-lions-bake.md new file mode 100644 index 0000000000..0ef4f77576 --- /dev/null +++ b/.changeset/curly-lions-bake.md @@ -0,0 +1,6 @@ +--- +"react-email": minor +"@react-email/ui": minor +--- + +add optional email config support From 53a5ff1947ba5b60ce4820362bc81ad6ba8f340f Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:41:49 +0400 Subject: [PATCH 09/12] Update build.ts --- packages/react-email/src/cli/commands/build.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-email/src/cli/commands/build.ts b/packages/react-email/src/cli/commands/build.ts index 7a81d83915..f3ad293c84 100644 --- a/packages/react-email/src/cli/commands/build.ts +++ b/packages/react-email/src/cli/commands/build.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from 'node:url'; import { getPackages } from '@manypkg/get-packages'; import logSymbols from 'log-symbols'; import { installDependencies, type PackageManagerName, runScript } from 'nypm'; -import ora from 'ora'; import { getEmailConfigPath } from '../../config/get-email-config-path.js'; import { type EmailsDirectory, From 4c418dd6bd17dd4c47d07d6ef97997cc9a910c58 Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:42:47 +0400 Subject: [PATCH 10/12] Delete get-env-variables-for-preview-app.spec.ts --- .../get-env-variables-for-preview-app.spec.ts | 36 ------------------- 1 file changed, 36 deletions(-) delete mode 100644 packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.spec.ts diff --git a/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.spec.ts b/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.spec.ts deleted file mode 100644 index 7dd3196a12..0000000000 --- a/packages/react-email/src/cli/utils/preview/get-env-variables-for-preview-app.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { getEnvVariablesForPreviewApp } from './get-env-variables-for-preview-app'; - -describe('getEnvVariablesForPreviewApp()', () => { - let temporaryDirectory = ''; - - beforeEach(() => { - temporaryDirectory = fs.mkdtempSync( - path.join(os.tmpdir(), 'react-email-preview-env-'), - ); - }); - - afterEach(() => { - fs.rmSync(temporaryDirectory, { recursive: true, force: true }); - }); - - it('includes the discovered email config path', () => { - const emailConfigPath = path.join(temporaryDirectory, 'email.config.cts'); - fs.writeFileSync(emailConfigPath, 'export default {};\n', 'utf8'); - - expect( - getEnvVariablesForPreviewApp('emails', '/preview', temporaryDirectory), - ).toMatchObject({ - REACT_EMAIL_INTERNAL_EMAIL_CONFIG_PATH: emailConfigPath, - REACT_EMAIL_INTERNAL_EMAILS_DIR_RELATIVE_PATH: 'emails', - REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH: path.join( - temporaryDirectory, - 'emails', - ), - REACT_EMAIL_INTERNAL_PREVIEW_SERVER_LOCATION: '/preview', - REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION: temporaryDirectory, - }); - }); -}); From 78396db18469876a1b9addf49335c03e00e39ba0 Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:43:26 +0400 Subject: [PATCH 11/12] Update tree.spec.ts --- packages/react-email/src/cli/utils/tree.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-email/src/cli/utils/tree.spec.ts b/packages/react-email/src/cli/utils/tree.spec.ts index 27851f9901..d1cab7a771 100644 --- a/packages/react-email/src/cli/utils/tree.spec.ts +++ b/packages/react-email/src/cli/utils/tree.spec.ts @@ -8,7 +8,6 @@ test('tree(__dirname, 2)', async () => { │ └── renderring-utilities-exporter.ts ├── preview │ ├── hot-reloading - │ ├── get-env-variables-for-preview-app.spec.ts │ ├── get-env-variables-for-preview-app.ts │ ├── index.ts │ ├── serve-static-file.ts From 7babd592603366a93b92cab9e7241239fbb54cde Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:16:39 +0400 Subject: [PATCH 12/12] chore: rerun e2e