Skip to content

Commit 5da45d8

Browse files
BilalG1N2D4
andauthored
Email Drafts (#849)
https://www.loom.com/share/cc379c5372244a169f3ae1d2cc91eae5?sid=ec5bc438-56d8-4cca-9bbc-6cf6c6d313ad <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Introduce email draft functionality with AI assistance, including creation, editing, and sending capabilities, and update APIs and UI components accordingly. > > - **Features**: > - Add email draft functionality: create, list, edit, preview, and send drafts. > - Integrate AI-assisted draft generation with `emailDraftAdapter` in `email-draft-adapter.ts`. > - Update `send-email` and `render-email` APIs to support drafts. > - **Backend**: > - Add `EmailDraft` model in `schema.prisma`. > - Implement draft CRUD operations in `email-drafts/route.tsx` and `email-drafts/[id]/route.tsx`. > - Update `render-email/route.tsx` and `send-email/route.tsx` to handle draft inputs. > - **Frontend**: > - Add email draft UI in `email-drafts/page-client.tsx` and `email-drafts/[draftId]/page-client.tsx`. > - Implement theme selection with `EmailThemeSelector` in `email-theme-selector.tsx`. > - Update sidebar navigation in `sidebar-layout.tsx` to include drafts. > - **Tests**: > - Add E2E tests for draft lifecycle and sending in `email-drafts.test.ts` and `send-email.test.ts`. > - **Misc**: > - Update `admin-interface.ts` and `server-interface.ts` to support draft operations. > - Add `XOR` type utility in `types.tsx` for exclusive option handling. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup> for c7ebb00. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> ---- <!-- ELLIPSIS_HIDDEN --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Email Drafts: create, list, edit, preview, theme-select, AI-assisted draft generation, send (marks draft as sent) and dashboard UI for drafting + recipient selection. * **Improvements** * Send/render APIs accept html, template, or draft inputs and an all-users option; per-recipient delivery/reporting, unified theme selector, expanded chat context for drafts, clearer schema validation errors, breadcrumb updates. * **Tests** * E2E coverage for draft lifecycle, draft-based send, all-users flow, and updated schema-validation snapshots. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
1 parent f22e1d6 commit 5da45d8

34 files changed

Lines changed: 1622 additions & 153 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- CreateEnum
2+
CREATE TYPE "DraftThemeMode" AS ENUM ('PROJECT_DEFAULT', 'NONE', 'CUSTOM');
3+
4+
-- CreateTable
5+
CREATE TABLE "EmailDraft" (
6+
"tenancyId" UUID NOT NULL,
7+
"id" UUID NOT NULL,
8+
"displayName" TEXT NOT NULL,
9+
"themeMode" "DraftThemeMode" NOT NULL DEFAULT 'PROJECT_DEFAULT',
10+
"themeId" TEXT,
11+
"tsxSource" TEXT NOT NULL,
12+
"sentAt" TIMESTAMP(3),
13+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14+
"updatedAt" TIMESTAMP(3) NOT NULL,
15+
16+
CONSTRAINT "EmailDraft_pkey" PRIMARY KEY ("tenancyId","id")
17+
);

apps/backend/prisma/schema.prisma

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,29 @@ model SentEmail {
673673
@@id([tenancyId, id])
674674
}
675675

676+
model EmailDraft {
677+
tenancyId String @db.Uuid
678+
679+
id String @default(uuid()) @db.Uuid
680+
681+
displayName String
682+
themeMode DraftThemeMode @default(PROJECT_DEFAULT)
683+
themeId String?
684+
tsxSource String
685+
sentAt DateTime?
686+
687+
createdAt DateTime @default(now())
688+
updatedAt DateTime @updatedAt
689+
690+
@@id([tenancyId, id])
691+
}
692+
693+
enum DraftThemeMode {
694+
PROJECT_DEFAULT
695+
NONE
696+
CUSTOM
697+
}
698+
676699
model CliAuthAttempt {
677700
tenancyId String @db.Uuid
678701

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

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { getEmailThemeForTemplate, 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 } from "@stackframe/stack-shared/dist/schema-fields";
4+
import { adaptSchema, templateThemeIdSchema, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
55
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
66

77
export const POST = createSmartRouteHandler({
@@ -15,12 +15,24 @@ export const POST = createSmartRouteHandler({
1515
type: yupString().oneOf(["admin"]).defined(),
1616
tenancy: adaptSchema.defined(),
1717
}).defined(),
18-
body: yupObject({
19-
theme_id: templateThemeIdSchema.nullable(),
20-
theme_tsx_source: yupString(),
21-
template_id: yupString(),
22-
template_tsx_source: yupString(),
23-
}),
18+
body: yupUnion(
19+
yupObject({
20+
template_id: yupString().uuid().defined(),
21+
theme_id: templateThemeIdSchema,
22+
}),
23+
yupObject({
24+
template_id: yupString().uuid().defined(),
25+
theme_tsx_source: yupString().defined(),
26+
}),
27+
yupObject({
28+
template_tsx_source: yupString().defined(),
29+
theme_id: templateThemeIdSchema,
30+
}),
31+
yupObject({
32+
template_tsx_source: yupString().defined(),
33+
theme_tsx_source: yupString().defined(),
34+
}),
35+
).defined(),
2436
}),
2537
response: yupObject({
2638
statusCode: yupNumber().oneOf([200]).defined(),
@@ -32,32 +44,40 @@ export const POST = createSmartRouteHandler({
3244
}).defined(),
3345
}),
3446
async handler({ body, auth: { tenancy } }) {
35-
if ((body.theme_id === undefined && !body.theme_tsx_source) || (body.theme_id && body.theme_tsx_source)) {
36-
throw new StatusError(400, "Exactly one of theme_id or theme_tsx_source must be provided");
37-
}
38-
if ((!body.template_id && !body.template_tsx_source) || (body.template_id && body.template_tsx_source)) {
39-
throw new StatusError(400, "Exactly one of template_id or template_tsx_source must be provided");
47+
const templateList = new Map(Object.entries(tenancy.config.emails.templates));
48+
const themeList = new Map(Object.entries(tenancy.config.emails.themes));
49+
let themeSource: string;
50+
if ("theme_tsx_source" in body) {
51+
themeSource = body.theme_tsx_source;
52+
} else {
53+
if (typeof body.theme_id === "string" && !themeList.has(body.theme_id)) {
54+
throw new StatusError(400, "No theme found with given id");
55+
}
56+
themeSource = getEmailThemeForTemplate(tenancy, body.theme_id);
4057
}
4158

42-
if (body.theme_id && !(body.theme_id in tenancy.config.emails.themes)) {
43-
throw new StatusError(400, "No theme found with given id");
59+
let contentSource: string;
60+
if ("template_tsx_source" in body) {
61+
contentSource = body.template_tsx_source;
62+
} else if ("template_id" in body) {
63+
const template = templateList.get(body.template_id);
64+
if (!template) {
65+
throw new StatusError(400, "No template found with given id");
66+
}
67+
contentSource = template.tsxSource;
68+
} else {
69+
throw new KnownErrors.SchemaError("Either template_id or template_tsx_source must be provided");
4470
}
45-
const templateList = new Map(Object.entries(tenancy.config.emails.templates));
46-
const themeSource = body.theme_id === undefined ? body.theme_tsx_source! : getEmailThemeForTemplate(tenancy, body.theme_id);
47-
const templateSource = body.template_id ? templateList.get(body.template_id)?.tsxSource : body.template_tsx_source;
4871

49-
if (!templateSource) {
50-
throw new StatusError(400, "No template found with given id");
51-
}
5272
const result = await renderEmailWithTemplate(
53-
templateSource,
73+
contentSource,
5474
themeSource,
5575
{
5676
project: { displayName: tenancy.project.display_name },
5777
previewMode: true,
5878
},
5979
);
60-
if ("error" in result) {
80+
if (result.status === "error") {
6181
throw new KnownErrors.EmailRenderingError(result.error);
6282
}
6383
return {

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

Lines changed: 76 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,50 @@ import { getEmailConfig, sendEmail } from "@/lib/emails";
33
import { getNotificationCategoryByName, hasNotificationEnabled } from "@/lib/notification-categories";
44
import { getPrismaClientForTenancy } from "@/prisma-client";
55
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
6-
import { KnownErrors } from "@stackframe/stack-shared";
7-
import { adaptSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields";
6+
import { adaptSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
87
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
98
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
109
import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler";
10+
import { KnownErrors } from "@stackframe/stack-shared";
11+
import { getEmailDraft, themeModeToTemplateThemeId } from "@/lib/email-drafts";
1112

1213
type UserResult = {
1314
user_id: string,
1415
user_email?: string,
1516
};
1617

18+
const bodyBase = yupObject({
19+
user_ids: yupArray(yupString().defined()).optional(),
20+
all_users: yupBoolean().oneOf([true]).optional(),
21+
subject: yupString().optional(),
22+
notification_category_name: yupString().optional(),
23+
theme_id: templateThemeIdSchema.nullable().meta({
24+
openapiField: { description: "The theme to use for the email. If not specified, the default theme will be used." }
25+
}),
26+
});
27+
1728
export const POST = createSmartRouteHandler({
1829
metadata: {
1930
summary: "Send email",
20-
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.",
21-
tags: ["Emails"],
31+
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.",
2232
},
2333
request: yupObject({
2434
auth: yupObject({
2535
type: serverOrHigherAuthTypeSchema,
2636
tenancy: adaptSchema.defined(),
2737
}).defined(),
28-
body: yupObject({
29-
user_ids: yupArray(yupString().defined()).defined(),
30-
theme_id: templateThemeIdSchema.nullable().meta({
31-
openapiField: { description: "The theme to use for the email. If not specified, the default theme will be used." }
32-
}),
33-
html: yupString().optional(),
34-
subject: yupString().optional(),
35-
notification_category_name: yupString().optional(),
36-
template_id: yupString().optional(),
37-
variables: yupRecord(yupString(), yupMixed()).optional(),
38-
}),
38+
body: yupUnion(
39+
bodyBase.concat(yupObject({
40+
html: yupString().defined(),
41+
})),
42+
bodyBase.concat(yupObject({
43+
template_id: yupString().uuid().defined(),
44+
variables: yupRecord(yupString(), yupMixed()).optional(),
45+
})),
46+
bodyBase.concat(yupObject({
47+
draft_id: yupString().defined(),
48+
})),
49+
).defined(),
3950
method: yupString().oneOf(["POST"]).defined(),
4051
}),
4152
response: yupObject({
@@ -55,80 +66,86 @@ export const POST = createSmartRouteHandler({
5566
if (auth.tenancy.config.emails.server.isShared) {
5667
throw new KnownErrors.RequiresCustomEmailServer();
5768
}
58-
if (!body.html && !body.template_id) {
59-
throw new KnownErrors.SchemaError("Either html or template_id must be provided");
60-
}
61-
if (body.html && (body.template_id || body.variables)) {
62-
throw new KnownErrors.SchemaError("If html is provided, cannot provide template_id or variables");
69+
if ((body.user_ids && body.all_users) || (!body.user_ids && !body.all_users)) {
70+
throw new KnownErrors.SchemaError("Exactly one of user_ids or all_users must be provided");
6371
}
72+
73+
const prisma = await getPrismaClientForTenancy(auth.tenancy);
6474
const emailConfig = await getEmailConfig(auth.tenancy);
6575
const defaultNotificationCategory = getNotificationCategoryByName(body.notification_category_name ?? "Transactional") ?? throwErr(400, "Notification category not found with given name");
66-
const themeSource = getEmailThemeForTemplate(auth.tenancy, body.theme_id);
76+
let themeSource = getEmailThemeForTemplate(auth.tenancy, body.theme_id);
77+
const variables = "variables" in body ? body.variables : undefined;
6778
const templates = new Map(Object.entries(auth.tenancy.config.emails.templates));
68-
const templateSource = body.template_id
69-
? (templates.get(body.template_id)?.tsxSource ?? throwErr(400, "Template not found with given id"))
70-
: createTemplateComponentFromHtml(body.html!);
79+
let templateSource: string;
80+
if ("template_id" in body) {
81+
templateSource = templates.get(body.template_id)?.tsxSource ?? throwErr(400, "No template found with given template_id");
82+
} else if ("html" in body) {
83+
templateSource = createTemplateComponentFromHtml(body.html);
84+
} else if ("draft_id" in body) {
85+
const draft = await getEmailDraft(prisma, auth.tenancy.id, body.draft_id) ?? throwErr(400, "No draft found with given draft_id");
86+
const theme_id = themeModeToTemplateThemeId(draft.themeMode, draft.themeId);
87+
templateSource = draft.tsxSource;
88+
if (body.theme_id === undefined) {
89+
themeSource = getEmailThemeForTemplate(auth.tenancy, theme_id);
90+
}
91+
} else {
92+
throw new KnownErrors.SchemaError("Either template_id, html, or draft_id must be provided");
93+
}
7194

72-
const prisma = await getPrismaClientForTenancy(auth.tenancy);
7395
const users = await prisma.projectUser.findMany({
7496
where: {
7597
tenancyId: auth.tenancy.id,
7698
projectUserId: {
77-
in: body.user_ids,
99+
in: body.user_ids
78100
},
79101
},
80102
include: {
81103
contactChannels: true,
82104
},
83105
});
84-
const missingUserIds = body.user_ids.filter(userId => !users.some(user => user.projectUserId === userId));
85-
if (missingUserIds.length > 0) {
106+
const missingUserIds = body.user_ids?.filter(userId => !users.some(user => user.projectUserId === userId));
107+
if (missingUserIds && missingUserIds.length > 0) {
86108
throw new KnownErrors.UserIdDoesNotExist(missingUserIds[0]);
87109
}
88110
const userMap = new Map(users.map(user => [user.projectUserId, user]));
89111
const userSendErrors: Map<string, string> = new Map();
90112
const userPrimaryEmails: Map<string, string> = new Map();
91113

92-
for (const userId of body.user_ids) {
93-
const user = userMap.get(userId);
94-
if (!user) {
95-
userSendErrors.set(userId, "User not found");
96-
continue;
97-
}
114+
for (const user of userMap.values()) {
98115
const primaryEmail = user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value;
99116
if (!primaryEmail) {
100-
userSendErrors.set(userId, "User does not have a primary email");
117+
userSendErrors.set(user.projectUserId, "User does not have a primary email");
101118
continue;
102119
}
103-
userPrimaryEmails.set(userId, primaryEmail);
120+
userPrimaryEmails.set(user.projectUserId, primaryEmail);
104121

105122
let currentNotificationCategory = defaultNotificationCategory;
106-
if (body.template_id) {
123+
if (!("html" in body)) {
107124
// We have to render email twice in this case, first pass is to get the notification category
108125
const renderedTemplateFirstPass = await renderEmailWithTemplate(
109126
templateSource,
110127
themeSource,
111128
{
112129
user: { displayName: user.displayName },
113130
project: { displayName: auth.tenancy.project.display_name },
114-
variables: body.variables,
131+
variables,
115132
},
116133
);
117134
if (renderedTemplateFirstPass.status === "error") {
118-
userSendErrors.set(userId, "There was an error rendering the email");
135+
userSendErrors.set(user.projectUserId, "There was an error rendering the email");
119136
continue;
120137
}
121138
const notificationCategory = getNotificationCategoryByName(renderedTemplateFirstPass.data.notificationCategory ?? "");
122139
if (!notificationCategory) {
123-
userSendErrors.set(userId, "Notification category not found with given name");
140+
userSendErrors.set(user.projectUserId, "Notification category not found with given name");
124141
continue;
125142
}
126143
currentNotificationCategory = notificationCategory;
127144
}
128145

129146
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy, user.projectUserId, currentNotificationCategory.id);
130147
if (!isNotificationEnabled) {
131-
userSendErrors.set(userId, "User has disabled notifications for this category");
148+
userSendErrors.set(user.projectUserId, "User has disabled notifications for this category");
132149
continue;
133150
}
134151

@@ -155,12 +172,12 @@ export const POST = createSmartRouteHandler({
155172
{
156173
user: { displayName: user.displayName },
157174
project: { displayName: auth.tenancy.project.display_name },
158-
variables: body.variables,
175+
variables,
159176
unsubscribeLink,
160177
},
161178
);
162179
if (renderedEmail.status === "error") {
163-
userSendErrors.set(userId, "There was an error rendering the email");
180+
userSendErrors.set(user.projectUserId, "There was an error rendering the email");
164181
continue;
165182
}
166183
try {
@@ -173,15 +190,27 @@ export const POST = createSmartRouteHandler({
173190
text: renderedEmail.data.text,
174191
});
175192
} catch {
176-
userSendErrors.set(userId, "Failed to send email");
193+
userSendErrors.set(user.projectUserId, "Failed to send email");
177194
}
178195
}
179196

180-
const results: UserResult[] = body.user_ids.map((userId) => ({
181-
user_id: userId,
182-
user_email: userPrimaryEmails.get(userId),
197+
const results: UserResult[] = Array.from(userMap.values()).map((user) => ({
198+
user_id: user.projectUserId,
199+
user_email: userPrimaryEmails.get(user.projectUserId) ?? user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value,
183200
}));
184201

202+
if ("draft_id" in body) {
203+
await prisma.emailDraft.update({
204+
where: {
205+
tenancyId_id: {
206+
tenancyId: auth.tenancy.id,
207+
id: body.draft_id,
208+
},
209+
},
210+
data: { sentAt: new Date() },
211+
});
212+
}
213+
185214
return {
186215
statusCode: 200,
187216
bodyType: 'json',

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const POST = createSmartRouteHandler({
3737
threadId: yupString().defined(),
3838
}),
3939
body: yupObject({
40-
context_type: yupString().oneOf(["email-theme", "email-template"]).defined(),
40+
context_type: yupString().oneOf(["email-theme", "email-template", "email-draft"]).defined(),
4141
messages: yupArray(yupObject({
4242
role: yupString().oneOf(["user", "assistant", "tool"]).defined(),
4343
content: yupMixed().defined(),

0 commit comments

Comments
 (0)