Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/curly-lions-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"react-email": minor
"@react-email/ui": minor
---

add optional email config support
26 changes: 19 additions & 7 deletions packages/react-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,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": {
Expand Down Expand Up @@ -54,7 +66,7 @@
"debounce": "^2.0.0",
"esbuild": "^0.28.0",
"glob": "^13.0.6",
"jiti": "2.4.2",
"jiti": "catalog:",
"log-symbols": "catalog:",
"marked": "^15.0.12",
"mime-types": "^3.0.0",
Expand Down
8 changes: 6 additions & 2 deletions packages/react-email/src/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
import { getPackages } from '@manypkg/get-packages';
import logSymbols from 'log-symbols';
import { installDependencies, runScript } from 'nypm';
import { getEmailConfigPath } from '../../config/get-email-config-path.js';
import {
type EmailsDirectory,
getEmailsDirectoryMetadata,
Expand All @@ -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 ? JSON.stringify(emailConfigPath.replaceAll('\\', '/')) : 'undefined'};
const rootDir = ${rootDir};
/** @type {import('next').NextConfig} */
const nextConfig = {
Expand All @@ -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
},
Expand Down
10 changes: 9 additions & 1 deletion packages/react-email/src/cli/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { glob } from 'glob';
import logSymbols from 'log-symbols';
import normalize from 'normalize-path';
import type React from 'react';
import { getEmailConfig, getEmailConfigPath } from '../../config/index.js';
import { renderingUtilitiesExporter } from '../utils/esbuild/renderring-utilities-exporter.js';
import {
type EmailsDirectory,
Expand Down Expand Up @@ -83,6 +84,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,
Expand All @@ -94,7 +99,10 @@ export const exportTemplates = async (
outExtension: { '.js': '.cjs' },
outdir: pathToWhereEmailMarkupShouldBeDumped,
platform: 'node',
plugins: [renderingUtilitiesExporter(allTemplates)],
plugins: [
renderingUtilitiesExporter(allTemplates),
...emailConfigPlugins,
],
write: true,
});
} catch (exception) {
Expand Down
97 changes: 97 additions & 0 deletions packages/react-email/src/cli/commands/testing/export.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('esbuild')>();
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');
Expand Down Expand Up @@ -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 () => '<html><body>rendered</body></html>',
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();
}
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from 'node:path';
import { getEmailConfigPath } from '../../../config/get-email-config-path.js';

export const getEnvVariablesForPreviewApp = (
relativePathToEmailsDirectory: string,
Expand All @@ -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;
};
48 changes: 48 additions & 0 deletions packages/react-email/src/config/get-email-config-path.spec.ts
Original file line number Diff line number Diff line change
@@ -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]!),
);
});
});
23 changes: 23 additions & 0 deletions packages/react-email/src/config/get-email-config-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import fs from 'node:fs';
import path from 'node:path';

export const supportedEmailConfigFilenames = [
'email.config.ts',
'email.config.mts',
'email.config.cts',
'email.config.js',
'email.config.mjs',
'email.config.cjs',
];

export const getEmailConfigPath = (
userProjectLocation: string,
): string | undefined => {
for (const filename of supportedEmailConfigFilenames) {
const emailConfigPath = path.join(userProjectLocation, filename);

if (fs.existsSync(emailConfigPath)) {
return emailConfigPath;
}
}
};
67 changes: 67 additions & 0 deletions packages/react-email/src/config/get-email-config.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createJiti>);

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<typeof createJiti>);

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<typeof createJiti>);

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.',
);
});
});
Loading