From 772f7ec0d04d73dc05c0ace0c098994c9a92e6ba Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 15 Aug 2025 18:06:05 -0700 Subject: [PATCH 01/14] wip drafts --- .../20250815012830_email_drafts/migration.sql | 17 ++ apps/backend/prisma/schema.prisma | 31 ++- .../api/latest/emails/render-email/route.tsx | 75 +++++--- .../internal/email-drafts/[id]/route.tsx | 81 ++++++++ .../latest/internal/email-drafts/route.tsx | 126 +++++++++++++ apps/backend/src/lib/email-rendering.tsx | 17 +- .../email-drafts/[draftId]/page-client.tsx | 176 ++++++++++++++++++ .../email-drafts/[draftId]/page.tsx | 11 ++ .../[projectId]/email-drafts/page-client.tsx | 113 +++++++++++ .../[projectId]/email-drafts/page.tsx | 12 ++ .../[templateId]/page-client.tsx | 41 +--- .../projects/[projectId]/sidebar-layout.tsx | 32 +++- .../src/components/email-theme-selector.tsx | 39 ++++ .../src/interface/admin-interface.ts | 35 ++++ .../apps/implementations/admin-app-impl.ts | 45 +++++ 15 files changed, 777 insertions(+), 74 deletions(-) create mode 100644 apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql create mode 100644 apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx create mode 100644 apps/backend/src/app/api/latest/internal/email-drafts/route.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx create mode 100644 apps/dashboard/src/components/email-theme-selector.tsx 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 e5274a3126..bc5e4e0d78 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -669,6 +669,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 @@ -726,11 +749,11 @@ enum SubscriptionStatus { } model Subscription { - id String @default(uuid()) @db.Uuid - tenancyId String @db.Uuid - customerId String @db.Uuid + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + customerId String @db.Uuid customerType CustomerType - offer Json + offer Json stripeSubscriptionId String status SubscriptionStatus 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..d91ca4b0e4 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,8 +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, 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"; +import { get, getOrUndefined, has } from "@stackframe/stack-shared/dist/utils/objects"; export const POST = createSmartRouteHandler({ metadata: { @@ -15,12 +16,38 @@ 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( + // template_id + theme_id + yupObject({ + template_id: yupString().uuid().defined(), + theme_id: templateThemeIdSchema, + }), + // template_id + theme_tsx_source + yupObject({ + template_id: yupString().uuid().defined(), + theme_tsx_source: yupString().defined(), + }), + // template_tsx_source + theme_id + yupObject({ + template_tsx_source: yupString().defined(), + theme_id: templateThemeIdSchema, + }), + // template_tsx_source + theme_tsx_source + yupObject({ + template_tsx_source: yupString().defined(), + theme_tsx_source: yupString().defined(), + }), + // draft_content + theme_id + yupObject({ + draft_content: yupString().defined(), + theme_id: templateThemeIdSchema, + }), + // draft_content + theme_tsx_source + yupObject({ + draft_content: yupString().defined(), + theme_tsx_source: yupString().defined(), + }), + ).defined(), }), response: yupObject({ statusCode: yupNumber().oneOf([200]).defined(), @@ -32,32 +59,36 @@ 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)); + let themeSource: string; + if ("theme_tsx_source" in body) { + themeSource = body.theme_tsx_source; + } else { + 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 { + contentSource = body.draft_content; } - 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/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..5cb3d2f664 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx @@ -0,0 +1,81 @@ +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"; + +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: ((): any => { + if (d.themeMode === "CUSTOM") return d.themeId; + if (d.themeMode === "NONE") return false; + return null; + })(), + 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(), + sent_at_millis: yupNumber().nullable().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 ?? undefined, + themeMode: (body.theme_id === undefined) ? undefined : ((body.theme_id === null) ? ("PROJECT_DEFAULT" as any) : (body.theme_id === false) ? ("NONE" as any) : ("CUSTOM" as any)), + themeId: body.theme_id === undefined ? undefined : (typeof body.theme_id === 'string' ? body.theme_id : null), + tsxSource: body.tsx_source ?? undefined, + sentAt: body.sent_at_millis === undefined ? undefined : (body.sent_at_millis ? new Date(body.sent_at_millis) : null), + }, + }); + return { statusCode: 200, bodyType: "json", body: { ok: "ok" } } as const; + }, +}); + 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..f09a2fb507 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/email-drafts/route.tsx @@ -0,0 +1,126 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { DraftThemeMode } from "@prisma/client"; +import { templateThemeIdSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; + +const templateThemeIdToThemeMode = (themeId: string | false | undefined): DraftThemeMode => { + if (themeId === undefined) { + return DraftThemeMode.PROJECT_DEFAULT; + } + if (themeId === false) { + return DraftThemeMode.NONE; + } + return DraftThemeMode.CUSTOM; +}; + +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; +}; + +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 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" }, + take: 200, + }); + 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, + })), + }, + }; + }, +}); + + +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/email-rendering.tsx b/apps/backend/src/lib/email-rendering.tsx index b58622e0c1..454a880ad6 100644 --- a/apps/backend/src/lib/email-rendering.tsx +++ b/apps/backend/src/lib/email-rendering.tsx @@ -43,7 +43,7 @@ export function createTemplateComponentFromHtml(html: string) { } export async function renderEmailWithTemplate( - templateComponent: string, + templateOrDraftComponent: string, themeComponent: string, options: { user?: { displayName: string | null }, @@ -68,7 +68,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" }) @@ -80,10 +80,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) } @@ -120,11 +120,12 @@ export async function renderEmailWithTemplate( "@react-email/components": "0.1.1", "arktype": "2.1.20", }; - const output = await freestyle.executeScript(result.data, { nodeModules }); - if ("error" in output) { - return Result.error(output.error as string); + try { + const output = await freestyle.executeScript(result.data, { nodeModules }); + return Result.ok(output.result as { html: string, text: string, subject: string, notificationCategory: string }); + } catch (error) { + return Result.error("Unable to render email"); } - return Result.ok(output.result as { html: string, text: string, subject: string, notificationCategory: string }); } 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..7124239c55 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table"; +import EmailPreview from "@/components/email-preview"; +import { useRouterConfirm } from "@/components/router"; +import { AssistantChat, CodeEditor, VibeCodeLayout } from "@/components/vibe-coding"; +import { createChatAdapter, createHistoryAdapter, ToolCallContent } from "@/components/vibe-coding/chat-adapters"; +import { UserAvatar } from "@stackframe/stack"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; +import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Typography, useToast } from "@stackframe/stack-ui"; +import { X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useAdminApp } from "../../use-admin-app"; +import { EmailThemeSelector } from "@/components/email-theme-selector"; + +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 [selectedUsers, setSelectedUsers] = useState([]); + const [selectedThemeId, setSelectedThemeId] = useState(draft?.themeId); + + useEffect(() => { + if (!draft) return; + if (draft.tsxSource === currentCode && draft.themeId === selectedThemeId) return; + setNeedConfirm(true); + return () => setNeedConfirm(false); + }, [setNeedConfirm, draft, currentCode, selectedThemeId]); + + const handleToolUpdate = (toolCall: ToolCallContent) => { + setCurrentCode(toolCall.args.content); + }; + + const handleNext = async () => { + try { + await stackAdminApp.updateEmailDraft(draftId, { tsxSource: currentCode, themeId: selectedThemeId }); + setStage("send"); + toast({ title: "Draft saved", variant: "success" }); + } catch (error) { + if (error instanceof KnownErrors.EmailRenderingError) { + toast({ title: "Failed to save draft", variant: "destructive", description: error.message }); + return; + } + throw error; + } + }; + + const handleSend = async (values: { scope: "all" | "users"; subject: string; notificationCategoryName: "Transactional" | "Marketing"; }) => { + const userIds = values.scope === "all" ? (await stackAdminApp.listUsers({ limit: 1000 })).map(u => u.id) : selectedUsers.map(u => u.id); + await stackAdminApp.sendEmail({ + userIds, + templateTsxSource: currentCode, + themeId: selectedThemeId || undefined, + } as any); + toast({ title: "Email sent", variant: "success" }); + }; + + return ( + <> + {stage === "edit" ? ( + + } + editorComponent={ + + + + + } + /> + } + chatComponent={ + + } + /> + ) : ( + + )} + + ); +} + +function SendStage({ selectedUsers, setSelectedUsers, onSend }: { + selectedUsers: any[], + setSelectedUsers: (fn: (prev: any[]) => any[]) => void, + onSend: (v: { scope: "all" | "users", subject: string, notificationCategoryName: "Transactional" | "Marketing" }) => Promise, +}) { + const [scope, setScope] = useState<"all" | "users">("all"); + const [subject, setSubject] = useState(""); + const [category, setCategory] = useState<"Transactional" | "Marketing">("Transactional"); + + const handleSubmit = async () => { + await onSend({ scope, subject, notificationCategoryName: category }); + }; + + return ( +
+ Recipients +
+ + +
+ + {scope === "users" && ( +
+ +
+ ( + + )} /> +
+
+ )} + +
+
+ Subject + setSubject(e.target.value)} /> +
+
+ Notification Category + +
+
+ +
+ +
+
+ ); +} + +function SelectedChips({ users, setSelectedUsers }: { users: any[]; setSelectedUsers: (fn: (prev: any[]) => any[]) => void; }) { + if (users.length === 0) return null; + return ( +
+ {users.map((user) => ( +
+ + {user.primaryEmail} + +
+ ))} +
+ ); +} + 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..1e049cc297 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx @@ -0,0 +1,113 @@ +"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 }); + if (draft?.id) { + 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..1d21c40703 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(); @@ -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 18bd75a920..260fde3147 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,7 +37,6 @@ import { Settings, Settings2, ShieldEllipsis, - SquarePen, User, Users, Webhook, @@ -177,11 +178,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' }, { @@ -191,6 +199,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 = "Draft"; + 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\/([^\/]+)$/); 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/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 512baf64ee..455628aab3 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -131,6 +131,41 @@ export class StackAdminInterface extends StackServerInterface { return result.templates; } + async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[]> { + const response = await this.sendAdminRequest(`/internal/email-drafts`, {}, null); + const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[] }; + return result.drafts; + } + + async createEmailDraft(options: { display_name?: string, theme_id?: string | false, tsx_source?: string }): Promise<{ id: string }> { + const response = await this.sendAdminRequest( + `/internal/email-drafts`, + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(options), + }, + null, + ); + return await response.json(); + } + + async updateEmailDraft(id: string, data: { display_name?: string, theme_id?: string | null | false, tsx_source?: string, sent_at_millis?: number | null }): Promise { + await this.sendAdminRequest( + `/internal/email-drafts/${id}`, + { + method: "PATCH", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(data), + }, + null, + ); + } + async listEmailThemes(): Promise<{ id: string, display_name: string }[]> { const response = await this.sendAdminRequest(`/internal/email-themes`, {}, null); const result = await response.json() as { themes: { id: string, display_name: string }[] }; diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 6d30c86b63..af4d3057b3 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -43,6 +43,9 @@ export class _StackAdminAppImplIncomplete { return await this._interface.listInternalEmailTemplates(); }); + private readonly _adminEmailDraftsCache = createCache(async () => { + return await this._interface.listInternalEmailDrafts(); + }); private readonly _adminTeamPermissionDefinitionsCache = createCache(async () => { return await this._interface.listTeamPermissionDefinitions(); }); @@ -298,6 +301,18 @@ export class _StackAdminAppImplIncomplete { + return crud.map((draft) => ({ + id: draft.id, + displayName: draft.display_name, + themeId: draft.theme_id, + tsxSource: draft.tsx_source, + sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null, + })); + }, [crud]); + } // END_PLATFORM async listEmailThemes(): Promise<{ id: string, displayName: string }[]> { const crud = Result.orThrow(await this._adminEmailThemesCache.getOrWait([], "write-only")); @@ -317,6 +332,17 @@ export class _StackAdminAppImplIncomplete { + const crud = Result.orThrow(await this._adminEmailDraftsCache.getOrWait([], "write-only")); + return crud.map((draft) => ({ + id: draft.id, + displayName: draft.display_name, + themeId: draft.theme_id, + tsxSource: draft.tsx_source, + sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null, + })); + } + async createTeamPermissionDefinition(data: AdminTeamPermissionDefinitionCreateOptions): Promise{ const crud = await this._interface.createTeamPermissionDefinition(adminTeamPermissionDefinitionCreateOptionsToCrud(data)); @@ -448,6 +474,25 @@ export class _StackAdminAppImplIncomplete { + const result = await this._interface.createEmailDraft({ + display_name: options.displayName, + theme_id: options.themeId, + tsx_source: options.tsxSource, + }); + await this._adminEmailDraftsCache.refresh([]); + return result; + } + + async updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise { + await this._interface.updateEmailDraft(id, { + display_name: data.displayName, + theme_id: (data.themeId === undefined) ? undefined : ((data.themeId === false) ? false : (data.themeId ?? null)), + tsx_source: data.tsxSource, + }); + await this._adminEmailDraftsCache.refresh([]); + } + async sendChatMessage( threadId: string, contextType: "email-theme" | "email-template", From 83fd9551b0f5b79f7d66e30066f445ef67b6b42c Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 18 Aug 2025 10:47:32 -0700 Subject: [PATCH 02/14] wip on send-email route --- .../api/latest/emails/send-email/route.tsx | 32 +++-- .../latest/internal/email-drafts/route.tsx | 34 ++--- .../email-drafts/[draftId]/page-client.tsx | 135 +++++++----------- .../src/components/email-preview.tsx | 4 +- .../components/vibe-coding/code-editor.tsx | 2 +- .../src/interface/admin-interface.ts | 4 +- .../src/interface/server-interface.ts | 2 + .../apps/implementations/admin-app-impl.ts | 8 +- .../stack-app/apps/interfaces/admin-app.ts | 6 +- .../template/src/lib/stack-app/email/index.ts | 4 +- 10 files changed, 110 insertions(+), 121 deletions(-) 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 77ccf24a30..5a0a9c3d82 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,7 +3,7 @@ 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, templateThemeIdSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString, yupUnion } 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"; @@ -25,17 +25,27 @@ export const POST = createSmartRouteHandler({ 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." } + body: yupUnion( + yupObject({ + html: yupString().defined(), + subject: yupString().optional(), + notification_category_name: yupString().optional(), + }), + yupObject({ + template_id: yupString().uuid().defined(), + variables: yupRecord(yupString(), yupMixed()).optional(), + }), + yupObject({ + draft_id: yupString().defined(), }), - html: yupString().optional(), - subject: yupString().optional(), - notification_category_name: yupString().optional(), - template_id: yupString().optional(), - variables: yupRecord(yupString(), yupMixed()).optional(), - }), + ).defined().concat( + 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." } + }), + }) + ), method: yupString().oneOf(["POST"]).defined(), }), response: yupObject({ 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 index f09a2fb507..587a2c980f 100644 --- a/apps/backend/src/app/api/latest/internal/email-drafts/route.tsx +++ b/apps/backend/src/app/api/latest/internal/email-drafts/route.tsx @@ -24,22 +24,6 @@ const themeModeToTemplateThemeId = (themeMode: DraftThemeMode, themeId: string | return themeId === null ? undefined : themeId; }; -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 GET = createSmartRouteHandler({ metadata: { hidden: true }, request: yupObject({ @@ -66,7 +50,7 @@ export const GET = createSmartRouteHandler({ const items = await prisma.emailDraft.findMany({ where: { tenancyId: tenancy.id }, orderBy: { updatedAt: "desc" }, - take: 200, + take: 50, }); return { statusCode: 200, @@ -85,6 +69,22 @@ export const GET = createSmartRouteHandler({ }); +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({ 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 index 7124239c55..4ca106b03d 100644 --- 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 @@ -5,11 +5,9 @@ import EmailPreview from "@/components/email-preview"; import { useRouterConfirm } from "@/components/router"; import { AssistantChat, CodeEditor, VibeCodeLayout } from "@/components/vibe-coding"; import { createChatAdapter, createHistoryAdapter, ToolCallContent } from "@/components/vibe-coding/chat-adapters"; -import { UserAvatar } from "@stackframe/stack"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; -import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Typography, useToast } from "@stackframe/stack-ui"; -import { X } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { Button, 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 { EmailThemeSelector } from "@/components/email-theme-selector"; @@ -23,7 +21,6 @@ export default function PageClient({ draftId }: { draftId: string }) { const [currentCode, setCurrentCode] = useState(draft?.tsxSource ?? ""); const [stage, setStage] = useState<"edit" | "send">("edit"); - const [selectedUsers, setSelectedUsers] = useState([]); const [selectedThemeId, setSelectedThemeId] = useState(draft?.themeId); useEffect(() => { @@ -47,26 +44,16 @@ export default function PageClient({ draftId }: { draftId: string }) { toast({ title: "Failed to save draft", variant: "destructive", description: error.message }); return; } - throw error; + toast({ title: "Failed to save draft", variant: "destructive", description: "Unknown error" }); } }; - const handleSend = async (values: { scope: "all" | "users"; subject: string; notificationCategoryName: "Transactional" | "Marketing"; }) => { - const userIds = values.scope === "all" ? (await stackAdminApp.listUsers({ limit: 1000 })).map(u => u.id) : selectedUsers.map(u => u.id); - await stackAdminApp.sendEmail({ - userIds, - templateTsxSource: currentCode, - themeId: selectedThemeId || undefined, - } as any); - toast({ title: "Email sent", variant: "success" }); - }; - return ( <> {stage === "edit" ? ( + } editorComponent={ - +
} @@ -89,88 +80,72 @@ export default function PageClient({ draftId }: { draftId: string }) { } /> ) : ( - + )} ); } -function SendStage({ selectedUsers, setSelectedUsers, onSend }: { - selectedUsers: any[], - setSelectedUsers: (fn: (prev: any[]) => any[]) => void, - onSend: (v: { scope: "all" | "users", subject: string, notificationCategoryName: "Transactional" | "Marketing" }) => Promise, -}) { +function SendStage({ draftId }: { draftId: string }) { + const stackAdminApp = useAdminApp(); const [scope, setScope] = useState<"all" | "users">("all"); - const [subject, setSubject] = useState(""); - const [category, setCategory] = useState<"Transactional" | "Marketing">("Transactional"); + const [selectedUserIds, setSelectedUserIds] = useState([]); const handleSubmit = async () => { - await onSend({ scope, subject, notificationCategoryName: category }); + const result = await stackAdminApp.sendEmail({ + userIds: selectedUserIds, + draftId, + }); + if (result.status === "ok") { + toast({ title: "Email sent", variant: "success" }); + return; + } + if (result.error instanceof KnownErrors.RequiresCustomEmailServer) { + toast({ title: "Action requires custom email server", variant: "destructive", description: "Please setup a custom email server and try again." }); + } else { + toast({ title: "Failed to send email", variant: "destructive", description: "Unknown error" }); + } }; return (
Recipients -
- - -
- + {scope === "users" && (
-
- ( - - )} /> + }> + ( + + )} + /> +
)} - -
-
- Subject - setSubject(e.target.value)} /> -
-
- Notification Category - -
-
- -
- +
+
); } - -function SelectedChips({ users, setSelectedUsers }: { users: any[]; setSelectedUsers: (fn: (prev: any[]) => any[]) => void; }) { - if (users.length === 0) return null; - return ( -
- {users.map((user) => ( -
- - {user.primaryEmail} - -
- ))} -
- ); -} - 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/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/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 455628aab3..03f144b159 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -131,9 +131,9 @@ export class StackAdminInterface extends StackServerInterface { return result.templates; } - async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[]> { + async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[]> { const response = await this.sendAdminRequest(`/internal/email-drafts`, {}, null); - const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[] }; + const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[] }; return result.drafts; } diff --git a/packages/stack-shared/src/interface/server-interface.ts b/packages/stack-shared/src/interface/server-interface.ts index c5aac2151e..2a734aa679 100644 --- a/packages/stack-shared/src/interface/server-interface.ts +++ b/packages/stack-shared/src/interface/server-interface.ts @@ -804,6 +804,7 @@ export class StackServerInterface extends StackClientInterface { notificationCategoryName?: string, templateId?: string, variables?: Record, + draftId?: string, }): Promise> { const res = await this.sendServerRequestAndCatchKnownError( "/emails/send-email", @@ -820,6 +821,7 @@ export class StackServerInterface extends StackClientInterface { notification_category_name: options.notificationCategoryName, template_id: options.templateId, variables: options.variables, + draft_id: options.draftId, }), }, null, diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index af4d3057b3..6cf5ae9cc3 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -301,7 +301,7 @@ export class _StackAdminAppImplIncomplete { return crud.map((draft) => ({ @@ -332,7 +332,7 @@ export class _StackAdminAppImplIncomplete { + async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> { const crud = Result.orThrow(await this._adminEmailDraftsCache.getOrWait([], "write-only")); return crud.map((draft) => ({ id: draft.id, @@ -484,10 +484,10 @@ export class _StackAdminAppImplIncomplete { + async updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise { await this._interface.updateEmailDraft(id, { display_name: data.displayName, - theme_id: (data.themeId === undefined) ? undefined : ((data.themeId === false) ? false : (data.themeId ?? null)), + theme_id: data.themeId, tsx_source: data.tsxSource, }); await this._adminEmailDraftsCache.refresh([]); diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 9b923d3f34..833a7388cd 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -34,10 +34,8 @@ export type StackAdminApp & AsyncStoreProperty<"emailPreview", [{ themeId?: string | null | false, themeTsxSource?: string, templateId?: string, templateTsxSource?: string }], string, false> & AsyncStoreProperty<"emailTemplates", [], { id: string, displayName: string, themeId?: string, tsxSource: string }[], true> + & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[], true> & { - useEmailTemplates(): { id: string, displayName: string, tsxSource: string }[], // THIS_LINE_PLATFORM react-like - listEmailTemplates(): Promise<{ id: string, displayName: string, tsxSource: string }[]>, - createInternalApiKey(options: InternalApiKeyCreateOptions): Promise, createTeamPermissionDefinition(data: AdminTeamPermissionDefinitionCreateOptions): Promise, @@ -77,6 +75,8 @@ export type StackAdminApp, createStripeWidgetAccountSession(): Promise<{ client_secret: string }>, createPurchaseUrl(options: { customerId: string, offerId: string }): Promise, + createEmailDraft(options: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<{ id: string }>, + updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise, } & StackServerApp ); diff --git a/packages/template/src/lib/stack-app/email/index.ts b/packages/template/src/lib/stack-app/email/index.ts index 842ffb9459..82e8162876 100644 --- a/packages/template/src/lib/stack-app/email/index.ts +++ b/packages/template/src/lib/stack-app/email/index.ts @@ -19,4 +19,6 @@ export type SendEmailOptions = } | { templateId: string, variables?: Record, - }) + }) | { + draftId: string, + } From 060335ce95413f2d69d7773eed03325ed738ddb0 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 18 Aug 2025 15:51:49 -0700 Subject: [PATCH 03/14] email drafts --- .../api/latest/emails/send-email/route.tsx | 108 +++++++------ .../internal/ai-chat/[threadId]/route.tsx | 3 +- .../latest/internal/email-drafts/route.tsx | 20 +-- .../src/lib/ai-chat/adapter-registry.ts | 4 +- .../src/lib/ai-chat/email-draft-adapter.ts | 50 ++++++ apps/backend/src/lib/email-drafts.tsx | 33 ++++ apps/backend/src/lib/email-rendering.tsx | 2 +- .../email-drafts/[draftId]/page-client.tsx | 108 +++++++------ .../projects/[projectId]/sidebar-layout.tsx | 12 +- .../src/components/assistant-ui/thread.tsx | 2 +- .../components/vibe-coding/chat-adapters.ts | 2 +- .../vibe-coding/draft-tool-components.tsx | 28 ++++ .../endpoints/api/v1/send-email.test.ts | 153 +++++++++++++++++- .../src/interface/admin-interface.ts | 2 +- .../src/interface/server-interface.ts | 4 +- packages/stack-shared/src/utils/types.tsx | 6 + .../apps/implementations/admin-app-impl.ts | 2 +- .../stack-app/apps/interfaces/admin-app.ts | 2 +- .../template/src/lib/stack-app/email/index.ts | 37 +++-- 19 files changed, 432 insertions(+), 146 deletions(-) create mode 100644 apps/backend/src/lib/ai-chat/email-draft-adapter.ts create mode 100644 apps/backend/src/lib/email-drafts.tsx create mode 100644 apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx 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 5a0a9c3d82..4c450f6b4c 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,22 +3,33 @@ 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, templateThemeIdSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString, yupUnion } 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 } 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"; +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.", + 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({ @@ -26,26 +37,17 @@ export const POST = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }).defined(), body: yupUnion( - yupObject({ + bodyBase.concat(yupObject({ html: yupString().defined(), - subject: yupString().optional(), - notification_category_name: yupString().optional(), - }), - yupObject({ + })), + bodyBase.concat(yupObject({ template_id: yupString().uuid().defined(), variables: yupRecord(yupString(), yupMixed()).optional(), - }), - yupObject({ + })), + bodyBase.concat(yupObject({ draft_id: yupString().defined(), - }), - ).defined().concat( - 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." } - }), - }) - ), + })), + ).defined(), method: yupString().oneOf(["POST"]).defined(), }), response: yupObject({ @@ -65,55 +67,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, @@ -121,16 +129,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; @@ -138,7 +146,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; } @@ -165,12 +173,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 { @@ -183,13 +191,13 @@ 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, })); return { 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..aa699184a7 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(), @@ -59,6 +59,7 @@ export const POST = createSmartRouteHandler({ messages: body.messages as any, tools: adapter.tools, }); + console.log(result.text); const contentBlocks: InferType = []; result.steps.forEach((step) => { 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 index 587a2c980f..767a3c78a8 100644 --- a/apps/backend/src/app/api/latest/internal/email-drafts/route.tsx +++ b/apps/backend/src/app/api/latest/internal/email-drafts/route.tsx @@ -1,28 +1,10 @@ import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { DraftThemeMode } from "@prisma/client"; 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"; -const templateThemeIdToThemeMode = (themeId: string | false | undefined): DraftThemeMode => { - if (themeId === undefined) { - return DraftThemeMode.PROJECT_DEFAULT; - } - if (themeId === false) { - return DraftThemeMode.NONE; - } - return DraftThemeMode.CUSTOM; -}; -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; -}; export const GET = createSmartRouteHandler({ metadata: { hidden: true }, 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 454a880ad6..0179c8044a 100644 --- a/apps/backend/src/lib/email-rendering.tsx +++ b/apps/backend/src/lib/email-rendering.tsx @@ -7,6 +7,7 @@ import { get, has } from '@stackframe/stack-shared/dist/utils/objects'; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { Tenancy } from './tenancies'; +import { PrismaClient } from '@prisma/client'; export function getActiveEmailTheme(tenancy: Tenancy) { const themeList = tenancy.config.emails.themes; @@ -128,7 +129,6 @@ export async function renderEmailWithTemplate( } } - 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 index 4ca106b03d..a91bdae183 100644 --- 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 @@ -2,21 +2,23 @@ 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 { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, toast, Typography, useToast } from "@stackframe/stack-ui"; +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 { EmailThemeSelector } from "@/components/email-theme-selector"; +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(); + type EmailDraft = { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }; + const drafts = stackAdminApp.useEmailDrafts() as EmailDraft[]; const draft = useMemo(() => drafts.find((d) => d.id === draftId), [drafts, draftId]); const [currentCode, setCurrentCode] = useState(draft?.tsxSource ?? ""); @@ -38,7 +40,6 @@ export default function PageClient({ draftId }: { draftId: string }) { try { await stackAdminApp.updateEmailDraft(draftId, { tsxSource: currentCode, themeId: selectedThemeId }); setStage("send"); - toast({ title: "Draft saved", variant: "success" }); } catch (error) { if (error instanceof KnownErrors.EmailRenderingError) { toast({ title: "Failed to save draft", variant: "destructive", description: error.message }); @@ -74,8 +75,8 @@ export default function PageClient({ draftId }: { draftId: string }) { chatComponent={ } /> } /> @@ -92,10 +93,11 @@ function SendStage({ draftId }: { draftId: string }) { const [selectedUserIds, setSelectedUserIds] = useState([]); const handleSubmit = async () => { - const result = await stackAdminApp.sendEmail({ - userIds: selectedUserIds, - draftId, - }); + const result = await stackAdminApp.sendEmail( + scope === "users" + ? { draftId, userIds: selectedUserIds } + : { draftId, allUsers: true } + ); if (result.status === "ok") { toast({ title: "Email sent", variant: "success" }); return; @@ -108,44 +110,58 @@ function SendStage({ draftId }: { draftId: string }) { }; return ( -
- Recipients - - {scope === "users" && ( -
-
- }> - ( - +
+ + +
+ Recipients + {scope === "users" && ( +
+ {selectedUserIds.length} selected + {selectedUserIds.length > 0 && ( + )} - /> - +
+ )}
-
- )} -
- -
+
+ +
+ {scope === "users" && ( +
+ }> + ( + + )} + /> + +
+ )} +
+ +
+ +
); } 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 260fde3147..ea5ade1a77 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 @@ -205,7 +205,7 @@ const navigationItems: (Label | Item | Hidden)[] = [ let item; let href; if (match) { - item = "Draft"; + item = ; href = `/email-drafts/${match[1]}`; } else { item = "Draft"; @@ -349,6 +349,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/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/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/send-email.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts index ccf51faf83..289540145d 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts @@ -87,6 +87,7 @@ describe("invalid requests", () => { }); it("should return 400 when using shared email config", async ({ expect }) => { + await Project.createAndSwitch(); const createUserResponse = await niceBackendFetch("/api/v1/users", { method: "POST", accessType: "server", @@ -372,8 +373,28 @@ describe("validation errors", () => { "status": 400, "body": { "code": "SCHEMA_ERROR", - "details": { "message": "Either html or template_id must be provided" }, - "error": "Either html or template_id must be provided", + "details": { + "message": deindent\` + Request validation failed on POST /api/v1/emails/send-email: + - body is not matched by any of the provided schemas: + Schema 0: + body.html must be defined + Schema 1: + body.template_id must be defined + Schema 2: + body.draft_id must be defined + \`, + }, + "error": deindent\` + Request validation failed on POST /api/v1/emails/send-email: + - body is not matched by any of the provided schemas: + Schema 0: + body.html must be defined + Schema 1: + body.template_id must be defined + Schema 2: + body.draft_id must be defined + \`, }, "headers": Headers { "x-stack-known-error": "SCHEMA_ERROR", @@ -413,6 +434,102 @@ describe("validation errors", () => { }); }); +describe("all users", () => { + it("should return 400 when both user_ids and all_users are provided", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Test Both user_ids and all_users", + config: { + email_config: testEmailConfig, + }, + }); + const user = await User.create(); + const response = await niceBackendFetch( + "/api/v1/emails/send-email", + { + method: "POST", + accessType: "server", + body: { + user_ids: [user.userId], + all_users: true, + html: "

Test email

", + subject: "Test Subject", + } + } + ); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": { + "code": "SCHEMA_ERROR", + "details": { "message": "Exactly one of user_ids or all_users must be provided" }, + "error": "Exactly one of user_ids or all_users must be provided", + }, + "headers": Headers { + "x-stack-known-error": "SCHEMA_ERROR", +
} editorComponent={ diff --git a/apps/e2e/tests/js/email.test.ts b/apps/e2e/tests/js/email.test.ts index 62957e9374..3389e22dbe 100644 --- a/apps/e2e/tests/js/email.test.ts +++ b/apps/e2e/tests/js/email.test.ts @@ -180,26 +180,3 @@ it("should handle missing required email content", async ({ expect }) => { expect(result.error.message).toMatchInlineSnapshot(`"Either html or template_id must be provided"`); } }); - -it("should handle html and templateId at the same time", async ({ expect }) => { - const { adminApp, serverApp } = await createApp(); - await setupEmailServer(adminApp); - - const user = await serverApp.createUser({ - primaryEmail: "test@example.com", - primaryEmailVerified: true, - }); - - const result = await serverApp.sendEmail({ - userIds: [user.id], - html: "

Test Email

", - templateId: DEFAULT_TEMPLATE_IDS.sign_in_invitation, - subject: "Test Email", - }); - - expect(result.status).toBe("error"); - if (result.status === "error") { - expect(KnownErrors.SchemaError.isInstance(result.error)).toBe(true); - expect(result.error.message).toMatchInlineSnapshot(`"If html is provided, cannot provide template_id or variables"`); - } -}); From 8bab612dcc4a442d6c051fcad9f333498fd9a156 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 18 Aug 2025 16:14:57 -0700 Subject: [PATCH 05/14] small fixes --- .../app/api/latest/internal/ai-chat/[threadId]/route.tsx | 1 - .../app/api/latest/internal/email-drafts/[id]/route.tsx | 8 ++------ apps/backend/src/lib/email-rendering.tsx | 3 +-- 3 files changed, 3 insertions(+), 9 deletions(-) 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 aa699184a7..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 @@ -59,7 +59,6 @@ export const POST = createSmartRouteHandler({ messages: body.messages as any, tools: adapter.tools, }); - console.log(result.text); const contentBlocks: InferType = []; result.steps.forEach((step) => { 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 index fc86cd21b1..9d5ec1adea 100644 --- 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 @@ -1,7 +1,7 @@ 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 } from "@/lib/email-drafts"; +import { templateThemeIdToThemeMode, themeModeToTemplateThemeId } from "@/lib/email-drafts"; export const GET = createSmartRouteHandler({ metadata: { hidden: true }, @@ -33,11 +33,7 @@ export const GET = createSmartRouteHandler({ id: d.id, display_name: d.displayName, tsx_source: d.tsxSource, - theme_id: ((): any => { - if (d.themeMode === "CUSTOM") return d.themeId; - if (d.themeMode === "NONE") return false; - return null; - })(), + theme_id: themeModeToTemplateThemeId(d.themeMode, d.themeId), sent_at_millis: d.sentAt ? d.sentAt.getTime() : null, }, }; diff --git a/apps/backend/src/lib/email-rendering.tsx b/apps/backend/src/lib/email-rendering.tsx index 0179c8044a..444e4ae235 100644 --- a/apps/backend/src/lib/email-rendering.tsx +++ b/apps/backend/src/lib/email-rendering.tsx @@ -1,13 +1,12 @@ import { Freestyle } from '@/lib/freestyle'; import { emptyEmailTheme } from '@stackframe/stack-shared/dist/helpers/emails'; -import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env'; +import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; import { bundleJavaScript } from '@stackframe/stack-shared/dist/utils/esbuild'; import { get, has } from '@stackframe/stack-shared/dist/utils/objects'; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { Tenancy } from './tenancies'; -import { PrismaClient } from '@prisma/client'; export function getActiveEmailTheme(tenancy: Tenancy) { const themeList = tenancy.config.emails.themes; From 0f1c9a32d2d60f06103b8c6dada9c52339bb230b Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 18 Aug 2025 16:20:27 -0700 Subject: [PATCH 06/14] fix tests --- .../api/latest/emails/render-email/route.tsx | 21 +++------- .../endpoints/api/v1/render-email.test.ts | 39 ++++++++++++++++++- apps/e2e/tests/js/email.test.ts | 13 ++++++- 3 files changed, 54 insertions(+), 19 deletions(-) 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 d91ca4b0e4..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 @@ -3,7 +3,6 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { adaptSchema, templateThemeIdSchema, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; -import { get, getOrUndefined, has } from "@stackframe/stack-shared/dist/utils/objects"; export const POST = createSmartRouteHandler({ metadata: { @@ -17,36 +16,22 @@ export const POST = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }).defined(), body: yupUnion( - // template_id + theme_id yupObject({ template_id: yupString().uuid().defined(), theme_id: templateThemeIdSchema, }), - // template_id + theme_tsx_source yupObject({ template_id: yupString().uuid().defined(), theme_tsx_source: yupString().defined(), }), - // template_tsx_source + theme_id yupObject({ template_tsx_source: yupString().defined(), theme_id: templateThemeIdSchema, }), - // template_tsx_source + theme_tsx_source yupObject({ template_tsx_source: yupString().defined(), theme_tsx_source: yupString().defined(), }), - // draft_content + theme_id - yupObject({ - draft_content: yupString().defined(), - theme_id: templateThemeIdSchema, - }), - // draft_content + theme_tsx_source - yupObject({ - draft_content: yupString().defined(), - theme_tsx_source: yupString().defined(), - }), ).defined(), }), response: yupObject({ @@ -60,10 +45,14 @@ export const POST = createSmartRouteHandler({ }), async handler({ body, auth: { tenancy } }) { 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); } @@ -77,7 +66,7 @@ export const POST = createSmartRouteHandler({ } contentSource = template.tsxSource; } else { - contentSource = body.draft_content; + throw new KnownErrors.SchemaError("Either template_id or template_tsx_source must be provided"); } const result = await renderEmailWithTemplate( diff --git a/apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts index b862e5e10e..640b8b8e29 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts @@ -66,8 +66,43 @@ it("should return 400 when both theme_id and theme_tsx_source are provided", asy expect(response).toMatchInlineSnapshot(` NiceResponse { "status": 400, - "body": "Exactly one of theme_id or theme_tsx_source must be provided", - "headers": Headers {