Skip to content

Commit 2334884

Browse files
authored
creating templates and variableSchemas (stack-auth#808)
1 parent 0a72170 commit 2334884

File tree

15 files changed

+248
-64
lines changed

15 files changed

+248
-64
lines changed

apps/backend/src/app/api/latest/emails/render-email/route.tsx

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { renderEmailWithTemplate } from "@/lib/email-rendering";
22
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
33
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
4-
import { adaptSchema, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4+
import { adaptSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
55
import { captureError, StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
66

77

@@ -28,7 +28,6 @@ export const POST = createSmartRouteHandler({
2828
bodyType: yupString().oneOf(["json"]).defined(),
2929
body: yupObject({
3030
html: yupString().defined(),
31-
schema: yupMixed(),
3231
subject: yupString(),
3332
notification_category: yupString(),
3433
}).defined(),
@@ -50,18 +49,14 @@ export const POST = createSmartRouteHandler({
5049
if (!templateSource) {
5150
throw new StatusError(400, "No template found with given id");
5251
}
53-
const variables = {
54-
projectDisplayName: tenancy.project.display_name,
55-
teamDisplayName: "My Team",
56-
userDisplayName: "John Doe",
57-
emailVerificationLink: "<email verification link>",
58-
otp: "3SLSWZ",
59-
magicLink: "<magic link>",
60-
passwordResetLink: "<password reset link>",
61-
teamInvitationLink: "<team invitation link>",
62-
signInInvitationLink: "<sign in invitation link>",
63-
};
64-
const result = await renderEmailWithTemplate(templateSource, themeSource, variables);
52+
const result = await renderEmailWithTemplate(
53+
templateSource,
54+
themeSource,
55+
{
56+
project: { displayName: tenancy.project.display_name },
57+
previewMode: true,
58+
},
59+
);
6560
if ("error" in result) {
6661
captureError('render-email', new StackAssertionError("Error rendering email with theme", { result }));
6762
throw new KnownErrors.EmailRenderingError(result.error);
@@ -71,7 +66,6 @@ export const POST = createSmartRouteHandler({
7166
bodyType: "json",
7267
body: {
7368
html: result.data.html,
74-
schema: result.data.schema,
7569
subject: result.data.subject,
7670
notification_category: result.data.notificationCategory,
7771
},

apps/backend/src/app/api/latest/emails/send-email/route.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,14 @@ export const POST = createSmartRouteHandler({
116116

117117

118118
const template = createTemplateComponentFromHtml(body.html, unsubscribeLink || undefined);
119-
const renderedEmail = await renderEmailWithTemplate(template, activeTheme.tsxSource);
119+
const renderedEmail = await renderEmailWithTemplate(
120+
template,
121+
activeTheme.tsxSource,
122+
{
123+
user: { displayName: user.displayName },
124+
project: { displayName: auth.tenancy.project.display_name },
125+
},
126+
);
120127
if (renderedEmail.status === "error") {
121128
userSendErrors.set(userId, "There was an error rendering the email");
122129
continue;

apps/backend/src/app/api/latest/internal/email-templates/[templateId]/route.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ export const PATCH = createSmartRouteHandler({
3737
throw new StatusError(StatusError.NotFound, "No template found with given id");
3838
}
3939
const theme = tenancy.completeConfig.emails.themes[tenancy.completeConfig.emails.selectedThemeId];
40-
const result = await renderEmailWithTemplate(body.tsx_source, theme.tsxSource, { projectDisplayName: tenancy.project.display_name });
40+
const result = await renderEmailWithTemplate(body.tsx_source, theme.tsxSource, {
41+
variables: { projectDisplayName: tenancy.project.display_name },
42+
previewMode: true,
43+
});
4144
if (result.status === "error") {
4245
throw new KnownErrors.EmailRenderingError(result.error);
4346
}
@@ -47,9 +50,6 @@ export const PATCH = createSmartRouteHandler({
4750
if (result.data.notificationCategory === undefined) {
4851
throw new KnownErrors.EmailRenderingError("NotificationCategory is required, import it from @stackframe/emails");
4952
}
50-
if (result.data.schema === undefined) {
51-
throw new KnownErrors.EmailRenderingError("schema is required and must be exported");
52-
}
5353

5454
await overrideEnvironmentConfigOverride({
5555
tx: globalPrismaClient,

apps/backend/src/app/api/latest/internal/email-templates/route.tsx

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
2-
import { adaptSchema, yupArray, yupNumber, yupObject, yupString, templateThemeIdSchema } from "@stackframe/stack-shared/dist/schema-fields";
3-
2+
import { adaptSchema, templateThemeIdSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
3+
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
4+
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
5+
import { overrideEnvironmentConfigOverride } from "@/lib/config";
6+
import { globalPrismaClient } from "@/prisma-client";
47

58
export const GET = createSmartRouteHandler({
69
metadata: {
@@ -40,3 +43,71 @@ export const GET = createSmartRouteHandler({
4043
};
4144
},
4245
});
46+
47+
export const POST = createSmartRouteHandler({
48+
metadata: {
49+
hidden: true,
50+
},
51+
request: yupObject({
52+
auth: yupObject({
53+
type: yupString().oneOf(["admin"]).defined(),
54+
tenancy: adaptSchema.defined(),
55+
}).defined(),
56+
body: yupObject({
57+
display_name: yupString().defined(),
58+
}),
59+
}),
60+
response: yupObject({
61+
statusCode: yupNumber().oneOf([200]).defined(),
62+
bodyType: yupString().oneOf(["json"]).defined(),
63+
body: yupObject({
64+
id: yupString().defined(),
65+
}).defined(),
66+
}),
67+
async handler({ body, auth: { tenancy } }) {
68+
const id = generateUuid();
69+
const defaultTemplateSource = deindent`
70+
import { type } from "arktype"
71+
import { Container } from "@react-email/components";
72+
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
73+
74+
export const variablesSchema = type({
75+
count: "number"
76+
});
77+
78+
export function EmailTemplate({ user, variables }: Props<typeof variablesSchema.infer>) {
79+
return (
80+
<Container>
81+
<Subject value={\`Hello \${user.displayName}!\`} />
82+
<NotificationCategory value="Transactional" />
83+
<div className="font-bold">Hi {user.displayName}!</div>
84+
<br />
85+
count is {variables.count}
86+
</Container>
87+
);
88+
}
89+
90+
EmailTemplate.PreviewVariables = {
91+
count: 10
92+
} satisfies typeof variablesSchema.infer
93+
`;
94+
95+
await overrideEnvironmentConfigOverride({
96+
tx: globalPrismaClient,
97+
projectId: tenancy.project.id,
98+
branchId: tenancy.branchId,
99+
environmentConfigOverrideOverride: {
100+
[`emails.templates.${id}`]: {
101+
displayName: body.display_name,
102+
tsxSource: defaultTemplateSource,
103+
themeId: null,
104+
},
105+
},
106+
});
107+
return {
108+
statusCode: 200,
109+
bodyType: "json",
110+
body: { id },
111+
};
112+
},
113+
});

apps/backend/src/app/api/latest/internal/email-templates/temp/[projectId]/route.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,9 @@ export const POST = createSmartRouteHandler({
123123
emailTemplates[configTemplateId].tsxSource,
124124
emptyThemeComponent,
125125
{
126-
projectDisplayName: "My Project",
127-
teamDisplayName: "My Team",
128-
userDisplayName: "John Doe",
129-
emailVerificationLink: "<email verification link>",
130-
otp: "3SLSWZ",
131-
magicLink: "<magic link>",
132-
passwordResetLink: "<password reset link>",
133-
teamInvitationLink: "<team invitation link>",
134-
signInInvitationLink: "<sign in invitation link>",
126+
project: { displayName: project.displayName },
127+
user: { displayName: "John Doe" },
128+
previewMode: true,
135129
}
136130
);
137131
rendered.push({

apps/backend/src/app/api/latest/internal/email-themes/[id]/route.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,11 @@ export const PATCH = createSmartRouteHandler({
7474
throw new StatusError(404, "No theme found with given id");
7575
}
7676
const theme = themeList[id];
77-
const result = await renderEmailWithTemplate(previewTemplateSource, body.tsx_source);
77+
const result = await renderEmailWithTemplate(
78+
previewTemplateSource,
79+
body.tsx_source,
80+
{ previewMode: true },
81+
);
7882
if (result.status === "error") {
7983
throw new KnownErrors.EmailRenderingError(result.error);
8084
}

apps/backend/src/lib/ai-chat/email-template-adapter.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,30 +28,36 @@ Create a new email template.
2828
The email template is a tsx file that is used to render the email content.
2929
It must use react-email components.
3030
It must export two things:
31-
- schema: An arktype schema for the email template props
32-
- EmailTemplate: A function that renders the email template.
31+
- variablesSchema: An arktype schema for the email template props
32+
- EmailTemplate: A function that renders the email template. You must set the PreviewVariables property to an object that satisfies the variablesSchema by doing EmailTemplate.PreviewVariables = { ...
3333
It must not import from any package besides "@react-email/components", "@stackframe/emails", and "arktype".
3434
It uses tailwind classes for all styling.
3535
3636
Here is an example of a valid email template:
3737
\`\`\`tsx
38+
import { type } from "arktype"
3839
import { Container } from "@react-email/components";
39-
import { Subject, NotificationCategory } from "@stackframe/emails";
40-
import { type } from "arktype";
40+
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
4141
42-
export const schema = type({
43-
projectDisplayName: "string",
42+
export const variablesSchema = type({
43+
count: "number"
4444
});
4545
46-
export function EmailTemplate({ projectDisplayName }: typeof schema.infer) {
46+
export function EmailTemplate({ user, variables }: Props<typeof variablesSchema.infer>) {
4747
return (
4848
<Container>
49-
<Subject value="Email Verification" />
49+
<Subject value={\`Hello \${user.displayName}!\`} />
5050
<NotificationCategory value="Transactional" />
51-
<div className="font-bold">Email Verification for { projectDisplayName }</div>
51+
<div className="font-bold">Hi {user.displayName}!</div>
52+
<br />
53+
count is {variables.count}
5254
</Container>
5355
);
5456
}
57+
58+
EmailTemplate.PreviewVariables = {
59+
count: 10
60+
} satisfies typeof variablesSchema.infer
5561
\`\`\`
5662
5763
Here is the user's current email template:

apps/backend/src/lib/email-rendering.tsx

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dis
33
import { Result } from "@stackframe/stack-shared/dist/utils/results";
44
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
55
import { bundleJavaScript } from '@stackframe/stack-shared/dist/utils/esbuild';
6+
import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors';
67

78
export function createTemplateComponentFromHtml(
89
html: string,
@@ -22,14 +23,29 @@ export function createTemplateComponentFromHtml(
2223
export async function renderEmailWithTemplate(
2324
templateComponent: string,
2425
themeComponent: string,
25-
variables: Record<string, string> = {},
26-
): Promise<Result<{ html: string, text: string, schema: any, subject?: string, notificationCategory?: string }, string>> {
26+
options: {
27+
user?: { displayName: string | null },
28+
project?: { displayName: string },
29+
variables?: Record<string, any>,
30+
previewMode?: boolean,
31+
},
32+
): Promise<Result<{ html: string, text: string, subject?: string, notificationCategory?: string }, string>> {
2733
const apiKey = getEnvVariable("STACK_FREESTYLE_API_KEY");
34+
const variables = options.variables ?? {};
35+
const previewMode = options.previewMode ?? false;
36+
const user = (previewMode && !options.user) ? { displayName: "John Doe" } : options.user;
37+
const project = (previewMode && !options.project) ? { displayName: "My Project" } : options.project;
38+
if (!user) {
39+
throw new StackAssertionError("User is required when not in preview mode", { user, project, variables });
40+
}
41+
if (!project) {
42+
throw new StackAssertionError("Project is required when not in preview mode", { user, project, variables });
43+
}
44+
2845
if (["development", "test"].includes(getNodeEnvironment()) && apiKey === "mock_stack_freestyle_key") {
2946
return Result.ok({
3047
html: `<div>Mock api key detected, \n\ntemplateComponent: ${templateComponent}\n\nthemeComponent: ${themeComponent}\n\n variables: ${JSON.stringify(variables)}</div>`,
3148
text: `<div>Mock api key detected, \n\ntemplateComponent: ${templateComponent}\n\nthemeComponent: ${themeComponent}\n\n variables: ${JSON.stringify(variables)}</div>`,
32-
schema: {},
3349
subject: "mock subject",
3450
notificationCategory: "mock notification category",
3551
});
@@ -40,20 +56,28 @@ export async function renderEmailWithTemplate(
4056
"/theme.tsx": themeComponent,
4157
"/template.tsx": templateComponent,
4258
"/render.tsx": deindent`
59+
import { configure } from "arktype/config"
60+
configure({ onUndeclaredKey: "delete" })
4361
import React from 'react';
44-
import * as TemplateModule from "./template.tsx";
45-
const { schema, EmailTemplate } = TemplateModule;
62+
import { render } from '@react-email/components';
63+
import { type } from "arktype";
4664
import { findComponentValue } from "./utils.tsx";
65+
import * as TemplateModule from "./template.tsx";
66+
const { variablesSchema, EmailTemplate } = TemplateModule;
4767
import { EmailTheme } from "./theme.tsx";
48-
import { render } from '@react-email/components';
49-
5068
export const renderAll = async () => {
51-
const EmailTemplateWithProps = <EmailTemplate ${variablesAsProps} />;
69+
const variables = variablesSchema({
70+
${previewMode ? "...(EmailTemplate.PreviewVariables || {})," : ""}
71+
...(${JSON.stringify(variables)}),
72+
})
73+
if (variables instanceof type.errors) {
74+
throw new Error(variables.summary)
75+
}
76+
const EmailTemplateWithProps = <EmailTemplate variables={variables} user={${JSON.stringify(user)}} project={${JSON.stringify(project)}} />;
5277
const Email = <EmailTheme>{EmailTemplateWithProps}</EmailTheme>;
5378
return {
5479
html: await render(Email),
5580
text: await render(Email, { plainText: true }),
56-
schema: schema ? schema.toJsonSchema() : undefined,
5781
subject: findComponentValue(EmailTemplateWithProps, "Subject"),
5882
notificationCategory: findComponentValue(EmailTemplateWithProps, "NotificationCategory"),
5983
};
@@ -82,7 +106,7 @@ export async function renderEmailWithTemplate(
82106
if ("error" in output) {
83107
return Result.error(output.error as string);
84108
}
85-
return Result.ok(output.result as { html: string, text: string, schema: any, subject: string, notificationCategory: string });
109+
return Result.ok(output.result as { html: string, text: string, subject: string, notificationCategory: string });
86110
}
87111

88112

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates-new/page-client.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"use client";
22

3+
import { FormDialog } from "@/components/form-dialog";
4+
import { InputField } from "@/components/form-fields";
35
import { useRouter } from "@/components/router";
46
import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, Card, Typography } from "@stackframe/stack-ui";
57
import { AlertCircle } from "lucide-react";
68
import { useState } from "react";
9+
import * as yup from "yup";
710
import { PageLayout } from "../page-layout";
811
import { useAdminApp } from "../use-admin-app";
912

@@ -16,7 +19,11 @@ export default function PageClient() {
1619
const [sharedSmtpWarningDialogOpen, setSharedSmtpWarningDialogOpen] = useState<string | null>(null);
1720

1821
return (
19-
<PageLayout title="Email Templates" description="Customize the emails sent to your users">
22+
<PageLayout
23+
title="Email Templates"
24+
description="Customize the emails sent to your users"
25+
actions={<NewTemplateButton />}
26+
>
2027
{emailConfig?.type === 'shared' && <Alert variant="default">
2128
<AlertCircle className="h-4 w-4" />
2229
<AlertTitle>Warning</AlertTitle>
@@ -71,3 +78,33 @@ export default function PageClient() {
7178
</PageLayout>
7279
);
7380
}
81+
82+
function NewTemplateButton() {
83+
const stackAdminApp = useAdminApp();
84+
const router = useRouter();
85+
86+
const handleCreateNewTemplate = async (values: { name: string }) => {
87+
const { id } = await stackAdminApp.createNewEmailTemplate(values.name);
88+
router.push(`email-templates-new/${id}`);
89+
};
90+
91+
return (
92+
<FormDialog
93+
title="New Template"
94+
trigger={<Button>New Template</Button>}
95+
onSubmit={handleCreateNewTemplate}
96+
formSchema={yup.object({
97+
name: yup.string().defined(),
98+
})}
99+
render={(form) => (
100+
<InputField
101+
control={form.control}
102+
name="name"
103+
label="Template Name"
104+
placeholder="Enter template name"
105+
required
106+
/>
107+
)}
108+
/>
109+
);
110+
}

0 commit comments

Comments
 (0)