Skip to content

Commit cca18bf

Browse files
authored
Email themes (stack-auth#743)
1 parent 7b44903 commit cca18bf

File tree

36 files changed

+5690
-419
lines changed

36 files changed

+5690
-419
lines changed

apps/backend/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,4 @@ STACK_ACCESS_TOKEN_EXPIRATION_TIME=# enter the expiration time for the access to
5353
STACK_SETUP_ADMIN_GITHUB_ID=# enter the account ID of the admin user here, and after running the seed script they will be able to access the internal project in the Stack dashboard. Optional, don't specify it for default value
5454
OTEL_EXPORTER_OTLP_ENDPOINT=# enter the OpenTelemetry endpoint here. Optional, default is `http://localhost:4318`
5555
STACK_INTEGRATION_CLIENTS_CONFIG=# a list of oidc-provider clients for integrations. If not provided, disables integrations
56+
STACK_FREESTYLE_API_KEY=# enter you freestyle.sh api key

apps/backend/.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@ STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING=yes
4343

4444
STACK_INTEGRATION_CLIENTS_CONFIG=[{"client_id": "neon-local", "client_secret": "neon-local-secret", "id_token_signed_response_alg": "ES256", "redirect_uris": ["http://localhost:30000/api/v2/identity/authorize", "http://localhost:30000/api/v2/auth/authorize"]}, {"client_id": "custom-local", "client_secret": "custom-local-secret", "id_token_signed_response_alg": "ES256", "redirect_uris": ["http://localhost:30000/api/v2/identity/authorize", "http://localhost:30000/api/v2/auth/authorize"]}]
4545
CRON_SECRET=mock_cron_secret
46+
STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key

apps/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"@vercel/otel": "^1.10.4",
6161
"bcrypt": "^5.1.1",
6262
"dotenv-cli": "^7.3.0",
63+
"freestyle-sandboxes": "^0.0.92",
6364
"jose": "^5.2.2",
6465
"json-diff": "^1.0.6",
6566
"next": "15.2.3",
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { EMAIL_THEMES, renderEmailWithTheme } from "@/lib/email-themes";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
4+
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
5+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
6+
import { captureError, StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
7+
8+
9+
export const POST = createSmartRouteHandler({
10+
metadata: {
11+
summary: "Render email theme",
12+
description: "Renders HTML content using the specified email theme",
13+
tags: ["Emails"],
14+
},
15+
request: yupObject({
16+
auth: yupObject({
17+
type: yupString().oneOf(["admin"]).defined(),
18+
}).nullable(),
19+
body: yupObject({
20+
theme: yupString().oneOf(Object.keys(EMAIL_THEMES) as (keyof typeof EMAIL_THEMES)[]).defined(),
21+
preview_html: yupString().defined(),
22+
}),
23+
}),
24+
response: yupObject({
25+
statusCode: yupNumber().oneOf([200]).defined(),
26+
bodyType: yupString().oneOf(["json"]).defined(),
27+
body: yupObject({
28+
html: yupString().defined(),
29+
}).defined(),
30+
}),
31+
async handler({ body }) {
32+
if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) {
33+
throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
34+
}
35+
const result = await renderEmailWithTheme(body.preview_html, body.theme);
36+
if ("error" in result) {
37+
captureError('render-email', new StackAssertionError("Error rendering email with theme", { result }));
38+
throw new KnownErrors.EmailRenderingError(result.error);
39+
}
40+
return {
41+
statusCode: 200,
42+
bodyType: "json",
43+
body: {
44+
html: result.html,
45+
},
46+
};
47+
},
48+
});
Lines changed: 96 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1+
import { renderEmailWithTheme } from "@/lib/email-themes";
12
import { getEmailConfig, sendEmail } from "@/lib/emails";
23
import { getNotificationCategoryByName, hasNotificationEnabled } from "@/lib/notification-categories";
4+
import { prismaClient } from "@/prisma-client";
35
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4-
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
6+
import { adaptSchema, serverOrHigherAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
7+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
58
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
6-
import { getUser } from "../../users/crud";
79
import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler";
8-
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
10+
11+
type UserResult = {
12+
user_id: string,
13+
user_email?: string,
14+
success: boolean,
15+
error?: string,
16+
};
917

1018
export const POST = createSmartRouteHandler({
1119
metadata: {
@@ -17,7 +25,7 @@ export const POST = createSmartRouteHandler({
1725
tenancy: adaptSchema.defined(),
1826
}).defined(),
1927
body: yupObject({
20-
user_id: yupString().defined(),
28+
user_ids: yupArray(yupString().defined()).defined(),
2129
html: yupString().defined(),
2230
subject: yupString().defined(),
2331
notification_category_name: yupString().defined(),
@@ -28,60 +36,108 @@ export const POST = createSmartRouteHandler({
2836
statusCode: yupNumber().oneOf([200]).defined(),
2937
bodyType: yupString().oneOf(["json"]).defined(),
3038
body: yupObject({
31-
user_email: yupString().defined(),
39+
results: yupArray(yupObject({
40+
user_id: yupString().defined(),
41+
user_email: yupString().optional(),
42+
success: yupBoolean().defined(),
43+
error: yupString().optional(),
44+
})).defined(),
3245
}).defined(),
3346
}),
3447
handler: async ({ body, auth }) => {
48+
if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) {
49+
throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
50+
}
3551
if (auth.tenancy.config.email_config.type === "shared") {
3652
throw new StatusError(400, "Cannot send custom emails when using shared email config");
3753
}
38-
const user = await getUser({ userId: body.user_id, tenancyId: auth.tenancy.id });
39-
if (!user) {
40-
throw new StatusError(404, "User not found");
41-
}
42-
if (!user.primary_email) {
43-
throw new StatusError(400, "User does not have a primary email");
44-
}
54+
const emailConfig = await getEmailConfig(auth.tenancy);
4555
const notificationCategory = getNotificationCategoryByName(body.notification_category_name);
4656
if (!notificationCategory) {
4757
throw new StatusError(404, "Notification category not found");
4858
}
49-
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy.id, user.id, notificationCategory.id);
50-
if (!isNotificationEnabled) {
51-
throw new StatusError(400, "User has disabled notifications for this category");
52-
}
5359

54-
let html = body.html;
55-
if (notificationCategory.can_disable) {
56-
const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({
57-
tenancy: auth.tenancy,
58-
method: {},
59-
data: {
60-
user_id: user.id,
61-
notification_category_id: notificationCategory.id,
60+
const users = await prismaClient.projectUser.findMany({
61+
where: {
62+
tenancyId: auth.tenancy.id,
63+
projectUserId: {
64+
in: body.user_ids,
6265
},
63-
callbackUrl: undefined
64-
});
65-
const unsubscribeLink = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
66-
unsubscribeLink.pathname = "/api/v1/emails/unsubscribe-link";
67-
unsubscribeLink.searchParams.set("code", code);
68-
html += `<br /><a href="${unsubscribeLink.toString()}">Click here to unsubscribe</a>`;
66+
},
67+
include: {
68+
contactChannels: true,
69+
},
70+
});
71+
const userMap = new Map(users.map(user => [user.projectUserId, user]));
72+
const userSendErrors: Map<string, string> = new Map();
73+
const userPrimaryEmails: Map<string, string> = new Map();
74+
75+
for (const userId of body.user_ids) {
76+
const user = userMap.get(userId);
77+
if (!user) {
78+
userSendErrors.set(userId, "User not found");
79+
continue;
80+
}
81+
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy.id, user.projectUserId, notificationCategory.id);
82+
if (!isNotificationEnabled) {
83+
userSendErrors.set(userId, "User has disabled notifications for this category");
84+
continue;
85+
}
86+
const primaryEmail = user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value;
87+
if (!primaryEmail) {
88+
userSendErrors.set(userId, "User does not have a primary email");
89+
continue;
90+
}
91+
userPrimaryEmails.set(userId, primaryEmail);
92+
93+
let unsubscribeLink: string | null = null;
94+
if (notificationCategory.can_disable) {
95+
const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({
96+
tenancy: auth.tenancy,
97+
method: {},
98+
data: {
99+
user_id: user.projectUserId,
100+
notification_category_id: notificationCategory.id,
101+
},
102+
callbackUrl: undefined
103+
});
104+
const unsubUrl = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
105+
unsubUrl.pathname = "/api/v1/emails/unsubscribe-link";
106+
unsubUrl.searchParams.set("code", code);
107+
unsubscribeLink = unsubUrl.toString();
108+
}
109+
110+
const renderedEmail = await renderEmailWithTheme(body.html, auth.tenancy.config.email_theme, unsubscribeLink);
111+
if ("error" in renderedEmail) {
112+
userSendErrors.set(userId, "There was an error rendering the email");
113+
continue;
114+
}
115+
116+
try {
117+
await sendEmail({
118+
tenancyId: auth.tenancy.id,
119+
emailConfig,
120+
to: primaryEmail,
121+
subject: body.subject,
122+
html: renderedEmail.html,
123+
text: renderedEmail.text,
124+
});
125+
} catch {
126+
userSendErrors.set(userId, "Failed to send email");
127+
}
69128
}
70129

71-
await sendEmail({
72-
tenancyId: auth.tenancy.id,
73-
emailConfig: await getEmailConfig(auth.tenancy),
74-
to: user.primary_email,
75-
subject: body.subject,
76-
html,
77-
});
130+
const results: UserResult[] = body.user_ids.map((userId) => ({
131+
user_id: userId,
132+
user_email: userPrimaryEmails.get(userId),
133+
success: !userSendErrors.has(userId),
134+
error: userSendErrors.get(userId),
135+
}));
78136

79137
return {
80138
statusCode: 200,
81139
bodyType: 'json',
82-
body: {
83-
user_email: user.primary_email,
84-
},
140+
body: { results },
85141
};
86142
},
87143
});

apps/backend/src/lib/config.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Organiza
394394
sender_name: renderedConfig.emails.server.senderName,
395395
sender_email: renderedConfig.emails.server.senderEmail,
396396
},
397+
email_theme: renderedConfig.emails.theme,
397398

398399
team_creator_default_permissions: typedEntries(renderedConfig.rbac.defaultPermissions.teamCreator)
399400
.filter(([_, perm]) => perm)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
2+
import { Result } from "@stackframe/stack-shared/dist/utils/results";
3+
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
4+
import { FreestyleSandboxes } from 'freestyle-sandboxes';
5+
6+
export async function renderEmailWithTheme(
7+
htmlContent: string,
8+
theme: keyof typeof EMAIL_THEMES,
9+
unsubscribeLink: string | null = null,
10+
) {
11+
const apiKey = getEnvVariable("STACK_FREESTYLE_API_KEY");
12+
const unsubscribeLinkHtml = unsubscribeLink ? `<br /><br /><a href="${unsubscribeLink}">Click here to unsubscribe</a>` : "";
13+
if (["development", "test"].includes(getNodeEnvironment()) && apiKey === "mock_stack_freestyle_key") {
14+
return {
15+
html: `<div>Mock api key detected, returning mock data ${unsubscribeLinkHtml}</div>`,
16+
text: "Mock api key detected, returning mock data",
17+
};
18+
}
19+
const freestyle = new FreestyleSandboxes({ apiKey });
20+
const TemplateComponent = EMAIL_THEMES[theme];
21+
const script = deindent`
22+
import React from 'react';
23+
import { render, Html, Tailwind, Body } from '@react-email/components';
24+
${TemplateComponent}
25+
export default async () => {
26+
const Email = <EmailTheme>${htmlContent + unsubscribeLinkHtml}</EmailTheme>
27+
return {
28+
html: await render(Email),
29+
text: await render(Email, { plainText: true }),
30+
};
31+
}
32+
`;
33+
const nodeModules = {
34+
"@react-email/components": "0.1.1",
35+
};
36+
const output = await freestyle.executeScript(script, { nodeModules });
37+
if ("error" in output) {
38+
return Result.error(output.error as string);
39+
}
40+
return output.result as { html: string, text: string };
41+
}
42+
43+
44+
const LightEmailTheme = `function EmailTheme({ children }: { children: React.ReactNode }) {
45+
return (
46+
<Html>
47+
<Tailwind>
48+
<Body>
49+
<div className="bg-white text-slate-800 p-4 rounded-lg max-w-[600px] mx-auto leading-relaxed">
50+
{children}
51+
</div>
52+
</Body>
53+
</Tailwind>
54+
</Html>
55+
);
56+
}`;
57+
58+
59+
const DarkEmailTheme = `function EmailTheme({ children }: { children: React.ReactNode }) {
60+
return (
61+
<Html>
62+
<Tailwind>
63+
<Body>
64+
<div className="bg-slate-900 text-slate-100 p-4 rounded-lg max-w-[600px] mx-auto leading-relaxed">
65+
{children}
66+
</div>
67+
</Body>
68+
</Tailwind>
69+
</Html>
70+
);
71+
}`;
72+
73+
74+
export const EMAIL_THEMES = {
75+
'default-light': LightEmailTheme,
76+
'default-dark': DarkEmailTheme,
77+
} as const;

apps/backend/src/lib/projects.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export async function createOrUpdateProject(
174174
senderName: dataOptions.email_config.sender_name,
175175
senderEmail: dataOptions.email_config.sender_email,
176176
} satisfies OrganizationRenderedConfig['emails']['server'] : undefined,
177+
'emails.theme': dataOptions.email_theme,
177178
// ======================= rbac =======================
178179
'rbac.defaultPermissions.teamMember': translateDefaultPermissions(dataOptions.team_member_default_permissions),
179180
'rbac.defaultPermissions.teamCreator': translateDefaultPermissions(dataOptions.team_creator_default_permissions),

apps/dashboard/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ NEXT_PUBLIC_STACK_SVIX_SERVER_URL=# For prod, leave it empty. For local developm
1111
# Misc, optional
1212
NEXT_PUBLIC_STACK_HEAD_TAGS='[{ "tagName": "script", "attributes": {}, "innerHTML": "// insert head tags here" }]'
1313
STACK_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translation provider here, for example: de-DE. Only works during development, not in production. Optional, by default don't translate
14+
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]'

0 commit comments

Comments
 (0)