diff --git a/apps/backend/src/lib/email-rendering.tsx b/apps/backend/src/lib/email-rendering.tsx index 0fd2529b6f..06664c4c68 100644 --- a/apps/backend/src/lib/email-rendering.tsx +++ b/apps/backend/src/lib/email-rendering.tsx @@ -1,7 +1,6 @@ -import { executeJavascript } from '@/lib/js-execution'; +import { executeJavascript, type ExecuteResult } from '@/lib/js-execution'; import { emptyEmailTheme } from '@stackframe/stack-shared/dist/helpers/emails'; -import { getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env'; -import { captureError, StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; +import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; import { bundleJavaScript } from '@stackframe/stack-shared/dist/utils/esbuild'; import { get, has } from '@stackframe/stack-shared/dist/utils/objects'; import { Result } from "@stackframe/stack-shared/dist/utils/results"; @@ -50,7 +49,7 @@ export function createTemplateComponentFromHtml(html: string) { const nodeModules = { "react-dom": "19.1.1", "react": "19.1.1", - "@react-email/components": "0.1.1", + "@react-email/components": "1.0.6", "arktype": "2.1.20", }; @@ -70,12 +69,9 @@ const entryJs = deindent` `; type EmailRenderResult = { html: string, text: string, subject?: string, notificationCategory?: string }; -type ExecuteResult = - | { status: "ok", data: unknown } - | { status: "error", error: unknown }; async function bundleAndExecute( - files: Record & { '/entry.js': string } + files: Record & { '/entry.js': string }, ): Promise> { const bundle = await bundleJavaScript(files, { keepAsImports: ['arktype', 'react', 'react/jsx-runtime', '@react-email/components'], @@ -87,40 +83,11 @@ async function bundleAndExecute( return Result.error(bundle.error); } - if (["development", "test"].includes(getNodeEnvironment())) { - const executeResult = await executeJavascript(bundle.data, { nodeModules, engine: 'freestyle' }) as ExecuteResult; - if (executeResult.status === "error") { - return Result.error(JSON.stringify(executeResult.error)); - } - return Result.ok(executeResult.data as T); - } - - const executeResult = await executeJavascript(bundle.data, { nodeModules }) as ExecuteResult; + const executeResult: ExecuteResult = await executeJavascript(bundle.data, { nodeModules }); if (executeResult.status === "error") { - const vercelResult = await executeJavascript(bundle.data, { nodeModules, engine: 'vercel-sandbox' }) as ExecuteResult; - if (vercelResult.status === "error") { - captureError("email-rendering-freestyle-and-vercel-runtime-error", new StackAssertionError( - "Email rendering failed with both freestyle and vercel-sandbox engines", - { - freestyleError: executeResult.error, - vercelError: vercelResult.error, - innerCode: bundle.data, - innerOptions: [ - { nodeModules, engine: 'freestyle' }, - { nodeModules, engine: 'vercel-sandbox' }, - ], - }, - )); - return Result.error(JSON.stringify(vercelResult.error)); - } - captureError("email-rendering-freestyle-runtime-error", new StackAssertionError( - "Email rendering failed with freestyle but succeeded with vercel-sandbox", - { freestyleError: executeResult.error, innerCode: bundle.data, innerOptions: { nodeModules, engine: 'freestyle' } } - )); - return Result.ok(vercelResult.data as T); + return Result.error(JSON.stringify(executeResult.error)); } - return Result.ok(executeResult.data as T); } diff --git a/apps/backend/src/lib/js-execution.tsx b/apps/backend/src/lib/js-execution.tsx index 97cab57b44..9449b5df1a 100644 --- a/apps/backend/src/lib/js-execution.tsx +++ b/apps/backend/src/lib/js-execution.tsx @@ -8,18 +8,21 @@ import { Freestyle as FreestyleClient } from 'freestyle-sandboxes'; export type ExecuteJavascriptOptions = { nodeModules?: Record, - engine?: 'freestyle' | 'vercel-sandbox', }; +export type ExecuteResult = + | { status: "ok", data: unknown } + | { status: "error", error: { message: string, stack?: string, cause?: unknown } }; + type JsEngine = { name: string, - execute: (code: string, options: ExecuteJavascriptOptions) => Promise, + execute: (code: string, options: ExecuteJavascriptOptions) => Promise, }; function createFreestyleEngine(): JsEngine { return { name: 'freestyle', - execute: async (code: string, options: ExecuteJavascriptOptions): Promise => { + execute: async (code: string, options: ExecuteJavascriptOptions): Promise => { const apiKey = getEnvVariable("STACK_FREESTYLE_API_KEY"); let baseUrl = getEnvVariable("STACK_FREESTYLE_API_ENDPOINT", "") || undefined; @@ -45,7 +48,7 @@ function createFreestyleEngine(): JsEngine { throw new StackAssertionError("Freestyle execution returned undefined result", { response, innerCode: code, innerOptions: options }); } - return response.result; + return response.result as ExecuteResult; }, }; } @@ -53,7 +56,7 @@ function createFreestyleEngine(): JsEngine { function createVercelSandboxEngine(): JsEngine { return { name: 'vercel-sandbox', - execute: async (code: string, options: ExecuteJavascriptOptions): Promise => { + execute: async (code: string, options: ExecuteJavascriptOptions): Promise => { const teamId = getEnvVariable("STACK_VERCEL_SANDBOX_TEAM_ID", ""); const projectId = getEnvVariable("STACK_VERCEL_SANDBOX_PROJECT_ID", ""); const token = getEnvVariable("STACK_VERCEL_SANDBOX_TOKEN", ""); @@ -122,51 +125,66 @@ const engineMap = new Map([ ['vercel-sandbox', createVercelSandboxEngine()], ]); -const engines: JsEngine[] = Array.from(engineMap.values()); - /** * Executes the given code with the given options. Returns the result of the code execution * if it is JSON-serializable. Has undefined behavior if it is not JSON-serializable or if * the code throws an error. */ -export async function executeJavascript(code: string, options: ExecuteJavascriptOptions = {}): Promise { +export async function executeJavascript(code: string, options: ExecuteJavascriptOptions = {}): Promise { return await traceSpan({ description: 'js-execution.executeJavascript', attributes: { 'js-execution.code.length': code.length.toString(), 'js-execution.nodeModules.count': options.nodeModules ? Object.keys(options.nodeModules).length.toString() : '0', - 'js-execution.engine': options.engine ?? 'auto', } }, async () => { - if (options.engine) { - const engine = engineMap.get(options.engine); - if (!engine) { - throw new StackAssertionError(`Unknown JS execution engine: ${options.engine}`); + + if (getEnvVariable("STACK_VERCEL_SANDBOX_TOKEN","") != "") { + if (!getNodeEnvironment().includes("prod")) { + throw new StackAssertionError("STACK_VERCEL_SANDBOX_TOKEN is set in non-production environment. We do not use Vercel Sandbox in non-production environments."); } - return await engine.execute(code, options); - } - const shouldSanityTest = Math.random() < 0.05; + const shouldSanityTest = Math.random() < 0.05; + if (shouldSanityTest) { + runAsynchronouslyAndWaitUntil(runSanityTest(code, options)); + } - if (shouldSanityTest) { - runAsynchronouslyAndWaitUntil(runSanityTest(code, options)); + return await runWithFallback(code, options); + } else { + return await runWithoutFallback(code, options); } - - // Normal execution: try engines in order with retry for first engine - return await runWithFallback(code, options); }); } +/** + * Compare two execution results for sanity test equality. + * For error results, we only compare status and message (not stack traces, + * which differ between execution environments). + */ +function areResultsEqual(a: ExecuteResult, b: ExecuteResult): boolean { + if (a.status !== b.status) return false; + + if (a.status === 'ok' && b.status === 'ok') { + return JSON.stringify(a.data) === JSON.stringify(b.data); + } + + if (a.status === 'error' && b.status === 'error') { + return a.error.message === b.error.message; + } + + return false; +} + async function runSanityTest(code: string, options: ExecuteJavascriptOptions) { const results: Array<{ engine: string, result: unknown }> = []; const failures: Array<{ engine: string, error: unknown }> = []; - for (const engine of engines) { + for (const [name, engine] of engineMap) { try { const result = await engine.execute(code, options); - results.push({ engine: engine.name, result }); + results.push({ engine: name, result }); } catch (error) { - failures.push({ engine: engine.name, error }); + failures.push({ engine: name, error }); } } @@ -181,8 +199,8 @@ async function runSanityTest(code: string, options: ExecuteJavascriptOptions) { return; } - const referenceResult = results[0].result; - const allEqual = results.every(r => JSON.stringify(r.result) === JSON.stringify(referenceResult)); + const referenceResult = results[0].result as ExecuteResult; + const allEqual = results.every(r => areResultsEqual(r.result as ExecuteResult, referenceResult)); if (!allEqual) { captureError("js-execution-sanity-test-mismatch", new StackAssertionError( "JS execution sanity test: engines returned different results", @@ -191,19 +209,15 @@ async function runSanityTest(code: string, options: ExecuteJavascriptOptions) { } } -async function runWithFallback(code: string, options: ExecuteJavascriptOptions): Promise { - const errors: Array<{ engine: string, error: unknown }> = []; +async function runWithFallback(code: string, options: ExecuteJavascriptOptions): Promise { + const freestyleEngine = engineMap.get("freestyle")!; + const vercelSandboxEngine = engineMap.get("vercel-sandbox")!; - for (let i = 0; i < engines.length; i++) { - const engine = engines[i]; - const isFirstEngine = i === 0; - - const maxAttempts = isFirstEngine ? 2 : 1; - - const retryResult = await Result.retry( + const maxAttempts = 2; + const retryResult = await Result.retry( async () => { try { - const result = await engine.execute(code, options); + const result = await freestyleEngine.execute(code, options); return Result.ok(result); } catch (error) { return Result.error(error); @@ -213,20 +227,33 @@ async function runWithFallback(code: string, options: ExecuteJavascriptOptions): { exponentialDelayBase: 500 } ); - if (retryResult.status === 'ok') { - return retryResult.data; - } - - const engineError = retryResult.error; - errors.push({ engine: engine.name, error: engineError }); + if (retryResult.status === 'ok') { + return retryResult.data; + } - if (i < engines.length - 1) { - captureError(`js-execution-${engine.name}-failed`, new StackAssertionError( - `JS execution engine '${engine.name}' failed, falling back to next engine`, - { error: engineError, attempts: retryResult.attempts, innerCode: code, innerOptions: options } + captureError(`js-execution-freestyle-failed`, new StackAssertionError( + `JS execution freestyle engine failed, falling back to vercel sandbox engine`, + { error: retryResult.error, innerCode: code, innerOptions: options } + )); + + try { + const result = await vercelSandboxEngine.execute(code, options); + return result; + } catch (error){ + captureError(`js-execution-vercel-sandbox-failed`, new StackAssertionError( + `JS execution vercel sandbox engine failed after fallback from freestyle engine`, + { error: error, innerCode: code, innerOptions: options } )); - } + throw new StackAssertionError("Email rendering service unavailable", { cause: error, innerCode: code, innerOptions: options }); } +} - throw errors[errors.length - 1].error; +async function runWithoutFallback(code: string, options: ExecuteJavascriptOptions): Promise { + const freestyleEngine = engineMap.get("freestyle")!; + try { + const result = await freestyleEngine.execute(code, options); + return result; + } catch (error) { + throw new StackAssertionError("Email rendering service unavailable", { cause: error, innerCode: code, innerOptions: options }); + } } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts index c9003e0c75..771e72c2e7 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts @@ -389,3 +389,189 @@ describe("create, patch, and get email theme", () => { }); }); +describe("invalid JSX inputs", () => { + it("should reject theme that throws an error when rendered", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Test Email Theme Project", + }); + + const response = await niceBackendFetch( + `/api/latest/internal/email-themes/${validThemeId}`, + { + method: "PATCH", + accessType: "admin", + body: { + tsx_source: ` + import { Html } from '@react-email/components'; + export function EmailTheme({ children }: { children: React.ReactNode }) { + throw new Error('Intentional error from theme'); + } + `, + }, + } + ); + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + code: "EMAIL_RENDERING_ERROR", + }); + expect(response.body.error).toContain("Intentional error from theme"); + }); + + it("should reject theme that does not export EmailTheme function", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Test Email Theme Project", + }); + + const response = await niceBackendFetch( + `/api/latest/internal/email-themes/${validThemeId}`, + { + method: "PATCH", + accessType: "admin", + body: { + tsx_source: ` + import { Html } from '@react-email/components'; + export function WrongFunctionName({ children }: { children: React.ReactNode }) { + return {children}; + } + `, + }, + } + ); + expect(response.status).toBe(400); + expect(response.body).toMatchInlineSnapshot(` + { + "code": "EMAIL_RENDERING_ERROR", + "details": { + "error": deindent\` + Build failed with 1 error: + virtual:/render.tsx:9:9: ERROR: No matching export in "virtual:/theme.tsx" for import "EmailTheme" + \`, + }, + "error": deindent\` + Failed to render email with theme: Build failed with 1 error: + virtual:/render.tsx:9:9: ERROR: No matching export in "virtual:/theme.tsx" for import "EmailTheme" + \`, + } + `); + }); + + it("should reject theme with invalid JSX syntax", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Test Email Theme Project", + }); + + const response = await niceBackendFetch( + `/api/latest/internal/email-themes/${validThemeId}`, + { + method: "PATCH", + accessType: "admin", + body: { + tsx_source: ` + export function EmailTheme({ children }) { + return
unclosed tag + } + `, + }, + } + ); + expect(response.status).toBe(400); + expect(response.body).toMatchInlineSnapshot(` + { + "code": "EMAIL_RENDERING_ERROR", + "details": { + "error": deindent\` + Build failed with 2 errors: + virtual:/theme.tsx:4:12: ERROR: The character "}" is not valid inside a JSX element + virtual:/theme.tsx:5:10: ERROR: Unexpected end of file before a closing "span" tag + \`, + }, + "error": deindent\` + Failed to render email with theme: Build failed with 2 errors: + virtual:/theme.tsx:4:12: ERROR: The character "}" is not valid inside a JSX element + virtual:/theme.tsx:5:10: ERROR: Unexpected end of file before a closing "span" tag + \`, + } + `); + }); + + it.todo("should reject theme that causes infinite loop during rendering", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Test Email Theme Project", + }); + + const response = await niceBackendFetch( + `/api/latest/internal/email-themes/${validThemeId}`, + { + method: "PATCH", + accessType: "admin", + body: { + tsx_source: ` + import { Html } from '@react-email/components'; + export function EmailTheme({ children }: { children: React.ReactNode }) { + while (true) {} + return {children}; + } + `, + }, + } + ); + // Should timeout or return an error, not hang indefinitely + expect(response.status).toBe(400); + expect(response.body).toMatchInlineSnapshot("todo"); + }); + + it.todo("should reject theme that allocates too much memory", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Test Email Theme Project", + }); + + const response = await niceBackendFetch( + `/api/latest/internal/email-themes/${validThemeId}`, + { + method: "PATCH", + accessType: "admin", + body: { + tsx_source: ` + import { Html } from '@react-email/components'; + export function EmailTheme({ children }: { children: React.ReactNode }) { + const arr = []; + for (let i = 0; i < 1e9; i++) { + arr.push(new Array(1e6).fill('x')); + } + return {children}; + } + `, + }, + } + ); + // Should fail due to memory limits, not hang or crash the server + expect(response.status).toBe(400); + expect(response.body).toMatchInlineSnapshot("todo"); + }); + + it("should reject theme that exports a non-function", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Test Email Theme Project", + }); + + const response = await niceBackendFetch( + `/api/latest/internal/email-themes/${validThemeId}`, + { + method: "PATCH", + accessType: "admin", + body: { + tsx_source: ` + export const EmailTheme = "not a function"; + `, + }, + } + ); + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + code: "EMAIL_RENDERING_ERROR", + }); + // Error message varies by runtime (Bun vs Node), just check it indicates a type error + expect(response.body.error).toBeDefined(); + }); +}); + diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts index cbf34cd43b..e343f06296 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts @@ -465,7 +465,7 @@ describe("send email to all users", () => { "delivered_at_millis": , "has_delivered": true, "has_rendered": true, - "html": "

All users test

", + "html": "

All users test

", "id": "", "is_high_priority": false, "is_paused": false, @@ -502,7 +502,7 @@ describe("send email to all users", () => { "delivered_at_millis": , "has_delivered": true, "has_rendered": true, - "html": "

All users test

", + "html": "

All users test

", "id": "", "is_high_priority": false, "is_paused": false, @@ -944,21 +944,29 @@ describe("template variables", () => { - - + +
- -
- + +
+ diff --git a/apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts index 18d8296872..930d9335c6 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts @@ -84,7 +84,7 @@ it("should send email notification to user when revoking an API key through cred "attachments": [], "body": { "html": deindent\` -
+
-
String: hello world
-
Number: 42
-
Boolean: true
-
Array: apple, banana, cherry
-
Object: deeply nested value
-
Null: is null
+ + + + + + +
+
String: hello world
+
Number: 42
+
Boolean: true
+
Array: apple, banana, cherry
+
Object: deeply nested value
+
Null: is null
+

API Key Revoked

@@ -97,16 +97,14 @@ it("should send email notification to user when revoking an API key through cred Please create a new API key if needed.

-
+
\`, "text": deindent\` API KEY REVOKED - Your API key "Test API Key to Revoke " for New Project has been - automatically revoked because it was found in a public repository. + Your API key "Test API Key to Revoke " for New Project has been automatically revoked because it was found in a public repository. - This is an automated security measure to protect your api keys from being - leaked. If you believe this was a mistake, please contact support. + This is an automated security measure to protect your api keys from being leaked. If you believe this was a mistake, please contact support. Please create a new API key if needed. \`, @@ -231,7 +229,7 @@ it("should send email notification to team members when revoking a team API key "attachments": [], "body": { "html": deindent\` -
+

API Key Revoked

@@ -244,16 +242,14 @@ it("should send email notification to team members when revoking a team API key Please create a new API key if needed.

-
+
\`, "text": deindent\` API KEY REVOKED - Your API key "Test Team API Key to Revoke" for New Project has been - automatically revoked because it was found in a public repository. + Your API key "Test Team API Key to Revoke" for New Project has been automatically revoked because it was found in a public repository. - This is an automated security measure to protect your api keys from being - leaked. If you believe this was a mistake, please contact support. + This is an automated security measure to protect your api keys from being leaked. If you believe this was a mistake, please contact support. Please create a new API key if needed. \`, @@ -271,7 +267,7 @@ it("should send email notification to team members when revoking a team API key "attachments": [], "body": { "html": deindent\` -
+

API Key Revoked

@@ -284,16 +280,14 @@ it("should send email notification to team members when revoking a team API key Please create a new API key if needed.

-
+
\`, "text": deindent\` API KEY REVOKED - Your API key "Test Team API Key to Revoke" for New Project has been - automatically revoked because it was found in a public repository. + Your API key "Test Team API Key to Revoke" for New Project has been automatically revoked because it was found in a public repository. - This is an automated security measure to protect your api keys from being - leaked. If you believe this was a mistake, please contact support. + This is an automated security measure to protect your api keys from being leaked. If you believe this was a mistake, please contact support. Please create a new API key if needed. \`, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts index 92da657e90..8040df2f60 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts @@ -219,7 +219,7 @@ export function EmailTemplate({ user, project }: Props) { NiceResponse { "status": 200, "body": { - "html": "
Preview for John Doe
", + "html": "
Preview for John Doe
", "notification_category": "Transactional", "subject": "Render Draft Subject", }, @@ -402,3 +402,264 @@ it("should fail rendering with non-existent theme id", async ({ expect }) => { } `); }); + +it("should reject draft that throws an error when rendered", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Email Drafts Invalid JSX Project", + config: { email_config: customEmailConfig }, + }); + + const createRes = await niceBackendFetch("/api/v1/internal/email-drafts", { + method: "POST", + accessType: "admin", + body: { + display_name: "Throwing Draft", + theme_id: false, + tsx_source: ` + import { Subject, NotificationCategory } from "@stackframe/emails"; + export function EmailTemplate() { + throw new Error('Intentional error from draft'); + } + `, + }, + }); + expect(createRes.status).toBe(200); + const draftId = createRes.body.id as string; + + // Attempt to render the draft + const renderRes = await niceBackendFetch("/api/v1/emails/render-email", { + method: "POST", + accessType: "admin", + body: { + template_tsx_source: ` + import { Subject, NotificationCategory } from "@stackframe/emails"; + export function EmailTemplate() { + throw new Error('Intentional error from draft'); + } + `, + theme_id: false, + }, + }); + expect(renderRes.status).toBe(400); + expect(renderRes.body).toMatchObject({ + code: "EMAIL_RENDERING_ERROR", + }); + expect(renderRes.body.error).toContain("Intentional error from draft"); +}); + +it("should reject draft that does not export EmailTemplate function", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Email Drafts Invalid JSX Project", + config: { email_config: customEmailConfig }, + }); + + const renderRes = await niceBackendFetch("/api/v1/emails/render-email", { + method: "POST", + accessType: "admin", + body: { + template_tsx_source: ` + import { Subject, NotificationCategory } from "@stackframe/emails"; + export function WrongFunctionName() { + return
This should fail
; + } + `, + theme_id: false, + }, + }); + expect(renderRes.status).toBe(400); + expect(renderRes.body).toMatchObject({ + code: "EMAIL_RENDERING_ERROR", + }); + // Error message varies by runtime + expect(renderRes.body.error).toBeDefined(); +}); + +it("should reject draft with invalid JSX syntax", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Email Drafts Invalid JSX Project", + config: { email_config: customEmailConfig }, + }); + + const renderRes = await niceBackendFetch("/api/v1/emails/render-email", { + method: "POST", + accessType: "admin", + body: { + template_tsx_source: ` + export function EmailTemplate() { + return
unclosed tag + } + `, + theme_id: false, + }, + }); + expect(renderRes.status).toBe(400); + expect(renderRes.body).toMatchInlineSnapshot(` + { + "code": "EMAIL_RENDERING_ERROR", + "details": { + "error": deindent\` + Build failed with 2 errors: + virtual:/template.tsx:4:8: ERROR: The character "}" is not valid inside a JSX element + virtual:/template.tsx:5:6: ERROR: Unexpected end of file before a closing "span" tag + \`, + }, + "error": deindent\` + Failed to render email with theme: Build failed with 2 errors: + virtual:/template.tsx:4:8: ERROR: The character "}" is not valid inside a JSX element + virtual:/template.tsx:5:6: ERROR: Unexpected end of file before a closing "span" tag + \`, + } + `); +}); + +it.todo("should reject draft that causes infinite loop during rendering", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Email Drafts Invalid JSX Project", + config: { email_config: customEmailConfig }, + }); + + const renderRes = await niceBackendFetch("/api/v1/emails/render-email", { + method: "POST", + accessType: "admin", + body: { + template_tsx_source: ` + import { Subject, NotificationCategory } from "@stackframe/emails"; + export function EmailTemplate() { + while (true) {} + return
Never reached
; + } + `, + theme_id: false, + }, + }); + // Should timeout or return an error, not hang indefinitely + expect(renderRes.status).toBe(400); + expect(renderRes.body).toMatchInlineSnapshot("todo"); +}); + +it.todo("should reject draft that allocates too much memory", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Email Drafts Invalid JSX Project", + config: { email_config: customEmailConfig }, + }); + + const renderRes = await niceBackendFetch("/api/v1/emails/render-email", { + method: "POST", + accessType: "admin", + body: { + template_tsx_source: ` + import { Subject, NotificationCategory } from "@stackframe/emails"; + export function EmailTemplate() { + const arr = []; + for (let i = 0; i < 1e9; i++) { + arr.push(new Array(1e6).fill('x')); + } + return
{arr.length}
; + } + `, + theme_id: false, + }, + }); + // Should fail due to memory limits, not hang or crash the server + expect(renderRes.status).toBe(400); + expect(renderRes.body).toMatchObject({ + code: "EMAIL_RENDERING_ERROR", + }); +}); + +it("should reject draft that exports a non-function", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Email Drafts Invalid JSX Project", + config: { email_config: customEmailConfig }, + }); + + const renderRes = await niceBackendFetch("/api/v1/emails/render-email", { + method: "POST", + accessType: "admin", + body: { + template_tsx_source: ` + export const EmailTemplate = "not a function"; + `, + theme_id: false, + }, + }); + expect(renderRes.status).toBe(400); + expect(renderRes.body).toMatchObject({ + code: "EMAIL_RENDERING_ERROR", + }); + // Error message varies by runtime + expect(renderRes.body.error).toBeDefined(); +}); + +it("should reject theme_tsx_source that throws an error when rendered", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Email Drafts Invalid JSX Project", + config: { email_config: customEmailConfig }, + }); + + const renderRes = await niceBackendFetch("/api/v1/emails/render-email", { + method: "POST", + accessType: "admin", + body: { + template_tsx_source: ` + import { Subject, NotificationCategory } from "@stackframe/emails"; + export function EmailTemplate() { + return
Valid template
; + } + `, + theme_tsx_source: ` + import { Html } from '@react-email/components'; + export function EmailTheme({ children }) { + throw new Error('Intentional error from theme'); + } + `, + }, + }); + expect(renderRes.status).toBe(400); + expect(renderRes.body).toMatchObject({ + code: "EMAIL_RENDERING_ERROR", + }); + expect(renderRes.body.error).toContain("Intentional error from theme"); +}); + +it("should reject theme_tsx_source that does not export EmailTheme function", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Email Drafts Invalid JSX Project", + config: { email_config: customEmailConfig }, + }); + + const renderRes = await niceBackendFetch("/api/v1/emails/render-email", { + method: "POST", + accessType: "admin", + body: { + template_tsx_source: ` + import { Subject, NotificationCategory } from "@stackframe/emails"; + export function EmailTemplate() { + return
Valid template
; + } + `, + theme_tsx_source: ` + import { Html } from '@react-email/components'; + export function WrongThemeName({ children }) { + return {children}; + } + `, + }, + }); + expect(renderRes.status).toBe(400); + expect(renderRes.body).toMatchInlineSnapshot(` + { + "code": "EMAIL_RENDERING_ERROR", + "details": { + "error": deindent\` + Build failed with 1 error: + virtual:/render.tsx:9:9: ERROR: No matching export in "virtual:/theme.tsx" for import "EmailTheme" + \`, + }, + "error": deindent\` + Failed to render email with theme: Build failed with 1 error: + virtual:/render.tsx:9:9: ERROR: No matching export in "virtual:/theme.tsx" for import "EmailTheme" + \`, + } + `); +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts index dbb9eaee6f..97543c7422 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts @@ -83,8 +83,206 @@ it("should allow adding and updating email templates with custom email config", expect(updateResponse).toMatchInlineSnapshot(` NiceResponse { "status": 200, - "body": { "rendered_html": "
Mock email template
Click here to unsubscribe from these emails
" }, + "body": { "rendered_html": "
Mock email template
Click here to unsubscribe from these emails
" }, "headers": Headers {