creating templates and variableSchemas (#808)
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Runs E2E API Tests with external source of truth / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled

This commit is contained in:
BilalG1 2025-07-25 18:49:14 -07:00 committed by GitHub
parent 0a72170b63
commit 2334884a79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 249 additions and 65 deletions

View File

@ -1,7 +1,7 @@
import { renderEmailWithTemplate } from "@/lib/email-rendering";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { adaptSchema, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { captureError, StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
@ -28,7 +28,6 @@ export const POST = createSmartRouteHandler({
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
html: yupString().defined(),
schema: yupMixed(),
subject: yupString(),
notification_category: yupString(),
}).defined(),
@ -50,18 +49,14 @@ export const POST = createSmartRouteHandler({
if (!templateSource) {
throw new StatusError(400, "No template found with given id");
}
const variables = {
projectDisplayName: tenancy.project.display_name,
teamDisplayName: "My Team",
userDisplayName: "John Doe",
emailVerificationLink: "<email verification link>",
otp: "3SLSWZ",
magicLink: "<magic link>",
passwordResetLink: "<password reset link>",
teamInvitationLink: "<team invitation link>",
signInInvitationLink: "<sign in invitation link>",
};
const result = await renderEmailWithTemplate(templateSource, themeSource, variables);
const result = await renderEmailWithTemplate(
templateSource,
themeSource,
{
project: { displayName: tenancy.project.display_name },
previewMode: true,
},
);
if ("error" in result) {
captureError('render-email', new StackAssertionError("Error rendering email with theme", { result }));
throw new KnownErrors.EmailRenderingError(result.error);
@ -71,7 +66,6 @@ export const POST = createSmartRouteHandler({
bodyType: "json",
body: {
html: result.data.html,
schema: result.data.schema,
subject: result.data.subject,
notification_category: result.data.notificationCategory,
},

View File

@ -116,7 +116,14 @@ export const POST = createSmartRouteHandler({
const template = createTemplateComponentFromHtml(body.html, unsubscribeLink || undefined);
const renderedEmail = await renderEmailWithTemplate(template, activeTheme.tsxSource);
const renderedEmail = await renderEmailWithTemplate(
template,
activeTheme.tsxSource,
{
user: { displayName: user.displayName },
project: { displayName: auth.tenancy.project.display_name },
},
);
if (renderedEmail.status === "error") {
userSendErrors.set(userId, "There was an error rendering the email");
continue;

View File

@ -37,7 +37,10 @@ export const PATCH = createSmartRouteHandler({
throw new StatusError(StatusError.NotFound, "No template found with given id");
}
const theme = tenancy.completeConfig.emails.themes[tenancy.completeConfig.emails.selectedThemeId];
const result = await renderEmailWithTemplate(body.tsx_source, theme.tsxSource, { projectDisplayName: tenancy.project.display_name });
const result = await renderEmailWithTemplate(body.tsx_source, theme.tsxSource, {
variables: { projectDisplayName: tenancy.project.display_name },
previewMode: true,
});
if (result.status === "error") {
throw new KnownErrors.EmailRenderingError(result.error);
}
@ -47,9 +50,6 @@ export const PATCH = createSmartRouteHandler({
if (result.data.notificationCategory === undefined) {
throw new KnownErrors.EmailRenderingError("NotificationCategory is required, import it from @stackframe/emails");
}
if (result.data.schema === undefined) {
throw new KnownErrors.EmailRenderingError("schema is required and must be exported");
}
await overrideEnvironmentConfigOverride({
tx: globalPrismaClient,

View File

@ -1,6 +1,9 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, yupArray, yupNumber, yupObject, yupString, templateThemeIdSchema } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, templateThemeIdSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import { overrideEnvironmentConfigOverride } from "@/lib/config";
import { globalPrismaClient } from "@/prisma-client";
export const GET = createSmartRouteHandler({
metadata: {
@ -40,3 +43,71 @@ export const GET = createSmartRouteHandler({
};
},
});
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: yupString().oneOf(["admin"]).defined(),
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
display_name: yupString().defined(),
}),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
id: yupString().defined(),
}).defined(),
}),
async handler({ body, auth: { tenancy } }) {
const id = generateUuid();
const defaultTemplateSource = deindent`
import { type } from "arktype"
import { Container } from "@react-email/components";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export const variablesSchema = type({
count: "number"
});
export function EmailTemplate({ user, variables }: Props<typeof variablesSchema.infer>) {
return (
<Container>
<Subject value={\`Hello \${user.displayName}!\`} />
<NotificationCategory value="Transactional" />
<div className="font-bold">Hi {user.displayName}!</div>
<br />
count is {variables.count}
</Container>
);
}
EmailTemplate.PreviewVariables = {
count: 10
} satisfies typeof variablesSchema.infer
`;
await overrideEnvironmentConfigOverride({
tx: globalPrismaClient,
projectId: tenancy.project.id,
branchId: tenancy.branchId,
environmentConfigOverrideOverride: {
[`emails.templates.${id}`]: {
displayName: body.display_name,
tsxSource: defaultTemplateSource,
themeId: null,
},
},
});
return {
statusCode: 200,
bodyType: "json",
body: { id },
};
},
});

View File

@ -123,15 +123,9 @@ export const POST = createSmartRouteHandler({
emailTemplates[configTemplateId].tsxSource,
emptyThemeComponent,
{
projectDisplayName: "My Project",
teamDisplayName: "My Team",
userDisplayName: "John Doe",
emailVerificationLink: "<email verification link>",
otp: "3SLSWZ",
magicLink: "<magic link>",
passwordResetLink: "<password reset link>",
teamInvitationLink: "<team invitation link>",
signInInvitationLink: "<sign in invitation link>",
project: { displayName: project.displayName },
user: { displayName: "John Doe" },
previewMode: true,
}
);
rendered.push({

View File

@ -74,7 +74,11 @@ export const PATCH = createSmartRouteHandler({
throw new StatusError(404, "No theme found with given id");
}
const theme = themeList[id];
const result = await renderEmailWithTemplate(previewTemplateSource, body.tsx_source);
const result = await renderEmailWithTemplate(
previewTemplateSource,
body.tsx_source,
{ previewMode: true },
);
if (result.status === "error") {
throw new KnownErrors.EmailRenderingError(result.error);
}

View File

@ -28,30 +28,36 @@ Create a new email template.
The email template is a tsx file that is used to render the email content.
It must use react-email components.
It must export two things:
- schema: An arktype schema for the email template props
- EmailTemplate: A function that renders the email template.
- variablesSchema: An arktype schema for the email template props
- 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 = { ...
It must not import from any package besides "@react-email/components", "@stackframe/emails", and "arktype".
It uses tailwind classes for all styling.
Here is an example of a valid email template:
\`\`\`tsx
import { type } from "arktype"
import { Container } from "@react-email/components";
import { Subject, NotificationCategory } from "@stackframe/emails";
import { type } from "arktype";
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
export const schema = type({
projectDisplayName: "string",
export const variablesSchema = type({
count: "number"
});
export function EmailTemplate({ projectDisplayName }: typeof schema.infer) {
export function EmailTemplate({ user, variables }: Props<typeof variablesSchema.infer>) {
return (
<Container>
<Subject value="Email Verification" />
<Subject value={\`Hello \${user.displayName}!\`} />
<NotificationCategory value="Transactional" />
<div className="font-bold">Email Verification for { projectDisplayName }</div>
<div className="font-bold">Hi {user.displayName}!</div>
<br />
count is {variables.count}
</Container>
);
}
EmailTemplate.PreviewVariables = {
count: 10
} satisfies typeof variablesSchema.infer
\`\`\`
Here is the user's current email template:

View File

@ -3,6 +3,7 @@ import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dis
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import { bundleJavaScript } from '@stackframe/stack-shared/dist/utils/esbuild';
import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors';
export function createTemplateComponentFromHtml(
html: string,
@ -22,14 +23,29 @@ export function createTemplateComponentFromHtml(
export async function renderEmailWithTemplate(
templateComponent: string,
themeComponent: string,
variables: Record<string, string> = {},
): Promise<Result<{ html: string, text: string, schema: any, subject?: string, notificationCategory?: string }, string>> {
options: {
user?: { displayName: string | null },
project?: { displayName: string },
variables?: Record<string, any>,
previewMode?: boolean,
},
): Promise<Result<{ html: string, text: string, subject?: string, notificationCategory?: string }, string>> {
const apiKey = getEnvVariable("STACK_FREESTYLE_API_KEY");
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 });
}
if (["development", "test"].includes(getNodeEnvironment()) && apiKey === "mock_stack_freestyle_key") {
return Result.ok({
html: `<div>Mock api key detected, \n\ntemplateComponent: ${templateComponent}\n\nthemeComponent: ${themeComponent}\n\n variables: ${JSON.stringify(variables)}</div>`,
text: `<div>Mock api key detected, \n\ntemplateComponent: ${templateComponent}\n\nthemeComponent: ${themeComponent}\n\n variables: ${JSON.stringify(variables)}</div>`,
schema: {},
subject: "mock subject",
notificationCategory: "mock notification category",
});
@ -40,20 +56,28 @@ export async function renderEmailWithTemplate(
"/theme.tsx": themeComponent,
"/template.tsx": templateComponent,
"/render.tsx": deindent`
import { configure } from "arktype/config"
configure({ onUndeclaredKey: "delete" })
import React from 'react';
import * as TemplateModule from "./template.tsx";
const { schema, EmailTemplate } = TemplateModule;
import { findComponentValue } from "./utils.tsx";
import { EmailTheme } from "./theme.tsx";
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 EmailTemplateWithProps = <EmailTemplate ${variablesAsProps} />;
const variables = variablesSchema({
${previewMode ? "...(EmailTemplate.PreviewVariables || {})," : ""}
...(${JSON.stringify(variables)}),
})
if (variables instanceof type.errors) {
throw new Error(variables.summary)
}
const EmailTemplateWithProps = <EmailTemplate variables={variables} user={${JSON.stringify(user)}} project={${JSON.stringify(project)}} />;
const Email = <EmailTheme>{EmailTemplateWithProps}</EmailTheme>;
return {
html: await render(Email),
text: await render(Email, { plainText: true }),
schema: schema ? schema.toJsonSchema() : undefined,
subject: findComponentValue(EmailTemplateWithProps, "Subject"),
notificationCategory: findComponentValue(EmailTemplateWithProps, "NotificationCategory"),
};
@ -82,7 +106,7 @@ export async function renderEmailWithTemplate(
if ("error" in output) {
return Result.error(output.error as string);
}
return Result.ok(output.result as { html: string, text: string, schema: any, subject: string, notificationCategory: string });
return Result.ok(output.result as { html: string, text: string, subject: string, notificationCategory: string });
}

View File

@ -1,9 +1,12 @@
"use client";
import { FormDialog } from "@/components/form-dialog";
import { InputField } from "@/components/form-fields";
import { useRouter } from "@/components/router";
import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, Card, Typography } from "@stackframe/stack-ui";
import { AlertCircle } from "lucide-react";
import { useState } from "react";
import * as yup from "yup";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
@ -16,7 +19,11 @@ export default function PageClient() {
const [sharedSmtpWarningDialogOpen, setSharedSmtpWarningDialogOpen] = useState<string | null>(null);
return (
<PageLayout title="Email Templates" description="Customize the emails sent to your users">
<PageLayout
title="Email Templates"
description="Customize the emails sent to your users"
actions={<NewTemplateButton />}
>
{emailConfig?.type === 'shared' && <Alert variant="default">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Warning</AlertTitle>
@ -71,3 +78,33 @@ export default function PageClient() {
</PageLayout>
);
}
function NewTemplateButton() {
const stackAdminApp = useAdminApp();
const router = useRouter();
const handleCreateNewTemplate = async (values: { name: string }) => {
const { id } = await stackAdminApp.createNewEmailTemplate(values.name);
router.push(`email-templates-new/${id}`);
};
return (
<FormDialog
title="New Template"
trigger={<Button>New Template</Button>}
onSubmit={handleCreateNewTemplate}
formSchema={yup.object({
name: yup.string().defined(),
})}
render={(form) => (
<InputField
control={form.control}
name="name"
label="Template Name"
placeholder="Enter template name"
required
/>
)}
/>
);
}

View File

@ -81,6 +81,15 @@ export default function CodeEditor({
declare module "@stackframe/emails" {
const Subject: React.FC<{value: string}>;
const NotificationCategory: React.FC<{value: "Transactional" | "Marketing"}>;
type Props<T> = {
variables: T;
project: {
displayName: string;
};
user: {
displayName: string | null;
};
};
}
`,
);

View File

@ -160,10 +160,9 @@ it("should render email when valid theme and template TSX sources are provided",
export default EmailTheme;
variables: {"projectDisplayName":"Stack Dashboard","teamDisplayName":"My Team","userDisplayName":"John Doe","emailVerificationLink":"<email verification link>","otp":"3SLSWZ","magicLink":"<magic link>","passwordResetLink":"<password reset link>","teamInvitationLink":"<team invitation link>","signInInvitationLink":"<sign in invitation link>"}</div>
variables: {}</div>
\`,
"notification_category": "mock notification category",
"schema": {},
"subject": "mock subject",
},
"headers": Headers { <some fields may have been hidden> },

View File

@ -1,4 +1,5 @@
export const previewTemplateSource = `
export const variablesSchema = v => v;
export function EmailTemplate() {
return (
<div>
@ -75,25 +76,39 @@ export const DEFAULT_EMAIL_THEMES = {
},
};
const EMAIL_TEMPLATE_EMAIL_VERIFICATION_ID = "e7d009ce-8d47-4528-b245-5bf119f2ffa3";
const EMAIL_TEMPLATE_PASSWORD_RESET_ID = "a70fb3a4-56c1-4e42-af25-49d25603abd0";
const EMAIL_TEMPLATE_MAGIC_LINK_ID = "822687fe-8d0a-4467-a0d1-416b6e639478";
const EMAIL_TEMPLATE_TEAM_INVITATION_ID = "e84de395-2076-4831-9c19-8e9a96a868e4";
const EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID = "066dd73c-36da-4fd0-b6d6-ebf87683f8bc";
export const DEFAULT_EMAIL_TEMPLATES = {
"e7d009ce-8d47-4528-b245-5bf119f2ffa3": {
[EMAIL_TEMPLATE_EMAIL_VERIFICATION_ID]: {
"displayName": "Email Verification",
"tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory } from \"@stackframe/emails\";\n\nexport const schema = type({\n userDisplayName: \"string\",\n projectDisplayName: \"string\",\n emailVerificationLink: \"string\"\n})\n\nexport function EmailTemplate({ \n userDisplayName, \n projectDisplayName, \n emailVerificationLink \n}: typeof schema.infer) \n{\n return (\n <>\n <Subject value={`Verify your email at ${projectDisplayName}`} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-[1.5] m-0 py-8 w-full min-h-full\">\n <Section className=\"bg-white\">\n <h3 className=\"text-black font-sans font-bold text-[20px] text-center py-4 px-6 m-0\">\n Verify your email at {projectDisplayName}\n </h3>\n <p className=\"text-[#474849] font-sans font-normal text-[14px] text-center pt-2 px-6 pb-4 m-0\">\n Hi{userDisplayName ? (\", \" + userDisplayName) : ''}! Please click on the following button to verify your email.\n </p>\n <div className=\"text-center py-3 px-6\">\n <Button\n href={emailVerificationLink}\n target=\"_blank\"\n className=\"text-black font-sans font-bold text-[14px] inline-block bg-[#f0f0f0] rounded-[4px] py-3 px-5 no-underline border-0\"\n >\n Verify my email\n </Button>\n </div>\n <div className=\"py-4 px-6\">\n <Hr />\n </div>\n <p className=\"text-[#474849] font-sans font-normal text-[12px] text-center pt-1 px-6 pb-6 m-0\">\n If you were not expecting this email, you can safely ignore it. \n </p>\n </Section>\n </div>\n </>\n )\n}\n"
"tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\nexport const variablesSchema = type({\n emailVerificationLink: \"string\"\n})\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={`Verify your email at ${project.displayName}`} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-[1.5] m-0 py-8 w-full min-h-full\">\n <Section className=\"bg-white\">\n <h3 className=\"text-black font-sans font-bold text-[20px] text-center py-4 px-6 m-0\">\n Verify your email at {project.displayName}\n </h3>\n <p className=\"text-[#474849] font-sans font-normal text-[14px] text-center pt-2 px-6 pb-4 m-0\">\n Hi{user.displayName ? (\", \" + user.displayName) : ''}! Please click on the following button to verify your email.\n </p>\n <div className=\"text-center py-3 px-6\">\n <Button\n href={variables.emailVerificationLink}\n target=\"_blank\"\n className=\"text-black font-sans font-bold text-[14px] inline-block bg-[#f0f0f0] rounded-[4px] py-3 px-5 no-underline border-0\"\n >\n Verify my email\n </Button>\n </div>\n <div className=\"py-4 px-6\">\n <Hr />\n </div>\n <p className=\"text-[#474849] font-sans font-normal text-[12px] text-center pt-1 px-6 pb-6 m-0\">\n If you were not expecting this email, you can safely ignore it. \n </p>\n </Section>\n </div>\n </>\n )\n}\n\nEmailTemplate.PreviewVariables = {\n emailVerificationLink: \"<email verification link>\"\n} satisfies typeof variablesSchema.infer"
},
"a70fb3a4-56c1-4e42-af25-49d25603abd0": {
[EMAIL_TEMPLATE_PASSWORD_RESET_ID]: {
"displayName": "Password Reset",
"tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\"\nimport { Subject, NotificationCategory } from \"@stackframe/emails\"\n\nexport const schema = type({\n userDisplayName: \"string\",\n projectDisplayName: \"string\",\n passwordResetLink: \"string\"\n})\n\nexport function EmailTemplate({ userDisplayName, projectDisplayName, passwordResetLink }: typeof schema.infer) {\n return (\n <>\n <Subject value={\"Reset your password at \" + projectDisplayName} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-tight leading-relaxed py-8 w-full min-h-full\">\n <Section>\n <h3 className=\"text-black bg-transparent font-sans font-bold text-[20px] text-center py-4 px-6 m-0\">\n Reset your password at {projectDisplayName}\n </h3>\n\n <p className=\"text-[#474849] bg-transparent text-sm font-sans font-normal text-center pt-2 pb-4 px-6 m-0\">\n Hi{userDisplayName ? (\", \" + userDisplayName) : \"\"}! Please click on the following button to start the password reset process.\n </p>\n\n <div className=\"bg-transparent text-center px-6 py-3\">\n <Button\n href={passwordResetLink}\n className=\"text-black text-sm font-sans font-bold bg-[#f0f0f0] rounded-[4px] inline-block py-3 px-5 no-underline border-none\"\n target=\"_blank\"\n >\n Reset my password\n </Button>\n </div>\n\n <div className=\"px-6 py-4\">\n <Hr />\n </div>\n\n <p className=\"text-[#474849] bg-transparent text-xs font-sans font-normal text-center pt-1 pb-6 px-6 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n )\n}\n"
"tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\"\nimport { Subject, NotificationCategory, Props} from \"@stackframe/emails\"\n\nexport const variablesSchema = type({\n passwordResetLink: \"string\"\n})\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={\"Reset your password at \" + project.displayName} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-tight leading-relaxed py-8 w-full min-h-full\">\n <Section>\n <h3 className=\"text-black bg-transparent font-sans font-bold text-[20px] text-center py-4 px-6 m-0\">\n Reset your password at {project.displayName}\n </h3>\n\n <p className=\"text-[#474849] bg-transparent text-sm font-sans font-normal text-center pt-2 pb-4 px-6 m-0\">\n Hi{user.displayName ? (\", \" + user.displayName) : \"\"}! Please click on the following button to start the password reset process.\n </p>\n\n <div className=\"bg-transparent text-center px-6 py-3\">\n <Button\n href={variables.passwordResetLink}\n className=\"text-black text-sm font-sans font-bold bg-[#f0f0f0] rounded-[4px] inline-block py-3 px-5 no-underline border-none\"\n target=\"_blank\"\n >\n Reset my password\n </Button>\n </div>\n\n <div className=\"px-6 py-4\">\n <Hr />\n </div>\n\n <p className=\"text-[#474849] bg-transparent text-xs font-sans font-normal text-center pt-1 pb-6 px-6 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n )\n}\n\nEmailTemplate.PreviewVariables = {\n passwordResetLink: \"<password reset link>\"\n} satisfies typeof variablesSchema.infer"
},
"822687fe-8d0a-4467-a0d1-416b6e639478": {
[EMAIL_TEMPLATE_MAGIC_LINK_ID]: {
"displayName": "Magic Link/OTP",
"tsxSource": "import React from 'react';\nimport { type } from 'arktype';\nimport { Section, Hr } from '@react-email/components';\nimport { Subject, NotificationCategory } from '@stackframe/emails';\n\nexport const schema = type({\n userDisplayName: 'string',\n projectDisplayName: 'string',\n magicLink: 'string',\n otp: 'string',\n});\n\nexport function EmailTemplate({ userDisplayName, projectDisplayName, magicLink, otp }: typeof schema.infer) {\n return (\n <>\n <Subject value={\"Sign in to \" + projectDisplayName + \": Your code is \" + otp} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-6 m-0 py-8 w-full min-h-full\">\n <Section className=\"mx-auto bg-white\">\n <h3 className=\"text-black bg-transparent font-sans font-bold text-xl text-center px-6 py-4 m-0\">\n Sign in to {projectDisplayName}\n </h3>\n <p className=\"text-[#474849] bg-transparent text-sm font-sans font-normal text-center px-6 py-4 m-0\">\n Hi{userDisplayName ? \", \" + userDisplayName : \"\"}! This is your one-time-password for signing in:\n </p>\n <p className=\"text-black bg-transparent text-2xl font-mono font-bold text-center px-6 py-4 m-0\">\n {otp}\n </p>\n <p className=\"text-black bg-transparent text-sm font-sans font-normal text-center px-6 py-4 m-0\">\n Or you can click on{' '}\n <a\n key={20}\n href={magicLink}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-blue-600 underline\"\n >\n this link\n </a>{' '}\n to sign in\n </p>\n <Hr className=\"px-6 py-4 bg-transparent\" />\n <p className=\"text-[#474849] bg-transparent text-xs font-sans font-normal text-center px-6 pt-1 pb-6 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n );\n}\n"
"tsxSource": "import { type } from 'arktype';\nimport { Section, Hr } from '@react-email/components';\nimport { Subject, NotificationCategory, Props } from '@stackframe/emails';\n\nexport const variablesSchema = type({\n magicLink: 'string',\n otp: 'string',\n});\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={\"Sign in to \" + project.displayName + \": Your code is \" + variables.otp} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-6 m-0 py-8 w-full min-h-full\">\n <Section className=\"mx-auto bg-white\">\n <h3 className=\"text-black bg-transparent font-sans font-bold text-xl text-center px-6 py-4 m-0\">\n Sign in to {project.displayName}\n </h3>\n <p className=\"text-[#474849] bg-transparent text-sm font-sans font-normal text-center px-6 py-4 m-0\">\n Hi{user.displayName ? \", \" + user.displayName : \"\"}! This is your one-time-password for signing in:\n </p>\n <p className=\"text-black bg-transparent text-2xl font-mono font-bold text-center px-6 py-4 m-0\">\n {variables.otp}\n </p>\n <p className=\"text-black bg-transparent text-sm font-sans font-normal text-center px-6 py-4 m-0\">\n Or you can click on{' '}\n <a\n key={20}\n href={variables.magicLink}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-blue-600 underline\"\n >\n this link\n </a>{' '}\n to sign in\n </p>\n <Hr className=\"px-6 py-4 bg-transparent\" />\n <p className=\"text-[#474849] bg-transparent text-xs font-sans font-normal text-center px-6 pt-1 pb-6 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n );\n}\n\nEmailTemplate.PreviewVariables = {\n magicLink: \"<magic link>\",\n otp: \"3SLSWZ\"\n} satisfies typeof variablesSchema.infer"
},
"066dd73c-36da-4fd0-b6d6-ebf87683f8bc": {
[EMAIL_TEMPLATE_TEAM_INVITATION_ID]: {
"displayName": "Team Invitation",
"tsxSource": "import { type } from \"arktype\";\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory } from \"@stackframe/emails\";\n\n\nexport const schema = type({\n userDisplayName: \"string\",\n teamDisplayName: \"string\",\n teamInvitationLink: \"string\"\n});\n\nexport function EmailTemplate({ userDisplayName, teamDisplayName, teamInvitationLink }: typeof schema.infer) {\n return (\n <>\n <Subject value={\"You have been invited to join \" + teamDisplayName} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-[1.5] m-0 py-8 w-full min-h-full\">\n <Section className=\"mx-auto max-w-lg bg-white\">\n <h3 className=\"text-black bg-transparent font-sans font-bold text-xl text-center px-6 pt-8 m-0\">\n You are invited to {teamDisplayName}\n </h3>\n <p className=\"text-[#474849] bg-transparent text-sm font-sans font-normal text-center px-6 pt-2 pb-4 m-0\">\n Hi{userDisplayName ? \", \" + userDisplayName : \"\"}! Please click the button below to join the team {teamDisplayName}\n </p>\n <div className=\"bg-transparent text-center px-6 py-3\">\n <Button\n href={teamInvitationLink}\n target=\"_blank\"\n className=\"text-black text-sm font-sans font-bold bg-[#f0f0f0] rounded-md inline-block px-5 py-3 no-underline border-0\"\n >\n Join team\n </Button>\n </div>\n <div className=\"px-6 py-4 bg-transparent\">\n <Hr />\n </div>\n <p className=\"text-[#474849] bg-transparent text-xs font-sans font-normal text-center px-6 pb-6 pt-1 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n );\n}\n"
"tsxSource": "import { type } from \"arktype\";\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\n\nexport const variablesSchema = type({\n teamDisplayName: \"string\",\n teamInvitationLink: \"string\"\n});\n\nexport function EmailTemplate({ user, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={\"You have been invited to join \" + variables.teamDisplayName} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-[1.5] m-0 py-8 w-full min-h-full\">\n <Section className=\"mx-auto max-w-lg bg-white\">\n <h3 className=\"text-black bg-transparent font-sans font-bold text-xl text-center px-6 pt-8 m-0\">\n You are invited to {variables.teamDisplayName}\n </h3>\n <p className=\"text-[#474849] bg-transparent text-sm font-sans font-normal text-center px-6 pt-2 pb-4 m-0\">\n Hi{user.displayName ? \", \" + user.displayName : \"\"}! Please click the button below to join the team {variables.teamDisplayName}\n </p>\n <div className=\"bg-transparent text-center px-6 py-3\">\n <Button\n href={variables.teamInvitationLink}\n target=\"_blank\"\n className=\"text-black text-sm font-sans font-bold bg-[#f0f0f0] rounded-md inline-block px-5 py-3 no-underline border-0\"\n >\n Join team\n </Button>\n </div>\n <div className=\"px-6 py-4 bg-transparent\">\n <Hr />\n </div>\n <p className=\"text-[#474849] bg-transparent text-xs font-sans font-normal text-center px-6 pb-6 pt-1 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n );\n}\n\nEmailTemplate.PreviewVariables = {\n teamDisplayName: \"My Team\",\n teamInvitationLink: \"<team invitation link>\"\n} satisfies typeof variablesSchema.infer "
},
"e84de395-2076-4831-9c19-8e9a96a868e4": {
[EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID]: {
"displayName": "Sign In Invitation",
"tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory } from \"@stackframe/emails\";\n\nexport const schema = type({\n userDisplayName: \"string\",\n projectDisplayName: \"string\",\n signInInvitationLink: \"string\",\n teamDisplayName: \"string\"\n})\n\nexport function EmailTemplate({\n userDisplayName,\n projectDisplayName,\n signInInvitationLink,\n teamDisplayName\n}: typeof schema.infer) {\n return (\n <>\n <Subject\n value={\"You have been invited to sign in to \" + projectDisplayName}\n />\n <NotificationCategory value=\"Transactional\" />\n\n <div className=\"bg-white text-gray-900 font-sans text-base font-normal leading-normal w-full min-h-full m-0 py-8\">\n <Section>\n <h3 className=\"text-black bg-transparent font-sans font-bold text-xl text-center pt-8 px-6 m-0\">\n You are invited to sign in to {teamDisplayName}\n </h3>\n\n <p className=\"text-gray-700 bg-transparent text-sm font-sans font-normal text-center pt-2 pb-4 px-6 m-0\">\n Hi\n {userDisplayName ? \", \" + userDisplayName : \"\"}! Please click on the following\n link to sign in to your account\n </p>\n\n <div className=\"bg-transparent text-center px-6 py-3\">\n <Button\n href={signInInvitationLink}\n className=\"text-black text-sm font-sans font-bold bg-gray-200 rounded-md inline-block py-3 px-5 no-underline border-none\"\n target=\"_blank\"\n >\n Sign in\n </Button>\n </div>\n\n <div className=\"px-6 py-4 bg-transparent\">\n <Hr />\n </div>\n\n <p className=\"text-gray-700 bg-transparent text-xs font-sans font-normal text-center pt-1 pb-6 px-6 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n )\n}\n"
"tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\nexport const variablesSchema = type({\n signInInvitationLink: \"string\",\n teamDisplayName: \"string\"\n})\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject\n value={\"You have been invited to sign in to \" + project.displayName}\n />\n <NotificationCategory value=\"Transactional\" />\n\n <div className=\"bg-white text-gray-900 font-sans text-base font-normal leading-normal w-full min-h-full m-0 py-8\">\n <Section>\n <h3 className=\"text-black bg-transparent font-sans font-bold text-xl text-center pt-8 px-6 m-0\">\n You are invited to sign in to {variables.teamDisplayName}\n </h3>\n\n <p className=\"text-gray-700 bg-transparent text-sm font-sans font-normal text-center pt-2 pb-4 px-6 m-0\">\n Hi\n {user.displayName ? \", \" + user.displayName : \"\"}! Please click on the following\n link to sign in to your account\n </p>\n\n <div className=\"bg-transparent text-center px-6 py-3\">\n <Button\n href={variables.signInInvitationLink}\n className=\"text-black text-sm font-sans font-bold bg-gray-200 rounded-md inline-block py-3 px-5 no-underline border-none\"\n target=\"_blank\"\n >\n Sign in\n </Button>\n </div>\n\n <div className=\"px-6 py-4 bg-transparent\">\n <Hr />\n </div>\n\n <p className=\"text-gray-700 bg-transparent text-xs font-sans font-normal text-center pt-1 pb-6 px-6 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n )\n}\n\nEmailTemplate.PreviewVariables = {\n signInInvitationLink: \"<sign in invitation link>\",\n teamDisplayName: \"My Team\"\n} satisfies typeof variablesSchema.infer"
}
};
export const DEFAULT_TEMPLATE_IDS = {
email_verification: EMAIL_TEMPLATE_EMAIL_VERIFICATION_ID,
password_reset: EMAIL_TEMPLATE_PASSWORD_RESET_ID,
magic_link: EMAIL_TEMPLATE_MAGIC_LINK_ID,
team_invitation: EMAIL_TEMPLATE_TEAM_INVITATION_ID,
sign_in_invitation: EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID,
} as const;

View File

@ -472,6 +472,23 @@ export class StackAdminInterface extends StackServerInterface {
return await response.json();
}
async createNewEmailTemplate(displayName: string): Promise<{ id: string }> {
const response = await this.sendAdminRequest(
`/internal/email-templates`,
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
display_name: displayName,
}),
},
null,
);
return await response.json();
}
async getAllProjectsIdsForMigration(cursor?: string): Promise<{ project_ids: string[], next_cursor: string | null }> {
const queryParams = cursor ? `?cursor=${encodeURIComponent(cursor)}` : '';
const response = await this.sendAdminRequest(

View File

@ -456,6 +456,12 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
await this._interface.sendSignInInvitationEmail(email, callbackUrl);
}
async createNewEmailTemplate(displayName: string): Promise<{ id: string }> {
const result = await this._interface.createNewEmailTemplate(displayName);
await this._adminNewEmailTemplatesCache.refresh([]);
return result;
}
async sendChatMessage(
threadId: string,
contextType: "email-theme" | "email-template",

View File

@ -83,6 +83,7 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
saveChatMessage(threadId: string, message: any): Promise<void>,
listChatMessages(threadId: string): Promise<{ messages: Array<any> }>,
updateNewEmailTemplate(id: string, tsxSource: string): Promise<{ renderedHtml: string }>,
createNewEmailTemplate(displayName: string): Promise<{ id: string }>,
getAllProjectsIdsForMigration(cursor?: string): Promise<{ projectIds: string[], nextCursor: string | null }>,
convertEmailTemplates(projectId: string): Promise<{ templatesConverted: number, totalTemplates: number, rendered: Array<{ legacyTemplateContent: any, templateType: string, renderedHtml: string | null }> }>,