Skip to content

Commit 4899632

Browse files
BilalG1N2D4
andauthored
Email sending sdk function, freestyle mock, small fixes (#813)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Enhances email sending API with template support, improved error handling, and new interfaces, while adding comprehensive tests and updating rendering logic. > > - **Behavior**: > - Adds support for sending emails using templates with variables and optional theming in `send-email/route.tsx`. > - Introduces per-user notification category checks before sending emails. > - Adds optional unsubscribe link in email themes. > - **Error Handling**: > - Refines error handling in `send-email/route.tsx` for missing content, non-existent user IDs, and shared email server configurations. > - Uses `KnownErrors` for specific error cases. > - **API Changes**: > - Adds new interfaces and methods for email sending in `server-interface.ts` and `admin-interface.ts`. > - Removes deprecated email sending methods from admin interfaces. > - **Testing**: > - Adds e2e tests in `email.test.ts` for various email sending scenarios, including HTML content, templates, and error cases. > - **Misc**: > - Updates email rendering logic in `email-rendering.tsx` to handle new template and theme options. > - Simplifies import statements and cleans up code structure across multiple files. > > <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 1d5a056. 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** * Enhanced email sending to support both raw HTML and template-based emails with variables and optional theming. * Added per-user notification category checks before sending emails. * Email themes now support an optional unsubscribe link in the footer. * **Improvements** * Updated email rendering to pass the unsubscribe link as a prop to themes. * Refined error handling for email sending. * Improved flexibility of email sending options and result reporting. * **API Changes** * Introduced new interfaces and methods for sending emails on the server side, including detailed result reporting. * Removed deprecated admin-side email sending methods and interfaces. * Added new types for email sending options and results. * **Bug Fixes** * Fixed navigation and property naming inconsistencies in dashboard email template editing and sending flows. * **Chores** * Simplified import statements and cleaned up internal code structure. * Updated Docker environment for freestyle mock service to use Bun runtime and adjusted port mappings. * **Tests** * Added comprehensive tests covering email sending scenarios, including error handling and multi-user support. * Updated existing tests to reflect refined email subjects, template rendering, and unsubscribe link features. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
1 parent 1089812 commit 4899632

34 files changed

Lines changed: 887 additions & 412 deletions

File tree

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

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

7-
import { templateThemeIdSchema } from "@stackframe/stack-shared/dist/schema-fields";
8-
97
export const POST = createSmartRouteHandler({
108
metadata: {
119
summary: "Render email theme",

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

Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1-
import { createTemplateComponentFromHtml, renderEmailWithTemplate } from "@/lib/email-rendering";
1+
import { createTemplateComponentFromHtml, getEmailThemeForTemplate, renderEmailWithTemplate } from "@/lib/email-rendering";
22
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 { adaptSchema, serverOrHigherAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
6+
import { adaptSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields";
77
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
88
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
9+
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
910
import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler";
11+
import { KnownErrors } from "@stackframe/stack-shared";
1012

1113
type UserResult = {
1214
user_id: string,
1315
user_email?: string,
14-
success: boolean,
15-
error?: string,
1616
};
1717

1818
export const POST = createSmartRouteHandler({
1919
metadata: {
20-
hidden: true,
20+
summary: "Send email",
21+
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.",
2122
},
2223
request: yupObject({
2324
auth: yupObject({
@@ -26,9 +27,14 @@ export const POST = createSmartRouteHandler({
2627
}).defined(),
2728
body: yupObject({
2829
user_ids: yupArray(yupString().defined()).defined(),
29-
html: yupString().defined(),
30-
subject: yupString().defined(),
31-
notification_category_name: yupString().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(),
3238
}),
3339
method: yupString().oneOf(["POST"]).defined(),
3440
}),
@@ -39,8 +45,6 @@ export const POST = createSmartRouteHandler({
3945
results: yupArray(yupObject({
4046
user_id: yupString().defined(),
4147
user_email: yupString().optional(),
42-
success: yupBoolean().defined(),
43-
error: yupString().optional(),
4448
})).defined(),
4549
}).defined(),
4650
}),
@@ -49,21 +53,23 @@ export const POST = createSmartRouteHandler({
4953
throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
5054
}
5155
if (auth.tenancy.config.emails.server.isShared) {
52-
throw new StatusError(400, "Cannot send custom emails when using shared email config");
56+
throw new KnownErrors.RequiresCustomEmailServer();
5357
}
54-
const emailConfig = await getEmailConfig(auth.tenancy);
55-
const notificationCategory = getNotificationCategoryByName(body.notification_category_name);
56-
if (!notificationCategory) {
57-
throw new StatusError(404, "Notification category not found");
58+
if (!body.html && !body.template_id) {
59+
throw new KnownErrors.SchemaError("Either html or template_id must be provided");
5860
}
59-
const themeList = auth.tenancy.config.emails.themes;
60-
if (!Object.keys(themeList).includes(auth.tenancy.config.emails.selectedThemeId)) {
61-
throw new StatusError(400, "No active theme found");
61+
if (body.html && (body.template_id || body.variables)) {
62+
throw new KnownErrors.SchemaError("If html is provided, cannot provide template_id or variables");
6263
}
63-
const activeTheme = themeList[auth.tenancy.config.emails.selectedThemeId];
64+
const emailConfig = await getEmailConfig(auth.tenancy);
65+
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);
67+
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!);
6471

6572
const prisma = await getPrismaClientForTenancy(auth.tenancy);
66-
6773
const users = await prisma.projectUser.findMany({
6874
where: {
6975
tenancyId: auth.tenancy.id,
@@ -75,6 +81,10 @@ export const POST = createSmartRouteHandler({
7581
contactChannels: true,
7682
},
7783
});
84+
const missingUserIds = body.user_ids.filter(userId => !users.some(user => user.projectUserId === userId));
85+
if (missingUserIds.length > 0) {
86+
throw new KnownErrors.UserIdDoesNotExist(missingUserIds[0]);
87+
}
7888
const userMap = new Map(users.map(user => [user.projectUserId, user]));
7989
const userSendErrors: Map<string, string> = new Map();
8090
const userPrimaryEmails: Map<string, string> = new Map();
@@ -85,26 +95,51 @@ export const POST = createSmartRouteHandler({
8595
userSendErrors.set(userId, "User not found");
8696
continue;
8797
}
88-
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy, user.projectUserId, notificationCategory.id);
89-
if (!isNotificationEnabled) {
90-
userSendErrors.set(userId, "User has disabled notifications for this category");
91-
continue;
92-
}
9398
const primaryEmail = user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value;
9499
if (!primaryEmail) {
95100
userSendErrors.set(userId, "User does not have a primary email");
96101
continue;
97102
}
98103
userPrimaryEmails.set(userId, primaryEmail);
99104

100-
let unsubscribeLink: string | null = null;
101-
if (notificationCategory.can_disable) {
105+
let currentNotificationCategory = defaultNotificationCategory;
106+
if (body.template_id) {
107+
// We have to render email twice in this case, first pass is to get the notification category
108+
const renderedTemplateFirstPass = await renderEmailWithTemplate(
109+
templateSource,
110+
themeSource,
111+
{
112+
user: { displayName: user.displayName },
113+
project: { displayName: auth.tenancy.project.display_name },
114+
variables: body.variables,
115+
},
116+
);
117+
if (renderedTemplateFirstPass.status === "error") {
118+
userSendErrors.set(userId, "There was an error rendering the email");
119+
continue;
120+
}
121+
const notificationCategory = getNotificationCategoryByName(renderedTemplateFirstPass.data.notificationCategory ?? "");
122+
if (!notificationCategory) {
123+
userSendErrors.set(userId, "Notification category not found with given name");
124+
continue;
125+
}
126+
currentNotificationCategory = notificationCategory;
127+
}
128+
129+
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy, user.projectUserId, currentNotificationCategory.id);
130+
if (!isNotificationEnabled) {
131+
userSendErrors.set(userId, "User has disabled notifications for this category");
132+
continue;
133+
}
134+
135+
let unsubscribeLink: string | undefined = undefined;
136+
if (currentNotificationCategory.can_disable) {
102137
const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({
103138
tenancy: auth.tenancy,
104139
method: {},
105140
data: {
106141
user_id: user.projectUserId,
107-
notification_category_id: notificationCategory.id,
142+
notification_category_id: currentNotificationCategory.id,
108143
},
109144
callbackUrl: undefined
110145
});
@@ -114,27 +149,26 @@ export const POST = createSmartRouteHandler({
114149
unsubscribeLink = unsubUrl.toString();
115150
}
116151

117-
118-
const template = createTemplateComponentFromHtml(body.html, unsubscribeLink || undefined);
119152
const renderedEmail = await renderEmailWithTemplate(
120-
template,
121-
activeTheme.tsxSource,
153+
templateSource,
154+
themeSource,
122155
{
123156
user: { displayName: user.displayName },
124157
project: { displayName: auth.tenancy.project.display_name },
158+
variables: body.variables,
159+
unsubscribeLink,
125160
},
126161
);
127162
if (renderedEmail.status === "error") {
128163
userSendErrors.set(userId, "There was an error rendering the email");
129164
continue;
130165
}
131-
132166
try {
133167
await sendEmail({
134168
tenancyId: auth.tenancy.id,
135169
emailConfig,
136170
to: primaryEmail,
137-
subject: body.subject,
171+
subject: body.subject ?? renderedEmail.data.subject ?? "",
138172
html: renderedEmail.data.html,
139173
text: renderedEmail.data.text,
140174
});
@@ -146,8 +180,6 @@ export const POST = createSmartRouteHandler({
146180
const results: UserResult[] = body.user_ids.map((userId) => ({
147181
user_id: userId,
148182
user_email: userPrimaryEmails.get(userId),
149-
success: !userSendErrors.has(userId),
150-
error: userSendErrors.get(userId),
151183
}));
152184

153185
return {

apps/backend/src/lib/email-rendering.tsx

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,12 @@ export function getEmailThemeForTemplate(tenancy: Tenancy, templateThemeId: stri
3131
return getActiveEmailTheme(tenancy).tsxSource;
3232
}
3333

34-
export function createTemplateComponentFromHtml(
35-
html: string,
36-
unsubscribeLink?: string,
37-
) {
38-
const unsubscribeLinkHtml = unsubscribeLink ? `<br /><br /><a href="${unsubscribeLink}">Click here to unsubscribe</a>` : "";
34+
export function createTemplateComponentFromHtml(html: string) {
3935
return deindent`
36+
export const variablesSchema = v => v;
4037
export function EmailTemplate() {
4138
return <>
4239
<div dangerouslySetInnerHTML={{ __html: ${JSON.stringify(html)}}} />
43-
${unsubscribeLinkHtml}
4440
</>
4541
};
4642
`;
@@ -53,6 +49,7 @@ export async function renderEmailWithTemplate(
5349
user?: { displayName: string | null },
5450
project?: { displayName: string },
5551
variables?: Record<string, any>,
52+
unsubscribeLink?: string,
5653
previewMode?: boolean,
5754
},
5855
): Promise<Result<{ html: string, text: string, subject?: string, notificationCategory?: string }, string>> {
@@ -68,14 +65,6 @@ export async function renderEmailWithTemplate(
6865
throw new StackAssertionError("Project is required when not in preview mode", { user, project, variables });
6966
}
7067

71-
if (["development", "test"].includes(getNodeEnvironment()) && apiKey === "mock_stack_freestyle_key") {
72-
return Result.ok({
73-
html: `<div>Mock api key detected, \n\ntemplateComponent: ${templateComponent}\n\nthemeComponent: ${themeComponent}\n\n variables: ${JSON.stringify(variables)}</div>`,
74-
text: `<div>Mock api key detected, \n\ntemplateComponent: ${templateComponent}\n\nthemeComponent: ${themeComponent}\n\n variables: ${JSON.stringify(variables)}</div>`,
75-
subject: `Mock subject, ${templateComponent.match(/<Subject\s+[^>]*\/>/g)?.[0]}`,
76-
notificationCategory: "mock notification category",
77-
});
78-
}
7968
const result = await bundleJavaScript({
8069
"/utils.tsx": findComponentValueUtil,
8170
"/theme.tsx": themeComponent,
@@ -98,8 +87,11 @@ export async function renderEmailWithTemplate(
9887
if (variables instanceof type.errors) {
9988
throw new Error(variables.summary)
10089
}
90+
const unsubscribeLink = ${previewMode ? "EmailTheme.PreviewProps?.unsubscribeLink" : JSON.stringify(options.unsubscribeLink)};
10191
const EmailTemplateWithProps = <EmailTemplate variables={variables} user={${JSON.stringify(user)}} project={${JSON.stringify(project)}} />;
102-
const Email = <EmailTheme>{EmailTemplateWithProps}</EmailTheme>;
92+
const Email = <EmailTheme unsubscribeLink={unsubscribeLink}>
93+
{${previewMode ? "EmailTheme.PreviewProps?.children ?? " : ""} EmailTemplateWithProps}
94+
</EmailTheme>;
10395
return {
10496
html: await render(Email),
10597
text: await render(Email, { plainText: true }),
@@ -124,6 +116,7 @@ export async function renderEmailWithTemplate(
124116

125117
const freestyle = new Freestyle({ apiKey });
126118
const nodeModules = {
119+
"react": "19.1.1",
127120
"@react-email/components": "0.1.1",
128121
"arktype": "2.1.20",
129122
};

apps/backend/src/lib/freestyle.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1+
import { traceSpan } from '@/utils/telemetry';
2+
import { getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
13
import { StackAssertionError, captureError, errorToNiceString } from '@stackframe/stack-shared/dist/utils/errors';
2-
import { traceSpan } from '@stackframe/stack-shared/dist/utils/telemetry';
34
import { FreestyleSandboxes } from 'freestyle-sandboxes';
45

56
export class Freestyle {
67
private freestyle: FreestyleSandboxes;
78

89
constructor(options: { apiKey: string }) {
9-
this.freestyle = new FreestyleSandboxes(options);
10+
let baseUrl = undefined;
11+
if (["development", "test"].includes(getNodeEnvironment()) && options.apiKey === "mock_stack_freestyle_key") {
12+
baseUrl = "http://localhost:8122";
13+
}
14+
this.freestyle = new FreestyleSandboxes({
15+
apiKey: options.apiKey,
16+
baseUrl,
17+
});
1018
}
1119

1220
async executeScript(script: string, options?: Parameters<FreestyleSandboxes['executeScript']>[1]) {

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/page-client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export default function PageClient() {
6161
title="Shared Email Server"
6262
okButton={{
6363
label: "Edit Templates Anyway", onClick: async () => {
64-
router.push(`email-templates-new/${sharedSmtpWarningDialogOpen}`);
64+
router.push(`email-templates/${sharedSmtpWarningDialogOpen}`);
6565
}
6666
}}
6767
cancelButton={{ label: "Cancel" }}

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ function SendEmailDialog(props: {
345345
await stackAdminApp.sendEmail({
346346
userIds: selectedUsers.map(user => user.id),
347347
subject: formData.subject,
348-
content: formData.content,
348+
html: formData.content,
349349
notificationCategoryName: formData.notificationCategoryName,
350350
});
351351

apps/dashboard/src/components/vibe-coding/code-editor.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ export default function CodeEditor({
9090
displayName: string | null;
9191
};
9292
};
93+
type ThemeProps = {
94+
children: React.ReactNode;
95+
unsubscribeLink?: string;
96+
};
9397
}
9498
`,
9599
);

apps/dev-launchpad/public/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ <h2 style="margin-top: 64px;">Background services</h2>
111111
4318: OTel collector
112112
</li>
113113
<li>
114-
8119: Freestyle mock
114+
8122: Freestyle mock
115115
</li>
116116
<li>
117117
8121: S3 mock

apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ it("should send a sign-in code per e-mail", async ({ expect }) => {
88
[
99
MailboxMessage {
1010
"from": "Stack Dashboard <noreply@example.com>",
11-
"subject": "Mock subject, <Subject value=\\"{\\"Sign in to \\" + project.displayName + \\": Your code is \\" + variables.otp} />\\"",
11+
"subject": "Sign in to Stack Dashboard: Your code is <stripped code>",
1212
"to": ["<default-mailbox--<stripped UUID>@stack-generated.example.com>"],
1313
<some fields may have been hidden>,
1414
},
@@ -100,31 +100,12 @@ it("should send otp code to user", async ({ expect }) => {
100100
});
101101

102102
const email = (await backendContext.value.mailbox.fetchMessages()).findLast((email) => email.subject.includes("Sign in"));
103-
const match = email?.body?.text.match(/"otp":"([A-Z0-9]{6})"/);
103+
const match = email?.body?.html.match(/\>([A-Z0-9]{6})\<\/p\>/);
104104
expect(match).toHaveLength(2);
105105
const code = match?.[1];
106106
expect(code).toHaveLength(6);
107107
});
108108

109-
it("should not send otp code to user if client version is older equal to 2.5.37", async ({ expect }) => {
110-
await Auth.Otp.sendSignInCode();
111-
const mailbox = backendContext.value.mailbox;
112-
await niceBackendFetch("/api/v1/auth/otp/send-sign-in-code", {
113-
method: "POST",
114-
accessType: "client",
115-
body: {
116-
email: mailbox.emailAddress,
117-
callback_url: "http://localhost:12345/some-callback-url",
118-
},
119-
headers: {
120-
"X-Stack-Client-Version": "js @stackframe/stack@2.5.37",
121-
},
122-
});
123-
124-
const email = (await backendContext.value.mailbox.fetchMessages()).findLast((email) => email.subject.includes("Sign in"));
125-
const match = email?.body?.text.match(/^[A-Z0-9]{6}$/sm);
126-
expect(match).toBeNull();
127-
});
128109

129110
it.todo("should create a team for newly created users if configured as such");
130111

0 commit comments

Comments
 (0)