Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
);
Comment thread
BilalG1 marked this conversation as resolved.
23 changes: 23 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,29 @@ model SentEmail {
@@id([tenancyId, id])
}

model EmailDraft {
tenancyId String @db.Uuid

id String @default(uuid()) @db.Uuid

displayName String
themeMode DraftThemeMode @default(PROJECT_DEFAULT)
themeId String?
tsxSource String
sentAt DateTime?

Comment thread
BilalG1 marked this conversation as resolved.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@id([tenancyId, id])
}

enum DraftThemeMode {
PROJECT_DEFAULT
NONE
CUSTOM
}

model CliAuthAttempt {
tenancyId String @db.Uuid

Expand Down
64 changes: 42 additions & 22 deletions apps/backend/src/app/api/latest/emails/render-email/route.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getEmailThemeForTemplate, renderEmailWithTemplate } from "@/lib/email-rendering";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { adaptSchema, templateThemeIdSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, templateThemeIdSchema, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";

export const POST = createSmartRouteHandler({
Expand All @@ -15,12 +15,24 @@ export const POST = createSmartRouteHandler({
type: yupString().oneOf(["admin"]).defined(),
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
theme_id: templateThemeIdSchema.nullable(),
theme_tsx_source: yupString(),
template_id: yupString(),
template_tsx_source: yupString(),
}),
body: yupUnion(
yupObject({
template_id: yupString().uuid().defined(),
theme_id: templateThemeIdSchema,
}),
yupObject({
template_id: yupString().uuid().defined(),
theme_tsx_source: yupString().defined(),
}),
yupObject({
template_tsx_source: yupString().defined(),
theme_id: templateThemeIdSchema,
}),
yupObject({
template_tsx_source: yupString().defined(),
theme_tsx_source: yupString().defined(),
}),
).defined(),
Comment thread
BilalG1 marked this conversation as resolved.
Comment thread
BilalG1 marked this conversation as resolved.
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
Expand All @@ -32,32 +44,40 @@ export const POST = createSmartRouteHandler({
}).defined(),
}),
async handler({ body, auth: { tenancy } }) {
if ((body.theme_id === undefined && !body.theme_tsx_source) || (body.theme_id && body.theme_tsx_source)) {
throw new StatusError(400, "Exactly one of theme_id or theme_tsx_source must be provided");
}
if ((!body.template_id && !body.template_tsx_source) || (body.template_id && body.template_tsx_source)) {
throw new StatusError(400, "Exactly one of template_id or template_tsx_source must be provided");
const templateList = new Map(Object.entries(tenancy.config.emails.templates));
const themeList = new Map(Object.entries(tenancy.config.emails.themes));
let themeSource: string;
if ("theme_tsx_source" in body) {
themeSource = body.theme_tsx_source;
} else {
if (typeof body.theme_id === "string" && !themeList.has(body.theme_id)) {
throw new StatusError(400, "No theme found with given id");
}
themeSource = getEmailThemeForTemplate(tenancy, body.theme_id);
}

if (body.theme_id && !(body.theme_id in tenancy.config.emails.themes)) {
throw new StatusError(400, "No theme found with given id");
let contentSource: string;
if ("template_tsx_source" in body) {
contentSource = body.template_tsx_source;
} else if ("template_id" in body) {
const template = templateList.get(body.template_id);
if (!template) {
throw new StatusError(400, "No template found with given id");
}
contentSource = template.tsxSource;
} else {
throw new KnownErrors.SchemaError("Either template_id or template_tsx_source must be provided");
}
const templateList = new Map(Object.entries(tenancy.config.emails.templates));
const themeSource = body.theme_id === undefined ? body.theme_tsx_source! : getEmailThemeForTemplate(tenancy, body.theme_id);
const templateSource = body.template_id ? templateList.get(body.template_id)?.tsxSource : body.template_tsx_source;

if (!templateSource) {
throw new StatusError(400, "No template found with given id");
}
const result = await renderEmailWithTemplate(
templateSource,
contentSource,
themeSource,
{
project: { displayName: tenancy.project.display_name },
previewMode: true,
},
);
if ("error" in result) {
if (result.status === "error") {
throw new KnownErrors.EmailRenderingError(result.error);
}
Comment thread
BilalG1 marked this conversation as resolved.
return {
Expand Down
123 changes: 76 additions & 47 deletions apps/backend/src/app/api/latest/emails/send-email/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,50 @@ import { getEmailConfig, sendEmail } from "@/lib/emails";
import { getNotificationCategoryByName, hasNotificationEnabled } from "@/lib/notification-categories";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { getEmailDraft, themeModeToTemplateThemeId } from "@/lib/email-drafts";

type UserResult = {
user_id: string,
user_email?: string,
};
Comment thread
BilalG1 marked this conversation as resolved.

const bodyBase = yupObject({
user_ids: yupArray(yupString().defined()).optional(),
all_users: yupBoolean().oneOf([true]).optional(),
subject: yupString().optional(),
notification_category_name: yupString().optional(),
theme_id: templateThemeIdSchema.nullable().meta({
openapiField: { description: "The theme to use for the email. If not specified, the default theme will be used." }
}),
});

export const POST = createSmartRouteHandler({
metadata: {
summary: "Send email",
description: "Send an email to a list of users. The content field should contain either {html, subject, notification_category_name} for HTML emails or {template_id, variables} for template-based emails.",
tags: ["Emails"],
description: "Send an email to a list of users. The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.",
},
request: yupObject({
auth: yupObject({
type: serverOrHigherAuthTypeSchema,
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
user_ids: yupArray(yupString().defined()).defined(),
theme_id: templateThemeIdSchema.nullable().meta({
openapiField: { description: "The theme to use for the email. If not specified, the default theme will be used." }
}),
html: yupString().optional(),
subject: yupString().optional(),
notification_category_name: yupString().optional(),
template_id: yupString().optional(),
variables: yupRecord(yupString(), yupMixed()).optional(),
}),
body: yupUnion(
bodyBase.concat(yupObject({
html: yupString().defined(),
})),
bodyBase.concat(yupObject({
template_id: yupString().uuid().defined(),
variables: yupRecord(yupString(), yupMixed()).optional(),
})),
bodyBase.concat(yupObject({
draft_id: yupString().defined(),
})),
).defined(),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
Expand All @@ -55,80 +66,86 @@ 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,
},
});
Comment thread
BilalG1 marked this conversation as resolved.
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]);
}
Comment thread
BilalG1 marked this conversation as resolved.
const userMap = new Map(users.map(user => [user.projectUserId, user]));
const userSendErrors: Map<string, string> = new Map();
const userPrimaryEmails: Map<string, string> = 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,
themeSource,
{
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;
}

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;
}

Expand All @@ -155,12 +172,12 @@ export const POST = createSmartRouteHandler({
{
user: { displayName: user.displayName },
project: { displayName: auth.tenancy.project.display_name },
variables: body.variables,
variables,
unsubscribeLink,
},
);
if (renderedEmail.status === "error") {
userSendErrors.set(userId, "There was an error rendering the email");
userSendErrors.set(user.projectUserId, "There was an error rendering the email");
continue;
}
try {
Expand All @@ -173,15 +190,27 @@ export const POST = createSmartRouteHandler({
text: renderedEmail.data.text,
});
} catch {
userSendErrors.set(userId, "Failed to send email");
userSendErrors.set(user.projectUserId, "Failed to send email");
}
}

const results: UserResult[] = body.user_ids.map((userId) => ({
user_id: userId,
user_email: userPrimaryEmails.get(userId),
const results: UserResult[] = Array.from(userMap.values()).map((user) => ({
user_id: user.projectUserId,
user_email: userPrimaryEmails.get(user.projectUserId) ?? user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value,
}));
Comment thread
BilalG1 marked this conversation as resolved.

if ("draft_id" in body) {
await prisma.emailDraft.update({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,
id: body.draft_id,
},
},
data: { sentAt: new Date() },
});
}
Comment thread
BilalG1 marked this conversation as resolved.

return {
statusCode: 200,
bodyType: 'json',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading