From 65ea0e1a21143d63ea9985d1609257b16216f7b5 Mon Sep 17 00:00:00 2001
From: Adam Chmara
Date: Mon, 16 Feb 2026 10:27:30 +0100
Subject: [PATCH 1/4] feat(novu): email step resolver init & publish cmds fixes
NV-7094 (#9989)
---
packages/novu/package.json | 1 +
.../templates/no-default-export.tsx | 11 +
.../__fixtures__/templates/no-react-email.tsx | 9 +
.../templates/should-be-ignored.test.tsx | 11 +
.../__fixtures__/templates/test-file.test.tsx | 11 +
.../__fixtures__/templates/test-template.tsx | 11 +
.../__fixtures__/templates/valid-template.tsx | 19 +
.../novu/src/commands/email/api/client.ts | 220 ++++++++++
packages/novu/src/commands/email/api/index.ts | 1 +
.../commands/email/bundler/bundler.spec.ts | 111 +++++
.../src/commands/email/bundler/bundler.ts | 122 ++++++
.../src/commands/email/bundler/config.spec.ts | 49 +++
.../novu/src/commands/email/bundler/config.ts | 111 +++++
.../novu/src/commands/email/bundler/index.ts | 2 +
.../commands/email/config/define-config.ts | 5 +
.../novu/src/commands/email/config/index.ts | 4 +
.../novu/src/commands/email/config/loader.ts | 90 ++++
.../src/commands/email/config/schema.spec.ts | 341 +++++++++++++++
.../novu/src/commands/email/config/schema.ts | 96 +++++
.../src/commands/email/discovery/index.ts | 2 +
.../email/discovery/step-discovery.spec.ts | 254 +++++++++++
.../email/discovery/step-discovery.ts | 277 ++++++++++++
.../discovery/template-discovery.spec.ts | 51 +++
.../email/discovery/template-discovery.ts | 155 +++++++
packages/novu/src/commands/email/index.ts | 2 +
packages/novu/src/commands/email/init.ts | 350 +++++++++++++++
packages/novu/src/commands/email/publish.ts | 407 ++++++++++++++++++
.../__snapshots__/step-file.spec.ts.snap | 97 +++++
.../__snapshots__/worker-wrapper.spec.ts.snap | 258 +++++++++++
.../src/commands/email/templates/index.ts | 1 +
.../email/templates/step-file.spec.ts | 33 ++
.../src/commands/email/templates/step-file.ts | 36 ++
.../email/templates/worker-wrapper.spec.ts | 54 +++
.../email/templates/worker-wrapper.ts | 133 ++++++
packages/novu/src/commands/email/types.ts | 42 ++
.../commands/email/utils/data-transforms.ts | 53 +++
.../src/commands/email/utils/environment.ts | 17 +
.../src/commands/email/utils/file-paths.ts | 30 ++
.../novu/src/commands/email/utils/index.ts | 17 +
.../novu/src/commands/email/utils/spinner.ts | 32 ++
.../novu/src/commands/email/utils/table.ts | 46 ++
.../src/commands/email/utils/validation.ts | 55 +++
packages/novu/src/index.ts | 42 ++
packages/novu/tsconfig.json | 2 +-
packages/novu/vitest.config.ts | 7 +
pnpm-lock.yaml | 241 +++++++++++
46 files changed, 3918 insertions(+), 1 deletion(-)
create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/no-default-export.tsx
create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/no-react-email.tsx
create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/should-be-ignored.test.tsx
create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/test-file.test.tsx
create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/test-template.tsx
create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/valid-template.tsx
create mode 100644 packages/novu/src/commands/email/api/client.ts
create mode 100644 packages/novu/src/commands/email/api/index.ts
create mode 100644 packages/novu/src/commands/email/bundler/bundler.spec.ts
create mode 100644 packages/novu/src/commands/email/bundler/bundler.ts
create mode 100644 packages/novu/src/commands/email/bundler/config.spec.ts
create mode 100644 packages/novu/src/commands/email/bundler/config.ts
create mode 100644 packages/novu/src/commands/email/bundler/index.ts
create mode 100644 packages/novu/src/commands/email/config/define-config.ts
create mode 100644 packages/novu/src/commands/email/config/index.ts
create mode 100644 packages/novu/src/commands/email/config/loader.ts
create mode 100644 packages/novu/src/commands/email/config/schema.spec.ts
create mode 100644 packages/novu/src/commands/email/config/schema.ts
create mode 100644 packages/novu/src/commands/email/discovery/index.ts
create mode 100644 packages/novu/src/commands/email/discovery/step-discovery.spec.ts
create mode 100644 packages/novu/src/commands/email/discovery/step-discovery.ts
create mode 100644 packages/novu/src/commands/email/discovery/template-discovery.spec.ts
create mode 100644 packages/novu/src/commands/email/discovery/template-discovery.ts
create mode 100644 packages/novu/src/commands/email/index.ts
create mode 100644 packages/novu/src/commands/email/init.ts
create mode 100644 packages/novu/src/commands/email/publish.ts
create mode 100644 packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap
create mode 100644 packages/novu/src/commands/email/templates/__snapshots__/worker-wrapper.spec.ts.snap
create mode 100644 packages/novu/src/commands/email/templates/index.ts
create mode 100644 packages/novu/src/commands/email/templates/step-file.spec.ts
create mode 100644 packages/novu/src/commands/email/templates/step-file.ts
create mode 100644 packages/novu/src/commands/email/templates/worker-wrapper.spec.ts
create mode 100644 packages/novu/src/commands/email/templates/worker-wrapper.ts
create mode 100644 packages/novu/src/commands/email/types.ts
create mode 100644 packages/novu/src/commands/email/utils/data-transforms.ts
create mode 100644 packages/novu/src/commands/email/utils/environment.ts
create mode 100644 packages/novu/src/commands/email/utils/file-paths.ts
create mode 100644 packages/novu/src/commands/email/utils/index.ts
create mode 100644 packages/novu/src/commands/email/utils/spinner.ts
create mode 100644 packages/novu/src/commands/email/utils/table.ts
create mode 100644 packages/novu/src/commands/email/utils/validation.ts
create mode 100644 packages/novu/vitest.config.ts
diff --git a/packages/novu/package.json b/packages/novu/package.json
index 32a69114332..44d870eb7d7 100644
--- a/packages/novu/package.json
+++ b/packages/novu/package.json
@@ -73,6 +73,7 @@
"commander": "^9.0.0",
"configstore": "^5.0.0",
"dotenv": "^16.4.5",
+ "esbuild": "^0.19.0",
"form-data": "^4.0.5",
"get-port": "^5.1.1",
"gradient-string": "^2.0.0",
diff --git a/packages/novu/src/commands/email/__fixtures__/templates/no-default-export.tsx b/packages/novu/src/commands/email/__fixtures__/templates/no-default-export.tsx
new file mode 100644
index 00000000000..3937a0c8032
--- /dev/null
+++ b/packages/novu/src/commands/email/__fixtures__/templates/no-default-export.tsx
@@ -0,0 +1,11 @@
+import { Body, Container, Html } from '@react-email/components';
+
+export function EmailComponent() {
+ return (
+
+
+ No default export
+
+
+ );
+}
diff --git a/packages/novu/src/commands/email/__fixtures__/templates/no-react-email.tsx b/packages/novu/src/commands/email/__fixtures__/templates/no-react-email.tsx
new file mode 100644
index 00000000000..6f52b673be2
--- /dev/null
+++ b/packages/novu/src/commands/email/__fixtures__/templates/no-react-email.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+
+export default function RegularComponent() {
+ return (
+
+
This has JSX but no React Email imports
+
+ );
+}
diff --git a/packages/novu/src/commands/email/__fixtures__/templates/should-be-ignored.test.tsx b/packages/novu/src/commands/email/__fixtures__/templates/should-be-ignored.test.tsx
new file mode 100644
index 00000000000..880168d4ff3
--- /dev/null
+++ b/packages/novu/src/commands/email/__fixtures__/templates/should-be-ignored.test.tsx
@@ -0,0 +1,11 @@
+import { Body, Container, Html } from '@react-email/components';
+
+export default function IgnoredEmail() {
+ return (
+
+
+ This file matches *.test.tsx pattern and should be ignored
+
+
+ );
+}
diff --git a/packages/novu/src/commands/email/__fixtures__/templates/test-file.test.tsx b/packages/novu/src/commands/email/__fixtures__/templates/test-file.test.tsx
new file mode 100644
index 00000000000..bc108b9e14f
--- /dev/null
+++ b/packages/novu/src/commands/email/__fixtures__/templates/test-file.test.tsx
@@ -0,0 +1,11 @@
+import { Body, Container, Html } from '@react-email/components';
+
+export default function TestEmail() {
+ return (
+
+
+ This is a test file and should be ignored
+
+
+ );
+}
diff --git a/packages/novu/src/commands/email/__fixtures__/templates/test-template.tsx b/packages/novu/src/commands/email/__fixtures__/templates/test-template.tsx
new file mode 100644
index 00000000000..bc108b9e14f
--- /dev/null
+++ b/packages/novu/src/commands/email/__fixtures__/templates/test-template.tsx
@@ -0,0 +1,11 @@
+import { Body, Container, Html } from '@react-email/components';
+
+export default function TestEmail() {
+ return (
+
+
+ This is a test file and should be ignored
+
+
+ );
+}
diff --git a/packages/novu/src/commands/email/__fixtures__/templates/valid-template.tsx b/packages/novu/src/commands/email/__fixtures__/templates/valid-template.tsx
new file mode 100644
index 00000000000..9b59b9fc43b
--- /dev/null
+++ b/packages/novu/src/commands/email/__fixtures__/templates/valid-template.tsx
@@ -0,0 +1,19 @@
+import { Body, Container, Head, Heading, Html, Text } from '@react-email/components';
+
+interface WelcomeEmailProps {
+ name?: string;
+}
+
+export default function WelcomeEmail({ name = 'User' }: WelcomeEmailProps) {
+ return (
+
+
+
+
+ Welcome, {name}!
+ Thanks for joining us.
+
+
+
+ );
+}
diff --git a/packages/novu/src/commands/email/api/client.ts b/packages/novu/src/commands/email/api/client.ts
new file mode 100644
index 00000000000..50af25a1157
--- /dev/null
+++ b/packages/novu/src/commands/email/api/client.ts
@@ -0,0 +1,220 @@
+import axios from 'axios';
+import FormData from 'form-data';
+import type { DeploymentResult, EnvironmentInfo, StepResolverManifestStep, StepResolverReleaseBundle } from '../types';
+
+export class StepResolverClient {
+ constructor(
+ private apiUrl: string,
+ private secretKey: string
+ ) {}
+
+ private getAuthHeaders() {
+ return {
+ Authorization: `ApiKey ${this.secretKey}`,
+ };
+ }
+
+ async validateConnection(): Promise {
+ try {
+ await axios.get(`${this.apiUrl}/v1/users/me`, {
+ headers: this.getAuthHeaders(),
+ });
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ if (error.response?.status === 401) {
+ throw new Error('Invalid API key. Please check your secret key.');
+ }
+ throw new Error(`Connection failed: ${error.response?.data?.message || error.message}`);
+ }
+ throw error;
+ }
+ }
+
+ async getEnvironmentInfo(): Promise {
+ try {
+ const response = await axios.get(`${this.apiUrl}/v1/environments/me`, {
+ headers: this.getAuthHeaders(),
+ });
+
+ const envData = response.data.data;
+
+ return {
+ _id: envData._id,
+ name: envData.name,
+ _organizationId: envData._organizationId,
+ };
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ if (error.response?.status === 401) {
+ throw new Error('Invalid API key. Please check your secret key.');
+ }
+ if (error.response?.status === 404) {
+ throw new Error('Environment not found. Please ensure your API key has proper permissions.');
+ }
+ throw new Error(`Failed to fetch environment: ${error.response?.data?.message || error.message}`);
+ }
+ throw error;
+ }
+ }
+
+ async deployRelease(
+ bundle: StepResolverReleaseBundle,
+ manifestSteps: StepResolverManifestStep[]
+ ): Promise {
+ try {
+ const formData = new FormData();
+ formData.append('manifest', JSON.stringify({ steps: manifestSteps }));
+ formData.append('bundle', Buffer.from(bundle.code, 'utf8'), {
+ filename: 'worker.mjs',
+ contentType: 'application/javascript+module',
+ });
+
+ const response = await axios.post(`${this.apiUrl}/v2/step-resolvers/deploy`, formData, {
+ headers: {
+ ...this.getAuthHeaders(),
+ ...formData.getHeaders(),
+ },
+ // Limit is enforced on the server side
+ maxBodyLength: Infinity,
+ });
+
+ const data = response.data.data;
+ if (
+ typeof data?.stepResolverHash !== 'string' ||
+ typeof data?.workerId !== 'string' ||
+ typeof data?.deployedAt !== 'string'
+ ) {
+ throw new Error('Invalid deployment response from API');
+ }
+
+ return {
+ stepResolverHash: data.stepResolverHash,
+ workerId: data.workerId,
+ selectedStepsCount: data.selectedStepsCount ?? manifestSteps.length,
+ deployedAt: data.deployedAt,
+ };
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ const apiMessage = this.formatApiErrorMessage(error.response?.data, error.message || 'Request failed');
+
+ if (error.response?.status === 401) {
+ throw new Error('Invalid API key. Please check your secret key.');
+ }
+ if (error.response?.status === 400) {
+ throw new Error(`Bad request: ${apiMessage}`);
+ }
+ if (error.response?.status === 404) {
+ const stepContext = this.extractStepContext(error.response.data);
+ if (stepContext) {
+ throw new Error(`Not found: ${stepContext}. Make sure the workflow and its steps exist before publishing.`);
+ }
+ throw new Error('Workflow or step not found. Make sure the workflow and its steps exist before publishing.');
+ }
+ if (error.response?.status === 429) {
+ throw new Error('Rate limit exceeded. Please try again later.');
+ }
+ if (error.response?.status >= 500) {
+ throw new Error(`Server error (${error.response.status}): ${apiMessage || 'Internal server error'}`);
+ }
+
+ throw new Error(`Deployment failed (${error.response?.status || 'unknown'}): ${apiMessage}`);
+ }
+
+ if (error instanceof Error) {
+ throw new Error(`Network error: ${error.message}`);
+ }
+
+ throw new Error('Unknown deployment error occurred');
+ }
+ }
+
+ private formatApiErrorMessage(data: unknown, fallback: string): string {
+ const root = asRecord(data);
+ if (!root) {
+ return fallback;
+ }
+
+ const baseMessage = this.readMessage(root) ?? this.readString(root.error) ?? fallback;
+ const stepContext = this.extractStepContext(root);
+
+ if (!stepContext) {
+ return baseMessage;
+ }
+
+ return `${baseMessage} (${stepContext})`;
+ }
+
+ private readMessage(payload: Record): string | undefined {
+ const rawMessage = payload.message;
+
+ if (typeof rawMessage === 'string' && rawMessage.trim().length > 0) {
+ return rawMessage;
+ }
+
+ if (Array.isArray(rawMessage)) {
+ const messages = rawMessage.filter(
+ (value): value is string => typeof value === 'string' && value.trim().length > 0
+ );
+ if (messages.length > 0) {
+ return messages.join(', ');
+ }
+ }
+
+ const messageRecord = asRecord(rawMessage);
+ if (messageRecord) {
+ const nestedMessage = this.readString(messageRecord.message);
+ if (nestedMessage) {
+ return nestedMessage;
+ }
+ }
+
+ return undefined;
+ }
+
+ private extractStepContext(payload: Record): string | undefined {
+ const possibleSources: Record[] = [payload];
+ const ctx = asRecord(payload.ctx);
+ if (ctx) {
+ possibleSources.push(ctx);
+ }
+
+ const nestedMessage = asRecord(payload.message);
+ if (nestedMessage) {
+ possibleSources.push(nestedMessage);
+ }
+
+ for (const source of possibleSources) {
+ const workflowId = this.readString(source.workflowId);
+ const stepId = this.readString(source.stepId);
+
+ if (workflowId && stepId) {
+ return `workflowId=${workflowId}, stepId=${stepId}`;
+ }
+ if (workflowId) {
+ return `workflowId=${workflowId}`;
+ }
+ if (stepId) {
+ return `stepId=${stepId}`;
+ }
+ }
+
+ return undefined;
+ }
+
+ private readString(value: unknown): string | undefined {
+ if (typeof value !== 'string') {
+ return undefined;
+ }
+
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+ }
+}
+
+function asRecord(value: unknown): Record | undefined {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return undefined;
+ }
+
+ return value as Record;
+}
diff --git a/packages/novu/src/commands/email/api/index.ts b/packages/novu/src/commands/email/api/index.ts
new file mode 100644
index 00000000000..b853a8bc7f0
--- /dev/null
+++ b/packages/novu/src/commands/email/api/index.ts
@@ -0,0 +1 @@
+export { StepResolverClient } from './client';
diff --git a/packages/novu/src/commands/email/bundler/bundler.spec.ts b/packages/novu/src/commands/email/bundler/bundler.spec.ts
new file mode 100644
index 00000000000..2e0ea1fb15c
--- /dev/null
+++ b/packages/novu/src/commands/email/bundler/bundler.spec.ts
@@ -0,0 +1,111 @@
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { afterEach, describe, expect, it } from 'vitest';
+import type { DiscoveredStep } from '../types';
+import { bundleRelease } from './bundler';
+
+describe('bundleRelease', () => {
+ let tempDir = '';
+
+ afterEach(() => {
+ if (tempDir && fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('should bundle release when aliases are provided', async () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'novu-bundler-alias-'));
+ const sourceDir = path.join(tempDir, 'src');
+ const stepDir = path.join(tempDir, 'novu');
+ fs.mkdirSync(sourceDir, { recursive: true });
+ fs.mkdirSync(stepDir, { recursive: true });
+
+ fs.writeFileSync(path.join(sourceDir, 'utils.ts'), "export const body = 'Hello from alias';\n", 'utf8');
+
+ const stepFilePath = path.join(stepDir, 'welcome.step.ts');
+ fs.writeFileSync(
+ stepFilePath,
+ `
+import { body } from '@emails/utils';
+
+export const stepId = 'welcome-email';
+export const workflowId = 'onboarding';
+export const type = 'email';
+
+export default async function () {
+ return {
+ subject: 'Welcome',
+ body
+ };
+}
+ `.trim(),
+ 'utf8'
+ );
+
+ const steps: DiscoveredStep[] = [
+ {
+ stepId: 'welcome-email',
+ workflowId: 'onboarding',
+ type: 'email',
+ filePath: stepFilePath,
+ relativePath: 'welcome.step.ts',
+ },
+ ];
+
+ const bundle = await bundleRelease(steps, tempDir, {
+ minify: false,
+ aliases: {
+ '@emails/*': './src/*',
+ },
+ });
+
+ expect(bundle.size).toBeGreaterThan(0);
+ expect(bundle.code).toContain('Hello from alias');
+ });
+
+ it('should show actionable unresolved import error when alias is missing', async () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'novu-bundler-error-'));
+ const stepDir = path.join(tempDir, 'novu');
+ fs.mkdirSync(stepDir, { recursive: true });
+
+ const stepFilePath = path.join(stepDir, 'missing-alias.step.ts');
+ fs.writeFileSync(
+ stepFilePath,
+ `
+import { body } from '@emails/utils';
+
+export const stepId = 'missing-alias';
+export const workflowId = 'onboarding';
+export const type = 'email';
+
+export default async function () {
+ return {
+ subject: 'Welcome',
+ body
+ };
+}
+ `.trim(),
+ 'utf8'
+ );
+
+ const steps: DiscoveredStep[] = [
+ {
+ stepId: 'missing-alias',
+ workflowId: 'onboarding',
+ type: 'email',
+ filePath: stepFilePath,
+ relativePath: 'missing-alias.step.ts',
+ },
+ ];
+
+ try {
+ await bundleRelease(steps, tempDir);
+ throw new Error('Expected bundling to fail');
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ expect(message).toContain('Unresolved imports');
+ expect(message).toContain('aliases field');
+ }
+ });
+});
diff --git a/packages/novu/src/commands/email/bundler/bundler.ts b/packages/novu/src/commands/email/bundler/bundler.ts
new file mode 100644
index 00000000000..02929fbcc70
--- /dev/null
+++ b/packages/novu/src/commands/email/bundler/bundler.ts
@@ -0,0 +1,122 @@
+import * as esbuild from 'esbuild';
+import { generateWorkerWrapper } from '../templates/worker-wrapper';
+import type { DiscoveredStep, StepResolverReleaseBundle } from '../types';
+import { getBundlerConfig } from './config';
+
+const MAX_BUNDLE_SIZE = 10 * 1024 * 1024; // 10MB in bytes
+const BUNDLE_LABEL = 'step-resolver-release';
+
+interface BundleBuildOptions {
+ minify?: boolean;
+ aliases?: Record;
+}
+
+export async function bundleRelease(
+ steps: DiscoveredStep[],
+ rootDir: string,
+ options: BundleBuildOptions = {}
+): Promise {
+ return bundleSteps(BUNDLE_LABEL, steps, rootDir, options);
+}
+
+export function formatBundleSize(size: number): string {
+ if (size < 1024) {
+ return `${size} B`;
+ } else if (size < 1024 * 1024) {
+ return `${(size / 1024).toFixed(2)} KB`;
+ } else {
+ return `${(size / 1024 / 1024).toFixed(2)} MB`;
+ }
+}
+
+function formatBundlingError(bundleLabel: string, error: unknown): Error {
+ if (isBuildFailure(error)) {
+ const unresolvedImports = error.errors.filter((entry) => entry.text.includes('Could not resolve'));
+ if (unresolvedImports.length > 0) {
+ const details = unresolvedImports
+ .map((entry) => {
+ if (!entry.location) {
+ return entry.text;
+ }
+
+ return `${entry.text} (${entry.location.file}:${entry.location.line}:${entry.location.column})`;
+ })
+ .join('\n • ');
+
+ return new Error(
+ `Failed to bundle release: ${bundleLabel}\n\n` +
+ `Unresolved imports:\n` +
+ ` • ${details}\n\n` +
+ `Hints:\n` +
+ ` • Add custom path aliases in novu.config.ts under the aliases field\n` +
+ ` • Or define aliases in tsconfig/jsconfig paths and run publish from the matching project root`
+ );
+ }
+
+ return new Error(`Failed to bundle release: ${bundleLabel}\n${error.message}`);
+ }
+
+ if (error instanceof Error) {
+ return new Error(`Failed to bundle release: ${bundleLabel}\n${error.message}`);
+ }
+
+ return new Error(`Failed to bundle release: ${bundleLabel}`);
+}
+
+function isBuildFailure(error: unknown): error is esbuild.BuildFailure {
+ return typeof error === 'object' && error !== null && 'errors' in error && Array.isArray(error.errors);
+}
+
+async function bundleSteps(
+ bundleId: string,
+ steps: DiscoveredStep[],
+ rootDir: string,
+ options: BundleBuildOptions
+): Promise<{ code: string; size: number }> {
+ const wrapperCode = generateWorkerWrapper(steps, rootDir);
+ const baseConfig = getBundlerConfig({
+ rootDir,
+ minify: options.minify,
+ aliases: options.aliases,
+ });
+
+ let result: esbuild.BuildResult;
+
+ try {
+ result = await esbuild.build({
+ ...baseConfig,
+ stdin: {
+ contents: wrapperCode,
+ loader: 'tsx',
+ resolveDir: rootDir,
+ sourcefile: `${bundleId}-worker.tsx`,
+ },
+ write: false,
+ metafile: true,
+ });
+ } catch (error) {
+ throw formatBundlingError(bundleId, error);
+ }
+
+ const outputFile = result.outputFiles?.[0];
+ if (!outputFile) {
+ throw new Error(`No output from esbuild for bundle: ${bundleId}`);
+ }
+
+ const code = outputFile.text;
+ const size = Buffer.byteLength(code, 'utf8');
+
+ if (size > MAX_BUNDLE_SIZE) {
+ throw new Error(
+ `Bundle too large: ${bundleId}\n\n` +
+ ` Bundle size: ${(size / 1024 / 1024).toFixed(1)} MB\n` +
+ ` Maximum: ${MAX_BUNDLE_SIZE / 1024 / 1024} MB (Cloudflare limit)\n\n` +
+ `Suggestions:\n` +
+ ` • Reduce template complexity\n` +
+ ` • Remove unused dependencies\n` +
+ ` • Publish specific workflows with --workflow for targeted updates`
+ );
+ }
+
+ return { code, size };
+}
diff --git a/packages/novu/src/commands/email/bundler/config.spec.ts b/packages/novu/src/commands/email/bundler/config.spec.ts
new file mode 100644
index 00000000000..13181e82aea
--- /dev/null
+++ b/packages/novu/src/commands/email/bundler/config.spec.ts
@@ -0,0 +1,49 @@
+import * as path from 'path';
+import { describe, expect, it } from 'vitest';
+import { getBundlerConfig } from './config';
+
+describe('getBundlerConfig', () => {
+ it('should normalize wildcard aliases and resolve relative targets from rootDir', () => {
+ const rootDir = '/tmp/novu-project';
+
+ const config = getBundlerConfig({
+ rootDir,
+ minify: false,
+ aliases: {
+ '@/*': './src/*',
+ '@emails/*': './emails/*',
+ '@core/': './core/',
+ },
+ });
+
+ expect(config.alias).toEqual({
+ '@': path.resolve(rootDir, './src'),
+ '@emails': path.resolve(rootDir, './emails'),
+ '@core': path.resolve(rootDir, './core'),
+ });
+ });
+
+ it('should preserve absolute alias targets', () => {
+ const rootDir = '/tmp/novu-project';
+ const absoluteTarget = '/tmp/shared';
+
+ const config = getBundlerConfig({
+ rootDir,
+ aliases: {
+ '@shared': absoluteTarget,
+ },
+ });
+
+ expect(config.alias).toEqual({
+ '@shared': absoluteTarget,
+ });
+ });
+
+ it('should not include alias option when aliases are not provided', () => {
+ const config = getBundlerConfig({
+ rootDir: '/tmp/novu-project',
+ });
+
+ expect(config.alias).toBeUndefined();
+ });
+});
diff --git a/packages/novu/src/commands/email/bundler/config.ts b/packages/novu/src/commands/email/bundler/config.ts
new file mode 100644
index 00000000000..8f4b554d633
--- /dev/null
+++ b/packages/novu/src/commands/email/bundler/config.ts
@@ -0,0 +1,111 @@
+import type { BuildOptions } from 'esbuild';
+import * as path from 'path';
+
+interface BundlerConfigOptions {
+ rootDir: string;
+ minify?: boolean;
+ aliases?: Record;
+}
+
+export function getBundlerConfig(options: BundlerConfigOptions): BuildOptions {
+ const { rootDir, minify = true, aliases } = options;
+ const normalizedAliases = normalizeAliases(aliases, rootDir);
+
+ return {
+ bundle: true,
+ platform: 'neutral',
+ format: 'esm',
+ target: 'es2022',
+ minify,
+ sourcemap: false,
+ jsx: 'automatic',
+ jsxImportSource: 'react',
+ conditions: ['worker', 'browser'],
+ mainFields: ['browser', 'module', 'main'],
+ alias: normalizedAliases,
+ external: [],
+ logLevel: 'warning',
+ loader: {
+ '.ts': 'tsx',
+ '.js': 'jsx',
+ },
+ define: {
+ 'process.env.NODE_ENV': '"production"',
+ 'process.env': '{}',
+ global: 'globalThis',
+ },
+ banner: {
+ js: `
+// Cloudflare Workers environment shims
+globalThis.process = globalThis.process || { env: { NODE_ENV: 'production' } };
+globalThis.global = globalThis.global || globalThis;
+
+// MessageChannel polyfill for React
+globalThis.MessageChannel = globalThis.MessageChannel || class MessageChannel {
+ constructor() {
+ this.port1 = { postMessage: () => {}, onmessage: null };
+ this.port2 = { postMessage: () => {}, onmessage: null };
+ }
+};
+ `.trim(),
+ },
+ };
+}
+
+function normalizeAliases(
+ aliases: Record | undefined,
+ rootDir: string
+): Record | undefined {
+ if (!aliases) {
+ return undefined;
+ }
+
+ const normalizedAliases: Record = {};
+
+ for (const [rawAlias, rawTarget] of Object.entries(aliases)) {
+ const alias = normalizeAliasKey(rawAlias);
+ const target = normalizeAliasTarget(rawTarget);
+
+ if (!alias || !target) {
+ continue;
+ }
+
+ normalizedAliases[alias] = path.isAbsolute(target) ? target : path.resolve(rootDir, target);
+ }
+
+ return Object.keys(normalizedAliases).length > 0 ? normalizedAliases : undefined;
+}
+
+function normalizeAliasKey(alias: string): string {
+ const trimmed = alias.trim();
+ if (!trimmed) {
+ return '';
+ }
+
+ if (trimmed.endsWith('/*')) {
+ return trimmed.slice(0, -2);
+ }
+
+ if (trimmed.endsWith('/')) {
+ return trimmed.slice(0, -1);
+ }
+
+ return trimmed;
+}
+
+function normalizeAliasTarget(target: string): string {
+ const trimmed = target.trim();
+ if (!trimmed) {
+ return '';
+ }
+
+ if (trimmed.endsWith('/*')) {
+ return trimmed.slice(0, -2);
+ }
+
+ if (trimmed.endsWith('/') && trimmed.length > 1) {
+ return trimmed.slice(0, -1);
+ }
+
+ return trimmed;
+}
diff --git a/packages/novu/src/commands/email/bundler/index.ts b/packages/novu/src/commands/email/bundler/index.ts
new file mode 100644
index 00000000000..c81281b100b
--- /dev/null
+++ b/packages/novu/src/commands/email/bundler/index.ts
@@ -0,0 +1,2 @@
+export { bundleRelease, formatBundleSize } from './bundler';
+export { getBundlerConfig } from './config';
diff --git a/packages/novu/src/commands/email/config/define-config.ts b/packages/novu/src/commands/email/config/define-config.ts
new file mode 100644
index 00000000000..1ae160bba28
--- /dev/null
+++ b/packages/novu/src/commands/email/config/define-config.ts
@@ -0,0 +1,5 @@
+import type { NovuConfig } from './schema';
+
+export function defineConfig(config: NovuConfig): NovuConfig {
+ return config;
+}
diff --git a/packages/novu/src/commands/email/config/index.ts b/packages/novu/src/commands/email/config/index.ts
new file mode 100644
index 00000000000..5a09dddc5aa
--- /dev/null
+++ b/packages/novu/src/commands/email/config/index.ts
@@ -0,0 +1,4 @@
+export { defineConfig } from './define-config';
+export { loadConfig } from './loader';
+export type { EmailStepConfig, NovuConfig } from './schema';
+export { validateConfig } from './schema';
diff --git a/packages/novu/src/commands/email/config/loader.ts b/packages/novu/src/commands/email/config/loader.ts
new file mode 100644
index 00000000000..c5042662963
--- /dev/null
+++ b/packages/novu/src/commands/email/config/loader.ts
@@ -0,0 +1,90 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { NovuConfig, validateConfig } from './schema';
+
+export async function loadConfig(configPath?: string): Promise {
+ const cwd = process.cwd();
+
+ const possiblePaths = configPath ? [path.resolve(cwd, configPath)] : await findConfigPaths(cwd);
+
+ for (const filePath of possiblePaths) {
+ if (fs.existsSync(filePath)) {
+ try {
+ const config = await loadConfigFile(filePath);
+
+ return validateConfig(config);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ throw new Error(`Config file: ${filePath}\n${errorMessage}`);
+ }
+ }
+ }
+
+ return null;
+}
+
+async function findConfigPaths(startDir: string): Promise {
+ const configNames = ['novu.config.ts', 'novu.config.js', 'novu.config.mjs', 'novu.config.cjs'];
+ const paths: string[] = [];
+
+ let currentDir = startDir;
+ let depth = 0;
+ const maxDepth = 3;
+
+ while (depth <= maxDepth) {
+ for (const name of configNames) {
+ paths.push(path.join(currentDir, name));
+ }
+
+ const parentDir = path.dirname(currentDir);
+ if (parentDir === currentDir) {
+ break;
+ }
+
+ currentDir = parentDir;
+ depth++;
+ }
+
+ return paths;
+}
+
+async function loadConfigFile(filePath: string): Promise {
+ const ext = path.extname(filePath);
+
+ if (ext === '.ts') {
+ return await loadTypeScriptConfig(filePath);
+ }
+
+ delete require.cache[require.resolve(filePath)];
+
+ const module = require(filePath) as { default?: unknown };
+
+ return module.default || module;
+}
+
+async function loadTypeScriptConfig(filePath: string): Promise {
+ const esbuild = require('esbuild');
+
+ const result = await esbuild.build({
+ entryPoints: [filePath],
+ bundle: true,
+ platform: 'node',
+ format: 'cjs',
+ write: false,
+ external: ['esbuild'],
+ logLevel: 'silent',
+ });
+
+ if (!result.outputFiles || result.outputFiles.length === 0) {
+ throw new Error('esbuild produced no output for config file');
+ }
+
+ const code = result.outputFiles[0].text;
+ const tempModule: { exports: { default?: unknown; [key: string]: unknown } } = {
+ exports: {},
+ };
+ const func = new Function('module', 'exports', 'require', code);
+ func(tempModule, tempModule.exports, require);
+
+ return tempModule.exports.default || tempModule.exports;
+}
diff --git a/packages/novu/src/commands/email/config/schema.spec.ts b/packages/novu/src/commands/email/config/schema.spec.ts
new file mode 100644
index 00000000000..ddec84c0bb1
--- /dev/null
+++ b/packages/novu/src/commands/email/config/schema.spec.ts
@@ -0,0 +1,341 @@
+import { describe, expect, it } from 'vitest';
+import { validateConfig } from './schema';
+
+describe('validateConfig', () => {
+ describe('valid configs', () => {
+ it('should accept valid config with all fields', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: {
+ 'welcome-email': {
+ template: 'emails/welcome.tsx',
+ subject: 'Welcome!',
+ },
+ },
+ },
+ },
+ },
+ outDir: './novu',
+ apiUrl: 'https://api.novu.co',
+ aliases: {
+ '@emails': './src/emails',
+ },
+ };
+
+ expect(() => validateConfig(config)).not.toThrow();
+ const result = validateConfig(config);
+ expect(result.workflows.onboarding.steps.email['welcome-email'].subject).toBe('Welcome!');
+ });
+
+ it('should accept valid config with optional subject', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: {
+ 'welcome-email': {
+ template: 'emails/welcome.tsx',
+ },
+ },
+ },
+ },
+ },
+ };
+
+ expect(() => validateConfig(config)).not.toThrow();
+ });
+
+ it('should accept valid config with multiple steps in different workflows', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: {
+ 'welcome-email': {
+ template: 'emails/welcome.tsx',
+ },
+ },
+ },
+ },
+ billing: {
+ steps: {
+ email: {
+ 'invoice-email': {
+ template: 'emails/invoice.tsx',
+ subject: 'Your Invoice',
+ },
+ },
+ },
+ },
+ },
+ };
+
+ expect(() => validateConfig(config)).not.toThrow();
+ });
+
+ it('should accept duplicate step IDs across different workflows', () => {
+ const config = {
+ workflows: {
+ signup: {
+ steps: {
+ email: {
+ confirmation: {
+ template: 'emails/signup-confirm.tsx',
+ subject: 'Confirm Your Signup',
+ },
+ },
+ },
+ },
+ booking: {
+ steps: {
+ email: {
+ confirmation: {
+ template: 'emails/booking-confirm.tsx',
+ subject: 'Booking Confirmed',
+ },
+ },
+ },
+ },
+ },
+ };
+
+ expect(() => validateConfig(config)).not.toThrow();
+ });
+ });
+
+ describe('invalid configs', () => {
+ it('should reject non-object config', () => {
+ expect(() => validateConfig(null)).toThrow('Invalid config: must be an object');
+ expect(() => validateConfig(undefined)).toThrow('Invalid config: must be an object');
+ expect(() => validateConfig('string')).toThrow('Invalid config: must be an object');
+ expect(() => validateConfig(123)).toThrow('Invalid config: must be an object');
+ });
+
+ it('should reject missing workflows field', () => {
+ const config = {};
+
+ expect(() => validateConfig(config)).toThrow('Invalid config: workflows field is required and must be an object');
+ });
+
+ it('should reject missing steps.email field', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {},
+ },
+ },
+ };
+
+ expect(() => validateConfig(config)).toThrow(
+ "workflows['onboarding'].steps.email is required and must be an object"
+ );
+ });
+
+ it('should reject missing template in step config', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: {
+ 'welcome-email': {},
+ },
+ },
+ },
+ },
+ };
+
+ expect(() => validateConfig(config)).toThrow(
+ "workflows['onboarding'].steps.email['welcome-email'].template is required and must be a string"
+ );
+ });
+
+ it('should reject invalid template type', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: {
+ 'welcome-email': {
+ template: 123,
+ },
+ },
+ },
+ },
+ },
+ };
+
+ expect(() => validateConfig(config)).toThrow(
+ "workflows['onboarding'].steps.email['welcome-email'].template is required and must be a string"
+ );
+ });
+
+ it('should reject invalid subject type', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: {
+ 'welcome-email': {
+ template: 'emails/welcome.tsx',
+ subject: 123,
+ },
+ },
+ },
+ },
+ },
+ };
+
+ expect(() => validateConfig(config)).toThrow(
+ "workflows['onboarding'].steps.email['welcome-email'].subject must be a string"
+ );
+ });
+
+ it('should reject invalid outDir type', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: {
+ 'welcome-email': {
+ template: 'emails/welcome.tsx',
+ },
+ },
+ },
+ },
+ },
+ outDir: 123,
+ };
+
+ expect(() => validateConfig(config)).toThrow('outDir must be a string');
+ });
+
+ it('should reject invalid apiUrl type', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: {
+ 'welcome-email': {
+ template: 'emails/welcome.tsx',
+ },
+ },
+ },
+ },
+ },
+ apiUrl: true,
+ };
+
+ expect(() => validateConfig(config)).toThrow('apiUrl must be a string');
+ });
+
+ it('should reject invalid aliases type', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: {
+ 'welcome-email': {
+ template: 'emails/welcome.tsx',
+ },
+ },
+ },
+ },
+ },
+ aliases: 'invalid',
+ };
+
+ expect(() => validateConfig(config)).toThrow('aliases must be an object');
+ });
+
+ it('should reject alias target with non-string value', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: {
+ 'welcome-email': {
+ template: 'emails/welcome.tsx',
+ },
+ },
+ },
+ },
+ },
+ aliases: {
+ '@emails': 123,
+ },
+ };
+
+ expect(() => validateConfig(config)).toThrow("aliases['@emails'] must be a string");
+ });
+
+ it('should reject alias target with empty string value', () => {
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: {
+ 'welcome-email': {
+ template: 'emails/welcome.tsx',
+ },
+ },
+ },
+ },
+ },
+ aliases: {
+ '@emails': ' ',
+ },
+ };
+
+ expect(() => validateConfig(config)).toThrow("aliases['@emails'] cannot be empty");
+ });
+
+ it('should collect multiple errors', () => {
+ const config = {
+ workflows: {
+ workflow1: {
+ steps: {
+ email: {
+ step1: {
+ template: 'emails/step1.tsx',
+ },
+ step2: {},
+ },
+ },
+ },
+ },
+ };
+
+ expect(() => validateConfig(config)).toThrow('Configuration validation errors:');
+ try {
+ validateConfig(config);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ expect(message).toContain("workflows['workflow1'].steps.email['step2'].template");
+ }
+ });
+
+ it('should reject duplicate step IDs within same workflow', () => {
+ const emailSteps: Record = {};
+ emailSteps['welcome'] = { template: 'emails/welcome1.tsx' };
+ emailSteps['welcome'] = { template: 'emails/welcome2.tsx' };
+
+ const config = {
+ workflows: {
+ onboarding: {
+ steps: {
+ email: emailSteps,
+ },
+ },
+ },
+ };
+
+ // Note: JavaScript objects can't have true duplicate keys, so this test verifies
+ // that our validation would catch duplicates if they were possible.
+ // In practice, duplicate keys in config files would be caught at parse time.
+ // This test just ensures the validation logic is correct.
+ expect(() => validateConfig(config)).not.toThrow();
+ });
+ });
+});
diff --git a/packages/novu/src/commands/email/config/schema.ts b/packages/novu/src/commands/email/config/schema.ts
new file mode 100644
index 00000000000..cb3cbd7632a
--- /dev/null
+++ b/packages/novu/src/commands/email/config/schema.ts
@@ -0,0 +1,96 @@
+export type EmailStepConfig = {
+ template: string;
+ subject?: string;
+};
+
+export type NovuConfig = {
+ outDir?: string;
+ workflows: {
+ [workflowId: string]: {
+ steps: {
+ email: Record;
+ };
+ };
+ };
+ apiUrl?: string;
+ aliases?: Record;
+};
+
+export function validateConfig(config: unknown): NovuConfig {
+ if (!config || typeof config !== 'object') {
+ throw new Error('Invalid config: must be an object');
+ }
+
+ const novuConfig = config as Partial;
+
+ if (!novuConfig.workflows || typeof novuConfig.workflows !== 'object') {
+ throw new Error('Invalid config: workflows field is required and must be an object');
+ }
+
+ const errors: string[] = [];
+
+ for (const [workflowId, workflow] of Object.entries(novuConfig.workflows)) {
+ if (!workflow || typeof workflow !== 'object') {
+ errors.push(`workflows['${workflowId}'] must be an object`);
+ continue;
+ }
+
+ if (!workflow.steps || typeof workflow.steps !== 'object') {
+ errors.push(`workflows['${workflowId}'].steps is required and must be an object`);
+ continue;
+ }
+
+ if (!workflow.steps.email || typeof workflow.steps.email !== 'object') {
+ errors.push(`workflows['${workflowId}'].steps.email is required and must be an object`);
+ continue;
+ }
+
+ const stepIds = new Set();
+
+ for (const [stepId, emailConfig] of Object.entries(workflow.steps.email)) {
+ if (stepIds.has(stepId)) {
+ errors.push(`Duplicate step ID '${stepId}' in workflow '${workflowId}'`);
+ }
+ stepIds.add(stepId);
+
+ if (!emailConfig.template || typeof emailConfig.template !== 'string') {
+ errors.push(`workflows['${workflowId}'].steps.email['${stepId}'].template is required and must be a string`);
+ }
+
+ if (emailConfig.subject !== undefined && typeof emailConfig.subject !== 'string') {
+ errors.push(`workflows['${workflowId}'].steps.email['${stepId}'].subject must be a string`);
+ }
+ }
+ }
+
+ if (novuConfig.outDir !== undefined && typeof novuConfig.outDir !== 'string') {
+ errors.push('outDir must be a string');
+ }
+
+ if (novuConfig.apiUrl !== undefined && typeof novuConfig.apiUrl !== 'string') {
+ errors.push('apiUrl must be a string');
+ }
+
+ if (novuConfig.aliases !== undefined) {
+ if (typeof novuConfig.aliases !== 'object' || novuConfig.aliases === null) {
+ errors.push('aliases must be an object');
+ } else {
+ for (const [alias, target] of Object.entries(novuConfig.aliases)) {
+ if (typeof target !== 'string') {
+ errors.push(`aliases['${alias}'] must be a string`);
+ continue;
+ }
+
+ if (target.trim().length === 0) {
+ errors.push(`aliases['${alias}'] cannot be empty`);
+ }
+ }
+ }
+ }
+
+ if (errors.length > 0) {
+ throw new Error(`Configuration validation errors:\n • ${errors.join('\n • ')}`);
+ }
+
+ return novuConfig as NovuConfig;
+}
diff --git a/packages/novu/src/commands/email/discovery/index.ts b/packages/novu/src/commands/email/discovery/index.ts
new file mode 100644
index 00000000000..9e36f974744
--- /dev/null
+++ b/packages/novu/src/commands/email/discovery/index.ts
@@ -0,0 +1,2 @@
+export { discoverStepFiles } from './step-discovery';
+export { type DiscoveredTemplate, discoverEmailTemplates } from './template-discovery';
diff --git a/packages/novu/src/commands/email/discovery/step-discovery.spec.ts b/packages/novu/src/commands/email/discovery/step-discovery.spec.ts
new file mode 100644
index 00000000000..27e0456c451
--- /dev/null
+++ b/packages/novu/src/commands/email/discovery/step-discovery.spec.ts
@@ -0,0 +1,254 @@
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { discoverStepFiles } from './step-discovery';
+
+describe('step-discovery', () => {
+ let tempDir: string;
+
+ beforeEach(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'novu-test-'));
+ });
+
+ afterEach(() => {
+ if (fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('discovers and validates a correct tsx step file', async () => {
+ writeStepFile(
+ 'welcome-email.step.tsx',
+ createStepFileContent({ stepId: 'welcome-email', workflowId: 'onboarding' })
+ );
+
+ const result = await discoverStepFiles(tempDir);
+
+ expect(result.valid).toBe(true);
+ expect(result.matchedFiles).toBe(1);
+ expect(result.errors).toHaveLength(0);
+ expect(result.steps).toHaveLength(1);
+ expect(result.steps[0]).toMatchObject({
+ stepId: 'welcome-email',
+ workflowId: 'onboarding',
+ type: 'email',
+ relativePath: 'welcome-email.step.tsx',
+ });
+ });
+
+ it('discovers valid js and jsx step files', async () => {
+ writeStepFile(
+ 'plain-js.step.js',
+ createStepFileContent({ stepId: 'plain-js', workflowId: 'workflow-js', useJsx: false })
+ );
+ writeStepFile(
+ 'template-jsx.step.jsx',
+ createStepFileContent({ stepId: 'template-jsx', workflowId: 'workflow-jsx', useJsx: true })
+ );
+
+ const result = await discoverStepFiles(tempDir);
+
+ expect(result.valid).toBe(true);
+ expect(result.matchedFiles).toBe(2);
+ expect(result.errors).toHaveLength(0);
+ expect(result.steps.map((step) => step.stepId)).toEqual(['plain-js', 'template-jsx']);
+ });
+
+ it('returns valid steps and errors when files are mixed', async () => {
+ writeStepFile(
+ 'valid.step.tsx',
+ createStepFileContent({ stepId: 'valid-step', workflowId: 'workflow-valid', useJsx: true })
+ );
+ writeStepFile(
+ 'invalid.step.tsx',
+ createStepFileContent({ includeStepId: false, workflowId: 'workflow-valid', useJsx: true })
+ );
+
+ const result = await discoverStepFiles(tempDir);
+
+ expect(result.valid).toBe(false);
+ expect(result.matchedFiles).toBe(2);
+ expect(result.steps).toHaveLength(1);
+ expect(result.steps[0].stepId).toBe('valid-step');
+
+ const invalidError = result.errors.find((error) => error.filePath.endsWith('invalid.step.tsx'));
+ expect(invalidError).toBeDefined();
+ expect(invalidError?.errors.some((error) => error.includes('stepId'))).toBe(true);
+ });
+
+ it.each([
+ ['stepId', { includeStepId: false }, "Missing required export: 'stepId' (must be a string literal)"],
+ ['workflowId', { includeWorkflowId: false }, "Missing required export: 'workflowId' (must be a string literal)"],
+ ['type', { includeType: false }, "Missing required export: 'type' (must be a string literal)"],
+ ])('detects missing %s export', async (_name, options, expectedError) => {
+ writeStepFile('missing-required.step.tsx', createStepFileContent(options));
+
+ const result = await discoverStepFiles(tempDir);
+
+ expect(result.valid).toBe(false);
+ expect(result.steps).toHaveLength(0);
+ expect(result.errors).toHaveLength(1);
+ expect(result.errors[0].errors).toContain(expectedError);
+ });
+
+ it('detects invalid type export', async () => {
+ writeStepFile('invalid-type.step.tsx', createStepFileContent({ type: 'sms' }));
+
+ const result = await discoverStepFiles(tempDir);
+
+ expect(result.valid).toBe(false);
+ expect(result.steps).toHaveLength(0);
+ expect(result.errors[0].errors.some((error) => error.includes("must be 'email'"))).toBe(true);
+ });
+
+ it('detects missing default export', async () => {
+ writeStepFile('missing-default.step.tsx', createStepFileContent({ includeDefaultExport: false }));
+
+ const result = await discoverStepFiles(tempDir);
+
+ expect(result.valid).toBe(false);
+ expect(result.steps).toHaveLength(0);
+ expect(result.errors[0].errors.some((error) => error.includes('default function'))).toBe(true);
+ });
+
+ it('detects missing react-email import', async () => {
+ writeStepFile('missing-import.step.tsx', createStepFileContent({ includeReactEmailImport: false }));
+
+ const result = await discoverStepFiles(tempDir);
+
+ expect(result.valid).toBe(false);
+ expect(result.steps).toHaveLength(0);
+ expect(result.errors[0].errors.some((error) => error.includes('@react-email'))).toBe(true);
+ });
+
+ it('allows duplicate step IDs across different workflows', async () => {
+ writeStepFile('first.step.tsx', createStepFileContent({ stepId: 'confirmation', workflowId: 'signup' }));
+ writeStepFile('second.step.tsx', createStepFileContent({ stepId: 'confirmation', workflowId: 'booking' }));
+
+ const result = await discoverStepFiles(tempDir);
+
+ expect(result.valid).toBe(true);
+ expect(result.matchedFiles).toBe(2);
+ expect(result.steps).toHaveLength(2);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it('detects duplicate step IDs within same workflow', async () => {
+ writeStepFile('first.step.tsx', createStepFileContent({ stepId: 'duplicate-step', workflowId: 'onboarding' }));
+ writeStepFile('second.step.tsx', createStepFileContent({ stepId: 'duplicate-step', workflowId: 'onboarding' }));
+
+ const result = await discoverStepFiles(tempDir);
+
+ expect(result.valid).toBe(false);
+ expect(result.matchedFiles).toBe(2);
+ expect(result.steps).toHaveLength(0);
+ expect(result.errors).toHaveLength(2);
+ expect(
+ result.errors.every((error) =>
+ error.errors.some((message) => message.includes("Duplicate stepId: 'duplicate-step' for workflow 'onboarding'"))
+ )
+ ).toBe(true);
+ });
+
+ it('returns empty result when no step files are found', async () => {
+ const result = await discoverStepFiles(tempDir);
+
+ expect(result.valid).toBe(true);
+ expect(result.matchedFiles).toBe(0);
+ expect(result.steps).toHaveLength(0);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it('returns discovered steps in deterministic path order', async () => {
+ writeStepFile('z-last.step.ts', createStepFileContent({ stepId: 'z-last', workflowId: 'wf-z', useJsx: false }));
+ writeStepFile(
+ 'nested/m-middle.step.ts',
+ createStepFileContent({ stepId: 'm-middle', workflowId: 'wf-m', useJsx: false })
+ );
+ writeStepFile('a-first.step.ts', createStepFileContent({ stepId: 'a-first', workflowId: 'wf-a', useJsx: false }));
+
+ const result = await discoverStepFiles(tempDir);
+
+ expect(result.valid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ expect(result.steps.map((step) => step.relativePath.replace(/\\/g, '/'))).toEqual([
+ 'a-first.step.ts',
+ 'nested/m-middle.step.ts',
+ 'z-last.step.ts',
+ ]);
+ });
+
+ function writeStepFile(relativePath: string, content: string) {
+ const absolutePath = path.join(tempDir, relativePath);
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
+ fs.writeFileSync(absolutePath, content);
+ }
+
+ function createStepFileContent({
+ stepId = 'welcome-email',
+ workflowId = 'onboarding',
+ type = 'email',
+ includeStepId = true,
+ includeWorkflowId = true,
+ includeType = true,
+ includeDefaultExport = true,
+ includeReactEmailImport = true,
+ useJsx = true,
+ }: {
+ stepId?: string;
+ workflowId?: string;
+ type?: string;
+ includeStepId?: boolean;
+ includeWorkflowId?: boolean;
+ includeType?: boolean;
+ includeDefaultExport?: boolean;
+ includeReactEmailImport?: boolean;
+ useJsx?: boolean;
+ } = {}): string {
+ const lines: string[] = [];
+
+ if (includeReactEmailImport) {
+ lines.push("import { render } from '@react-email/components';");
+ }
+
+ if (useJsx) {
+ lines.push("import EmailTemplate from '../emails/welcome';");
+ }
+
+ lines.push('');
+
+ if (includeStepId) {
+ lines.push(`export const stepId = '${stepId}';`);
+ }
+
+ if (includeWorkflowId) {
+ lines.push(`export const workflowId = '${workflowId}';`);
+ }
+
+ if (includeType) {
+ lines.push(`export const type = '${type}';`);
+ }
+
+ lines.push('');
+
+ if (includeDefaultExport) {
+ lines.push('export default async function({ payload }) {');
+ lines.push(' return {');
+ lines.push(" subject: payload?.subject || 'Welcome',");
+
+ if (useJsx) {
+ lines.push(' body: await render(),');
+ } else {
+ lines.push(" body: await render('Hello'),");
+ }
+
+ lines.push(' };');
+ lines.push('}');
+ }
+
+ lines.push('');
+
+ return lines.join('\n');
+ }
+});
diff --git a/packages/novu/src/commands/email/discovery/step-discovery.ts b/packages/novu/src/commands/email/discovery/step-discovery.ts
new file mode 100644
index 00000000000..aaf9d2e2532
--- /dev/null
+++ b/packages/novu/src/commands/email/discovery/step-discovery.ts
@@ -0,0 +1,277 @@
+import fg from 'fast-glob';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as ts from 'typescript';
+import type { DiscoveredStep, StepDiscoveryResult, ValidationError } from '../types';
+
+interface StepMetadata {
+ stepId?: string;
+ workflowId?: string;
+ type?: string;
+}
+
+interface AnalyzedStepFile {
+ filePath: string;
+ relativePath: string;
+ metadata: StepMetadata;
+ hasDefaultExport: boolean;
+ hasReactEmailImport: boolean;
+ parseErrors: string[];
+}
+
+const STEP_FILE_PATTERN = '**/*.step.{ts,tsx,js,jsx}';
+
+export async function discoverStepFiles(stepsDir: string): Promise {
+ const matchedStepFiles = await fg([STEP_FILE_PATTERN], {
+ cwd: stepsDir,
+ absolute: false,
+ onlyFiles: true,
+ });
+
+ const relativeStepFiles = matchedStepFiles.sort((a, b) => a.localeCompare(b));
+ const analyses = relativeStepFiles.map((relativePath) =>
+ analyzeStepFile(path.resolve(stepsDir, relativePath), relativePath)
+ );
+ const duplicateStepIdErrors = buildDuplicateStepIdErrors(analyses);
+
+ const steps: DiscoveredStep[] = [];
+ const errors: ValidationError[] = [];
+
+ for (const analysis of analyses) {
+ const fileErrors = [...buildValidationErrors(analysis), ...(duplicateStepIdErrors.get(analysis.filePath) ?? [])];
+ if (fileErrors.length > 0) {
+ errors.push({
+ filePath: path.relative(process.cwd(), analysis.filePath),
+ errors: fileErrors,
+ });
+ continue;
+ }
+
+ const { stepId, workflowId, type } = analysis.metadata;
+ if (stepId && workflowId && type) {
+ steps.push({
+ stepId,
+ workflowId,
+ type,
+ filePath: analysis.filePath,
+ relativePath: analysis.relativePath,
+ });
+ }
+ }
+
+ return {
+ valid: errors.length === 0,
+ matchedFiles: relativeStepFiles.length,
+ steps,
+ errors,
+ };
+}
+
+function analyzeStepFile(filePath: string, relativePath: string): AnalyzedStepFile {
+ try {
+ const sourceCode = fs.readFileSync(filePath, 'utf-8');
+ const sourceFile = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.Latest, true, getScriptKind(filePath));
+
+ return {
+ filePath,
+ relativePath,
+ metadata: extractStepMetadata(sourceFile),
+ hasDefaultExport: hasDefaultExportInFile(sourceFile),
+ hasReactEmailImport: hasReactEmailImportInFile(sourceFile),
+ parseErrors: extractParseDiagnostics(sourceFile),
+ };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return {
+ filePath,
+ relativePath,
+ metadata: {},
+ hasDefaultExport: false,
+ hasReactEmailImport: false,
+ parseErrors: [`Failed to read or parse file: ${errorMessage}`],
+ };
+ }
+}
+
+function extractStepMetadata(sourceFile: ts.SourceFile): StepMetadata {
+ const metadata: StepMetadata = {};
+
+ function visit(node: ts.Node) {
+ if (ts.isVariableStatement(node) && hasModifier(node.modifiers, ts.SyntaxKind.ExportKeyword)) {
+ extractExportedStringLiterals(node, metadata);
+ }
+ ts.forEachChild(node, visit);
+ }
+
+ visit(sourceFile);
+ return metadata;
+}
+
+function extractExportedStringLiterals(node: ts.VariableStatement, metadata: StepMetadata): void {
+ for (const declaration of node.declarationList.declarations) {
+ if (
+ !ts.isIdentifier(declaration.name) ||
+ !declaration.initializer ||
+ !ts.isStringLiteral(declaration.initializer)
+ ) {
+ continue;
+ }
+
+ const exportName = declaration.name.text;
+ const exportValue = declaration.initializer.text;
+
+ if (exportName === 'stepId') metadata.stepId = exportValue;
+ if (exportName === 'workflowId') metadata.workflowId = exportValue;
+ if (exportName === 'type') metadata.type = exportValue;
+ }
+}
+
+function hasDefaultExportInFile(sourceFile: ts.SourceFile): boolean {
+ let hasExport = false;
+
+ function visit(node: ts.Node) {
+ if (ts.isFunctionDeclaration(node) && hasModifier(node.modifiers, ts.SyntaxKind.DefaultKeyword)) {
+ hasExport = true;
+ }
+ if (ts.isExportAssignment(node) && !node.isExportEquals) {
+ hasExport = true;
+ }
+ if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
+ if (node.exportClause.elements.some((el) => el.name.text === 'default')) {
+ hasExport = true;
+ }
+ }
+ ts.forEachChild(node, visit);
+ }
+
+ visit(sourceFile);
+ return hasExport;
+}
+
+function hasReactEmailImportInFile(sourceFile: ts.SourceFile): boolean {
+ let hasImport = false;
+
+ function visit(node: ts.Node) {
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
+ const moduleText = node.moduleSpecifier.text;
+ if (moduleText === 'react-email' || moduleText.startsWith('@react-email/')) {
+ hasImport = true;
+ }
+ }
+ ts.forEachChild(node, visit);
+ }
+
+ visit(sourceFile);
+ return hasImport;
+}
+
+function extractParseDiagnostics(sourceFile: ts.SourceFile): string[] {
+ const parseDiagnostics = (sourceFile as ts.SourceFile & { parseDiagnostics?: readonly ts.DiagnosticWithLocation[] })
+ .parseDiagnostics;
+
+ return (parseDiagnostics ?? []).map((diagnostic) => formatParseDiagnostic(sourceFile, diagnostic));
+}
+
+function buildValidationErrors(analysis: AnalyzedStepFile): string[] {
+ const errors: string[] = [...analysis.parseErrors];
+
+ if (!analysis.metadata.stepId) {
+ errors.push("Missing required export: 'stepId' (must be a string literal)");
+ }
+
+ if (!analysis.metadata.workflowId) {
+ errors.push("Missing required export: 'workflowId' (must be a string literal)");
+ }
+
+ if (!analysis.metadata.type) {
+ errors.push("Missing required export: 'type' (must be a string literal)");
+ } else if (analysis.metadata.type !== 'email') {
+ errors.push(`Invalid type: '${analysis.metadata.type}' (must be 'email')`);
+ }
+
+ if (!analysis.hasDefaultExport) {
+ errors.push('Missing default function export');
+ }
+
+ if (!analysis.hasReactEmailImport) {
+ errors.push("Missing import from '@react-email'");
+ }
+
+ return errors;
+}
+
+function buildDuplicateStepIdErrors(analyses: AnalyzedStepFile[]): Map {
+ const filesByCompositeKey = groupAnalysesByCompositeKey(analyses);
+ return buildErrorsForDuplicates(filesByCompositeKey);
+}
+
+function groupAnalysesByCompositeKey(analyses: AnalyzedStepFile[]): Map {
+ const grouped = new Map();
+
+ for (const analysis of analyses) {
+ if (!analysis.metadata.stepId || !analysis.metadata.workflowId) {
+ continue;
+ }
+
+ const key = `${analysis.metadata.workflowId}:${analysis.metadata.stepId}`;
+ const files = grouped.get(key) ?? [];
+ files.push(analysis);
+ grouped.set(key, files);
+ }
+
+ return grouped;
+}
+
+function buildErrorsForDuplicates(filesByKey: Map): Map {
+ const errors = new Map();
+
+ for (const [compositeKey, files] of filesByKey) {
+ if (files.length <= 1) {
+ continue;
+ }
+
+ const firstColonIndex = compositeKey.indexOf(':');
+ const workflowId = firstColonIndex >= 0 ? compositeKey.substring(0, firstColonIndex) : compositeKey;
+ const stepId = firstColonIndex >= 0 ? compositeKey.substring(firstColonIndex + 1) : '';
+ const relativePaths = files.map((file) => path.relative(process.cwd(), file.filePath));
+
+ for (const file of files) {
+ const currentFilePath = path.relative(process.cwd(), file.filePath);
+ const duplicateLocations = relativePaths.filter((candidate) => candidate !== currentFilePath);
+ const entryErrors = errors.get(file.filePath) ?? [];
+ entryErrors.push(
+ `Duplicate stepId: '${stepId}' for workflow '${workflowId}' is also defined in ${duplicateLocations.join(', ')}`
+ );
+ errors.set(file.filePath, entryErrors);
+ }
+ }
+
+ return errors;
+}
+
+function getScriptKind(filePath: string): ts.ScriptKind {
+ const extension = path.extname(filePath).toLowerCase();
+
+ switch (extension) {
+ case '.ts':
+ return ts.ScriptKind.TS;
+ case '.tsx':
+ return ts.ScriptKind.TSX;
+ case '.js':
+ return ts.ScriptKind.JS;
+ case '.jsx':
+ return ts.ScriptKind.JSX;
+ default:
+ return ts.ScriptKind.Unknown;
+ }
+}
+
+function hasModifier(modifiers: readonly ts.ModifierLike[] | undefined, kind: ts.SyntaxKind): boolean {
+ return (modifiers ?? []).some((modifier) => modifier.kind === kind);
+}
+
+function formatParseDiagnostic(sourceFile: ts.SourceFile, diagnostic: ts.DiagnosticWithLocation): string {
+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
+ const position = sourceFile.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
+ return `Syntax error at ${position.line + 1}:${position.character + 1}: ${message}`;
+}
diff --git a/packages/novu/src/commands/email/discovery/template-discovery.spec.ts b/packages/novu/src/commands/email/discovery/template-discovery.spec.ts
new file mode 100644
index 00000000000..b317848b67c
--- /dev/null
+++ b/packages/novu/src/commands/email/discovery/template-discovery.spec.ts
@@ -0,0 +1,51 @@
+import path from 'path';
+import { describe, expect, it } from 'vitest';
+import { discoverEmailTemplates } from './template-discovery';
+
+const fixturesDir = path.join(__dirname, '../__fixtures__/templates');
+
+describe('discoverEmailTemplates', () => {
+ it('should find templates with default export and React Email imports', async () => {
+ const templates = await discoverEmailTemplates(fixturesDir);
+
+ const validTemplate = templates.find((t) => t.relativePath.includes('valid-template'));
+ expect(validTemplate).toBeDefined();
+ });
+
+ it('should ignore test files', async () => {
+ const templates = await discoverEmailTemplates(fixturesDir);
+
+ const testFile = templates.find((t) => t.relativePath.includes('test-file.test'));
+ expect(testFile).toBeUndefined();
+ });
+
+ it('should ignore files without default export', async () => {
+ const templates = await discoverEmailTemplates(fixturesDir);
+
+ const noDefault = templates.find((t) => t.relativePath.includes('no-default-export'));
+ expect(noDefault).toBeUndefined();
+ });
+
+ it('should ignore files without React Email imports', async () => {
+ const templates = await discoverEmailTemplates(fixturesDir);
+
+ const noReactEmail = templates.find((t) => t.relativePath.includes('no-react-email'));
+ expect(noReactEmail).toBeUndefined();
+ });
+
+ it('should return templates with correct structure', async () => {
+ const templates = await discoverEmailTemplates(fixturesDir);
+
+ expect(templates.length).toBeGreaterThan(0);
+ const template = templates[0];
+ expect(template).toHaveProperty('filePath');
+ expect(template).toHaveProperty('relativePath');
+ });
+
+ it('should handle empty directory', async () => {
+ const tempDir = path.join(__dirname, '../__fixtures__/empty');
+ const templates = await discoverEmailTemplates(tempDir);
+
+ expect(Array.isArray(templates)).toBe(true);
+ });
+});
diff --git a/packages/novu/src/commands/email/discovery/template-discovery.ts b/packages/novu/src/commands/email/discovery/template-discovery.ts
new file mode 100644
index 00000000000..db8c44533dd
--- /dev/null
+++ b/packages/novu/src/commands/email/discovery/template-discovery.ts
@@ -0,0 +1,155 @@
+import fg from 'fast-glob';
+import fs from 'fs/promises';
+import path from 'path';
+import ts from 'typescript';
+
+export type DiscoveredTemplate = {
+ filePath: string;
+ relativePath: string;
+};
+
+const DEFAULT_IGNORES = [
+ '**/node_modules/**',
+ '**/.git/**',
+ '**/.next/**',
+ '**/dist/**',
+ '**/build/**',
+ '**/out/**',
+ '**/coverage/**',
+ '**/.turbo/**',
+ '**/.vercel/**',
+ '**/.cache/**',
+ '**/tmp/**',
+ '**/*.test.{ts,tsx,js,jsx}',
+ '**/*.spec.{ts,tsx,js,jsx}',
+ '**/__tests__/**',
+ '**/__mocks__/**',
+ '**/test/**',
+ '**/tests/**',
+ '**/*.stories.{ts,tsx,js,jsx}',
+ '**/*.story.{ts,tsx,js,jsx}',
+ '**/.storybook/**',
+ '**/*.config.{ts,js}',
+ '**/*.d.ts',
+];
+
+export async function discoverEmailTemplates(rootDir: string = process.cwd()): Promise {
+ const files = await fg(['**/*.{tsx,jsx,ts,js}'], {
+ cwd: rootDir,
+ dot: true,
+ absolute: false,
+ ignore: DEFAULT_IGNORES,
+ followSymbolicLinks: true,
+ });
+
+ const out: DiscoveredTemplate[] = [];
+ const CONCURRENCY = 32;
+
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
+ const batch = files.slice(i, i + CONCURRENCY);
+ const batchRes = await Promise.all(
+ batch.map(async (relativePath) => {
+ const filePath = path.join(rootDir, relativePath);
+ const exportNames = await findReactEmailExportsTsAst(filePath);
+ if (!exportNames.length) return null;
+ if (!exportNames.includes('default')) return null;
+
+ return { filePath, relativePath } satisfies DiscoveredTemplate;
+ })
+ );
+ for (const r of batchRes) if (r) out.push(r);
+ }
+
+ return out;
+}
+
+async function findReactEmailExportsTsAst(filePath: string): Promise {
+ let text: string;
+ try {
+ text = await fs.readFile(filePath, 'utf8');
+ } catch {
+ return [];
+ }
+
+ // Quick check to skip files without React Email imports
+ if (!text.includes('@react-email/') && !text.includes('react-email')) {
+ return [];
+ }
+
+ const scriptKind = getScriptKind(filePath);
+
+ const sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, /*setParentNodes*/ true, scriptKind);
+
+ let hasReactEmailImport = false;
+ let hasJsx = false;
+
+ const exports: string[] = [];
+
+ function visit(node: ts.Node) {
+ // imports
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
+ const s = node.moduleSpecifier.text;
+ if (s === '@react-email/components' || s.startsWith('@react-email/') || s === 'react-email') {
+ hasReactEmailImport = true;
+ }
+ }
+
+ // JSX usage
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
+ hasJsx = true;
+ }
+
+ // export default ...
+ if (ts.isExportAssignment(node) && !node.isExportEquals) {
+ exports.push('default');
+ }
+
+ // export named declarations: export const Foo = ..., export function Foo() ...
+ if (ts.isVariableStatement(node) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
+ for (const d of node.declarationList.declarations) {
+ if (ts.isIdentifier(d.name)) exports.push(d.name.text);
+ }
+ }
+
+ if (ts.isFunctionDeclaration(node) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
+ const hasDefault = node.modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
+ if (hasDefault) {
+ exports.push('default');
+ }
+ if (node.name) {
+ exports.push(node.name.text);
+ }
+ }
+
+ // export { Foo, Bar as Baz }
+ if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
+ for (const el of node.exportClause.elements) {
+ exports.push(el.name.text);
+ }
+ }
+
+ ts.forEachChild(node, visit);
+ }
+
+ visit(sf);
+
+ if (!hasReactEmailImport || !hasJsx || exports.length === 0) return [];
+ return Array.from(new Set(exports));
+}
+
+function getScriptKind(filePath: string): ts.ScriptKind {
+ const extension = path.extname(filePath).toLowerCase();
+
+ switch (extension) {
+ case '.tsx':
+ return ts.ScriptKind.TSX;
+ case '.ts':
+ return ts.ScriptKind.TS;
+ case '.jsx':
+ return ts.ScriptKind.JSX;
+ case '.js':
+ return ts.ScriptKind.JS;
+ default:
+ return ts.ScriptKind.JS;
+ }
+}
diff --git a/packages/novu/src/commands/email/index.ts b/packages/novu/src/commands/email/index.ts
new file mode 100644
index 00000000000..7b5db695027
--- /dev/null
+++ b/packages/novu/src/commands/email/index.ts
@@ -0,0 +1,2 @@
+export { emailInit } from './init';
+export { emailPublish } from './publish';
diff --git a/packages/novu/src/commands/email/init.ts b/packages/novu/src/commands/email/init.ts
new file mode 100644
index 00000000000..c2ef119ad62
--- /dev/null
+++ b/packages/novu/src/commands/email/init.ts
@@ -0,0 +1,350 @@
+import * as fs from 'fs';
+import ora from 'ora';
+import * as path from 'path';
+import { cyan, green, red, yellow } from 'picocolors';
+import prompts from 'prompts';
+import { loadConfig } from './config/loader';
+import type { NovuConfig } from './config/schema';
+import { discoverEmailTemplates } from './discovery';
+import { generateStepFile } from './templates/step-file';
+import {
+ buildWorkflowsFromSteps,
+ type ConfiguredStep,
+ flattenConfigToSteps,
+ groupStepsByWorkflow,
+} from './utils/data-transforms';
+import { isInteractive } from './utils/environment';
+import { StepFilePathResolver } from './utils/file-paths';
+import { renderTable } from './utils/table';
+import {
+ generateStepIdFromFilename,
+ generateWorkflowIdFromStepId,
+ validateStepId,
+ validateWorkflowId,
+} from './utils/validation';
+
+interface InitOptions {
+ config?: string;
+ force?: boolean;
+ out?: string;
+ dryRun?: boolean;
+}
+
+export async function emailInit(options: InitOptions): Promise {
+ try {
+ const config = await loadConfig(options.config);
+
+ if (config) {
+ await runInitWithConfig(config, options);
+ } else {
+ if (!isInteractive()) {
+ console.error(red('❌ No novu.config.ts found'));
+ console.error('');
+ console.error('In CI/non-interactive mode, you must provide a config file.');
+ console.error('');
+ console.error('Create novu.config.ts with your step definitions:');
+ console.error('');
+ console.error(cyan(' export default {'));
+ console.error(cyan(' workflows: {'));
+ console.error(cyan(" 'onboarding': {"));
+ console.error(cyan(' steps: {'));
+ console.error(cyan(' email: {'));
+ console.error(cyan(" 'welcome-email': {"));
+ console.error(cyan(" template: 'emails/welcome.tsx',"));
+ console.error(cyan(' },'));
+ console.error(cyan(' },'));
+ console.error(cyan(' },'));
+ console.error(cyan(' },'));
+ console.error(cyan(' },'));
+ console.error(cyan(' };'));
+ console.error('');
+ console.error('Or run this command interactively locally first to generate the config.');
+ process.exit(1);
+ }
+
+ await runInitInteractive(options);
+ }
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('Configuration validation errors:')) {
+ const parts = error.message.split('\n');
+ const configPath = parts[0].replace('Config file: ', '');
+ const errorsPart = parts.slice(1).join('\n');
+
+ console.error('');
+ console.error(red('✖ Configuration validation failed'));
+ console.error('');
+ console.error(`Config file: ${cyan(configPath)}`);
+ console.error('');
+ console.error(red('Errors:'));
+ console.error(errorsPart);
+ console.error('');
+ } else {
+ console.error('');
+ console.error(red('❌ Init failed:'), error instanceof Error ? error.message : error);
+ console.error('');
+ }
+ process.exit(1);
+ }
+}
+
+async function runInitWithConfig(config: NovuConfig, options: InitOptions): Promise {
+ const rootDir = process.cwd();
+ const configPath = options.config || 'novu.config.ts';
+
+ console.log(cyan(`\n🔍 Reading configuration from ${configPath}\n`));
+
+ const spinner = ora('Validating configuration...').start();
+
+ const allSteps = flattenConfigToSteps(config);
+ spinner.text = `Found ${allSteps.length} step definition(s)`;
+
+ const errors: string[] = [];
+ for (const { workflowId, stepId, emailConfig } of allSteps) {
+ const templateAbsPath = path.resolve(rootDir, emailConfig.template);
+ if (!fs.existsSync(templateAbsPath)) {
+ errors.push(`Template not found: ${emailConfig.template} (workflow: ${workflowId}, step: ${stepId})`);
+ }
+ }
+
+ if (errors.length > 0) {
+ spinner.fail('Validation failed');
+ console.error('');
+ console.error(red('Errors:'));
+ for (const error of errors) {
+ console.error(red(` • ${error}`));
+ }
+ console.error('');
+ process.exit(1);
+ }
+
+ spinner.succeed('Configuration valid');
+ console.log('');
+
+ console.log('Steps to generate:');
+ renderTable(allSteps, [
+ { header: 'Workflow ID', getValue: (s) => s.workflowId },
+ { header: 'Step ID', getValue: (s) => s.stepId },
+ { header: 'Template', getValue: (s) => s.emailConfig.template },
+ ]);
+ console.log('');
+
+ if (options.dryRun) {
+ console.log(yellow('🔍 Dry run mode - no files will be created'));
+ console.log('');
+ return;
+ }
+
+ const outDir = options.out || config.outDir || './novu';
+ const outDirPath = path.resolve(rootDir, outDir);
+ const pathResolver = new StepFilePathResolver(rootDir, outDirPath);
+
+ console.log(green(`📁 Generating step handlers in ${outDir}\n`));
+
+ let createdCount = 0;
+ let skippedCount = 0;
+
+ for (const { workflowId, stepId, emailConfig } of allSteps) {
+ const workflowDir = pathResolver.getWorkflowDir(workflowId);
+
+ if (!fs.existsSync(workflowDir)) {
+ fs.mkdirSync(workflowDir, { recursive: true });
+ }
+
+ const stepFilePath = pathResolver.getStepFilePath(workflowId, stepId);
+ const relativeStepPath = pathResolver.getRelativeStepPath(workflowId, stepId);
+
+ if (fs.existsSync(stepFilePath) && !options.force) {
+ console.log(yellow(` ⊘ ${relativeStepPath}`) + ' (exists, use --force to overwrite)');
+ skippedCount++;
+ continue;
+ }
+
+ const templateImportPath = pathResolver.getTemplateImportPath(workflowId, emailConfig.template);
+ const stepFileContent = generateStepFile(stepId, workflowId, templateImportPath, emailConfig);
+
+ fs.writeFileSync(stepFilePath, stepFileContent, 'utf8');
+
+ console.log(green(` ✓ ${relativeStepPath}`));
+ createdCount++;
+ }
+
+ console.log('');
+ console.log(green(`✅ Generated ${createdCount} step handler(s)`));
+ if (skippedCount > 0) {
+ console.log(yellow(` Skipped ${skippedCount} file(s)`));
+ }
+
+ console.log('');
+ console.log(cyan('📝 Next steps:'));
+ console.log(' 1. Review the generated files in ' + outDir);
+ console.log(' 2. Customize handlers if needed');
+ console.log(" 3. Run 'npx novu email publish' to deploy");
+ console.log('');
+}
+
+async function runInitInteractive(options: InitOptions): Promise {
+ console.log(yellow('\n⚠️ No novu.config.ts found\n'));
+ console.log("Let's create one! I'll scan for email templates and help you set up.\n");
+
+ const spinner = ora('Scanning for email templates...').start();
+ const templates = await discoverEmailTemplates();
+
+ if (templates.length === 0) {
+ spinner.fail('No email templates found');
+ console.error('');
+ console.error(red('Expected React Email templates in:'));
+ console.error(' • emails/**/*.tsx');
+ console.error(' • emails/**/*.jsx');
+ console.error(' • src/emails/**/*.tsx');
+ console.error(' • src/emails/**/*.jsx');
+ console.error('');
+ console.error('Create your email templates first, then run init again.');
+ console.error('');
+ console.error(cyan('Learn more: https://react.email/docs/introduction'));
+ console.error('');
+ process.exit(1);
+ }
+
+ spinner.succeed(`Found ${templates.length} template(s):`);
+ for (const template of templates) {
+ console.log(` • ${template.relativePath}`);
+ }
+ console.log('');
+
+ console.log(cyan("📝 Let's configure the templates:\n"));
+
+ const configuredSteps: ConfiguredStep[] = [];
+ const existingStepIdsByWorkflow = new Map>();
+
+ for (const template of templates) {
+ console.log(green(`\n📧 ${template.relativePath}`));
+
+ const includeResponse = await prompts({
+ type: 'confirm',
+ name: 'include',
+ message: 'Include this template?',
+ initial: true,
+ });
+
+ if (includeResponse.include === undefined) {
+ console.log(yellow('\n⚠️ Setup cancelled\n'));
+ process.exit(130);
+ }
+
+ if (includeResponse.include === false) {
+ console.log(yellow(' Skipped'));
+ continue;
+ }
+
+ const filename = path.basename(template.relativePath);
+ const suggestedStepId = generateStepIdFromFilename(filename);
+ const suggestedWorkflowId = generateWorkflowIdFromStepId(suggestedStepId);
+
+ const workflowResponse = await prompts({
+ type: 'text',
+ name: 'workflowId',
+ message: 'Workflow ID:',
+ initial: suggestedWorkflowId,
+ validate: validateWorkflowId,
+ });
+
+ if (!workflowResponse.workflowId) {
+ console.log(yellow('\n⚠️ Setup cancelled\n'));
+ process.exit(130);
+ }
+
+ const workflowId = workflowResponse.workflowId;
+ const existingStepIds = existingStepIdsByWorkflow.get(workflowId) || new Set();
+
+ const stepResponse = await prompts({
+ type: 'text',
+ name: 'stepId',
+ message: 'Step ID:',
+ initial: suggestedStepId,
+ validate: (value) => validateStepId(value, existingStepIds),
+ });
+
+ if (!stepResponse.stepId) {
+ console.log(yellow('\n⚠️ Setup cancelled\n'));
+ process.exit(130);
+ }
+
+ const subjectResponse = await prompts({
+ type: 'text',
+ name: 'subject',
+ message: 'Default subject (optional):',
+ initial: '',
+ });
+
+ if (!subjectResponse || !('subject' in subjectResponse)) {
+ console.log(yellow('\n⚠️ Setup cancelled\n'));
+ process.exit(130);
+ }
+
+ existingStepIds.add(stepResponse.stepId);
+ existingStepIdsByWorkflow.set(workflowId, existingStepIds);
+
+ configuredSteps.push({
+ workflowId,
+ stepId: stepResponse.stepId,
+ config: {
+ template: template.relativePath,
+ ...(subjectResponse.subject && { subject: subjectResponse.subject }),
+ },
+ });
+
+ console.log('');
+ }
+
+ if (configuredSteps.length === 0) {
+ console.log('');
+ console.log(yellow('⚠️ No templates selected — aborting initialization'));
+ console.log('');
+ return;
+ }
+
+ console.log(cyan('💾 Saving configuration to novu.config.ts...'));
+
+ const configContent = generateConfigFile(configuredSteps);
+ fs.writeFileSync('novu.config.ts', configContent, 'utf8');
+
+ console.log(green(' ✓ Created novu.config.ts\n'));
+
+ const workflows = buildWorkflowsFromSteps(configuredSteps);
+ const config: NovuConfig = { workflows };
+
+ await runInitWithConfig(config, options);
+}
+
+function escapeString(value: string): string {
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+}
+
+function generateConfigFile(steps: ConfiguredStep[]): string {
+ const workflowsMap = groupStepsByWorkflow(steps);
+
+ let content = 'export default {\n workflows: {\n';
+
+ for (const [workflowId, workflowSteps] of workflowsMap) {
+ content += ` '${escapeString(workflowId)}': {\n`;
+ content += ` steps: {\n`;
+ content += ` email: {\n`;
+
+ for (const { stepId, config } of workflowSteps) {
+ content += ` '${escapeString(stepId)}': {\n`;
+ content += ` template: '${escapeString(config.template)}',\n`;
+ if (config.subject) {
+ content += ` subject: '${escapeString(config.subject)}',\n`;
+ }
+ content += ` },\n`;
+ }
+
+ content += ` },\n`;
+ content += ` },\n`;
+ content += ` },\n`;
+ }
+
+ content += ' },\n};\n';
+
+ return content;
+}
diff --git a/packages/novu/src/commands/email/publish.ts b/packages/novu/src/commands/email/publish.ts
new file mode 100644
index 00000000000..75512a7bc59
--- /dev/null
+++ b/packages/novu/src/commands/email/publish.ts
@@ -0,0 +1,407 @@
+import * as fs from 'fs/promises';
+import ora from 'ora';
+import * as path from 'path';
+import { green, red, yellow } from 'picocolors';
+import { StepResolverClient } from './api';
+import { bundleRelease, formatBundleSize } from './bundler';
+import { loadConfig } from './config/loader';
+import { discoverStepFiles } from './discovery';
+import type {
+ DeploymentResult,
+ DiscoveredStep,
+ EnvironmentInfo,
+ StepResolverManifestStep,
+ StepResolverReleaseBundle,
+} from './types';
+import { renderTable, withSpinner } from './utils';
+
+interface PublishOptions {
+ secretKey?: string;
+ apiUrl?: string;
+ config?: string;
+ out?: string;
+ workflow?: string[] | string;
+ bundleOutDir?: string | boolean;
+ dryRun?: boolean;
+}
+
+const DEFAULT_API_URL = 'https://api.novu.co';
+const DEFAULT_STEPS_DIR = './novu';
+const RELEASE_ARTIFACT_BASENAME = 'step-resolver-release';
+
+export async function emailPublish(options: PublishOptions): Promise {
+ try {
+ const rootDir = process.cwd();
+ const config = await loadConfig(options.config);
+ const apiUrl = options.apiUrl || process.env.NOVU_API_URL || config?.apiUrl || DEFAULT_API_URL;
+ const secretKey = options.secretKey || process.env.NOVU_SECRET_KEY;
+ assertSecretKey(secretKey);
+
+ const stepsDirLabel = options.out || config?.outDir || DEFAULT_STEPS_DIR;
+ const stepsDir = path.resolve(rootDir, stepsDirLabel);
+ console.log('');
+ const client = new StepResolverClient(apiUrl, secretKey);
+ await authenticate(client, apiUrl);
+
+ const discoveredSteps = await discoverAndValidateSteps(stepsDir, stepsDirLabel);
+ const selectedSteps = selectStepsByWorkflow(discoveredSteps, options.workflow);
+ printDiscoveredSteps(selectedSteps, discoveredSteps.length, options.workflow);
+
+ const shouldMinifyBundles = !options.bundleOutDir;
+ if (!shouldMinifyBundles) {
+ console.log(yellow('ℹ Debug bundle mode enabled: generating unminified release bundle.'));
+ console.log('');
+ }
+
+ const releaseBundle = await buildReleaseBundle(selectedSteps, rootDir, shouldMinifyBundles, config?.aliases);
+ const manifestSteps = selectedSteps.map((step) => ({
+ workflowId: step.workflowId,
+ stepId: step.stepId,
+ }));
+
+ const bundleOutputDir = resolveBundleOutputDir(options.bundleOutDir, rootDir);
+ if (bundleOutputDir) {
+ await writeBundleArtifactsWithSpinner(releaseBundle, manifestSteps, bundleOutputDir, rootDir);
+ }
+
+ if (options.dryRun) {
+ printDryRunSummary(releaseBundle, selectedSteps, manifestSteps);
+ return;
+ }
+
+ const deployment = await deployRelease(client, releaseBundle, manifestSteps);
+ printSuccessSummary(deployment, selectedSteps);
+ } catch (error) {
+ console.error('');
+ console.error(red('❌ Publish failed:'), error instanceof Error ? error.message : error);
+ console.error('');
+ process.exit(1);
+ }
+}
+
+function assertSecretKey(secretKey?: string): asserts secretKey is string {
+ if (secretKey) {
+ return;
+ }
+
+ console.error('');
+ console.error(red('❌ Authentication required'));
+ console.error('');
+ console.error('Provide your API key via:');
+ console.error(' 1. CLI flag: npx novu email publish --secret-key nv-xxx');
+ console.error(' 2. Environment: export NOVU_SECRET_KEY=nv-xxx');
+ console.error(' 3. .env file: NOVU_SECRET_KEY=nv-xxx');
+ console.error('');
+ console.error('Get your API key at: https://dashboard.novu.co/api-keys');
+ console.error('');
+ process.exit(1);
+}
+
+async function authenticate(client: StepResolverClient, apiUrl: string): Promise {
+ const envInfo = await withSpinner(
+ 'Authenticating with Novu...',
+ async () => {
+ try {
+ await client.validateConnection();
+ const envInfo = await client.getEnvironmentInfo();
+ return envInfo;
+ } catch (error) {
+ console.error(`Using API URL: ${apiUrl}`);
+ console.error('(For EU region, use: --api-url https://eu.api.novu.co)');
+ console.error('');
+ throw error;
+ }
+ },
+ { successMessage: 'Authenticated with Novu', failMessage: 'Authentication failed' }
+ );
+
+ console.log(` ${green('✓')} Environment: ${envInfo.name} (${envInfo._id})`);
+ console.log('');
+ return envInfo;
+}
+
+async function discoverAndValidateSteps(stepsDir: string, stepsDirLabel: string): Promise {
+ return withSpinner(
+ `Discovering steps in ${stepsDirLabel}...`,
+ async () => {
+ const discovery = await discoverStepFiles(stepsDir);
+
+ if (discovery.matchedFiles === 0) {
+ console.error('');
+ console.error(red(`❌ No step files found in ${stepsDir}`));
+ console.error('');
+ console.error('Expected *.step.tsx, *.step.ts, *.step.jsx, or *.step.js files.');
+ console.error('');
+ console.error("Run 'npx novu email init' first to generate step handlers.");
+ console.error('');
+ throw new Error('No step files found');
+ }
+
+ if (!discovery.valid) {
+ console.error('');
+ console.error(red('❌ Step file validation failed'));
+ console.error('');
+
+ for (const fileError of discovery.errors) {
+ console.error(red(`Errors in ${fileError.filePath}:`));
+ for (const error of fileError.errors) {
+ console.error(red(` • ${error}`));
+ }
+ console.error('');
+ }
+
+ console.error("Fix these errors and run 'npx novu email init --force' to regenerate step files.");
+ console.error('');
+ throw new Error('Step file validation failed');
+ }
+
+ return discovery.steps;
+ },
+ { successMessage: 'Discovered step files', failMessage: 'Discovery failed' }
+ );
+}
+
+function selectStepsByWorkflow(
+ discoveredSteps: DiscoveredStep[],
+ requestedWorkflowOption?: string[] | string
+): DiscoveredStep[] {
+ const requestedWorkflows = normalizeRequestedWorkflows(requestedWorkflowOption);
+ if (requestedWorkflows.length === 0) {
+ return discoveredSteps;
+ }
+
+ const requestedSet = new Set(requestedWorkflows);
+ const selectedSteps = discoveredSteps.filter((step) => requestedSet.has(step.workflowId));
+ const missingWorkflows = requestedWorkflows.filter(
+ (workflowId) => !selectedSteps.some((step) => step.workflowId === workflowId)
+ );
+
+ if (missingWorkflows.length > 0) {
+ console.error(red(`❌ Step(s) not found for workflow(s): ${missingWorkflows.join(', ')}`));
+ console.error('');
+ console.error('Available workflows:');
+ const availableWorkflows = Array.from(new Set(discoveredSteps.map((step) => step.workflowId))).sort();
+ for (const workflow of availableWorkflows) {
+ console.error(` • ${workflow}`);
+ }
+ console.error('');
+ process.exit(1);
+ }
+
+ return selectedSteps;
+}
+
+function normalizeRequestedWorkflows(requestedWorkflowOption?: string[] | string): string[] {
+ if (!requestedWorkflowOption) {
+ return [];
+ }
+
+ if (Array.isArray(requestedWorkflowOption)) {
+ return requestedWorkflowOption;
+ }
+
+ return [requestedWorkflowOption];
+}
+
+function printDiscoveredSteps(
+ steps: DiscoveredStep[],
+ totalDiscoveredSteps: number,
+ selectedWorkflowOption?: string[] | string
+) {
+ for (const step of steps) {
+ console.log(` ${green('✓')} ${step.stepId} (workflow: ${step.workflowId})`);
+ }
+
+ const workflowCount = new Set(steps.map((step) => step.workflowId)).size;
+ const requestedWorkflows = normalizeRequestedWorkflows(selectedWorkflowOption);
+
+ console.log('');
+ if (requestedWorkflows.length > 0) {
+ console.log(
+ ` Found ${steps.length} step(s) across ${workflowCount} workflow(s) (filtered from ${totalDiscoveredSteps} total step(s))`
+ );
+ } else {
+ console.log(` Found ${steps.length} step(s) across ${workflowCount} workflow(s)`);
+ }
+ console.log('');
+}
+
+async function buildReleaseBundle(
+ selectedSteps: DiscoveredStep[],
+ rootDir: string,
+ minify: boolean,
+ aliases?: Record
+): Promise {
+ const bundle = await withSpinner(
+ 'Packaging steps...',
+ async () => {
+ return bundleRelease(selectedSteps, rootDir, { minify, aliases });
+ },
+ { successMessage: 'Packaged successfully', failMessage: 'Packaging failed' }
+ );
+
+ const workflowCount = new Set(selectedSteps.map((step) => step.workflowId)).size;
+ console.log(
+ ` ${green('✓')} ${selectedSteps.length} step(s), ${workflowCount} workflow(s), ${formatBundleSize(bundle.size)}`
+ );
+ console.log('');
+ return bundle;
+}
+
+async function deployRelease(
+ client: StepResolverClient,
+ releaseBundle: StepResolverReleaseBundle,
+ manifestSteps: StepResolverManifestStep[]
+): Promise {
+ const deploySpinner = ora('Publishing...').start();
+
+ try {
+ const result = await client.deployRelease(releaseBundle, manifestSteps);
+ deploySpinner.stop();
+
+ return result;
+ } catch (error) {
+ deploySpinner.fail('Publishing failed');
+ throw error;
+ }
+}
+
+function printDryRunSummary(
+ bundle: StepResolverReleaseBundle,
+ selectedSteps: DiscoveredStep[],
+ manifestSteps: StepResolverManifestStep[]
+): void {
+ const workflowCount = new Set(selectedSteps.map((step) => step.workflowId)).size;
+
+ console.log(yellow('🔍 Dry run mode - skipping deployment'));
+ console.log('');
+ console.log('Package summary:');
+ console.log(` • Size: ${formatBundleSize(bundle.size)}`);
+ console.log(` • Steps: ${selectedSteps.length}`);
+ console.log(` • Workflows: ${workflowCount}`);
+ console.log('');
+ console.log('Included steps:');
+ for (const step of manifestSteps) {
+ console.log(` • ${step.stepId} (workflow: ${step.workflowId})`);
+ }
+ console.log('');
+ console.log(green('✅ Ready to publish!'));
+ console.log('');
+}
+
+function printSuccessSummary(deployment: DeploymentResult, steps: DiscoveredStep[]): void {
+ console.log(green('✅ Published successfully!'));
+ console.log('');
+
+ const workflowCount = new Set(steps.map((step) => step.workflowId)).size;
+ const stepText = deployment.selectedStepsCount === 1 ? 'step' : 'steps';
+ const workflowText = workflowCount === 1 ? 'workflow' : 'workflows';
+ console.log(
+ ` ${deployment.selectedStepsCount} ${stepText} across ${workflowCount} ${workflowText} ${deployment.selectedStepsCount === 1 ? 'is' : 'are'} now live`
+ );
+ console.log('');
+ console.log(` Version: ${deployment.stepResolverHash}`);
+ console.log(` Published: ${formatDeploymentTime(deployment.deployedAt)}`);
+ console.log('');
+
+ renderTable(
+ steps,
+ [
+ { header: 'Step', getValue: (s) => s.stepId },
+ { header: 'Workflow', getValue: (s) => s.workflowId },
+ { header: 'Status', getValue: () => green('Live') },
+ ],
+ ' '
+ );
+ console.log('');
+}
+
+function formatDeploymentTime(isoString: string): string {
+ const date = new Date(isoString);
+ const month = date.toLocaleString('en-US', { month: 'short' });
+ const day = date.getDate();
+ const year = date.getFullYear();
+ const time = date.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
+ return `${month} ${day}, ${year} at ${time}`;
+}
+
+interface ReleaseArtifactFiles {
+ bundlePath: string;
+ manifestPath: string;
+ metadataPath: string;
+}
+
+async function writeBundleArtifactsWithSpinner(
+ bundle: StepResolverReleaseBundle,
+ manifestSteps: StepResolverManifestStep[],
+ outputDir: string,
+ rootDir: string
+): Promise {
+ const outputDirLabel = path.relative(rootDir, outputDir) || '.';
+
+ return withSpinner(
+ `Writing bundle artifacts to ${outputDirLabel}...`,
+ async () => {
+ const artifacts = await writeBundleArtifacts(bundle, manifestSteps, outputDir);
+
+ console.log(` ${green('✓')} ${path.relative(rootDir, artifacts.bundlePath)}`);
+ console.log(` ${green('✓')} ${path.relative(rootDir, artifacts.manifestPath)}`);
+ console.log(` ${green('✓')} ${path.relative(rootDir, artifacts.metadataPath)}`);
+ console.log('');
+ },
+ { successMessage: `Saved bundle artifacts to ${outputDirLabel}`, failMessage: 'Failed to write bundle artifacts' }
+ );
+}
+
+async function writeBundleArtifacts(
+ bundle: StepResolverReleaseBundle,
+ manifestSteps: StepResolverManifestStep[],
+ outputDir: string
+): Promise {
+ await fs.mkdir(outputDir, { recursive: true });
+
+ const bundlePath = path.join(outputDir, `${RELEASE_ARTIFACT_BASENAME}.worker.mjs`);
+ const manifestPath = path.join(outputDir, `${RELEASE_ARTIFACT_BASENAME}.manifest.json`);
+ const metadataPath = path.join(outputDir, `${RELEASE_ARTIFACT_BASENAME}.meta.json`);
+ const workflowIds = Array.from(new Set(manifestSteps.map((step) => step.workflowId))).sort((a, b) =>
+ a.localeCompare(b)
+ );
+ const stepIds = manifestSteps.map((step) => step.stepId);
+
+ await fs.writeFile(bundlePath, bundle.code, 'utf8');
+ await fs.writeFile(manifestPath, `${JSON.stringify({ steps: manifestSteps }, null, 2)}\n`, 'utf8');
+ await fs.writeFile(
+ metadataPath,
+ `${JSON.stringify(
+ {
+ releaseId: RELEASE_ARTIFACT_BASENAME,
+ size: bundle.size,
+ workflowIds,
+ stepIds,
+ createdAt: new Date().toISOString(),
+ },
+ null,
+ 2
+ )}\n`,
+ 'utf8'
+ );
+
+ return {
+ bundlePath,
+ manifestPath,
+ metadataPath,
+ };
+}
+
+function resolveBundleOutputDir(bundleOutDir: PublishOptions['bundleOutDir'], rootDir: string): string | undefined {
+ if (!bundleOutDir) {
+ return undefined;
+ }
+
+ if (bundleOutDir === true) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return path.resolve(rootDir, '.novu', 'bundles', timestamp);
+ }
+
+ return path.resolve(rootDir, bundleOutDir);
+}
diff --git a/packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap b/packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap
new file mode 100644
index 00000000000..e113d47990d
--- /dev/null
+++ b/packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap
@@ -0,0 +1,97 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`generateStepFile > should match snapshot 1`] = `
+"import { render } from '@react-email/components';
+import EmailTemplate from '../emails/welcome';
+
+export const stepId = 'welcome-email';
+export const workflowId = 'onboarding';
+export const type = 'email';
+
+export default async function({ payload, subscriber, context, steps }) {
+ return {
+ subject: payload.subject || 'No Subject',
+ body: await render(
+
+ ),
+ };
+}
+"
+`;
+
+exports[`generateStepFile > should match snapshot with different import paths > nested-import 1`] = `
+"import { render } from '@react-email/components';
+import EmailTemplate from '../../src/emails/welcome';
+
+export const stepId = 'welcome-email';
+export const workflowId = 'onboarding';
+export const type = 'email';
+
+export default async function({ payload, subscriber, context, steps }) {
+ return {
+ subject: payload.subject || 'No Subject',
+ body: await render(
+
+ ),
+ };
+}
+"
+`;
+
+exports[`generateStepFile > should match snapshot with different import paths > relative-import 1`] = `
+"import { render } from '@react-email/components';
+import EmailTemplate from './emails/welcome';
+
+export const stepId = 'welcome-email';
+export const workflowId = 'onboarding';
+export const type = 'email';
+
+export default async function({ payload, subscriber, context, steps }) {
+ return {
+ subject: payload.subject || 'No Subject',
+ body: await render(
+
+ ),
+ };
+}
+"
+`;
+
+exports[`generateStepFile > should match snapshot with subject > with-subject 1`] = `
+"import { render } from '@react-email/components';
+import EmailTemplate from '../emails/welcome';
+
+export const stepId = 'welcome-email';
+export const workflowId = 'onboarding';
+export const type = 'email';
+
+export default async function({ payload, subscriber, context, steps }) {
+ return {
+ subject: payload.subject || 'Welcome to Acme!',
+ body: await render(
+
+ ),
+ };
+}
+"
+`;
diff --git a/packages/novu/src/commands/email/templates/__snapshots__/worker-wrapper.spec.ts.snap b/packages/novu/src/commands/email/templates/__snapshots__/worker-wrapper.spec.ts.snap
new file mode 100644
index 00000000000..c8b37791dac
--- /dev/null
+++ b/packages/novu/src/commands/email/templates/__snapshots__/worker-wrapper.spec.ts.snap
@@ -0,0 +1,258 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`generateWorkerWrapper > should handle empty steps array > empty-steps 1`] = `
+"
+
+const stepHandlers = new Map([
+
+]);
+
+const JSON_HEADERS = { 'Content-Type': 'application/json' };
+
+function isObject(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function jsonResponse(body, status, extraHeaders = {}) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { ...JSON_HEADERS, ...extraHeaders },
+ });
+}
+
+export default {
+ async fetch(request) {
+ try {
+ if (request.method !== 'POST') {
+ return jsonResponse({ error: 'Method not allowed' }, 405, { Allow: 'POST' });
+ }
+
+ const url = new URL(request.url);
+ const stepName = url.searchParams.get('step') || request.headers.get('X-Step-Name');
+
+ if (!stepName) {
+ return jsonResponse(
+ { error: 'Missing step name', message: 'Provide step name via ?step= query param or X-Step-Name header' },
+ 400
+ );
+ }
+
+ const step = stepHandlers.get(stepName);
+ if (!step) {
+ return jsonResponse(
+ { error: 'Step not found', stepName, available: Array.from(stepHandlers.keys()) },
+ 404
+ );
+ }
+
+ let body = {};
+ const rawBody = await request.text();
+ if (rawBody) {
+ try {
+ body = JSON.parse(rawBody);
+ } catch {
+ return jsonResponse({ error: 'Invalid JSON body' }, 400);
+ }
+ }
+
+ if (!isObject(body)) {
+ return jsonResponse({ error: 'Invalid request body', message: 'Body must be a JSON object' }, 400);
+ }
+
+ const payload = body.payload ?? {};
+ const subscriber = body.subscriber ?? {};
+ const context = body.context ?? {};
+ const stepOutputs = body.steps ?? {};
+
+ if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs)) {
+ return jsonResponse(
+ { error: 'Invalid request body', message: 'payload, subscriber, context, and steps must be JSON objects' },
+ 400
+ );
+ }
+
+ const result = await step.handler({ payload, subscriber, context, steps: stepOutputs });
+
+ return jsonResponse(
+ { stepId: step.stepId, workflowId: step.workflowId, subject: result.subject, body: result.body },
+ 200
+ );
+ } catch (error) {
+ console.error('Error executing step handler:', error);
+ return jsonResponse({ error: 'Step execution failed', message: 'Internal server error' }, 500);
+ }
+ },
+};"
+`;
+
+exports[`generateWorkerWrapper > should handle single step > single-step 1`] = `
+"import stepHandler0, { stepId as stepId0, workflowId as workflowId0 } from "./novu/welcome-email.step";
+
+const stepHandlers = new Map([
+ ["welcome-email", { handler: stepHandler0, stepId: stepId0, workflowId: workflowId0 }]
+]);
+
+const JSON_HEADERS = { 'Content-Type': 'application/json' };
+
+function isObject(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function jsonResponse(body, status, extraHeaders = {}) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { ...JSON_HEADERS, ...extraHeaders },
+ });
+}
+
+export default {
+ async fetch(request) {
+ try {
+ if (request.method !== 'POST') {
+ return jsonResponse({ error: 'Method not allowed' }, 405, { Allow: 'POST' });
+ }
+
+ const url = new URL(request.url);
+ const stepName = url.searchParams.get('step') || request.headers.get('X-Step-Name');
+
+ if (!stepName) {
+ return jsonResponse(
+ { error: 'Missing step name', message: 'Provide step name via ?step= query param or X-Step-Name header' },
+ 400
+ );
+ }
+
+ const step = stepHandlers.get(stepName);
+ if (!step) {
+ return jsonResponse(
+ { error: 'Step not found', stepName, available: Array.from(stepHandlers.keys()) },
+ 404
+ );
+ }
+
+ let body = {};
+ const rawBody = await request.text();
+ if (rawBody) {
+ try {
+ body = JSON.parse(rawBody);
+ } catch {
+ return jsonResponse({ error: 'Invalid JSON body' }, 400);
+ }
+ }
+
+ if (!isObject(body)) {
+ return jsonResponse({ error: 'Invalid request body', message: 'Body must be a JSON object' }, 400);
+ }
+
+ const payload = body.payload ?? {};
+ const subscriber = body.subscriber ?? {};
+ const context = body.context ?? {};
+ const stepOutputs = body.steps ?? {};
+
+ if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs)) {
+ return jsonResponse(
+ { error: 'Invalid request body', message: 'payload, subscriber, context, and steps must be JSON objects' },
+ 400
+ );
+ }
+
+ const result = await step.handler({ payload, subscriber, context, steps: stepOutputs });
+
+ return jsonResponse(
+ { stepId: step.stepId, workflowId: step.workflowId, subject: result.subject, body: result.body },
+ 200
+ );
+ } catch (error) {
+ console.error('Error executing step handler:', error);
+ return jsonResponse({ error: 'Step execution failed', message: 'Internal server error' }, 500);
+ }
+ },
+};"
+`;
+
+exports[`generateWorkerWrapper > should match snapshot 1`] = `
+"import stepHandler0, { stepId as stepId0, workflowId as workflowId0 } from "./novu/welcome-email.step";
+import stepHandler1, { stepId as stepId1, workflowId as workflowId1 } from "./novu/verify-email.step";
+
+const stepHandlers = new Map([
+ ["welcome-email", { handler: stepHandler0, stepId: stepId0, workflowId: workflowId0 }],
+ ["verify-email", { handler: stepHandler1, stepId: stepId1, workflowId: workflowId1 }]
+]);
+
+const JSON_HEADERS = { 'Content-Type': 'application/json' };
+
+function isObject(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function jsonResponse(body, status, extraHeaders = {}) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { ...JSON_HEADERS, ...extraHeaders },
+ });
+}
+
+export default {
+ async fetch(request) {
+ try {
+ if (request.method !== 'POST') {
+ return jsonResponse({ error: 'Method not allowed' }, 405, { Allow: 'POST' });
+ }
+
+ const url = new URL(request.url);
+ const stepName = url.searchParams.get('step') || request.headers.get('X-Step-Name');
+
+ if (!stepName) {
+ return jsonResponse(
+ { error: 'Missing step name', message: 'Provide step name via ?step= query param or X-Step-Name header' },
+ 400
+ );
+ }
+
+ const step = stepHandlers.get(stepName);
+ if (!step) {
+ return jsonResponse(
+ { error: 'Step not found', stepName, available: Array.from(stepHandlers.keys()) },
+ 404
+ );
+ }
+
+ let body = {};
+ const rawBody = await request.text();
+ if (rawBody) {
+ try {
+ body = JSON.parse(rawBody);
+ } catch {
+ return jsonResponse({ error: 'Invalid JSON body' }, 400);
+ }
+ }
+
+ if (!isObject(body)) {
+ return jsonResponse({ error: 'Invalid request body', message: 'Body must be a JSON object' }, 400);
+ }
+
+ const payload = body.payload ?? {};
+ const subscriber = body.subscriber ?? {};
+ const context = body.context ?? {};
+ const stepOutputs = body.steps ?? {};
+
+ if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs)) {
+ return jsonResponse(
+ { error: 'Invalid request body', message: 'payload, subscriber, context, and steps must be JSON objects' },
+ 400
+ );
+ }
+
+ const result = await step.handler({ payload, subscriber, context, steps: stepOutputs });
+
+ return jsonResponse(
+ { stepId: step.stepId, workflowId: step.workflowId, subject: result.subject, body: result.body },
+ 200
+ );
+ } catch (error) {
+ console.error('Error executing step handler:', error);
+ return jsonResponse({ error: 'Step execution failed', message: 'Internal server error' }, 500);
+ }
+ },
+};"
+`;
diff --git a/packages/novu/src/commands/email/templates/index.ts b/packages/novu/src/commands/email/templates/index.ts
new file mode 100644
index 00000000000..8363b974b3d
--- /dev/null
+++ b/packages/novu/src/commands/email/templates/index.ts
@@ -0,0 +1 @@
+export { generateStepFile } from './step-file';
diff --git a/packages/novu/src/commands/email/templates/step-file.spec.ts b/packages/novu/src/commands/email/templates/step-file.spec.ts
new file mode 100644
index 00000000000..baeb3d40827
--- /dev/null
+++ b/packages/novu/src/commands/email/templates/step-file.spec.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+import type { EmailStepConfig } from '../config/schema';
+import { generateStepFile } from './step-file';
+
+describe('generateStepFile', () => {
+ const stepId = 'welcome-email';
+ const workflowId = 'onboarding';
+ const baseConfig: EmailStepConfig = {
+ template: 'emails/welcome.tsx',
+ };
+
+ it('should match snapshot', () => {
+ const result = generateStepFile(stepId, workflowId, '../emails/welcome', baseConfig);
+ expect(result).toMatchSnapshot();
+ });
+
+ it('should match snapshot with subject', () => {
+ const config: EmailStepConfig = {
+ ...baseConfig,
+ subject: 'Welcome to Acme!',
+ };
+ const result = generateStepFile(stepId, workflowId, '../emails/welcome', config);
+ expect(result).toMatchSnapshot('with-subject');
+ });
+
+ it('should match snapshot with different import paths', () => {
+ const result1 = generateStepFile(stepId, workflowId, './emails/welcome', baseConfig);
+ expect(result1).toMatchSnapshot('relative-import');
+
+ const result2 = generateStepFile(stepId, workflowId, '../../src/emails/welcome', baseConfig);
+ expect(result2).toMatchSnapshot('nested-import');
+ });
+});
diff --git a/packages/novu/src/commands/email/templates/step-file.ts b/packages/novu/src/commands/email/templates/step-file.ts
new file mode 100644
index 00000000000..c50b6ba28ef
--- /dev/null
+++ b/packages/novu/src/commands/email/templates/step-file.ts
@@ -0,0 +1,36 @@
+import type { EmailStepConfig } from '../config/schema';
+
+function escapeString(value: string): string {
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+}
+
+export function generateStepFile(
+ stepId: string,
+ workflowId: string,
+ templateImportPath: string,
+ emailConfig: EmailStepConfig
+): string {
+ const defaultSubject = emailConfig.subject || 'No Subject';
+
+ return `import { render } from '@react-email/components';
+import EmailTemplate from '${escapeString(templateImportPath)}';
+
+export const stepId = '${escapeString(stepId)}';
+export const workflowId = '${escapeString(workflowId)}';
+export const type = 'email';
+
+export default async function({ payload, subscriber, context, steps }) {
+ return {
+ subject: payload.subject || '${escapeString(defaultSubject)}',
+ body: await render(
+
+ ),
+ };
+}
+`;
+}
diff --git a/packages/novu/src/commands/email/templates/worker-wrapper.spec.ts b/packages/novu/src/commands/email/templates/worker-wrapper.spec.ts
new file mode 100644
index 00000000000..3df077a0a84
--- /dev/null
+++ b/packages/novu/src/commands/email/templates/worker-wrapper.spec.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import type { DiscoveredStep } from '../types';
+import { generateWorkerWrapper } from './worker-wrapper';
+
+describe('generateWorkerWrapper', () => {
+ const mockSteps: DiscoveredStep[] = [
+ {
+ stepId: 'welcome-email',
+ workflowId: 'onboarding',
+ type: 'email',
+ filePath: '/root/novu/welcome-email.step.tsx',
+ relativePath: 'welcome-email.step.tsx',
+ },
+ {
+ stepId: 'verify-email',
+ workflowId: 'onboarding',
+ type: 'email',
+ filePath: '/root/novu/verify-email.step.tsx',
+ relativePath: 'verify-email.step.tsx',
+ },
+ ];
+
+ it('should match snapshot', () => {
+ const result = generateWorkerWrapper(mockSteps, '/root');
+ expect(result).toMatchSnapshot();
+ });
+
+ it('should handle empty steps array', () => {
+ const result = generateWorkerWrapper([], '/root');
+ expect(result).toMatchSnapshot('empty-steps');
+ });
+
+ it('should handle single step', () => {
+ const result = generateWorkerWrapper([mockSteps[0]], '/root');
+ expect(result).toMatchSnapshot('single-step');
+ });
+
+ it('should keep workflow IDs in step entries', () => {
+ const result = generateWorkerWrapper(mockSteps, '/root');
+
+ expect(result).toContain('workflowId: workflowId0');
+ expect(result).toContain('workflowId: workflowId1');
+ });
+
+ it('should generate map-based dispatch and invalid JSON handling', () => {
+ const result = generateWorkerWrapper(mockSteps, '/root');
+
+ expect(result).toContain('const stepHandlers = new Map([');
+ expect(result).toContain('function jsonResponse(body, status, extraHeaders = {})');
+ expect(result).toContain("Allow: 'POST'");
+ expect(result).toContain("error: 'Invalid JSON body'");
+ expect(result).toContain("message: 'Internal server error'");
+ });
+});
diff --git a/packages/novu/src/commands/email/templates/worker-wrapper.ts b/packages/novu/src/commands/email/templates/worker-wrapper.ts
new file mode 100644
index 00000000000..c677b7d858b
--- /dev/null
+++ b/packages/novu/src/commands/email/templates/worker-wrapper.ts
@@ -0,0 +1,133 @@
+import * as path from 'path';
+import type { DiscoveredStep } from '../types';
+
+export function generateWorkerWrapper(steps: DiscoveredStep[], rootDir: string): string {
+ return [
+ generateImports(steps, rootDir),
+ generateStepHandlersMap(steps),
+ generateWorkerUtilities(),
+ generateFetchHandler(),
+ ].join('\n\n');
+}
+
+function generateImports(steps: DiscoveredStep[], rootDir: string): string {
+ return steps
+ .map(
+ (s, i) =>
+ `import stepHandler${i}, { stepId as stepId${i}, workflowId as workflowId${i} } from ${JSON.stringify(getImportPath(s.filePath, rootDir))};`
+ )
+ .join('\n');
+}
+
+function generateStepHandlersMap(steps: DiscoveredStep[]): string {
+ const entries = steps
+ .map(
+ (s, i) =>
+ ` [${JSON.stringify(s.stepId)}, { handler: stepHandler${i}, stepId: stepId${i}, workflowId: workflowId${i} }]`
+ )
+ .join(',\n');
+
+ return `const stepHandlers = new Map([\n${entries}\n]);`;
+}
+
+function generateWorkerUtilities(): string {
+ return `const JSON_HEADERS = { 'Content-Type': 'application/json' };
+
+function isObject(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function jsonResponse(body, status, extraHeaders = {}) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { ...JSON_HEADERS, ...extraHeaders },
+ });
+}`;
+}
+
+function generateFetchHandler(): string {
+ return `export default {
+ async fetch(request) {
+ try {
+ ${generateRequestHandler()}
+ } catch (error) {
+ console.error('Error executing step handler:', error);
+ return jsonResponse({ error: 'Step execution failed', message: 'Internal server error' }, 500);
+ }
+ },
+};`;
+}
+
+function generateRequestHandler(): string {
+ return `if (request.method !== 'POST') {
+ return jsonResponse({ error: 'Method not allowed' }, 405, { Allow: 'POST' });
+ }
+
+ const url = new URL(request.url);
+ const stepName = url.searchParams.get('step') || request.headers.get('X-Step-Name');
+
+ if (!stepName) {
+ return jsonResponse(
+ { error: 'Missing step name', message: 'Provide step name via ?step= query param or X-Step-Name header' },
+ 400
+ );
+ }
+
+ const step = stepHandlers.get(stepName);
+ if (!step) {
+ return jsonResponse(
+ { error: 'Step not found', stepName, available: Array.from(stepHandlers.keys()) },
+ 404
+ );
+ }
+
+ ${generateBodyValidation()}
+
+ const result = await step.handler({ payload, subscriber, context, steps: stepOutputs });
+
+ return jsonResponse(
+ { stepId: step.stepId, workflowId: step.workflowId, subject: result.subject, body: result.body },
+ 200
+ );`;
+}
+
+function generateBodyValidation(): string {
+ return `let body = {};
+ const rawBody = await request.text();
+ if (rawBody) {
+ try {
+ body = JSON.parse(rawBody);
+ } catch {
+ return jsonResponse({ error: 'Invalid JSON body' }, 400);
+ }
+ }
+
+ if (!isObject(body)) {
+ return jsonResponse({ error: 'Invalid request body', message: 'Body must be a JSON object' }, 400);
+ }
+
+ const payload = body.payload ?? {};
+ const subscriber = body.subscriber ?? {};
+ const context = body.context ?? {};
+ const stepOutputs = body.steps ?? {};
+
+ if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs)) {
+ return jsonResponse(
+ { error: 'Invalid request body', message: 'payload, subscriber, context, and steps must be JSON objects' },
+ 400
+ );
+ }`;
+}
+
+function getImportPath(filePath: string, rootDir: string): string {
+ // Use rootDir-relative imports so esbuild can resolve local step handlers.
+ const withoutExt = filePath.replace(/\.(ts|tsx|js|jsx)$/, '');
+ const normalizedRootDir = path.resolve(rootDir);
+ const relativeImportPath = path.relative(normalizedRootDir, withoutExt).split(path.sep).join('/');
+
+ if (relativeImportPath.startsWith('.') || relativeImportPath.startsWith('/')) {
+ return relativeImportPath;
+ }
+
+ return `./${relativeImportPath}`;
+}
diff --git a/packages/novu/src/commands/email/types.ts b/packages/novu/src/commands/email/types.ts
new file mode 100644
index 00000000000..7a4cc825f16
--- /dev/null
+++ b/packages/novu/src/commands/email/types.ts
@@ -0,0 +1,42 @@
+export interface DiscoveredStep {
+ stepId: string;
+ workflowId: string;
+ type: string;
+ filePath: string;
+ relativePath: string;
+}
+
+export interface ValidationError {
+ filePath: string;
+ errors: string[];
+}
+
+export interface StepDiscoveryResult {
+ valid: boolean;
+ matchedFiles: number;
+ steps: DiscoveredStep[];
+ errors: ValidationError[];
+}
+
+export interface StepResolverReleaseBundle {
+ code: string;
+ size: number;
+}
+
+export interface StepResolverManifestStep {
+ workflowId: string;
+ stepId: string;
+}
+
+export interface DeploymentResult {
+ stepResolverHash: string;
+ workerId: string;
+ selectedStepsCount: number;
+ deployedAt: string;
+}
+
+export interface EnvironmentInfo {
+ _id: string;
+ name: string;
+ _organizationId: string;
+}
diff --git a/packages/novu/src/commands/email/utils/data-transforms.ts b/packages/novu/src/commands/email/utils/data-transforms.ts
new file mode 100644
index 00000000000..200435071df
--- /dev/null
+++ b/packages/novu/src/commands/email/utils/data-transforms.ts
@@ -0,0 +1,53 @@
+import type { EmailStepConfig, NovuConfig } from '../config/schema';
+
+export interface StepWithMetadata {
+ workflowId: string;
+ stepId: string;
+ emailConfig: EmailStepConfig;
+}
+
+export interface ConfiguredStep {
+ workflowId: string;
+ stepId: string;
+ config: EmailStepConfig;
+}
+
+export function flattenConfigToSteps(config: NovuConfig): StepWithMetadata[] {
+ const steps: StepWithMetadata[] = [];
+
+ for (const [workflowId, workflow] of Object.entries(config.workflows)) {
+ for (const [stepId, emailConfig] of Object.entries(workflow.steps.email)) {
+ steps.push({ workflowId, stepId, emailConfig });
+ }
+ }
+
+ return steps;
+}
+
+export function groupStepsByWorkflow(steps: T[]): Map {
+ const grouped = new Map();
+
+ for (const step of steps) {
+ const existing = grouped.get(step.workflowId);
+ if (!existing) {
+ grouped.set(step.workflowId, [step]);
+ } else {
+ existing.push(step);
+ }
+ }
+
+ return grouped;
+}
+
+export function buildWorkflowsFromSteps(steps: ConfiguredStep[]): NovuConfig['workflows'] {
+ const workflows: NovuConfig['workflows'] = {};
+
+ for (const { workflowId, stepId, config } of steps) {
+ if (!workflows[workflowId]) {
+ workflows[workflowId] = { steps: { email: {} } };
+ }
+ workflows[workflowId].steps.email[stepId] = config;
+ }
+
+ return workflows;
+}
diff --git a/packages/novu/src/commands/email/utils/environment.ts b/packages/novu/src/commands/email/utils/environment.ts
new file mode 100644
index 00000000000..ce38ccfeb9a
--- /dev/null
+++ b/packages/novu/src/commands/email/utils/environment.ts
@@ -0,0 +1,17 @@
+export function isCI(): boolean {
+ return !!(
+ process.env.CI ||
+ process.env.CONTINUOUS_INTEGRATION ||
+ process.env.GITHUB_ACTIONS ||
+ process.env.GITLAB_CI ||
+ process.env.CIRCLECI ||
+ process.env.TRAVIS ||
+ process.env.JENKINS_URL ||
+ process.env.BUILDKITE ||
+ process.env.DRONE
+ );
+}
+
+export function isInteractive(): boolean {
+ return !isCI() && process.stdin.isTTY === true && process.stdout.isTTY === true;
+}
diff --git a/packages/novu/src/commands/email/utils/file-paths.ts b/packages/novu/src/commands/email/utils/file-paths.ts
new file mode 100644
index 00000000000..fe7c0cea89f
--- /dev/null
+++ b/packages/novu/src/commands/email/utils/file-paths.ts
@@ -0,0 +1,30 @@
+import * as path from 'path';
+
+export class StepFilePathResolver {
+ constructor(
+ private readonly rootDir: string,
+ private readonly outDirPath: string
+ ) {}
+
+ getWorkflowDir(workflowId: string): string {
+ return path.join(this.outDirPath, workflowId);
+ }
+
+ getStepFilePath(workflowId: string, stepId: string): string {
+ return path.join(this.getWorkflowDir(workflowId), `${stepId}.step.tsx`);
+ }
+
+ getRelativeStepPath(workflowId: string, stepId: string): string {
+ return path.relative(this.outDirPath, this.getStepFilePath(workflowId, stepId));
+ }
+
+ getTemplateImportPath(workflowId: string, templatePath: string): string {
+ const workflowDir = this.getWorkflowDir(workflowId);
+ const templateAbsPath = path.resolve(this.rootDir, templatePath);
+ const relativeImportPath = path.relative(workflowDir, templateAbsPath);
+
+ const importPath = relativeImportPath.replace(/\\/g, '/').replace(/\.(tsx?|jsx?)$/, '');
+
+ return importPath.startsWith('.') ? importPath : `./${importPath}`;
+ }
+}
diff --git a/packages/novu/src/commands/email/utils/index.ts b/packages/novu/src/commands/email/utils/index.ts
new file mode 100644
index 00000000000..e24bdc8e3bd
--- /dev/null
+++ b/packages/novu/src/commands/email/utils/index.ts
@@ -0,0 +1,17 @@
+export {
+ buildWorkflowsFromSteps,
+ type ConfiguredStep,
+ flattenConfigToSteps,
+ groupStepsByWorkflow,
+ type StepWithMetadata,
+} from './data-transforms';
+export { isCI, isInteractive } from './environment';
+export { StepFilePathResolver } from './file-paths';
+export { withSpinner } from './spinner';
+export { renderTable } from './table';
+export {
+ generateStepIdFromFilename,
+ generateWorkflowIdFromStepId,
+ validateStepId,
+ validateWorkflowId,
+} from './validation';
diff --git a/packages/novu/src/commands/email/utils/spinner.ts b/packages/novu/src/commands/email/utils/spinner.ts
new file mode 100644
index 00000000000..d7dbec1530a
--- /dev/null
+++ b/packages/novu/src/commands/email/utils/spinner.ts
@@ -0,0 +1,32 @@
+import ora from 'ora';
+import { red } from 'picocolors';
+
+export async function withSpinner(
+ message: string,
+ fn: () => Promise,
+ options?: {
+ successMessage?: string;
+ failMessage?: string;
+ exitOnError?: boolean;
+ }
+): Promise {
+ const spinner = ora(message).start();
+
+ try {
+ const result = await fn();
+ spinner.succeed(options?.successMessage || message);
+ return result;
+ } catch (error) {
+ spinner.fail(options?.failMessage);
+ console.error('');
+ if (error instanceof Error) {
+ console.error(red(error.message));
+ }
+ console.error('');
+
+ if (options?.exitOnError !== false) {
+ process.exit(1);
+ }
+ throw error;
+ }
+}
diff --git a/packages/novu/src/commands/email/utils/table.ts b/packages/novu/src/commands/email/utils/table.ts
new file mode 100644
index 00000000000..ecf60de1afd
--- /dev/null
+++ b/packages/novu/src/commands/email/utils/table.ts
@@ -0,0 +1,46 @@
+type TableColumn = {
+ header: string;
+ getValue: (item: T) => string;
+};
+
+function stripAnsi(str: string): string {
+ return str.replace(/\u001b\[\d+m/g, '');
+}
+
+export function renderTable(items: T[], columns: TableColumn[], indent = ''): void {
+ if (items.length === 0) {
+ return;
+ }
+
+ const widths = columns.map(
+ (col) => Math.max(col.header.length, ...items.map((item) => stripAnsi(col.getValue(item)).length)) + 2
+ );
+
+ const topBorder = '┌' + widths.map((w) => '─'.repeat(w)).join('┬') + '┐';
+ const middleBorder = '├' + widths.map((w) => '─'.repeat(w)).join('┼') + '┤';
+ const bottomBorder = '└' + widths.map((w) => '─'.repeat(w)).join('┴') + '┘';
+
+ console.log(indent + topBorder);
+
+ const headerRow = '│ ' + columns.map((col, i) => col.header.padEnd(widths[i] - 1)).join('│ ') + '│';
+ console.log(indent + headerRow);
+
+ console.log(indent + middleBorder);
+
+ for (const item of items) {
+ const dataRow =
+ '│ ' +
+ columns
+ .map((col, i) => {
+ const value = col.getValue(item);
+ const strippedValue = stripAnsi(value);
+ const padding = widths[i] - 1 - strippedValue.length;
+ return value + ' '.repeat(Math.max(0, padding));
+ })
+ .join('│ ') +
+ '│';
+ console.log(indent + dataRow);
+ }
+
+ console.log(indent + bottomBorder);
+}
diff --git a/packages/novu/src/commands/email/utils/validation.ts b/packages/novu/src/commands/email/utils/validation.ts
new file mode 100644
index 00000000000..1f08b8ebe4d
--- /dev/null
+++ b/packages/novu/src/commands/email/utils/validation.ts
@@ -0,0 +1,55 @@
+const ID_REGEX = /^[a-z0-9-]+$/;
+const MAX_LENGTH = 50;
+
+export function validateWorkflowId(value: string): string | true {
+ if (!value) {
+ return 'Workflow ID is required';
+ }
+
+ if (!ID_REGEX.test(value)) {
+ return 'Use lowercase, numbers, and hyphens only (e.g., "my-workflow")';
+ }
+
+ if (value.length > MAX_LENGTH) {
+ return `Maximum ${MAX_LENGTH} characters`;
+ }
+
+ return true;
+}
+
+export function validateStepId(value: string, existingStepIds: Set = new Set()): string | true {
+ if (!value) {
+ return 'Step ID is required';
+ }
+
+ if (!ID_REGEX.test(value)) {
+ return 'Use lowercase, numbers, and hyphens only (e.g., "welcome-email")';
+ }
+
+ if (value.length > MAX_LENGTH) {
+ return `Maximum ${MAX_LENGTH} characters`;
+ }
+
+ if (existingStepIds.has(value)) {
+ return `Step ID "${value}" already exists`;
+ }
+
+ return true;
+}
+
+export function generateStepIdFromFilename(filename: string): string {
+ return filename
+ .replace(/\.(tsx?|jsx?)$/, '')
+ .replace(/([A-Z])/g, '-$1')
+ .replace(/[_\s]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .toLowerCase();
+}
+
+export function generateWorkflowIdFromStepId(stepId: string): string {
+ if (stepId.endsWith('-email')) {
+ return stepId.slice(0, -6);
+ }
+
+ return stepId;
+}
diff --git a/packages/novu/src/index.ts b/packages/novu/src/index.ts
index 1750710aa1c..6077d35e3a0 100644
--- a/packages/novu/src/index.ts
+++ b/packages/novu/src/index.ts
@@ -3,6 +3,7 @@
import { Command } from 'commander';
import { v4 as uuidv4 } from 'uuid';
import { DevCommandOptions, devCommand } from './commands';
+import { emailInit, emailPublish } from './commands/email';
import { IInitCommandOptions, init } from './commands/init';
import { sync } from './commands/sync';
import { pullTranslations, pushTranslations } from './commands/translations';
@@ -136,4 +137,45 @@ translationsCommand
await pushTranslations(options);
});
+const emailCommand = program.command('email').description('Manage Novu email step resolvers');
+
+emailCommand
+ .command('init')
+ .description('Generate step handler files from novu.config.ts')
+ .option('-c, --config ', 'Path to config file')
+ .option('--force', 'Overwrite existing handler files')
+ .option('--out ', 'Output directory for handlers')
+ .option('--dry-run', 'Show what would be generated')
+ .action(async (options) => {
+ analytics.track({
+ identity: {
+ anonymousId,
+ },
+ data: {},
+ event: 'Email Init Command',
+ });
+ await emailInit(options);
+ });
+
+emailCommand
+ .command('publish')
+ .description('Bundle and deploy step handlers to Novu')
+ .option('-s, --secret-key ', 'Novu API secret key', NOVU_SECRET_KEY || '')
+ .option('-a, --api-url ', 'Novu API URL')
+ .option('-c, --config ', 'Path to config file')
+ .option('--out ', 'Directory containing step handlers')
+ .option('--workflow ', 'Deploy only specific workflows')
+ .option('--bundle-out-dir [path]', 'Write bundled workflow artifacts to a directory for debugging')
+ .option('--dry-run', 'Bundle without deploying')
+ .action(async (options) => {
+ analytics.track({
+ identity: {
+ anonymousId,
+ },
+ data: {},
+ event: 'Email Publish Command',
+ });
+ await emailPublish(options);
+ });
+
program.parse(process.argv);
diff --git a/packages/novu/tsconfig.json b/packages/novu/tsconfig.json
index d2680b83633..dcac6a1f0e8 100644
--- a/packages/novu/tsconfig.json
+++ b/packages/novu/tsconfig.json
@@ -17,5 +17,5 @@
"types": ["node"]
},
"include": ["src/**/*", "src/**/*.d.ts"],
- "exclude": ["node_modules", "src/commands/init/templates"]
+ "exclude": ["node_modules", "src/commands/init/templates", "src/**/__fixtures__"]
}
diff --git a/packages/novu/vitest.config.ts b/packages/novu/vitest.config.ts
new file mode 100644
index 00000000000..767240818fe
--- /dev/null
+++ b/packages/novu/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ exclude: ['**/node_modules/**', '**/dist/**', '**/__fixtures__/**'],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 22be3116cca..c366d883a12 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3334,6 +3334,9 @@ importers:
dotenv:
specifier: ^16.4.5
version: 16.5.0
+ esbuild:
+ specifier: ^0.19.0
+ version: 0.19.12
fast-glob:
specifier: 3.3.1
version: 3.3.1
@@ -6405,6 +6408,12 @@ packages:
engines: {node: '>=4'}
deprecated: Support for this package will stop 2025-12-31
+ '@esbuild/aix-ppc64@0.19.12':
+ resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [aix]
+
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -6435,6 +6444,12 @@ packages:
cpu: [arm64]
os: [android]
+ '@esbuild/android-arm64@0.19.12':
+ resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
'@esbuild/android-arm64@0.21.5':
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
engines: {node: '>=12'}
@@ -6465,6 +6480,12 @@ packages:
cpu: [arm]
os: [android]
+ '@esbuild/android-arm@0.19.12':
+ resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
'@esbuild/android-arm@0.21.5':
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
engines: {node: '>=12'}
@@ -6495,6 +6516,12 @@ packages:
cpu: [x64]
os: [android]
+ '@esbuild/android-x64@0.19.12':
+ resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
'@esbuild/android-x64@0.21.5':
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
engines: {node: '>=12'}
@@ -6525,6 +6552,12 @@ packages:
cpu: [arm64]
os: [darwin]
+ '@esbuild/darwin-arm64@0.19.12':
+ resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
'@esbuild/darwin-arm64@0.21.5':
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
engines: {node: '>=12'}
@@ -6555,6 +6588,12 @@ packages:
cpu: [x64]
os: [darwin]
+ '@esbuild/darwin-x64@0.19.12':
+ resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
'@esbuild/darwin-x64@0.21.5':
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
engines: {node: '>=12'}
@@ -6585,6 +6624,12 @@ packages:
cpu: [arm64]
os: [freebsd]
+ '@esbuild/freebsd-arm64@0.19.12':
+ resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
'@esbuild/freebsd-arm64@0.21.5':
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
engines: {node: '>=12'}
@@ -6615,6 +6660,12 @@ packages:
cpu: [x64]
os: [freebsd]
+ '@esbuild/freebsd-x64@0.19.12':
+ resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
'@esbuild/freebsd-x64@0.21.5':
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
engines: {node: '>=12'}
@@ -6645,6 +6696,12 @@ packages:
cpu: [arm64]
os: [linux]
+ '@esbuild/linux-arm64@0.19.12':
+ resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
'@esbuild/linux-arm64@0.21.5':
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
engines: {node: '>=12'}
@@ -6675,6 +6732,12 @@ packages:
cpu: [arm]
os: [linux]
+ '@esbuild/linux-arm@0.19.12':
+ resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
'@esbuild/linux-arm@0.21.5':
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
engines: {node: '>=12'}
@@ -6705,6 +6768,12 @@ packages:
cpu: [ia32]
os: [linux]
+ '@esbuild/linux-ia32@0.19.12':
+ resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
'@esbuild/linux-ia32@0.21.5':
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
engines: {node: '>=12'}
@@ -6735,6 +6804,12 @@ packages:
cpu: [loong64]
os: [linux]
+ '@esbuild/linux-loong64@0.19.12':
+ resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
'@esbuild/linux-loong64@0.21.5':
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
engines: {node: '>=12'}
@@ -6765,6 +6840,12 @@ packages:
cpu: [mips64el]
os: [linux]
+ '@esbuild/linux-mips64el@0.19.12':
+ resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
'@esbuild/linux-mips64el@0.21.5':
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
engines: {node: '>=12'}
@@ -6795,6 +6876,12 @@ packages:
cpu: [ppc64]
os: [linux]
+ '@esbuild/linux-ppc64@0.19.12':
+ resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
'@esbuild/linux-ppc64@0.21.5':
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
engines: {node: '>=12'}
@@ -6825,6 +6912,12 @@ packages:
cpu: [riscv64]
os: [linux]
+ '@esbuild/linux-riscv64@0.19.12':
+ resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
'@esbuild/linux-riscv64@0.21.5':
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
engines: {node: '>=12'}
@@ -6855,6 +6948,12 @@ packages:
cpu: [s390x]
os: [linux]
+ '@esbuild/linux-s390x@0.19.12':
+ resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
'@esbuild/linux-s390x@0.21.5':
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
engines: {node: '>=12'}
@@ -6885,6 +6984,12 @@ packages:
cpu: [x64]
os: [linux]
+ '@esbuild/linux-x64@0.19.12':
+ resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
'@esbuild/linux-x64@0.21.5':
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
engines: {node: '>=12'}
@@ -6927,6 +7032,12 @@ packages:
cpu: [x64]
os: [netbsd]
+ '@esbuild/netbsd-x64@0.19.12':
+ resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
'@esbuild/netbsd-x64@0.21.5':
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
engines: {node: '>=12'}
@@ -6975,6 +7086,12 @@ packages:
cpu: [x64]
os: [openbsd]
+ '@esbuild/openbsd-x64@0.19.12':
+ resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
'@esbuild/openbsd-x64@0.21.5':
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
engines: {node: '>=12'}
@@ -7011,6 +7128,12 @@ packages:
cpu: [x64]
os: [sunos]
+ '@esbuild/sunos-x64@0.19.12':
+ resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
'@esbuild/sunos-x64@0.21.5':
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
engines: {node: '>=12'}
@@ -7041,6 +7164,12 @@ packages:
cpu: [arm64]
os: [win32]
+ '@esbuild/win32-arm64@0.19.12':
+ resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
'@esbuild/win32-arm64@0.21.5':
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
engines: {node: '>=12'}
@@ -7071,6 +7200,12 @@ packages:
cpu: [ia32]
os: [win32]
+ '@esbuild/win32-ia32@0.19.12':
+ resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
'@esbuild/win32-ia32@0.21.5':
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
engines: {node: '>=12'}
@@ -7101,6 +7236,12 @@ packages:
cpu: [x64]
os: [win32]
+ '@esbuild/win32-x64@0.19.12':
+ resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
'@esbuild/win32-x64@0.21.5':
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
engines: {node: '>=12'}
@@ -17615,6 +17756,11 @@ packages:
engines: {node: '>=12'}
hasBin: true
+ esbuild@0.19.12:
+ resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==}
+ engines: {node: '>=12'}
+ hasBin: true
+
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -32204,6 +32350,9 @@ snapshots:
to-pascal-case: 1.0.0
unescape-js: 1.1.4
+ '@esbuild/aix-ppc64@0.19.12':
+ optional: true
+
'@esbuild/aix-ppc64@0.21.5':
optional: true
@@ -32219,6 +32368,9 @@ snapshots:
'@esbuild/android-arm64@0.18.20':
optional: true
+ '@esbuild/android-arm64@0.19.12':
+ optional: true
+
'@esbuild/android-arm64@0.21.5':
optional: true
@@ -32234,6 +32386,9 @@ snapshots:
'@esbuild/android-arm@0.18.20':
optional: true
+ '@esbuild/android-arm@0.19.12':
+ optional: true
+
'@esbuild/android-arm@0.21.5':
optional: true
@@ -32249,6 +32404,9 @@ snapshots:
'@esbuild/android-x64@0.18.20':
optional: true
+ '@esbuild/android-x64@0.19.12':
+ optional: true
+
'@esbuild/android-x64@0.21.5':
optional: true
@@ -32264,6 +32422,9 @@ snapshots:
'@esbuild/darwin-arm64@0.18.20':
optional: true
+ '@esbuild/darwin-arm64@0.19.12':
+ optional: true
+
'@esbuild/darwin-arm64@0.21.5':
optional: true
@@ -32279,6 +32440,9 @@ snapshots:
'@esbuild/darwin-x64@0.18.20':
optional: true
+ '@esbuild/darwin-x64@0.19.12':
+ optional: true
+
'@esbuild/darwin-x64@0.21.5':
optional: true
@@ -32294,6 +32458,9 @@ snapshots:
'@esbuild/freebsd-arm64@0.18.20':
optional: true
+ '@esbuild/freebsd-arm64@0.19.12':
+ optional: true
+
'@esbuild/freebsd-arm64@0.21.5':
optional: true
@@ -32309,6 +32476,9 @@ snapshots:
'@esbuild/freebsd-x64@0.18.20':
optional: true
+ '@esbuild/freebsd-x64@0.19.12':
+ optional: true
+
'@esbuild/freebsd-x64@0.21.5':
optional: true
@@ -32324,6 +32494,9 @@ snapshots:
'@esbuild/linux-arm64@0.18.20':
optional: true
+ '@esbuild/linux-arm64@0.19.12':
+ optional: true
+
'@esbuild/linux-arm64@0.21.5':
optional: true
@@ -32339,6 +32512,9 @@ snapshots:
'@esbuild/linux-arm@0.18.20':
optional: true
+ '@esbuild/linux-arm@0.19.12':
+ optional: true
+
'@esbuild/linux-arm@0.21.5':
optional: true
@@ -32354,6 +32530,9 @@ snapshots:
'@esbuild/linux-ia32@0.18.20':
optional: true
+ '@esbuild/linux-ia32@0.19.12':
+ optional: true
+
'@esbuild/linux-ia32@0.21.5':
optional: true
@@ -32369,6 +32548,9 @@ snapshots:
'@esbuild/linux-loong64@0.18.20':
optional: true
+ '@esbuild/linux-loong64@0.19.12':
+ optional: true
+
'@esbuild/linux-loong64@0.21.5':
optional: true
@@ -32384,6 +32566,9 @@ snapshots:
'@esbuild/linux-mips64el@0.18.20':
optional: true
+ '@esbuild/linux-mips64el@0.19.12':
+ optional: true
+
'@esbuild/linux-mips64el@0.21.5':
optional: true
@@ -32399,6 +32584,9 @@ snapshots:
'@esbuild/linux-ppc64@0.18.20':
optional: true
+ '@esbuild/linux-ppc64@0.19.12':
+ optional: true
+
'@esbuild/linux-ppc64@0.21.5':
optional: true
@@ -32414,6 +32602,9 @@ snapshots:
'@esbuild/linux-riscv64@0.18.20':
optional: true
+ '@esbuild/linux-riscv64@0.19.12':
+ optional: true
+
'@esbuild/linux-riscv64@0.21.5':
optional: true
@@ -32429,6 +32620,9 @@ snapshots:
'@esbuild/linux-s390x@0.18.20':
optional: true
+ '@esbuild/linux-s390x@0.19.12':
+ optional: true
+
'@esbuild/linux-s390x@0.21.5':
optional: true
@@ -32444,6 +32638,9 @@ snapshots:
'@esbuild/linux-x64@0.18.20':
optional: true
+ '@esbuild/linux-x64@0.19.12':
+ optional: true
+
'@esbuild/linux-x64@0.21.5':
optional: true
@@ -32465,6 +32662,9 @@ snapshots:
'@esbuild/netbsd-x64@0.18.20':
optional: true
+ '@esbuild/netbsd-x64@0.19.12':
+ optional: true
+
'@esbuild/netbsd-x64@0.21.5':
optional: true
@@ -32489,6 +32689,9 @@ snapshots:
'@esbuild/openbsd-x64@0.18.20':
optional: true
+ '@esbuild/openbsd-x64@0.19.12':
+ optional: true
+
'@esbuild/openbsd-x64@0.21.5':
optional: true
@@ -32507,6 +32710,9 @@ snapshots:
'@esbuild/sunos-x64@0.18.20':
optional: true
+ '@esbuild/sunos-x64@0.19.12':
+ optional: true
+
'@esbuild/sunos-x64@0.21.5':
optional: true
@@ -32522,6 +32728,9 @@ snapshots:
'@esbuild/win32-arm64@0.18.20':
optional: true
+ '@esbuild/win32-arm64@0.19.12':
+ optional: true
+
'@esbuild/win32-arm64@0.21.5':
optional: true
@@ -32537,6 +32746,9 @@ snapshots:
'@esbuild/win32-ia32@0.18.20':
optional: true
+ '@esbuild/win32-ia32@0.19.12':
+ optional: true
+
'@esbuild/win32-ia32@0.21.5':
optional: true
@@ -32552,6 +32764,9 @@ snapshots:
'@esbuild/win32-x64@0.18.20':
optional: true
+ '@esbuild/win32-x64@0.19.12':
+ optional: true
+
'@esbuild/win32-x64@0.21.5':
optional: true
@@ -46445,6 +46660,32 @@ snapshots:
'@esbuild/win32-x64': 0.18.20
optional: true
+ esbuild@0.19.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.19.12
+ '@esbuild/android-arm': 0.19.12
+ '@esbuild/android-arm64': 0.19.12
+ '@esbuild/android-x64': 0.19.12
+ '@esbuild/darwin-arm64': 0.19.12
+ '@esbuild/darwin-x64': 0.19.12
+ '@esbuild/freebsd-arm64': 0.19.12
+ '@esbuild/freebsd-x64': 0.19.12
+ '@esbuild/linux-arm': 0.19.12
+ '@esbuild/linux-arm64': 0.19.12
+ '@esbuild/linux-ia32': 0.19.12
+ '@esbuild/linux-loong64': 0.19.12
+ '@esbuild/linux-mips64el': 0.19.12
+ '@esbuild/linux-ppc64': 0.19.12
+ '@esbuild/linux-riscv64': 0.19.12
+ '@esbuild/linux-s390x': 0.19.12
+ '@esbuild/linux-x64': 0.19.12
+ '@esbuild/netbsd-x64': 0.19.12
+ '@esbuild/openbsd-x64': 0.19.12
+ '@esbuild/sunos-x64': 0.19.12
+ '@esbuild/win32-arm64': 0.19.12
+ '@esbuild/win32-ia32': 0.19.12
+ '@esbuild/win32-x64': 0.19.12
+
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
From 3e673c66664690292762c25d3b18c23796fc5263 Mon Sep 17 00:00:00 2001
From: Dima Grossman
Date: Mon, 16 Feb 2026 14:39:39 +0200
Subject: [PATCH 2/4] fix(dashboard): Forward readonly and disabled to
VariableInput (#10048)
---
.../src/components/primitives/variable-editor.tsx | 4 +++-
.../workflow-editor/control-input/control-input.tsx | 3 +++
.../workflow-editor/steps/controls/text-widget.tsx | 2 ++
.../app/workflow/services/subscriber-process.worker.ts | 8 --------
4 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/apps/dashboard/src/components/primitives/variable-editor.tsx b/apps/dashboard/src/components/primitives/variable-editor.tsx
index e9eeae10b9f..ca2de894219 100644
--- a/apps/dashboard/src/components/primitives/variable-editor.tsx
+++ b/apps/dashboard/src/components/primitives/variable-editor.tsx
@@ -46,6 +46,7 @@ type VariableEditorProps = {
skipContainerClick?: boolean;
children?: React.ReactNode;
disabled?: boolean;
+ readOnly?: boolean;
} & Pick<
EditorProps,
| 'className'
@@ -97,6 +98,7 @@ export function VariableEditor({
onManageSchemaClick = () => {},
children,
disabled = false,
+ readOnly = false,
}: VariableEditorProps) {
const containerRef = useRef(null);
const track = useTelemetry();
@@ -377,7 +379,7 @@ export function VariableEditor({
onChange={onChange}
onBlur={onBlur}
tagStyles={tagStyles}
- editable={!disabled}
+ editable={!disabled && !readOnly}
/>
{isVariablePopoverOpen && (
(null);
const lastCompletionRef = useRef(null);
@@ -134,6 +136,7 @@ export function ControlInput({
onManageSchemaClick={openSchemaDrawer}
onCreateNewVariable={handleCreateNewVariable}
disabled={disabled}
+ readOnly={readOnly}
>
diff --git a/apps/worker/src/app/workflow/services/subscriber-process.worker.ts b/apps/worker/src/app/workflow/services/subscriber-process.worker.ts
index caad5a6331d..ab11f180671 100644
--- a/apps/worker/src/app/workflow/services/subscriber-process.worker.ts
+++ b/apps/worker/src/app/workflow/services/subscriber-process.worker.ts
@@ -52,14 +52,6 @@ export class SubscriberProcessWorker extends SubscriberProcessWorkerService {
return;
}
- const organizationExists = await this.organizationExist(data);
-
- if (!organizationExists) {
- Logger.log(`Organization not found for organizationId ${data.organizationId}. Skipping job.`, LOG_CONTEXT);
-
- return;
- }
-
return await new Promise((resolve, reject) => {
const _this = this;
From 8ecf1fbb10a29d29347600f0a9b684029b4dadd3 Mon Sep 17 00:00:00 2001
From: Dima Grossman
Date: Mon, 16 Feb 2026 14:51:51 +0200
Subject: [PATCH 3/4] perf(api-service): Conditional preference fetches in
GetPreferences for global (#10049)
---
.../get-preferences.usecase.ts | 80 +++++++++++--------
1 file changed, 45 insertions(+), 35 deletions(-)
diff --git a/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts b/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts
index 47770a9ec10..71083014507 100644
--- a/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts
+++ b/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts
@@ -256,30 +256,35 @@ export class GetPreferences {
organizationId: command.organizationId,
};
- const workflowPreferences = await this.inMemoryLRUCacheService.get(
- InMemoryLRUCacheStore.WORKFLOW_PREFERENCES,
- `${command.environmentId}:${command.templateId}`,
- async (): Promise<[PreferencesEntity | null, PreferencesEntity | null]> => {
- const preferences = await this.preferencesRepository.find(
- {
- ...baseQuery,
- _templateId: command.templateId,
- type: { $in: [PreferencesTypeEnum.WORKFLOW_RESOURCE, PreferencesTypeEnum.USER_WORKFLOW] },
- },
- undefined,
- queryOptions
- );
-
- const workflowResourcePreference =
- preferences.find((p) => p.type === PreferencesTypeEnum.WORKFLOW_RESOURCE) ?? null;
- const workflowUserPreference = preferences.find((p) => p.type === PreferencesTypeEnum.USER_WORKFLOW) ?? null;
-
- return [workflowResourcePreference, workflowUserPreference];
- },
- cacheOptions
- );
+ let workflowResourcePreference: PreferencesEntity | null = null;
+ let workflowUserPreference: PreferencesEntity | null = null;
+
+ if (command.templateId) {
+ const workflowPreferences = await this.inMemoryLRUCacheService.get(
+ InMemoryLRUCacheStore.WORKFLOW_PREFERENCES,
+ `${command.environmentId}:${command.templateId}`,
+ async (): Promise<[PreferencesEntity | null, PreferencesEntity | null]> => {
+ const preferences = await this.preferencesRepository.find(
+ {
+ ...baseQuery,
+ _templateId: command.templateId,
+ type: { $in: [PreferencesTypeEnum.WORKFLOW_RESOURCE, PreferencesTypeEnum.USER_WORKFLOW] },
+ },
+ undefined,
+ queryOptions
+ );
+
+ const workflowResourcePref =
+ preferences.find((p) => p.type === PreferencesTypeEnum.WORKFLOW_RESOURCE) ?? null;
+ const workflowUserPref = preferences.find((p) => p.type === PreferencesTypeEnum.USER_WORKFLOW) ?? null;
+
+ return [workflowResourcePref, workflowUserPref];
+ },
+ cacheOptions
+ );
- const [workflowResourcePreference, workflowUserPreference] = workflowPreferences;
+ [workflowResourcePreference, workflowUserPreference] = workflowPreferences;
+ }
let subscriberWorkflowPreference: PreferencesEntity | null = null;
let subscriberGlobalPreference: PreferencesEntity | null = null;
@@ -295,18 +300,20 @@ export class GetPreferences {
enabled: useContextFiltering,
});
- [subscriberWorkflowPreference, subscriberGlobalPreference] = await Promise.all([
- this.preferencesRepository.findOne(
- {
- ...baseQuery,
- _subscriberId: command.subscriberId,
- _templateId: command.templateId,
- type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW,
- ...contextQuery,
- },
- undefined,
- queryOptions
- ),
+ const [workflowPref, globalPref] = await Promise.all([
+ command.templateId
+ ? this.preferencesRepository.findOne(
+ {
+ ...baseQuery,
+ _subscriberId: command.subscriberId,
+ _templateId: command.templateId,
+ type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW,
+ ...contextQuery,
+ },
+ undefined,
+ queryOptions
+ )
+ : Promise.resolve(null),
this.preferencesRepository.findOne(
{
...baseQuery,
@@ -318,6 +325,9 @@ export class GetPreferences {
queryOptions
),
]);
+
+ subscriberWorkflowPreference = workflowPref;
+ subscriberGlobalPreference = globalPref;
}
const result: PreferenceSet = {};
From 13a6f45f6b8cfb085189fc5fd9d9646c0f2faa78 Mon Sep 17 00:00:00 2001
From: Dima Grossman
Date: Mon, 16 Feb 2026 14:55:25 +0200
Subject: [PATCH 4/4] fix(dashboard): Email layout name saving issue fixes
NV-7084 (#10045)
Co-authored-by: Cursor Agent
---
.../layouts/layout-editor-settings-drawer.tsx | 31 ++++++++++++++-----
1 file changed, 23 insertions(+), 8 deletions(-)
diff --git a/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx b/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx
index be2fef322b0..eaec8bdbf6c 100644
--- a/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx
+++ b/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx
@@ -4,7 +4,7 @@ import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
import { EnvironmentTypeEnum, PermissionsEnum, ResourceOriginEnum } from '@novu/shared';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { formatDistanceToNow } from 'date-fns';
-import { forwardRef, useCallback, useState } from 'react';
+import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { RiDeleteBin2Line, RiSettings4Line } from 'react-icons/ri';
import { useBlocker, useNavigate } from 'react-router-dom';
@@ -81,13 +81,20 @@ export const LayoutEditorSettingsDrawer = forwardRef {
+ if (isOpen && !wasOpenRef.current && layout) {
+ form.reset({
+ name: layout.name || '',
+ layoutId: layout.layoutId || '',
+ isTranslationEnabled: layout.isTranslationEnabled || false,
+ });
+ }
+ wasOpenRef.current = isOpen;
+ }, [isOpen, layout, form]);
+
const hasUnsavedChanges = form.formState.isDirty;
useBeforeUnload(hasUnsavedChanges);
@@ -105,7 +112,12 @@ export const LayoutEditorSettingsDrawer = forwardRef {
+ onSuccess: (data) => {
+ form.reset({
+ name: data.name || '',
+ layoutId: data.layoutId || '',
+ isTranslationEnabled: data.isTranslationEnabled || false,
+ });
showSuccessToast('Layout updated successfully', '', toastOptions);
onOpenChange(false);
},
@@ -240,7 +252,10 @@ export const LayoutEditorSettingsDrawer = forwardRef {
+ e.stopPropagation();
+ form.handleSubmit(onSubmit)(e);
+ }}
className="flex h-full flex-col"
>