diff --git a/apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql b/apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql new file mode 100644 index 0000000000..b10d2c4a49 --- /dev/null +++ b/apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql @@ -0,0 +1,17 @@ +-- CreateEnum +CREATE TYPE "DraftThemeMode" AS ENUM ('PROJECT_DEFAULT', 'NONE', 'CUSTOM'); + +-- CreateTable +CREATE TABLE "EmailDraft" ( + "tenancyId" UUID NOT NULL, + "id" UUID NOT NULL, + "displayName" TEXT NOT NULL, + "themeMode" "DraftThemeMode" NOT NULL DEFAULT 'PROJECT_DEFAULT', + "themeId" TEXT, + "tsxSource" TEXT NOT NULL, + "sentAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EmailDraft_pkey" PRIMARY KEY ("tenancyId","id") +); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 67b5bdc86f..6839dd6a30 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -673,6 +673,29 @@ model SentEmail { @@id([tenancyId, id]) } +model EmailDraft { + tenancyId String @db.Uuid + + id String @default(uuid()) @db.Uuid + + displayName String + themeMode DraftThemeMode @default(PROJECT_DEFAULT) + themeId String? + tsxSource String + sentAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([tenancyId, id]) +} + +enum DraftThemeMode { + PROJECT_DEFAULT + NONE + CUSTOM +} + model CliAuthAttempt { tenancyId String @db.Uuid 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 b4daeb7225..fef522f6f1 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,7 +1,7 @@ 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, templateThemeIdSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, templateThemeIdSchema, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; export const POST = createSmartRouteHandler({ @@ -15,12 +15,24 @@ export const POST = createSmartRouteHandler({ type: yupString().oneOf(["admin"]).defined(), tenancy: adaptSchema.defined(), }).defined(), - body: yupObject({ - theme_id: templateThemeIdSchema.nullable(), - theme_tsx_source: yupString(), - template_id: yupString(), - template_tsx_source: yupString(), - }), + body: yupUnion( + yupObject({ + template_id: yupString().uuid().defined(), + theme_id: templateThemeIdSchema, + }), + yupObject({ + template_id: yupString().uuid().defined(), + theme_tsx_source: yupString().defined(), + }), + yupObject({ + template_tsx_source: yupString().defined(), + theme_id: templateThemeIdSchema, + }), + yupObject({ + template_tsx_source: yupString().defined(), + theme_tsx_source: yupString().defined(), + }), + ).defined(), }), response: yupObject({ statusCode: yupNumber().oneOf([200]).defined(), @@ -32,32 +44,40 @@ export const POST = createSmartRouteHandler({ }).defined(), }), async handler({ body, auth: { tenancy } }) { - if ((body.theme_id === undefined && !body.theme_tsx_source) || (body.theme_id && body.theme_tsx_source)) { - throw new StatusError(400, "Exactly one of theme_id or theme_tsx_source must be provided"); - } - if ((!body.template_id && !body.template_tsx_source) || (body.template_id && body.template_tsx_source)) { - throw new StatusError(400, "Exactly one of template_id or template_tsx_source must be provided"); + const templateList = new Map(Object.entries(tenancy.config.emails.templates)); + const themeList = new Map(Object.entries(tenancy.config.emails.themes)); + let themeSource: string; + if ("theme_tsx_source" in body) { + themeSource = body.theme_tsx_source; + } else { + if (typeof body.theme_id === "string" && !themeList.has(body.theme_id)) { + throw new StatusError(400, "No theme found with given id"); + } + themeSource = getEmailThemeForTemplate(tenancy, body.theme_id); } - if (body.theme_id && !(body.theme_id in tenancy.config.emails.themes)) { - throw new StatusError(400, "No theme found with given id"); + let contentSource: string; + if ("template_tsx_source" in body) { + contentSource = body.template_tsx_source; + } else if ("template_id" in body) { + const template = templateList.get(body.template_id); + if (!template) { + throw new StatusError(400, "No template found with given id"); + } + contentSource = template.tsxSource; + } else { + throw new KnownErrors.SchemaError("Either template_id or template_tsx_source must be provided"); } - const templateList = new Map(Object.entries(tenancy.config.emails.templates)); - const themeSource = body.theme_id === undefined ? body.theme_tsx_source! : getEmailThemeForTemplate(tenancy, body.theme_id); - const templateSource = body.template_id ? templateList.get(body.template_id)?.tsxSource : body.template_tsx_source; - if (!templateSource) { - throw new StatusError(400, "No template found with given id"); - } const result = await renderEmailWithTemplate( - templateSource, + contentSource, themeSource, { project: { displayName: tenancy.project.display_name }, previewMode: true, }, ); - if ("error" in result) { + if (result.status === "error") { throw new KnownErrors.EmailRenderingError(result.error); } return { 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 363ac1a486..f43462c8cb 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 @@ -3,39 +3,50 @@ 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 { KnownErrors } from "@stackframe/stack-shared"; -import { adaptSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { getEmailDraft, themeModeToTemplateThemeId } from "@/lib/email-drafts"; type UserResult = { user_id: string, user_email?: string, }; +const bodyBase = yupObject({ + user_ids: yupArray(yupString().defined()).optional(), + all_users: yupBoolean().oneOf([true]).optional(), + subject: yupString().optional(), + notification_category_name: yupString().optional(), + theme_id: templateThemeIdSchema.nullable().meta({ + openapiField: { description: "The theme to use for the email. If not specified, the default theme will be used." } + }), +}); + export const POST = createSmartRouteHandler({ metadata: { 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.", - tags: ["Emails"], + description: "Send an email to a list of users. The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.", }, request: yupObject({ auth: yupObject({ type: serverOrHigherAuthTypeSchema, tenancy: adaptSchema.defined(), }).defined(), - body: yupObject({ - user_ids: yupArray(yupString().defined()).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(), - }), + body: yupUnion( + bodyBase.concat(yupObject({ + html: yupString().defined(), + })), + bodyBase.concat(yupObject({ + template_id: yupString().uuid().defined(), + variables: yupRecord(yupString(), yupMixed()).optional(), + })), + bodyBase.concat(yupObject({ + draft_id: yupString().defined(), + })), + ).defined(), method: yupString().oneOf(["POST"]).defined(), }), response: yupObject({ @@ -55,55 +66,61 @@ export const POST = createSmartRouteHandler({ if (auth.tenancy.config.emails.server.isShared) { throw new KnownErrors.RequiresCustomEmailServer(); } - if (!body.html && !body.template_id) { - throw new KnownErrors.SchemaError("Either html or template_id must be provided"); - } - if (body.html && (body.template_id || body.variables)) { - throw new KnownErrors.SchemaError("If html is provided, cannot provide template_id or variables"); + if ((body.user_ids && body.all_users) || (!body.user_ids && !body.all_users)) { + throw new KnownErrors.SchemaError("Exactly one of user_ids or all_users must be provided"); } + + const prisma = await getPrismaClientForTenancy(auth.tenancy); 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); + let themeSource = getEmailThemeForTemplate(auth.tenancy, body.theme_id); + const variables = "variables" in body ? body.variables : undefined; 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!); + let templateSource: string; + if ("template_id" in body) { + templateSource = templates.get(body.template_id)?.tsxSource ?? throwErr(400, "No template found with given template_id"); + } else if ("html" in body) { + templateSource = createTemplateComponentFromHtml(body.html); + } else if ("draft_id" in body) { + const draft = await getEmailDraft(prisma, auth.tenancy.id, body.draft_id) ?? throwErr(400, "No draft found with given draft_id"); + const theme_id = themeModeToTemplateThemeId(draft.themeMode, draft.themeId); + templateSource = draft.tsxSource; + if (body.theme_id === undefined) { + themeSource = getEmailThemeForTemplate(auth.tenancy, theme_id); + } + } else { + throw new KnownErrors.SchemaError("Either template_id, html, or draft_id must be provided"); + } - const prisma = await getPrismaClientForTenancy(auth.tenancy); const users = await prisma.projectUser.findMany({ where: { tenancyId: auth.tenancy.id, projectUserId: { - in: body.user_ids, + in: body.user_ids }, }, include: { contactChannels: true, }, }); - const missingUserIds = body.user_ids.filter(userId => !users.some(user => user.projectUserId === userId)); - if (missingUserIds.length > 0) { + const missingUserIds = body.user_ids?.filter(userId => !users.some(user => user.projectUserId === userId)); + if (missingUserIds && 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(); - for (const userId of body.user_ids) { - const user = userMap.get(userId); - if (!user) { - userSendErrors.set(userId, "User not found"); - continue; - } + for (const user of userMap.values()) { const primaryEmail = user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value; if (!primaryEmail) { - userSendErrors.set(userId, "User does not have a primary email"); + userSendErrors.set(user.projectUserId, "User does not have a primary email"); continue; } - userPrimaryEmails.set(userId, primaryEmail); + userPrimaryEmails.set(user.projectUserId, primaryEmail); let currentNotificationCategory = defaultNotificationCategory; - if (body.template_id) { + if (!("html" in body)) { // We have to render email twice in this case, first pass is to get the notification category const renderedTemplateFirstPass = await renderEmailWithTemplate( templateSource, @@ -111,16 +128,16 @@ export const POST = createSmartRouteHandler({ { user: { displayName: user.displayName }, project: { displayName: auth.tenancy.project.display_name }, - variables: body.variables, + variables, }, ); if (renderedTemplateFirstPass.status === "error") { - userSendErrors.set(userId, "There was an error rendering the email"); + userSendErrors.set(user.projectUserId, "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"); + userSendErrors.set(user.projectUserId, "Notification category not found with given name"); continue; } currentNotificationCategory = notificationCategory; @@ -128,7 +145,7 @@ export const POST = createSmartRouteHandler({ const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy, user.projectUserId, currentNotificationCategory.id); if (!isNotificationEnabled) { - userSendErrors.set(userId, "User has disabled notifications for this category"); + userSendErrors.set(user.projectUserId, "User has disabled notifications for this category"); continue; } @@ -155,12 +172,12 @@ export const POST = createSmartRouteHandler({ { user: { displayName: user.displayName }, project: { displayName: auth.tenancy.project.display_name }, - variables: body.variables, + variables, unsubscribeLink, }, ); if (renderedEmail.status === "error") { - userSendErrors.set(userId, "There was an error rendering the email"); + userSendErrors.set(user.projectUserId, "There was an error rendering the email"); continue; } try { @@ -173,15 +190,27 @@ export const POST = createSmartRouteHandler({ text: renderedEmail.data.text, }); } catch { - userSendErrors.set(userId, "Failed to send email"); + userSendErrors.set(user.projectUserId, "Failed to send email"); } } - const results: UserResult[] = body.user_ids.map((userId) => ({ - user_id: userId, - user_email: userPrimaryEmails.get(userId), + const results: UserResult[] = Array.from(userMap.values()).map((user) => ({ + user_id: user.projectUserId, + user_email: userPrimaryEmails.get(user.projectUserId) ?? user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value, })); + if ("draft_id" in body) { + await prisma.emailDraft.update({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: body.draft_id, + }, + }, + data: { sentAt: new Date() }, + }); + } + return { statusCode: 200, bodyType: 'json', diff --git a/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx b/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx index e07ed018a0..50563fd588 100644 --- a/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx +++ b/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx @@ -37,7 +37,7 @@ export const POST = createSmartRouteHandler({ threadId: yupString().defined(), }), body: yupObject({ - context_type: yupString().oneOf(["email-theme", "email-template"]).defined(), + context_type: yupString().oneOf(["email-theme", "email-template", "email-draft"]).defined(), messages: yupArray(yupObject({ role: yupString().oneOf(["user", "assistant", "tool"]).defined(), content: yupMixed().defined(), diff --git a/apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx b/apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx new file mode 100644 index 0000000000..5d9568e105 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx @@ -0,0 +1,80 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { templateThemeIdSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { templateThemeIdToThemeMode, themeModeToTemplateThemeId } from "@/lib/email-drafts"; + +export const GET = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: yupString().oneOf(["admin"]).defined(), + tenancy: yupObject({}).defined(), + }).defined(), + params: yupObject({ id: yupString().uuid().defined() }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + id: yupString().uuid().defined(), + display_name: yupString().defined(), + tsx_source: yupString().defined(), + theme_id: templateThemeIdSchema, + sent_at_millis: yupNumber().nullable().optional(), + }).defined(), + }), + async handler({ auth: { tenancy }, params }) { + const prisma = await getPrismaClientForTenancy(tenancy); + const d = await prisma.emailDraft.findFirstOrThrow({ where: { tenancyId: tenancy.id, id: params.id } }); + return { + statusCode: 200, + bodyType: "json", + body: { + id: d.id, + display_name: d.displayName, + tsx_source: d.tsxSource, + theme_id: themeModeToTemplateThemeId(d.themeMode, d.themeId), + sent_at_millis: d.sentAt ? d.sentAt.getTime() : null, + }, + }; + }, +}); + +export const PATCH = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: yupString().oneOf(["admin"]).defined(), + tenancy: yupObject({}).defined(), + }).defined(), + params: yupObject({ id: yupString().uuid().defined() }).defined(), + body: yupObject({ + display_name: yupString().optional(), + theme_id: templateThemeIdSchema.optional(), + tsx_source: yupString().optional(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ ok: yupString().oneOf(["ok"]).defined() }).defined(), + }), + async handler({ auth: { tenancy }, params, body }) { + const prisma = await getPrismaClientForTenancy(tenancy); + await prisma.emailDraft.update({ + where: { tenancyId_id: { tenancyId: tenancy.id, id: params.id } }, + data: { + displayName: body.display_name, + themeMode: templateThemeIdToThemeMode(body.theme_id), + themeId: body.theme_id === false ? null : body.theme_id, + tsxSource: body.tsx_source, + }, + }); + return { + statusCode: 200, + bodyType: "json", + body: { ok: "ok" }, + }; + }, +}); + diff --git a/apps/backend/src/app/api/latest/internal/email-drafts/route.tsx b/apps/backend/src/app/api/latest/internal/email-drafts/route.tsx new file mode 100644 index 0000000000..82c16850b9 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/email-drafts/route.tsx @@ -0,0 +1,106 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { templateThemeIdSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; +import { templateThemeIdToThemeMode, themeModeToTemplateThemeId } from "@/lib/email-drafts"; + + +export const GET = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: yupString().oneOf(["admin"]).defined(), + tenancy: yupObject({}).defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + drafts: yupArray(yupObject({ + id: yupString().uuid().defined(), + display_name: yupString().defined(), + tsx_source: yupString().defined(), + theme_id: templateThemeIdSchema, + sent_at_millis: yupNumber().nullable().optional(), + })).defined(), + }).defined(), + }), + async handler({ auth: { tenancy } }) { + const prisma = await getPrismaClientForTenancy(tenancy); + const items = await prisma.emailDraft.findMany({ + where: { tenancyId: tenancy.id }, + orderBy: { updatedAt: "desc" }, + }); + return { + statusCode: 200, + bodyType: "json", + body: { + drafts: items.map(d => ({ + id: d.id, + display_name: d.displayName, + tsx_source: d.tsxSource, + theme_id: themeModeToTemplateThemeId(d.themeMode, d.themeId), + sent_at_millis: d.sentAt ? d.sentAt.getTime() : null, + })), + }, + }; + }, +}); + + +const defaultDraftSource = deindent` + import { Container } from "@react-email/components"; + import { Subject, NotificationCategory, Props } from "@stackframe/emails"; + + export function EmailTemplate({ user, project }: Props) { + return ( + + + +
Hi {user.displayName}!
+
+
+ ); + } +`; + +export const POST = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: yupString().oneOf(["admin"]).defined(), + tenancy: yupObject({}).defined(), + }).defined(), + body: yupObject({ + display_name: yupString().defined(), + theme_id: templateThemeIdSchema, + tsx_source: yupString().optional(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ id: yupString().uuid().defined() }).defined(), + }), + async handler({ body, auth: { tenancy } }) { + const prisma = await getPrismaClientForTenancy(tenancy); + + const draft = await prisma.emailDraft.create({ + data: { + tenancyId: tenancy.id, + displayName: body.display_name, + themeMode: templateThemeIdToThemeMode(body.theme_id), + themeId: body.theme_id === false ? undefined : body.theme_id, + tsxSource: body.tsx_source ?? defaultDraftSource, + }, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { id: draft.id }, + }; + }, +}); + diff --git a/apps/backend/src/lib/ai-chat/adapter-registry.ts b/apps/backend/src/lib/ai-chat/adapter-registry.ts index 781617bd99..63106a2ce9 100644 --- a/apps/backend/src/lib/ai-chat/adapter-registry.ts +++ b/apps/backend/src/lib/ai-chat/adapter-registry.ts @@ -2,6 +2,7 @@ import { Tool } from "ai"; import { type Tenancy } from "../tenancies"; import { emailTemplateAdapter } from "./email-template-adapter"; import { emailThemeAdapter } from "./email-theme-adapter"; +import { emailDraftAdapter } from "./email-draft-adapter"; export type ChatAdapterContext = { tenancy: Tenancy, @@ -13,11 +14,12 @@ type ChatAdapter = { tools: Record, } -type ContextType = "email-theme" | "email-template"; +type ContextType = "email-theme" | "email-template" | "email-draft"; const CHAT_ADAPTERS: Record ChatAdapter> = { "email-theme": emailThemeAdapter, "email-template": emailTemplateAdapter, + "email-draft": emailDraftAdapter, }; export function getChatAdapter(contextType: ContextType, tenancy: Tenancy, threadId: string): ChatAdapter { diff --git a/apps/backend/src/lib/ai-chat/email-draft-adapter.ts b/apps/backend/src/lib/ai-chat/email-draft-adapter.ts new file mode 100644 index 0000000000..7519fe6750 --- /dev/null +++ b/apps/backend/src/lib/ai-chat/email-draft-adapter.ts @@ -0,0 +1,50 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { ChatAdapterContext } from "./adapter-registry"; + +const EMAIL_DRAFT_SYSTEM_PROMPT = ` +You are a helpful assistant that can help with email template development. +YOU MUST WRITE A FULL REACT COMPONENT WHEN CALLING THE createEmailTemplate TOOL. +`; + +export const emailDraftAdapter = (context: ChatAdapterContext) => ({ + systemPrompt: EMAIL_DRAFT_SYSTEM_PROMPT, + tools: { + createEmailTemplate: tool({ + description: CREATE_EMAIL_DRAFT_TOOL_DESCRIPTION(), + parameters: z.object({ + content: z.string().describe("A react component that renders the email template"), + }), + }), + }, +}); + + +const CREATE_EMAIL_DRAFT_TOOL_DESCRIPTION = () => { + return ` +Create a new email draft. +The email draft is a tsx file that is used to render the email content. +It must use react-email components. +It must export one thing: +- EmailTemplate: A function that renders the email draft +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 draft: +\`\`\`tsx +import { Container } from "@react-email/components"; +import { Subject, NotificationCategory, Props } from "@stackframe/emails"; + +export function EmailTemplate({ user, project }: Props) { + return ( + + + +
Hi {user.displayName}!
+
+
+ ); +} +\`\`\` +`; +}; diff --git a/apps/backend/src/lib/email-drafts.tsx b/apps/backend/src/lib/email-drafts.tsx new file mode 100644 index 0000000000..b0be40464a --- /dev/null +++ b/apps/backend/src/lib/email-drafts.tsx @@ -0,0 +1,33 @@ +import { DraftThemeMode, PrismaClient } from "@prisma/client"; + +export async function getEmailDraft(prisma: PrismaClient, tenancyId: string, draftId: string) { + const draft = await prisma.emailDraft.findUnique({ + where: { + tenancyId_id: { + tenancyId, + id: draftId + } + }, + }); + return draft; +} + +export const templateThemeIdToThemeMode = (themeId: string | false | undefined): DraftThemeMode => { + if (themeId === undefined) { + return DraftThemeMode.PROJECT_DEFAULT; + } + if (themeId === false) { + return DraftThemeMode.NONE; + } + return DraftThemeMode.CUSTOM; +}; + +export const themeModeToTemplateThemeId = (themeMode: DraftThemeMode, themeId: string | null): string | false | undefined => { + if (themeMode === DraftThemeMode.PROJECT_DEFAULT) { + return undefined; + } + if (themeMode === DraftThemeMode.NONE) { + return false; + } + return themeId === null ? undefined : themeId; +}; diff --git a/apps/backend/src/lib/email-rendering.tsx b/apps/backend/src/lib/email-rendering.tsx index 9a49ec960a..f3ac36faec 100644 --- a/apps/backend/src/lib/email-rendering.tsx +++ b/apps/backend/src/lib/email-rendering.tsx @@ -42,7 +42,7 @@ export function createTemplateComponentFromHtml(html: string) { } export async function renderEmailWithTemplate( - templateComponent: string, + templateOrDraftComponent: string, themeComponent: string, options: { user?: { displayName: string | null }, @@ -66,7 +66,7 @@ export async function renderEmailWithTemplate( const result = await bundleJavaScript({ "/utils.tsx": findComponentValueUtil, "/theme.tsx": themeComponent, - "/template.tsx": templateComponent, + "/template.tsx": templateOrDraftComponent, "/render.tsx": deindent` import { configure } from "arktype/config" configure({ onUndeclaredKey: "delete" }) @@ -78,10 +78,10 @@ export async function renderEmailWithTemplate( const { variablesSchema, EmailTemplate } = TemplateModule; import { EmailTheme } from "./theme.tsx"; export const renderAll = async () => { - const variables = variablesSchema({ + const variables = variablesSchema ? variablesSchema({ ${previewMode ? "...(EmailTemplate.PreviewVariables || {})," : ""} ...(${JSON.stringify(variables)}), - }) + }) : {}; if (variables instanceof type.errors) { throw new Error(variables.summary) } @@ -125,7 +125,6 @@ export async function renderEmailWithTemplate( return Result.ok(output.data.result as { html: string, text: string, subject: string, notificationCategory: string }); } - const findComponentValueUtil = `import React from 'react'; export function findComponentValue(element, targetStackComponent) { const matches = []; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx new file mode 100644 index 0000000000..fcf546c6b9 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table"; +import EmailPreview from "@/components/email-preview"; +import { EmailThemeSelector } from "@/components/email-theme-selector"; +import { useRouterConfirm } from "@/components/router"; +import { AssistantChat, CodeEditor, VibeCodeLayout } from "@/components/vibe-coding"; +import { createChatAdapter, createHistoryAdapter, ToolCallContent } from "@/components/vibe-coding/chat-adapters"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, toast, Typography, useToast } from "@stackframe/stack-ui"; +import { Suspense, useEffect, useMemo, useState } from "react"; +import { useAdminApp } from "../../use-admin-app"; +import { EmailDraftUI } from "@/components/vibe-coding/draft-tool-components"; + +export default function PageClient({ draftId }: { draftId: string }) { + const stackAdminApp = useAdminApp(); + const { setNeedConfirm } = useRouterConfirm(); + const { toast } = useToast(); + + const drafts = stackAdminApp.useEmailDrafts(); + const draft = useMemo(() => drafts.find((d) => d.id === draftId), [drafts, draftId]); + + const [currentCode, setCurrentCode] = useState(draft?.tsxSource ?? ""); + const [stage, setStage] = useState<"edit" | "send">("edit"); + const [selectedThemeId, setSelectedThemeId] = useState(draft?.themeId); + + useEffect(() => { + if (!draft) return; + if (draft.tsxSource === currentCode && draft.themeId === selectedThemeId) return; + if (stage !== "edit") return; + + setNeedConfirm(true); + return () => setNeedConfirm(false); + }, [setNeedConfirm, draft, currentCode, selectedThemeId, stage]); + + const handleToolUpdate = (toolCall: ToolCallContent) => { + setCurrentCode(toolCall.args.content); + }; + + const handleNext = async () => { + try { + await stackAdminApp.updateEmailDraft(draftId, { tsxSource: currentCode, themeId: selectedThemeId }); + setStage("send"); + } catch (error) { + if (error instanceof KnownErrors.EmailRenderingError) { + toast({ title: "Failed to save draft", variant: "destructive", description: error.message }); + return; + } + toast({ title: "Failed to save draft", variant: "destructive", description: "Unknown error" }); + } + }; + + return ( + <> + {stage === "edit" ? ( + + } + editorComponent={ + + + + + } + /> + } + chatComponent={ + } + /> + } + /> + ) : ( + + )} + + ); +} + +function SendStage({ draftId }: { draftId: string }) { + const stackAdminApp = useAdminApp(); + const [scope, setScope] = useState<"all" | "users">("all"); + const [selectedUserIds, setSelectedUserIds] = useState([]); + + const handleSubmit = async () => { + await stackAdminApp.sendEmail( + scope === "users" + ? { draftId, userIds: selectedUserIds } + : { draftId, allUsers: true } + ); + toast({ title: "Email sent", variant: "success" }); + }; + + return ( +
+ + +
+ Recipients + {scope === "users" && ( +
+ {selectedUserIds.length} selected + {selectedUserIds.length > 0 && ( + + )} +
+ )} +
+
+ +
+ {scope === "users" && ( +
+ }> + ( + + )} + /> + +
+ )} +
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx new file mode 100644 index 0000000000..c270ca82c4 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx @@ -0,0 +1,11 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Email Draft", +}; + +export default async function Page(props: { params: Promise<{ draftId: string }> }) { + const params = await props.params; + return ; +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx new file mode 100644 index 0000000000..36d940fe03 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx @@ -0,0 +1,111 @@ +"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"; + +export default function PageClient() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const emailConfig = project.config.emailConfig; + const router = useRouter(); + const drafts = stackAdminApp.useEmailDrafts(); + const [sharedSmtpWarningDialogOpen, setSharedSmtpWarningDialogOpen] = useState(null); + + return ( + } + > + {emailConfig?.type === 'shared' && + + Warning + + You are using a shared email server. If you want to send manual emails, you need to configure a custom SMTP server. + + } + + {drafts.map((draft: any) => ( + +
+ + {draft.displayName} + +
+ +
+
+
+ ))} + + setSharedSmtpWarningDialogOpen(null)} + title="Shared Email Server" + okButton={{ + label: "Open Draft Anyway", onClick: async () => { + router.push(`email-drafts/${sharedSmtpWarningDialogOpen}`); + } + }} + cancelButton={{ label: "Cancel" }} + > + + + Warning + + You are using a shared email server. You can open the draft anyway, but you will not be able to send emails. + + + +
+ ); +} + +function NewDraftButton() { + const stackAdminApp = useAdminApp(); + const router = useRouter(); + + const handleCreateNewDraft = async (values: { name: string }) => { + const draft = await stackAdminApp.createEmailDraft({ displayName: values.name }); + router.push(`email-drafts/${draft.id}`); + }; + + return ( + New Draft} + onSubmit={handleCreateNewDraft} + formSchema={yup.object({ + name: yup.string().defined(), + })} + render={(form) => ( + + )} + /> + ); +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx new file mode 100644 index 0000000000..e31004154d --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx @@ -0,0 +1,12 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Email Drafts", +}; + +export default function Page() { + return ( + + ); +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx index 184fc6dc79..c559dd5d0b 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx @@ -12,10 +12,11 @@ import { } from "@/components/vibe-coding"; import { ToolCallContent } from "@/components/vibe-coding/chat-adapters"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; -import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, toast } from "@stackframe/stack-ui"; +import { Button, toast } from "@stackframe/stack-ui"; import { useEffect, useState } from "react"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; +import { EmailThemeSelector } from "@/components/email-theme-selector"; export default function PageClient(props: { templateId: string }) { const stackAdminApp = useAdminApp(); @@ -61,7 +62,7 @@ export default function PageClient(props: { templateId: string }) { - + } editorComponent={ @@ -70,7 +71,7 @@ export default function PageClient(props: { templateId: string }) { onCodeChange={setCurrentCode} action={
- void, - className?: string, -} - -function themeIdToSelectString(themeId: string | undefined | false): string { - return JSON.stringify(themeId ?? null); -} -function selectStringToThemeId(value: string): string | undefined | false { - return JSON.parse(value) ?? undefined; -} - -function ThemeSelector({ selectedThemeId, onThemeChange, className }: ThemeSelectorProps) { - const stackAdminApp = useAdminApp(); - const themes = stackAdminApp.useEmailThemes(); - return ( - - ); -} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index b46a403e57..6c2c90a62c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -24,8 +24,10 @@ import { } from "@stackframe/stack-ui"; import { Book, + FilePen, Globe, KeyRound, + LayoutTemplate, Link as LinkIcon, LockKeyhole, LucideIcon, @@ -35,10 +37,9 @@ import { Settings, Settings2, ShieldEllipsis, - SquarePen, User, Users, - Webhook + Webhook, } from "lucide-react"; import { useTheme } from "next-themes"; import { usePathname } from "next/navigation"; @@ -178,11 +179,18 @@ const navigationItems: (Label | Item | Hidden)[] = [ icon: Mail, type: 'item' }, + { + name: "Drafts", + href: "/email-drafts", + regex: /^\/projects\/[^\/]+\/email-drafts$/, + icon: FilePen, + type: 'item', + }, { name: "Templates", href: "/email-templates", regex: /^\/projects\/[^\/]+\/email-templates$/, - icon: SquarePen, + icon: LayoutTemplate, type: 'item' }, { @@ -192,6 +200,26 @@ const navigationItems: (Label | Item | Hidden)[] = [ icon: Palette, type: 'item', }, + { + name: (pathname: string) => { + const match = pathname.match(/^\/projects\/[^\/]+\/email-drafts\/([^\/]+)$/); + let item; + let href; + if (match) { + item = ; + href = `/email-drafts/${match[1]}`; + } else { + item = "Draft"; + href = ""; + } + return [ + { item: "Drafts", href: "/email-drafts" }, + { item, href }, + ]; + }, + regex: /^\/projects\/[^\/]+\/email-drafts\/[^\/]+$/, + type: 'hidden', + }, { name: (pathname: string) => { const match = pathname.match(/^\/projects\/[^\/]+\/email-themes\/([^\/]+)$/); @@ -334,6 +362,16 @@ function TemplateBreadcrumbItem(props: { templateId: string }) { return template.displayName; } +function DraftBreadcrumbItem(props: { draftId: string }) { + const stackAdminApp = useAdminApp(); + const drafts = stackAdminApp.useEmailDrafts(); + const draft = drafts.find((d) => d.id === props.draftId); + if (!draft) { + return null; + } + return draft.displayName; +} + function NavItem({ item, href, onClick }: { item: Item, href: string, onClick?: () => void }) { const pathname = usePathname(); const selected = useMemo(() => { diff --git a/apps/dashboard/src/components/assistant-ui/thread.tsx b/apps/dashboard/src/components/assistant-ui/thread.tsx index e41b362f0c..4927981f28 100644 --- a/apps/dashboard/src/components/assistant-ui/thread.tsx +++ b/apps/dashboard/src/components/assistant-ui/thread.tsx @@ -78,7 +78,7 @@ const ThreadWelcome: FC = () => { How can I help you today?

- + {/* */} ); diff --git a/apps/dashboard/src/components/email-preview.tsx b/apps/dashboard/src/components/email-preview.tsx index 0504bb11cc..a095584120 100644 --- a/apps/dashboard/src/components/email-preview.tsx +++ b/apps/dashboard/src/components/email-preview.tsx @@ -44,7 +44,7 @@ function EmailPreviewContent({ templateId, templateTsxSource, }: { - themeId?: string | null | false, + themeId?: string | undefined | false, themeTsxSource?: string, templateId?: string, templateTsxSource?: string, @@ -69,7 +69,7 @@ function EmailPreviewContent({ type EmailPreviewProps = | ({ - themeId: string | null | false, + themeId: string | undefined | false, themeTsxSource?: undefined, } | { themeId?: undefined, diff --git a/apps/dashboard/src/components/email-theme-selector.tsx b/apps/dashboard/src/components/email-theme-selector.tsx new file mode 100644 index 0000000000..a8e6eb6e3f --- /dev/null +++ b/apps/dashboard/src/components/email-theme-selector.tsx @@ -0,0 +1,39 @@ +import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@stackframe/stack-ui"; + +type EmailThemeSelectorProps = { + selectedThemeId: string | undefined | false, + onThemeChange: (themeId: string | undefined | false) => void, + className?: string, +} + +function themeIdToSelectString(themeId: string | undefined | false): string { + return JSON.stringify(themeId ?? null); +} +function selectStringToThemeId(value: string): string | undefined | false { + return JSON.parse(value) ?? undefined; +} + +export function EmailThemeSelector({ selectedThemeId, onThemeChange, className }: EmailThemeSelectorProps) { + const stackAdminApp = useAdminApp(); + const themes = stackAdminApp.useEmailThemes(); + return ( + + ); +} diff --git a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts index 270d3cc6a2..9d49d59467 100644 --- a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts +++ b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts @@ -15,7 +15,7 @@ const isToolCall = (content: { type: string }): content is ToolCallContent => { export function createChatAdapter( adminApp: StackAdminApp, threadId: string, - contextType: "email-theme" | "email-template", + contextType: "email-theme" | "email-template" | "email-draft", onToolCall: (toolCall: ToolCallContent) => void ): ChatModelAdapter { return { diff --git a/apps/dashboard/src/components/vibe-coding/code-editor.tsx b/apps/dashboard/src/components/vibe-coding/code-editor.tsx index 8a751c7646..9bc83850d7 100644 --- a/apps/dashboard/src/components/vibe-coding/code-editor.tsx +++ b/apps/dashboard/src/components/vibe-coding/code-editor.tsx @@ -81,7 +81,7 @@ export default function CodeEditor({ declare module "@stackframe/emails" { const Subject: React.FC<{value: string}>; const NotificationCategory: React.FC<{value: "Transactional" | "Marketing"}>; - type Props = { + type Props = { variables: T; project: { displayName: string; diff --git a/apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx b/apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx new file mode 100644 index 0000000000..92fda4fe7a --- /dev/null +++ b/apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx @@ -0,0 +1,28 @@ +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { Button, Card } from "@stackframe/stack-ui"; +import { Undo2 } from "lucide-react"; + +type EmailDraftUIProps = { + setCurrentCode: (code: string) => void, +} + +export const EmailDraftUI = ({ setCurrentCode }: EmailDraftUIProps) => { + const ToolUI = makeAssistantToolUI< + { content: string }, + "success" + >({ + toolName: "createEmailTemplate", + render: ({ args }) => { + return ( + + Created draft + + + ); + }, + }); + + return ; +}; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts new file mode 100644 index 0000000000..9ad793602d --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts @@ -0,0 +1,405 @@ +import { it } from "../../../../../helpers"; +import { Project, niceBackendFetch } from "../../../../backend-helpers"; + +const customEmailConfig = { + type: "standard", + host: "localhost", + port: 2500, + username: "test", + password: "test", + sender_name: "Test Project", + sender_email: "test@example.com", +} as const; + +it("should create, list, get, and update email drafts", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Email Drafts CRUD Project", + config: { email_config: customEmailConfig }, + }); + + const initialSource = `import { Container } from "@react-email/components"; +import { Subject, NotificationCategory, Props } from "@stackframe/emails"; +export function EmailTemplate({ user, project }: Props) { + return ( + + + +
Hi {user.displayName}
+
+ ); +}`; + + const createRes = await niceBackendFetch("/api/v1/internal/email-drafts", { + method: "POST", + accessType: "admin", + body: { + display_name: "My Draft", + theme_id: false, + tsx_source: initialSource, + }, + }); + expect(createRes).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { "id": "" }, + "headers": Headers {