Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@prisma/adapter-pg": "^6.12.0",
"@prisma/client": "^6.12.0",
"@prisma/instrumentation": "^6.12.0",
"@react-email/render": "^1.2.1",
"@sentry/nextjs": "^10.11.0",
"@simplewebauthn/server": "^11.0.0",
"@stackframe/stack": "workspace:*",
Expand All @@ -85,6 +86,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
189 changes: 121 additions & 68 deletions apps/backend/src/app/api/latest/emails/send-email/route.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { createTemplateComponentFromHtml, getEmailThemeForTemplate, renderEmailWithTemplate } from "@/lib/email-rendering";
import { getEmailConfig, sendEmail } from "@/lib/emails";
import { getEmailDraft, themeModeToTemplateThemeId } from "@/lib/email-drafts";
import { createTemplateComponentFromHtml, getEmailThemeForTemplate, renderEmailsWithTemplateBatched } from "@/lib/email-rendering";
import { getEmailConfig, sendEmail, sendEmailResendBatched } from "@/lib/emails";
import { getNotificationCategoryByName, hasNotificationEnabled } from "@/lib/notification-categories";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
import { getChunks } from "@stackframe/stack-shared/dist/utils/arrays";
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,
Expand Down Expand Up @@ -108,96 +110,147 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.UserIdDoesNotExist(missingUserIds[0]);
}
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 user of userMap.values()) {
const primaryEmail = user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value;
if (!primaryEmail) {
userSendErrors.set(user.projectUserId, "User does not have a primary email");
continue;
if (primaryEmail) {
userPrimaryEmails.set(user.projectUserId, primaryEmail);
}
userPrimaryEmails.set(user.projectUserId, primaryEmail);
}

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

const BATCH_SIZE = 100;

let currentNotificationCategory = defaultNotificationCategory;
const resolveCategoriesForUsers = async (usersWithPrimary: typeof users) => {
const currentCategories = new Map<string, ReturnType<typeof getNotificationCategoryByName>>();
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,
},
);
if (renderedTemplateFirstPass.status === "error") {
userSendErrors.set(user.projectUserId, "There was an error rendering the email");
continue;
const firstPassInputs = usersWithPrimary.map((user) => ({
user: { displayName: user.displayName },
project: { displayName: auth.tenancy.project.display_name },
variables,
}));

const chunks = getChunks(firstPassInputs, BATCH_SIZE);
const userChunks = getChunks(usersWithPrimary, BATCH_SIZE);
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const correspondingUsers = userChunks[i];
const rendered = await renderEmailsWithTemplateBatched(templateSource, themeSource, chunk);
if (rendered.status === "error") {
continue;
}
const outputs = rendered.data;
for (let j = 0; j < outputs.length; j++) {
const output = outputs[j];
const user = correspondingUsers[j];
const category = getNotificationCategoryByName(output.notificationCategory ?? "");
currentCategories.set(user.projectUserId, category);
}
}
const notificationCategory = getNotificationCategoryByName(renderedTemplateFirstPass.data.notificationCategory ?? "");
if (!notificationCategory) {
userSendErrors.set(user.projectUserId, "Notification category not found with given name");
continue;
} else {
for (const user of usersWithPrimary) {
currentCategories.set(user.projectUserId, defaultNotificationCategory);
}
currentNotificationCategory = notificationCategory;
}
return currentCategories;
};

const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy, user.projectUserId, currentNotificationCategory.id);
if (!isNotificationEnabled) {
userSendErrors.set(user.projectUserId, "User has disabled notifications for this category");
continue;
}
const getAllowedUsersWithUnsub = async (usersWithPrimary: typeof users, currentCategories: Map<string, ReturnType<typeof getNotificationCategoryByName>>) => {
const allowed = await Promise.all(usersWithPrimary.map(async (user) => {
const category = currentCategories.get(user.projectUserId) ?? defaultNotificationCategory;
const enabled = await hasNotificationEnabled(auth.tenancy, user.projectUserId, category.id);
return enabled ? { user, category } : null;
})).then(r => r.filter((x): x is { user: typeof users[number], category: NonNullable<ReturnType<typeof getNotificationCategoryByName>> } => Boolean(x)));

let unsubscribeLink: string | undefined = undefined;
if (currentNotificationCategory.can_disable) {
const unsubLinks = new Map<string, string | undefined>();
await Promise.all(allowed.map(async ({ user, category }) => {
if (!category.can_disable) {
unsubLinks.set(user.projectUserId, undefined);
return;
}
const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({
tenancy: auth.tenancy,
method: {},
data: {
user_id: user.projectUserId,
notification_category_id: currentNotificationCategory.id,
notification_category_id: category.id,
},
callbackUrl: undefined
});
const unsubUrl = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
unsubUrl.pathname = "/api/v1/emails/unsubscribe-link";
unsubUrl.searchParams.set("code", code);
unsubscribeLink = unsubUrl.toString();
}
unsubLinks.set(user.projectUserId, unsubUrl.toString());
}));
return { allowed, unsubLinks };
};

const renderedEmail = await renderEmailWithTemplate(
templateSource,
themeSource,
{
user: { displayName: user.displayName },
project: { displayName: auth.tenancy.project.display_name },
variables,
unsubscribeLink,
},
);
if (renderedEmail.status === "error") {
userSendErrors.set(user.projectUserId, "There was an error rendering the email");
continue;
const renderAndSendBatches = async (finalUsers: typeof users, unsubLinks: Map<string, string | undefined>) => {
const finalInputs = finalUsers.map((user) => ({
user: { displayName: user.displayName },
project: { displayName: auth.tenancy.project.display_name },
variables,
unsubscribeLink: unsubLinks.get(user.projectUserId),
}));

const inputChunks = getChunks(finalInputs, BATCH_SIZE);
const userChunks = getChunks(finalUsers, BATCH_SIZE);

for (let i = 0; i < inputChunks.length; i++) {
const chunk = inputChunks[i];
const correspondingUsers = userChunks[i];
const rendered = await renderEmailsWithTemplateBatched(templateSource, themeSource, chunk);
if (rendered.status === "error") {
continue;
}
const outputs = rendered.data;
const emailOptions = outputs.map((output, idx) => {
const user = correspondingUsers[idx];
const email = userPrimaryEmails.get(user.projectUserId);
if (!email) return null;
return {
tenancyId: auth.tenancy.id,
emailConfig,
to: email,
subject: body.subject ?? output.subject ?? "",
html: output.html,
text: output.text,
};
}).filter((option): option is NonNullable<typeof option> => Boolean(option));

if (emailConfig.host === "smtp.resend.com") {
await sendEmailResendBatched(emailConfig.password, emailOptions);
} else {
await Promise.allSettled(emailOptions.map(option => sendEmail(option)));
}
}
try {
await sendEmail({
tenancyId: auth.tenancy.id,
emailConfig,
to: primaryEmail,
subject: body.subject ?? renderedEmail.data.subject ?? "",
html: renderedEmail.data.html,
text: renderedEmail.data.text,
};

runAsynchronouslyAndWaitUntil((async () => {
const usersArray = Array.from(userMap.values());

const usersWithPrimary = usersArray.filter(u => userPrimaryEmails.has(u.projectUserId));
const currentCategories = await resolveCategoriesForUsers(usersWithPrimary);
const { allowed, unsubLinks } = await getAllowedUsersWithUnsub(usersWithPrimary, currentCategories);
const finalUsers = allowed.map(({ user }) => user);
await renderAndSendBatches(finalUsers, unsubLinks);

if ("draft_id" in body) {
await prisma.emailDraft.update({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,
id: body.draft_id,
},
},
data: { sentAt: new Date() },
});
} catch {
userSendErrors.set(user.projectUserId, "Failed to send email");
}
}

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,
}));
})());

if ("draft_id" in body) {
await prisma.emailDraft.update({
Expand Down
81 changes: 81 additions & 0 deletions apps/backend/src/lib/email-rendering.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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 { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env';

export function getActiveEmailTheme(tenancy: Tenancy) {
const themeList = tenancy.config.emails.themes;
Expand Down Expand Up @@ -125,6 +126,86 @@ export async function renderEmailWithTemplate(
return Result.ok(output.data.result as { html: string, text: string, subject: string, notificationCategory: string });
}

export async function renderEmailsWithTemplateBatched(
templateOrDraftComponent: string,
themeComponent: string,
inputs: Array<{
user: { displayName: string | null },
project: { displayName: string },
variables?: Record<string, any>,
unsubscribeLink?: string,
}>,
): Promise<Result<Array<{ html: string, text: string, subject?: string, notificationCategory?: string }>, string>> {
const apiKey = getEnvVariable("STACK_FREESTYLE_API_KEY");

const serializedInputs = JSON.stringify(inputs);

const result = await bundleJavaScript({
"/utils.tsx": findComponentValueUtil,
"/theme.tsx": themeComponent,
"/template.tsx": templateOrDraftComponent,
"/render.tsx": deindent`
import { configure } from "arktype/config"
configure({ onUndeclaredKey: "delete" })
import React from 'react';
import { render } from '@react-email/components';
import { type } from "arktype";
import { findComponentValue } from "./utils.tsx";
import * as TemplateModule from "./template.tsx";
const { variablesSchema, EmailTemplate } = TemplateModule;
import { EmailTheme } from "./theme.tsx";

export const renderAll = async () => {
const inputs = ${serializedInputs}
const renderOne = async (input: any) => {
const variables = variablesSchema ? variablesSchema({
...(input.variables || {}),
}) : {};
if (variables instanceof type.errors) {
throw new Error(variables.summary)
}
const EmailTemplateWithProps = <EmailTemplate variables={variables} user={input.user} project={input.project} />;
const Email = <EmailTheme unsubscribeLink={input.unsubscribeLink}>
{ EmailTemplateWithProps }
</EmailTheme>;
return {
html: await render(Email),
text: await render(Email, { plainText: true }),
subject: findComponentValue(EmailTemplateWithProps, "Subject"),
notificationCategory: findComponentValue(EmailTemplateWithProps, "NotificationCategory"),
};
};

return await Promise.all(inputs.map(renderOne));
}
`,
"/entry.js": deindent`
import { renderAll } from "./render.tsx";
export default renderAll;
`,
}, {
keepAsImports: ['arktype', 'react', 'react/jsx-runtime', '@react-email/components'],
externalPackages: { '@stackframe/emails': stackframeEmailsPackage },
format: 'esm',
sourcemap: false,
});
if (result.status === "error") {
return Result.error(result.error);
}

const freestyle = new Freestyle({ apiKey });
const nodeModules = {
"react": "19.1.1",
"@react-email/components": "0.1.1",
"arktype": "2.1.20",
};
const executeResult = await freestyle.executeScript(result.data, { nodeModules });
if (executeResult.status === "error") {
return Result.error(executeResult.error);
}
return Result.ok(executeResult.data.result as Array<{ html: string, text: string, subject?: string, notificationCategory?: string }>);
}

const findComponentValueUtil = `import React from 'react';
export function findComponentValue(element, targetStackComponent) {
const matches = [];
Expand Down
Loading