Skip to content

Commit 11b6b42

Browse files
Emails redesign (#1076)
1 parent c7ef526 commit 11b6b42

104 files changed

Lines changed: 13745 additions & 2044 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"typescript.tsdk": "node_modules/typescript/lib",
88
"editor.tabSize": 2,
99
"cSpell.words": [
10+
"glassmorphic",
1011
"sparkline",
1112
"Clickhouse",
1213
"pushable",

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
103103
- When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples.
104104
- Each migration file runs in its own transaction with a relatively short timeout. Split long-running operations into separate migration files to avoid timeouts. For example, when adding CHECK constraints, use `NOT VALID` in one migration, then `VALIDATE CONSTRAINT` in a separate migration file.
105105
- **When building frontend code, always carefully deal with loading and error states.** Be very explicit with these; some components make this easy, eg. the button onClick already takes an async callback for loading state, but make sure this is done everywhere, and make sure errors are NEVER just silently swallowed.
106+
- Any design components you add or modify in the dashboard, update the Playground page accordingly to showcase the changes.
106107
- Unless very clearly equivalent from types, prefer explicit null/undefinedness checks over boolean checks, eg. `foo == null` instead of `!foo`.
107108

108109
### Code-related

apps/backend/.env.development

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ STACK_OPENAI_API_KEY=mock_openai_api_key
5858
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
5959
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
6060

61+
STACK_OPENROUTER_API_KEY=mock-openrouter-api-key
62+
6163
# Email monitor configuration for tests
6264
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification
6365
STACK_EMAIL_MONITOR_PROJECT_ID=internal

apps/backend/src/app/api/latest/emails/render-email/route.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { getEmailThemeForThemeId, renderEmailWithTemplate } from "@/lib/email-rendering";
22
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
33
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
4-
import { adaptSchema, templateThemeIdSchema, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
4+
import { adaptSchema, templateThemeIdSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
55
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
6+
import type { EditableMetadata } from "@stackframe/stack-shared/dist/utils/jsx-editable-transpiler";
67

78
export const POST = createSmartRouteHandler({
89
metadata: {
@@ -19,18 +20,26 @@ export const POST = createSmartRouteHandler({
1920
yupObject({
2021
template_id: yupString().uuid().defined(),
2122
theme_id: templateThemeIdSchema,
23+
editable_markers: yupBoolean().optional(),
24+
editable_source: yupString().oneOf(['template', 'theme', 'both']).optional(),
2225
}),
2326
yupObject({
2427
template_id: yupString().uuid().defined(),
2528
theme_tsx_source: yupString().defined(),
29+
editable_markers: yupBoolean().optional(),
30+
editable_source: yupString().oneOf(['template', 'theme', 'both']).optional(),
2631
}),
2732
yupObject({
2833
template_tsx_source: yupString().defined(),
2934
theme_id: templateThemeIdSchema,
35+
editable_markers: yupBoolean().optional(),
36+
editable_source: yupString().oneOf(['template', 'theme', 'both']).optional(),
3037
}),
3138
yupObject({
3239
template_tsx_source: yupString().defined(),
3340
theme_tsx_source: yupString().defined(),
41+
editable_markers: yupBoolean().optional(),
42+
editable_source: yupString().oneOf(['template', 'theme', 'both']).optional(),
3443
}),
3544
).defined(),
3645
}),
@@ -41,6 +50,7 @@ export const POST = createSmartRouteHandler({
4150
html: yupString().defined(),
4251
subject: yupString(),
4352
notification_category: yupString(),
53+
editable_regions: yupMixed<Record<string, EditableMetadata>>().optional(),
4454
}).defined(),
4555
}),
4656
async handler({ body, auth: { tenancy } }) {
@@ -69,12 +79,17 @@ export const POST = createSmartRouteHandler({
6979
throw new KnownErrors.SchemaError("Either template_id or template_tsx_source must be provided");
7080
}
7181

82+
const editableMarkers = 'editable_markers' in body && body.editable_markers === true;
83+
const editableSource = ('editable_source' in body ? body.editable_source : 'template') as 'template' | 'theme' | 'both';
84+
7285
const result = await renderEmailWithTemplate(
7386
contentSource,
7487
themeSource,
7588
{
7689
project: { displayName: tenancy.project.display_name },
7790
previewMode: true,
91+
editableMarkers,
92+
editableSource,
7893
themeProps: {
7994
projectLogos: {
8095
logoUrl: tenancy.project.logo_url ?? undefined,
@@ -95,6 +110,7 @@ export const POST = createSmartRouteHandler({
95110
html: result.data.html,
96111
subject: result.data.subject,
97112
notification_category: result.data.notificationCategory,
113+
editable_regions: result.data.editableRegions,
98114
},
99115
};
100116
},

apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx

Lines changed: 85 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,25 @@ const toolCallContentSchema = yupObject({
2222
});
2323

2424
const contentSchema = yupArray(yupUnion(textContentSchema, toolCallContentSchema)).defined();
25-
const openai = createOpenAI({ apiKey: getEnvVariable("STACK_OPENAI_API_KEY", "MISSING_OPENAI_API_KEY") });
25+
26+
const messageSchema = yupObject({
27+
role: yupString().oneOf(["user", "assistant", "tool"]).defined(),
28+
content: yupMixed().defined(),
29+
});
30+
31+
// Mock mode sentinel value - when API key is not configured, we return mock responses
32+
const MOCK_API_KEY_SENTINEL = "mock-openrouter-api-key";
33+
const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", MOCK_API_KEY_SENTINEL);
34+
const isMockMode = apiKey === MOCK_API_KEY_SENTINEL;
35+
36+
// Only create OpenAI client if not in mock mode
37+
const openai = isMockMode ? null : createOpenAI({
38+
apiKey,
39+
baseURL: "https://openrouter.ai/api/v1",
40+
});
41+
42+
// AI request timeout in milliseconds (2 minutes)
43+
const AI_REQUEST_TIMEOUT_MS = 120_000;
2644

2745
export const POST = createSmartRouteHandler({
2846
metadata: {
@@ -38,10 +56,7 @@ export const POST = createSmartRouteHandler({
3856
}),
3957
body: yupObject({
4058
context_type: yupString().oneOf(["email-theme", "email-template", "email-draft"]).defined(),
41-
messages: yupArray(yupObject({
42-
role: yupString().oneOf(["user", "assistant", "tool"]).defined(),
43-
content: yupMixed().defined(),
44-
})).defined().min(1),
59+
messages: yupArray(messageSchema).defined().min(1),
4560
}),
4661
}),
4762
response: yupObject({
@@ -52,39 +67,76 @@ export const POST = createSmartRouteHandler({
5267
}).defined(),
5368
}),
5469
async handler({ body, params, auth: { tenancy } }) {
70+
// Mock mode: return a simple text response without calling AI
71+
if (isMockMode) {
72+
return {
73+
statusCode: 200,
74+
bodyType: "json",
75+
body: {
76+
content: [{
77+
type: "text",
78+
text: "This is a mock AI response. Configure a real API key to enable AI features.",
79+
}],
80+
},
81+
};
82+
}
83+
5584
const adapter = getChatAdapter(body.context_type, tenancy, params.threadId);
56-
const result = await generateText({
57-
model: openai("gpt-4o"),
58-
system: adapter.systemPrompt,
59-
messages: body.messages as any,
60-
tools: adapter.tools,
61-
});
85+
// Model is configurable via env var; no default to surface missing config errors
86+
const modelName = getEnvVariable("STACK_AI_MODEL");
6287

63-
const contentBlocks: InferType<typeof contentSchema> = [];
64-
result.steps.forEach((step) => {
65-
if (step.text) {
66-
contentBlocks.push({
67-
type: "text",
68-
text: step.text,
69-
});
70-
}
71-
step.toolCalls.forEach(toolCall => {
72-
contentBlocks.push({
73-
type: "tool-call",
74-
toolName: toolCall.toolName,
75-
toolCallId: toolCall.toolCallId,
76-
args: toolCall.args,
77-
argsText: JSON.stringify(toolCall.args),
78-
result: "success",
88+
if (!openai) {
89+
// This shouldn't happen since we check isMockMode above, but guard anyway
90+
throw new Error("OpenAI client not initialized - STACK_OPENROUTER_API_KEY may be missing");
91+
}
92+
93+
// Validate messages structure before passing to AI
94+
const validatedMessages = body.messages.map(msg => ({
95+
role: msg.role,
96+
content: msg.content,
97+
})) as any; // Cast needed: content is a mixed type from yup schema that doesn't map to AI SDK's strict typing
98+
99+
// Create abort controller for timeout
100+
const controller = new AbortController();
101+
const timeoutId = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS);
102+
103+
try {
104+
const result = await generateText({
105+
model: openai(modelName),
106+
system: adapter.systemPrompt,
107+
messages: validatedMessages,
108+
tools: adapter.tools,
109+
abortSignal: controller.signal,
110+
});
111+
112+
const contentBlocks: InferType<typeof contentSchema> = [];
113+
result.steps.forEach((step) => {
114+
if (step.text) {
115+
contentBlocks.push({
116+
type: "text",
117+
text: step.text,
118+
});
119+
}
120+
step.toolCalls.forEach(toolCall => {
121+
contentBlocks.push({
122+
type: "tool-call",
123+
toolName: toolCall.toolName,
124+
toolCallId: toolCall.toolCallId,
125+
args: toolCall.args,
126+
argsText: JSON.stringify(toolCall.args),
127+
result: "success",
128+
});
79129
});
80130
});
81-
});
82131

83-
return {
84-
statusCode: 200,
85-
bodyType: "json",
86-
body: { content: contentBlocks },
87-
};
132+
return {
133+
statusCode: 200,
134+
bodyType: "json",
135+
body: { content: contentBlocks },
136+
};
137+
} finally {
138+
clearTimeout(timeoutId);
139+
}
88140
},
89141
});
90142

apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { templateThemeIdToThemeMode, themeModeToTemplateThemeId } from "@/lib/email-drafts";
12
import { getPrismaClientForTenancy } from "@/prisma-client";
23
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
34
import { templateThemeIdSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4-
import { templateThemeIdToThemeMode, themeModeToTemplateThemeId } from "@/lib/email-drafts";
5+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
56

67
export const GET = createSmartRouteHandler({
78
metadata: { hidden: true },
@@ -25,7 +26,10 @@ export const GET = createSmartRouteHandler({
2526
}),
2627
async handler({ auth: { tenancy }, params }) {
2728
const prisma = await getPrismaClientForTenancy(tenancy);
28-
const d = await prisma.emailDraft.findFirstOrThrow({ where: { tenancyId: tenancy.id, id: params.id } });
29+
const d = await prisma.emailDraft.findFirst({ where: { tenancyId: tenancy.id, id: params.id } });
30+
if (!d) {
31+
throw new StatusError(StatusError.NotFound, "No draft found with given id");
32+
}
2933
return {
3034
statusCode: 200,
3135
bodyType: "json",
@@ -78,3 +82,34 @@ export const PATCH = createSmartRouteHandler({
7882
},
7983
});
8084

85+
export const DELETE = createSmartRouteHandler({
86+
metadata: { hidden: true },
87+
request: yupObject({
88+
auth: yupObject({
89+
type: yupString().oneOf(["admin"]).defined(),
90+
tenancy: yupObject({}).defined(),
91+
}).defined(),
92+
params: yupObject({ id: yupString().uuid().defined() }).defined(),
93+
}),
94+
response: yupObject({
95+
statusCode: yupNumber().oneOf([200]).defined(),
96+
bodyType: yupString().oneOf(["json"]).defined(),
97+
body: yupObject({ ok: yupString().oneOf(["ok"]).defined() }).defined(),
98+
}),
99+
async handler({ auth: { tenancy }, params }) {
100+
const prisma = await getPrismaClientForTenancy(tenancy);
101+
const existing = await prisma.emailDraft.findFirst({ where: { tenancyId: tenancy.id, id: params.id } });
102+
if (!existing) {
103+
throw new StatusError(StatusError.NotFound, "No draft found with given id");
104+
}
105+
await prisma.emailDraft.delete({
106+
where: { tenancyId_id: { tenancyId: tenancy.id, id: params.id } },
107+
});
108+
return {
109+
statusCode: 200,
110+
bodyType: "json",
111+
body: { ok: "ok" },
112+
};
113+
},
114+
});
115+

apps/backend/src/app/api/latest/internal/email-templates/[templateId]/route.tsx

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export const PATCH = createSmartRouteHandler({
3838
if (!Object.keys(templateList).includes(templateId)) {
3939
throw new StatusError(StatusError.NotFound, "No template found with given id");
4040
}
41+
42+
// Note: theme_id validation is handled by templateThemeIdSchema in the request schema
43+
4144
const theme = getActiveEmailTheme(tenancy);
4245
const result = await renderEmailWithTemplate(body.tsx_source, theme.tsxSource, {
4346
variables: { projectDisplayName: tenancy.project.display_name },
@@ -61,13 +64,19 @@ export const PATCH = createSmartRouteHandler({
6164
throw new KnownErrors.EmailRenderingError("NotificationCategory is required, import it from @stackframe/emails");
6265
}
6366

67+
const configOverride: Record<string, any> = {
68+
[`emails.templates.${templateId}.tsxSource`]: body.tsx_source,
69+
};
70+
71+
// Only add themeId if it's explicitly provided
72+
if (body.theme_id !== undefined) {
73+
configOverride[`emails.templates.${templateId}.themeId`] = body.theme_id;
74+
}
75+
6476
await overrideEnvironmentConfigOverride({
6577
projectId: tenancy.project.id,
6678
branchId: tenancy.branchId,
67-
environmentConfigOverrideOverride: {
68-
[`emails.templates.${templateId}.tsxSource`]: body.tsx_source,
69-
...(body.theme_id ? { [`emails.templates.${templateId}.themeId`]: body.theme_id } : {}),
70-
},
79+
environmentConfigOverrideOverride: configOverride,
7180
});
7281

7382
return {
@@ -79,3 +88,46 @@ export const PATCH = createSmartRouteHandler({
7988
};
8089
},
8190
});
91+
92+
export const DELETE = createSmartRouteHandler({
93+
metadata: {
94+
hidden: true,
95+
},
96+
request: yupObject({
97+
auth: yupObject({
98+
type: yupString().oneOf(["admin"]).defined(),
99+
tenancy: adaptSchema.defined(),
100+
}).defined(),
101+
params: yupObject({
102+
templateId: yupString().uuid().defined(),
103+
}).defined(),
104+
}),
105+
response: yupObject({
106+
statusCode: yupNumber().oneOf([200]).defined(),
107+
bodyType: yupString().oneOf(["json"]).defined(),
108+
body: yupObject({}).defined(),
109+
}),
110+
async handler({ auth: { tenancy }, params: { templateId } }) {
111+
if (tenancy.config.emails.server.isShared) {
112+
throw new KnownErrors.RequiresCustomEmailServer();
113+
}
114+
const templateList = tenancy.config.emails.templates;
115+
if (!Object.keys(templateList).includes(templateId)) {
116+
throw new StatusError(StatusError.NotFound, "No template found with given id");
117+
}
118+
119+
await overrideEnvironmentConfigOverride({
120+
projectId: tenancy.project.id,
121+
branchId: tenancy.branchId,
122+
environmentConfigOverrideOverride: {
123+
[`emails.templates.${templateId}`]: null as any,
124+
},
125+
});
126+
127+
return {
128+
statusCode: 200,
129+
bodyType: "json",
130+
body: {},
131+
};
132+
},
133+
});

0 commit comments

Comments
 (0)