stack/apps/backend/src/lib/email-rendering.tsx
2026-02-16 14:57:17 -08:00

425 lines
14 KiB
TypeScript

import { executeJavascript, type ExecuteResult } from '@/lib/js-execution';
import { emptyEmailTheme } from '@stackframe/stack-shared/dist/helpers/emails';
import { StackAssertionError, captureError } 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 {
type EditableMetadata,
transpileJsxForEditing,
convertSentinelTokensToComments,
} from "@stackframe/stack-shared/dist/utils/jsx-editable-transpiler";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import { Tenancy } from './tenancies';
export function getActiveEmailTheme(tenancy: Tenancy) {
const themeList = tenancy.config.emails.themes;
const currentActiveTheme = tenancy.config.emails.selectedThemeId;
if (!(has(themeList, currentActiveTheme))) {
throw new StackAssertionError("No active email theme found", {
themeList,
currentActiveTheme,
});
}
return get(themeList, currentActiveTheme);
}
/**
* If themeId is a string, and it is a valid theme id, return the theme's tsxSource.
* If themeId is false, return the empty email theme.
* If themeId is null or undefined, return the currently active email theme.
*/
export function getEmailThemeForThemeId(tenancy: Tenancy, themeId: string | null | false | undefined) {
const themeList = tenancy.config.emails.themes;
if (themeId && has(themeList, themeId)) {
return get(themeList, themeId).tsxSource;
}
if (themeId === false) {
return emptyEmailTheme;
}
return getActiveEmailTheme(tenancy).tsxSource;
}
export function createTemplateComponentFromHtml(html: string) {
return deindent`
export const variablesSchema = v => v;
export function EmailTemplate() {
return <>
<div dangerouslySetInnerHTML={{ __html: ${JSON.stringify(html)}}} />
</>
};
`;
}
const nodeModules = {
"react-dom": "19.1.1",
"react": "19.1.1",
"@react-email/components": "1.0.6",
"arktype": "2.1.20",
};
const entryJs = deindent`
export default async () => {
try {
const { renderAll } = await import("./render.tsx");
const result = await renderAll();
return { status: "ok", data: result };
} catch (e) {
if (e instanceof Error) {
return { status: "error", error: { message: e.message, stack: e.stack, cause: e.cause } };
}
return { status: "error", error: { message: String(e), stack: undefined, cause: undefined } };
}
};
`;
type EmailRenderResult = {
html: string,
text: string,
subject?: string,
notificationCategory?: string,
editableRegions?: Record<string, EditableMetadata>,
};
async function bundleAndExecute<T>(
files: Record<string, string> & { '/entry.js': string },
): Promise<Result<T, string>> {
const bundle = await bundleJavaScript(files, {
keepAsImports: ['arktype', 'react', 'react/jsx-runtime', '@react-email/components'],
externalPackages: { '@stackframe/emails': stackframeEmailsPackage },
format: 'esm',
sourcemap: false,
});
if (bundle.status === "error") {
return Result.error(bundle.error);
}
const executeResult: ExecuteResult = await executeJavascript(bundle.data, { nodeModules });
if (executeResult.status === "error") {
return Result.error(JSON.stringify(executeResult.error));
}
return Result.ok(executeResult.data as T);
}
export async function renderEmailWithTemplate(
templateOrDraftComponent: string,
themeComponent: string,
options: {
user?: { displayName: string | null },
project?: { displayName: string },
variables?: Record<string, any>,
editableMarkers?: boolean,
editableSource?: 'template' | 'theme' | 'both',
themeProps?: {
unsubscribeLink?: string,
projectLogos: {
logoUrl?: string,
logoFullUrl?: string,
logoDarkModeUrl?: string,
logoFullDarkModeUrl?: string,
},
},
previewMode?: boolean,
},
): Promise<Result<EmailRenderResult, string>> {
const variables = options.variables ?? {};
const previewMode = options.previewMode ?? false;
const user = (previewMode && !options.user) ? { displayName: "John Doe" } : options.user;
const project = (previewMode && !options.project) ? { displayName: "My Project" } : options.project;
if (!user) {
throw new StackAssertionError("User is required when not in preview mode", { user, project, variables });
}
if (!project) {
throw new StackAssertionError("Project is required when not in preview mode", { user, project, variables });
}
// Process editable markers if requested
const editableMarkers = options.editableMarkers ?? false;
const editableSource = options.editableSource ?? 'template';
let editableRegions: Record<string, EditableMetadata> = {};
let processedTemplate = templateOrDraftComponent;
let processedTheme = themeComponent;
if (editableMarkers) {
// Transpile template if needed
if (editableSource === 'template' || editableSource === 'both') {
try {
const templateResult = transpileJsxForEditing(templateOrDraftComponent, { sourceFile: 'template' });
processedTemplate = templateResult.code;
editableRegions = { ...editableRegions, ...templateResult.editableRegions };
} catch (e) {
// If transpilation fails, fall back to original source
// This can happen with complex or invalid JSX
captureError("email-transpilation-template-error", new StackAssertionError(
"Failed to transpile template for editable markers",
{ error: e instanceof Error ? e.message : String(e) }
));
}
}
// Transpile theme if needed
if (editableSource === 'theme' || editableSource === 'both') {
try {
const themeResult = transpileJsxForEditing(themeComponent, { sourceFile: 'theme' });
processedTheme = themeResult.code;
editableRegions = { ...editableRegions, ...themeResult.editableRegions };
} catch (e) {
// If transpilation fails, fall back to original source
captureError("email-transpilation-theme-error", new StackAssertionError(
"Failed to transpile theme for editable markers",
{ error: e instanceof Error ? e.message : String(e) }
));
}
}
}
const files = {
"/utils.tsx": findComponentValueUtil,
"/theme.tsx": processedTheme,
"/template.tsx": processedTemplate,
"/render.tsx": deindent`
import { configure } from "arktype/config"
configure({ onUndeclaredKey: "delete" })
import React from 'react';
import { render } from '@react-email/components';
import { type } from "arktype";
import { findComponentValue } from "./utils.tsx";
import * as TemplateModule from "./template.tsx";
const { variablesSchema, EmailTemplate } = TemplateModule;
import { EmailTheme } from "./theme.tsx";
export const renderAll = async () => {
const variables = variablesSchema ? variablesSchema({
${previewMode ? "...(EmailTemplate.PreviewVariables || {})," : ""}
...(${JSON.stringify(variables)}),
}) : {};
if (variables instanceof type.errors) {
throw new Error(variables.summary)
}
const themeProps = {
...${JSON.stringify(options.themeProps || {})},
...${previewMode ? "EmailTheme.PreviewProps" : "{}"},
}
const EmailTemplateWithProps = <EmailTemplate variables={variables} user={${JSON.stringify(user)}} project={${JSON.stringify(project)}} />;
const Email = <EmailTheme {...themeProps}>
{${previewMode ? "EmailTheme.PreviewProps?.children ?? " : ""} EmailTemplateWithProps}
</EmailTheme>;
return {
html: await render(Email),
text: await render(Email, { plainText: true }),
subject: findComponentValue(EmailTemplateWithProps, "Subject"),
notificationCategory: findComponentValue(EmailTemplateWithProps, "NotificationCategory"),
};
}
`,
"/entry.js": entryJs,
};
const result = await bundleAndExecute<EmailRenderResult>(files);
// Post-process HTML to convert sentinel tokens to HTML comments if editable markers enabled
if (result.status === "ok" && editableMarkers) {
const processedHtml = convertSentinelTokensToComments(result.data.html);
return Result.ok({
...result.data,
html: processedHtml,
editableRegions: Object.keys(editableRegions).length > 0 ? editableRegions : undefined,
});
}
return result;
}
export type RenderEmailRequestForTenancy = {
templateSource: string,
themeSource: string,
input: {
user: { displayName: string | null },
project: { displayName: string },
variables?: Record<string, any>,
unsubscribeLink?: string,
themeProps?: {
projectLogos: {
logoUrl?: string,
logoFullUrl?: string,
logoDarkModeUrl?: string,
logoFullDarkModeUrl?: string,
},
},
},
};
export async function renderEmailsForTenancyBatched(requests: RenderEmailRequestForTenancy[]): Promise<Result<EmailRenderResult[], string>> {
if (requests.length === 0) {
return Result.ok([]);
}
const files: Record<string, string> = {
"/utils.tsx": findComponentValueUtil,
};
for (let index = 0; index < requests.length; index++) {
const request = requests[index];
files[`/template-${index}.tsx`] = request.templateSource;
files[`/theme-${index}.tsx`] = request.themeSource;
}
const serializedInputs = JSON.stringify(requests.map((request) => ({
user: request.input.user,
project: request.input.project,
variables: request.input.variables ?? null,
unsubscribeLink: request.input.unsubscribeLink ?? null,
themeProps: request.input.themeProps ?? null,
})));
files["/render.tsx"] = deindent`
import { configure } from "arktype/config";
configure({ onUndeclaredKey: "delete" });
import React from "react";
import { render } from "@react-email/components";
import { type } from "arktype";
import { findComponentValue } from "./utils.tsx";
${requests.map((_, index) => `import * as TemplateModule${index} from "./template-${index}.tsx";`).join("\n")}
${requests.map((_, index) => `const { variablesSchema: variablesSchema${index}, EmailTemplate: EmailTemplate${index} } = TemplateModule${index};`).join("\n")}
${requests.map((_, index) => `import { EmailTheme as EmailTheme${index} } from "./theme-${index}.tsx";`).join("\n")}
export const renderAll = async () => {
const inputs = ${serializedInputs};
const results = [];
${requests.map((_, index) => deindent`
{
const input = inputs[${index}];
const schema = variablesSchema${index};
const variables = schema ? schema({ ...(input.variables || {}) }) : {};
if (variables instanceof type.errors) {
throw new Error(variables.summary);
}
const TemplateWithProps = <EmailTemplate${index} variables={variables} user={input.user} project={input.project} />;
const Email = <EmailTheme${index} unsubscribeLink={input.unsubscribeLink ?? undefined} projectLogos={input.themeProps?.projectLogos ?? {}}>
{TemplateWithProps}
</EmailTheme${index}>;
results.push({
html: await render(Email),
text: await render(Email, { plainText: true }),
subject: findComponentValue(TemplateWithProps, "Subject"),
notificationCategory: findComponentValue(TemplateWithProps, "NotificationCategory"),
});
}
`).join("\n")}
return results;
};
`;
files["/entry.js"] = entryJs;
return await bundleAndExecute<EmailRenderResult[]>(files as Record<string, string> & { '/entry.js': string });
}
const findComponentValueUtil = `import React from 'react';
export function findComponentValue(element, targetStackComponent) {
const matches = [];
function traverse(node) {
if (!React.isValidElement(node)) return;
const type = node.type;
const isTarget =
type &&
typeof type === "function" &&
"__stackComponent" in type &&
type.__stackComponent === targetStackComponent;
if (isTarget) {
matches.push(node);
}
const children = node.props?.children;
if (Array.isArray(children)) {
children.forEach(traverse);
} else if (children) {
traverse(children);
}
}
traverse(element.type(element.props || {}));
if (matches.length === 0) {
return undefined;
}
if (matches.length !== 1) {
throw new Error(
\`Expected exactly one occurrence of component "\${targetStackComponent}", found \${matches.length}.\`
);
}
const matched = matches[0];
const value = matched.props?.value;
if (typeof value !== "string") {
throw new Error(
\`The "value" prop of "\${targetStackComponent}" must be a string.\`
);
}
return value;
}`;
// issues with using jsx in external packages, using React.createElement instead
const stackframeEmailsPackage = deindent`
import React from 'react';
import { Img } from '@react-email/components';
export const Subject = (props) => null;
Subject.__stackComponent = "Subject";
export const NotificationCategory = (props) => null;
NotificationCategory.__stackComponent = "NotificationCategory";
export function Logo(props) {
return React.createElement(
"div",
{ className: "flex gap-2 items-center" },
React.createElement(Img, {
src: props.logoUrl,
alt: "Logo",
className: "h-8",
}),
);
}
export function FullLogo(props) {
return React.createElement(Img, {
src: props.logoFullUrl,
alt: "Full Logo",
className: "h-16",
});
}
export function ProjectLogo(props) {
const { mode = "light" } = props;
const {
logoUrl,
logoFullUrl,
logoDarkModeUrl,
logoFullDarkModeUrl,
} = props.data ?? {};
if (mode === "dark" && logoFullDarkModeUrl) {
return React.createElement(FullLogo, { logoFullUrl: logoFullDarkModeUrl });
}
if (mode === "dark" && logoDarkModeUrl) {
return React.createElement(Logo, {
logoUrl: logoDarkModeUrl,
});
}
if (mode === "light" && logoFullUrl) {
return React.createElement(FullLogo, { logoFullUrl });
}
if (mode === "light" && logoUrl) {
return React.createElement(Logo, {
logoUrl,
});
}
return null;
}
`;