Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"posthog-node": "^4.1.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"resend": "^6.0.1",
"semver": "^7.6.3",
"sharp": "^0.32.6",
"stripe": "^18.3.0",
Expand Down
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")
);
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?

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(),
}),
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);
}
return {
Expand Down
Loading
Loading