diff --git a/apps/backend/src/app/api/latest/emails/render-email/route.tsx b/apps/backend/src/app/api/latest/emails/render-email/route.tsx index fa8d76bb84..b4daeb7225 100644 --- a/apps/backend/src/app/api/latest/emails/render-email/route.tsx +++ b/apps/backend/src/app/api/latest/emails/render-email/route.tsx @@ -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", diff --git a/apps/backend/src/app/api/latest/emails/send-email/route.tsx b/apps/backend/src/app/api/latest/emails/send-email/route.tsx index 699804330b..77ccf24a30 100644 --- a/apps/backend/src/app/api/latest/emails/send-email/route.tsx +++ b/apps/backend/src/app/api/latest/emails/send-email/route.tsx @@ -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({ @@ -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(), }), @@ -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(), }), @@ -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, @@ -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 = new Map(); const userPrimaryEmails: Map = new Map(); @@ -85,11 +95,6 @@ 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"); @@ -97,14 +102,44 @@ export const POST = createSmartRouteHandler({ } 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 }); @@ -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, }); @@ -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 { diff --git a/apps/backend/src/lib/email-rendering.tsx b/apps/backend/src/lib/email-rendering.tsx index d39ffa4d16..b58622e0c1 100644 --- a/apps/backend/src/lib/email-rendering.tsx +++ b/apps/backend/src/lib/email-rendering.tsx @@ -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 ? `

Click here to unsubscribe` : ""; +export function createTemplateComponentFromHtml(html: string) { return deindent` + export const variablesSchema = v => v; export function EmailTemplate() { return <>
- ${unsubscribeLinkHtml} }; `; @@ -53,6 +49,7 @@ export async function renderEmailWithTemplate( user?: { displayName: string | null }, project?: { displayName: string }, variables?: Record, + unsubscribeLink?: string, previewMode?: boolean, }, ): Promise> { @@ -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: `
Mock api key detected, \n\ntemplateComponent: ${templateComponent}\n\nthemeComponent: ${themeComponent}\n\n variables: ${JSON.stringify(variables)}
`, - text: `
Mock api key detected, \n\ntemplateComponent: ${templateComponent}\n\nthemeComponent: ${themeComponent}\n\n variables: ${JSON.stringify(variables)}
`, - subject: `Mock subject, ${templateComponent.match(/]*\/>/g)?.[0]}`, - notificationCategory: "mock notification category", - }); - } const result = await bundleJavaScript({ "/utils.tsx": findComponentValueUtil, "/theme.tsx": themeComponent, @@ -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 = ; - const Email = {EmailTemplateWithProps}; + const Email = + {${previewMode ? "EmailTheme.PreviewProps?.children ?? " : ""} EmailTemplateWithProps} + ; return { html: await render(Email), text: await render(Email, { plainText: true }), @@ -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", }; diff --git a/apps/backend/src/lib/freestyle.tsx b/apps/backend/src/lib/freestyle.tsx index 5f763a9bc2..b6b0ea6ceb 100644 --- a/apps/backend/src/lib/freestyle.tsx +++ b/apps/backend/src/lib/freestyle.tsx @@ -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[1]) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/page-client.tsx index 6e89669a9b..72368022e5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/page-client.tsx @@ -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" }} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx index 8a87267eb6..9a00567cd6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx @@ -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, }); diff --git a/apps/dashboard/src/components/vibe-coding/code-editor.tsx b/apps/dashboard/src/components/vibe-coding/code-editor.tsx index 6e73e48a61..8a751c7646 100644 --- a/apps/dashboard/src/components/vibe-coding/code-editor.tsx +++ b/apps/dashboard/src/components/vibe-coding/code-editor.tsx @@ -90,6 +90,10 @@ export default function CodeEditor({ displayName: string | null; }; }; + type ThemeProps = { + children: React.ReactNode; + unsubscribeLink?: string; + }; } `, ); diff --git a/apps/dev-launchpad/public/index.html b/apps/dev-launchpad/public/index.html index 41fc2ee64d..37e69a11e7 100644 --- a/apps/dev-launchpad/public/index.html +++ b/apps/dev-launchpad/public/index.html @@ -111,7 +111,7 @@

Background services

4318: OTel collector
  • - 8119: Freestyle mock + 8122: Freestyle mock
  • 8121: S3 mock diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts index 36478179ba..e857c5b301 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts @@ -8,7 +8,7 @@ it("should send a sign-in code per e-mail", async ({ expect }) => { [ MailboxMessage { "from": "Stack Dashboard ", - "subject": "Mock subject, \\"", + "subject": "Sign in to Stack Dashboard: Your code is ", "to": ["@stack-generated.example.com>"],