Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { getEmailThemeForTemplate, renderEmailWithTemplate } from "@/lib/email-rendering";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { adaptSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, templateThemeIdSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";

import { templateThemeIdSchema } from "@stackframe/stack-shared/dist/schema-fields";

export const POST = createSmartRouteHandler({
metadata: {
summary: "Render email theme",
Expand Down
104 changes: 68 additions & 36 deletions apps/backend/src/app/api/latest/emails/send-email/route.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import { createTemplateComponentFromHtml, renderEmailWithTemplate } from "@/lib/email-rendering";
import { createTemplateComponentFromHtml, getEmailThemeForTemplate, renderEmailWithTemplate } from "@/lib/email-rendering";
import { getEmailConfig, sendEmail } from "@/lib/emails";
import { getNotificationCategoryByName, hasNotificationEnabled } from "@/lib/notification-categories";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, serverOrHigherAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler";
import { KnownErrors } from "@stackframe/stack-shared";

type UserResult = {
user_id: string,
user_email?: string,
success: boolean,
error?: string,
};

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
summary: "Send email",
description: "Send an email to a list of users. The content field should contain either {html, subject, notification_category_name} for HTML emails or {template_id, variables} for template-based emails.",
},
request: yupObject({
auth: yupObject({
Expand All @@ -26,9 +27,14 @@ export const POST = createSmartRouteHandler({
}).defined(),
body: yupObject({
user_ids: yupArray(yupString().defined()).defined(),
html: yupString().defined(),
subject: yupString().defined(),
notification_category_name: yupString().defined(),
theme_id: templateThemeIdSchema.nullable().meta({
openapiField: { description: "The theme to use for the email. If not specified, the default theme will be used." }
}),
html: yupString().optional(),
subject: yupString().optional(),
notification_category_name: yupString().optional(),
template_id: yupString().optional(),
variables: yupRecord(yupString(), yupMixed()).optional(),
}),
method: yupString().oneOf(["POST"]).defined(),
}),
Expand All @@ -39,8 +45,6 @@ export const POST = createSmartRouteHandler({
results: yupArray(yupObject({
user_id: yupString().defined(),
user_email: yupString().optional(),
success: yupBoolean().defined(),
error: yupString().optional(),
})).defined(),
}).defined(),
}),
Expand All @@ -49,21 +53,23 @@ export const POST = createSmartRouteHandler({
throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
}
if (auth.tenancy.config.emails.server.isShared) {
throw new StatusError(400, "Cannot send custom emails when using shared email config");
throw new KnownErrors.RequiresCustomEmailServer();
}
const emailConfig = await getEmailConfig(auth.tenancy);
const notificationCategory = getNotificationCategoryByName(body.notification_category_name);
if (!notificationCategory) {
throw new StatusError(404, "Notification category not found");
if (!body.html && !body.template_id) {
throw new KnownErrors.SchemaError("Either html or template_id must be provided");
}
const themeList = auth.tenancy.config.emails.themes;
if (!Object.keys(themeList).includes(auth.tenancy.config.emails.selectedThemeId)) {
throw new StatusError(400, "No active theme found");
if (body.html && (body.template_id || body.variables)) {
throw new KnownErrors.SchemaError("If html is provided, cannot provide template_id or variables");
}
const activeTheme = themeList[auth.tenancy.config.emails.selectedThemeId];
const emailConfig = await getEmailConfig(auth.tenancy);
const defaultNotificationCategory = getNotificationCategoryByName(body.notification_category_name ?? "Transactional") ?? throwErr(400, "Notification category not found with given name");
const themeSource = getEmailThemeForTemplate(auth.tenancy, body.theme_id);
const templates = new Map(Object.entries(auth.tenancy.config.emails.templates));
const templateSource = body.template_id
? (templates.get(body.template_id)?.tsxSource ?? throwErr(400, "Template not found with given id"))
: createTemplateComponentFromHtml(body.html!);

const prisma = await getPrismaClientForTenancy(auth.tenancy);

const users = await prisma.projectUser.findMany({
where: {
tenancyId: auth.tenancy.id,
Expand All @@ -75,6 +81,10 @@ export const POST = createSmartRouteHandler({
contactChannels: true,
},
});
const missingUserIds = body.user_ids.filter(userId => !users.some(user => user.projectUserId === userId));
if (missingUserIds.length > 0) {
throw new KnownErrors.UserIdDoesNotExist(missingUserIds[0]);
}
const userMap = new Map(users.map(user => [user.projectUserId, user]));
const userSendErrors: Map<string, string> = new Map();
const userPrimaryEmails: Map<string, string> = new Map();
Expand All @@ -85,26 +95,51 @@ export const POST = createSmartRouteHandler({
userSendErrors.set(userId, "User not found");
continue;
}
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy, user.projectUserId, notificationCategory.id);
if (!isNotificationEnabled) {
userSendErrors.set(userId, "User has disabled notifications for this category");
continue;
}
const primaryEmail = user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value;
if (!primaryEmail) {
userSendErrors.set(userId, "User does not have a primary email");
continue;
}
userPrimaryEmails.set(userId, primaryEmail);

let unsubscribeLink: string | null = null;
if (notificationCategory.can_disable) {
let currentNotificationCategory = defaultNotificationCategory;
if (body.template_id) {
// We have to render email twice in this case, first pass is to get the notification category
const renderedTemplateFirstPass = await renderEmailWithTemplate(
templateSource,
themeSource,
{
user: { displayName: user.displayName },
project: { displayName: auth.tenancy.project.display_name },
variables: body.variables,
},
);
if (renderedTemplateFirstPass.status === "error") {
userSendErrors.set(userId, "There was an error rendering the email");
continue;
}
const notificationCategory = getNotificationCategoryByName(renderedTemplateFirstPass.data.notificationCategory ?? "");
if (!notificationCategory) {
userSendErrors.set(userId, "Notification category not found with given name");
continue;
}
currentNotificationCategory = notificationCategory;
}

const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy, user.projectUserId, currentNotificationCategory.id);
if (!isNotificationEnabled) {
userSendErrors.set(userId, "User has disabled notifications for this category");
continue;
}

let unsubscribeLink: string | undefined = undefined;
if (currentNotificationCategory.can_disable) {
const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({
tenancy: auth.tenancy,
method: {},
data: {
user_id: user.projectUserId,
notification_category_id: notificationCategory.id,
notification_category_id: currentNotificationCategory.id,
},
callbackUrl: undefined
});
Expand All @@ -114,27 +149,26 @@ export const POST = createSmartRouteHandler({
unsubscribeLink = unsubUrl.toString();
}


const template = createTemplateComponentFromHtml(body.html, unsubscribeLink || undefined);
const renderedEmail = await renderEmailWithTemplate(
template,
activeTheme.tsxSource,
templateSource,
themeSource,
{
user: { displayName: user.displayName },
project: { displayName: auth.tenancy.project.display_name },
variables: body.variables,
unsubscribeLink,
},
);
if (renderedEmail.status === "error") {
userSendErrors.set(userId, "There was an error rendering the email");
continue;
}

try {
await sendEmail({
tenancyId: auth.tenancy.id,
emailConfig,
to: primaryEmail,
subject: body.subject,
subject: body.subject ?? renderedEmail.data.subject ?? "",
html: renderedEmail.data.html,
text: renderedEmail.data.text,
});
Expand All @@ -146,8 +180,6 @@ export const POST = createSmartRouteHandler({
const results: UserResult[] = body.user_ids.map((userId) => ({
user_id: userId,
user_email: userPrimaryEmails.get(userId),
success: !userSendErrors.has(userId),
error: userSendErrors.get(userId),
}));

return {
Expand Down
23 changes: 8 additions & 15 deletions apps/backend/src/lib/email-rendering.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,12 @@ export function getEmailThemeForTemplate(tenancy: Tenancy, templateThemeId: stri
return getActiveEmailTheme(tenancy).tsxSource;
}

export function createTemplateComponentFromHtml(
html: string,
unsubscribeLink?: string,
) {
const unsubscribeLinkHtml = unsubscribeLink ? `<br /><br /><a href="${unsubscribeLink}">Click here to unsubscribe</a>` : "";
export function createTemplateComponentFromHtml(html: string) {
return deindent`
export const variablesSchema = v => v;
export function EmailTemplate() {
return <>
<div dangerouslySetInnerHTML={{ __html: ${JSON.stringify(html)}}} />
${unsubscribeLinkHtml}
</>
};
`;
Expand All @@ -53,6 +49,7 @@ export async function renderEmailWithTemplate(
user?: { displayName: string | null },
project?: { displayName: string },
variables?: Record<string, any>,
unsubscribeLink?: string,
previewMode?: boolean,
},
): Promise<Result<{ html: string, text: string, subject?: string, notificationCategory?: string }, string>> {
Expand All @@ -68,14 +65,6 @@ export async function renderEmailWithTemplate(
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>`,
subject: `Mock subject, ${templateComponent.match(/<Subject\s+[^>]*\/>/g)?.[0]}`,
notificationCategory: "mock notification category",
});
}
const result = await bundleJavaScript({
"/utils.tsx": findComponentValueUtil,
"/theme.tsx": themeComponent,
Expand All @@ -98,8 +87,11 @@ export async function renderEmailWithTemplate(
if (variables instanceof type.errors) {
throw new Error(variables.summary)
}
const unsubscribeLink = ${previewMode ? "EmailTheme.PreviewProps?.unsubscribeLink" : JSON.stringify(options.unsubscribeLink)};
const EmailTemplateWithProps = <EmailTemplate variables={variables} user={${JSON.stringify(user)}} project={${JSON.stringify(project)}} />;
const Email = <EmailTheme>{EmailTemplateWithProps}</EmailTheme>;
const Email = <EmailTheme unsubscribeLink={unsubscribeLink}>
{${previewMode ? "EmailTheme.PreviewProps?.children ?? " : ""} EmailTemplateWithProps}
</EmailTheme>;
return {
html: await render(Email),
text: await render(Email, { plainText: true }),
Expand All @@ -124,6 +116,7 @@ export async function renderEmailWithTemplate(

const freestyle = new Freestyle({ apiKey });
const nodeModules = {
"react": "19.1.1",
"@react-email/components": "0.1.1",
"arktype": "2.1.20",
};
Expand Down
12 changes: 10 additions & 2 deletions apps/backend/src/lib/freestyle.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { traceSpan } from '@/utils/telemetry';
import { getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
import { StackAssertionError, captureError, errorToNiceString } from '@stackframe/stack-shared/dist/utils/errors';
import { traceSpan } from '@stackframe/stack-shared/dist/utils/telemetry';
import { FreestyleSandboxes } from 'freestyle-sandboxes';

export class Freestyle {
private freestyle: FreestyleSandboxes;

constructor(options: { apiKey: string }) {
this.freestyle = new FreestyleSandboxes(options);
let baseUrl = undefined;
if (["development", "test"].includes(getNodeEnvironment()) && options.apiKey === "mock_stack_freestyle_key") {
baseUrl = "http://localhost:8122";
}
this.freestyle = new FreestyleSandboxes({
apiKey: options.apiKey,
baseUrl,
});
}

async executeScript(script: string, options?: Parameters<FreestyleSandboxes['executeScript']>[1]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default function PageClient() {
title="Shared Email Server"
okButton={{
label: "Edit Templates Anyway", onClick: async () => {
router.push(`email-templates-new/${sharedSmtpWarningDialogOpen}`);
router.push(`email-templates/${sharedSmtpWarningDialogOpen}`);
}
}}
cancelButton={{ label: "Cancel" }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ function SendEmailDialog(props: {
await stackAdminApp.sendEmail({
userIds: selectedUsers.map(user => user.id),
subject: formData.subject,
content: formData.content,
html: formData.content,
notificationCategoryName: formData.notificationCategoryName,
});

Expand Down
4 changes: 4 additions & 0 deletions apps/dashboard/src/components/vibe-coding/code-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export default function CodeEditor({
displayName: string | null;
};
};
type ThemeProps = {
children: React.ReactNode;
unsubscribeLink?: string;
};
}
`,
);
Expand Down
2 changes: 1 addition & 1 deletion apps/dev-launchpad/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ <h2 style="margin-top: 64px;">Background services</h2>
4318: OTel collector
</li>
<li>
8119: Freestyle mock
8122: Freestyle mock
</li>
<li>
8121: S3 mock
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ it("should send a sign-in code per e-mail", async ({ expect }) => {
[
MailboxMessage {
"from": "Stack Dashboard <noreply@example.com>",
"subject": "Mock subject, <Subject value=\\"{\\"Sign in to \\" + project.displayName + \\": Your code is \\" + variables.otp} />\\"",
"subject": "Sign in to Stack Dashboard: Your code is <stripped code>",
"to": ["<default-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
Expand Down Expand Up @@ -100,31 +100,12 @@ it("should send otp code to user", async ({ expect }) => {
});

const email = (await backendContext.value.mailbox.fetchMessages()).findLast((email) => email.subject.includes("Sign in"));
const match = email?.body?.text.match(/"otp":"([A-Z0-9]{6})"/);
const match = email?.body?.html.match(/\>([A-Z0-9]{6})\<\/p\>/);
expect(match).toHaveLength(2);
const code = match?.[1];
expect(code).toHaveLength(6);
});

it("should not send otp code to user if client version is older equal to 2.5.37", async ({ expect }) => {
await Auth.Otp.sendSignInCode();
const mailbox = backendContext.value.mailbox;
await niceBackendFetch("/api/v1/auth/otp/send-sign-in-code", {
method: "POST",
accessType: "client",
body: {
email: mailbox.emailAddress,
callback_url: "http://localhost:12345/some-callback-url",
},
headers: {
"X-Stack-Client-Version": "js @stackframe/stack@2.5.37",
},
});

const email = (await backendContext.value.mailbox.fetchMessages()).findLast((email) => email.subject.includes("Sign in"));
const match = email?.body?.text.match(/^[A-Z0-9]{6}$/sm);
expect(match).toBeNull();
});

it.todo("should create a team for newly created users if configured as such");

Expand Down
Loading