diff --git a/apps/backend/src/app/api/latest/emails/send-email/route.tsx b/apps/backend/src/app/api/latest/emails/send-email/route.tsx
index aa2f14d5a6..77ccf24a30 100644
--- a/apps/backend/src/app/api/latest/emails/send-email/route.tsx
+++ b/apps/backend/src/app/api/latest/emails/send-email/route.tsx
@@ -27,7 +27,9 @@ export const POST = createSmartRouteHandler({
}).defined(),
body: yupObject({
user_ids: yupArray(yupString().defined()).defined(),
- theme_id: templateThemeIdSchema.nullable().label("The theme to use for the email. If not specified, the default theme will be used."),
+ 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(),
diff --git a/apps/backend/src/lib/email-rendering.tsx b/apps/backend/src/lib/email-rendering.tsx
index 82fc4c5037..b58622e0c1 100644
--- a/apps/backend/src/lib/email-rendering.tsx
+++ b/apps/backend/src/lib/email-rendering.tsx
@@ -65,14 +65,6 @@ export async function renderEmailWithTemplate(
throw new StackAssertionError("Project is required when not in preview mode", { user, project, variables });
}
- if (["development", "test"].includes(getNodeEnvironment()) && apiKey === "mock_stack_freestyle_key") {
- return Result.ok({
- html: `
Mock api key detected, \n\ntemplateComponent: ${templateComponent}\n\nthemeComponent: ${themeComponent}\n\n variables: ${JSON.stringify(variables)}
`,
- text: `Mock api key detected, \n\ntemplateComponent: ${templateComponent}\n\nthemeComponent: ${themeComponent}\n\n variables: ${JSON.stringify(variables)}
`,
- subject: `Mock subject, ${templateComponent.match(/]*\/>/g)?.[0]}`,
- notificationCategory: "mock notification category",
- });
- }
const result = await bundleJavaScript({
"/utils.tsx": findComponentValueUtil,
"/theme.tsx": themeComponent,
@@ -124,6 +116,7 @@ export async function renderEmailWithTemplate(
const freestyle = new Freestyle({ apiKey });
const nodeModules = {
+ "react": "19.1.1",
"@react-email/components": "0.1.1",
"arktype": "2.1.20",
};
diff --git a/apps/backend/src/lib/freestyle.tsx b/apps/backend/src/lib/freestyle.tsx
index 5f763a9bc2..b6b0ea6ceb 100644
--- a/apps/backend/src/lib/freestyle.tsx
+++ b/apps/backend/src/lib/freestyle.tsx
@@ -1,12 +1,20 @@
+import { traceSpan } from '@/utils/telemetry';
+import { getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
import { StackAssertionError, captureError, errorToNiceString } from '@stackframe/stack-shared/dist/utils/errors';
-import { traceSpan } from '@stackframe/stack-shared/dist/utils/telemetry';
import { FreestyleSandboxes } from 'freestyle-sandboxes';
export class Freestyle {
private freestyle: FreestyleSandboxes;
constructor(options: { apiKey: string }) {
- this.freestyle = new FreestyleSandboxes(options);
+ let baseUrl = undefined;
+ if (["development", "test"].includes(getNodeEnvironment()) && options.apiKey === "mock_stack_freestyle_key") {
+ baseUrl = "http://localhost:8122";
+ }
+ this.freestyle = new FreestyleSandboxes({
+ apiKey: options.apiKey,
+ baseUrl,
+ });
}
async executeScript(script: string, options?: Parameters[1]) {
diff --git a/apps/dev-launchpad/public/index.html b/apps/dev-launchpad/public/index.html
index 41fc2ee64d..37e69a11e7 100644
--- a/apps/dev-launchpad/public/index.html
+++ b/apps/dev-launchpad/public/index.html
@@ -111,7 +111,7 @@ Background services
4318: OTel collector
- 8119: Freestyle mock
+ 8122: Freestyle mock
8121: S3 mock
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts
index 36478179ba..e857c5b301 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts
@@ -8,7 +8,7 @@ it("should send a sign-in code per e-mail", async ({ expect }) => {
[
MailboxMessage {
"from": "Stack Dashboard ",
- "subject": "Mock subject, \\"",
+ "subject": "Sign in to Stack Dashboard: Your code is ",
"to": ["@stack-generated.example.com>"],
,
},
@@ -100,31 +100,12 @@ it("should send otp code to user", async ({ expect }) => {
});
const email = (await backendContext.value.mailbox.fetchMessages()).findLast((email) => email.subject.includes("Sign in"));
- const match = email?.body?.text.match(/"otp":"([A-Z0-9]{6})"/);
+ const match = email?.body?.html.match(/\>([A-Z0-9]{6})\<\/p\>/);
expect(match).toHaveLength(2);
const code = match?.[1];
expect(code).toHaveLength(6);
});
-it("should not send otp code to user if client version is older equal to 2.5.37", async ({ expect }) => {
- await Auth.Otp.sendSignInCode();
- const mailbox = backendContext.value.mailbox;
- await niceBackendFetch("/api/v1/auth/otp/send-sign-in-code", {
- method: "POST",
- accessType: "client",
- body: {
- email: mailbox.emailAddress,
- callback_url: "http://localhost:12345/some-callback-url",
- },
- headers: {
- "X-Stack-Client-Version": "js @stackframe/stack@2.5.37",
- },
- });
-
- const email = (await backendContext.value.mailbox.fetchMessages()).findLast((email) => email.subject.includes("Sign in"));
- const match = email?.body?.text.match(/^[A-Z0-9]{6}$/sm);
- expect(match).toBeNull();
-});
it.todo("should create a team for newly created users if configured as such");
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts
index aba4dfe917..fad96a3e9d 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts
@@ -197,7 +197,7 @@ it("should sign in with otp code", async ({ expect }) => {
expect(sendSignInCodeResponse.body.nonce).toBeDefined();
const email = (await backendContext.value.mailbox.fetchMessages()).findLast((message) => message.subject.includes("Sign in to")) ?? throwErr("Sign-in code message not found");
- const match = email.body?.text.match(/"otp":"([A-Z0-9]{6})"/);
+ const match = email.body?.html.match(/\>([A-Z0-9]{6})\<\/p\>/);
const signInResponse = await niceBackendFetch("/api/v1/auth/otp/sign-in", {
method: "POST",
@@ -270,7 +270,7 @@ it("should set the code to invalid after too many attempts", async ({ expect })
});
const email = (await backendContext.value.mailbox.fetchMessages()).findLast((message) => message.subject.includes("Sign in to")) ?? throwErr("Sign-in code message not found");
- const match = email.body?.text.match(/"otp":"([A-Z0-9]{6})"/);
+ const match = email.body?.html.match(/\>([A-Z0-9]{6})\<\/p\>/);
for (let i = 0; i < 25; i++) {
await niceBackendFetch("/api/v1/auth/otp/sign-in", {
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/password/reset.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/password/reset.test.ts
index aedd20c6db..dd6aa5ef71 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/auth/password/reset.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/password/reset.test.ts
@@ -22,10 +22,10 @@ async function getResetCode() {
`);
const messages = await backendContext.value.mailbox.fetchMessages();
const messagesNoBody = await backendContext.value.mailbox.fetchMessages({ noBody: true });
- expect(messagesNoBody.at(-1)).toMatchInlineSnapshot(`
- MailboxMessage {
+ expect(messagesNoBody.find(m => m.subject.includes("Reset your password at"))).toMatchInlineSnapshot(`
+ MailboxMessage {
"from": "Stack Dashboard ",
- "subject": "Mock subject, \\"",
+ "subject": "Reset your password at Stack Dashboard",
"to": ["@stack-generated.example.com>"],
,
}
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/password/send-reset-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/password/send-reset-code.test.ts
index 4fb98bd2ea..14c0810807 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/auth/password/send-reset-code.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/password/send-reset-code.test.ts
@@ -24,13 +24,13 @@ it("should send a password reset code per e-mail", async ({ expect }) => {
[
MailboxMessage {
"from": "Stack Dashboard ",
- "subject": "Mock subject, ",
+ "subject": "Verify your email at Stack Dashboard",
"to": ["@stack-generated.example.com>"],
,
},
MailboxMessage {
"from": "Stack Dashboard ",
- "subject": "Mock subject, \\"",
+ "subject": "Reset your password at Stack Dashboard",
"to": ["@stack-generated.example.com>"],
,
},
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts
index 1c31997c76..bb5c4eb4ec 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts
@@ -22,7 +22,7 @@ it("should sign up new users", async ({ expect }) => {
[
MailboxMessage {
"from": "Stack Dashboard ",
- "subject": "Mock subject, ",
+ "subject": "Verify your email at Stack Dashboard",
"to": ["@stack-generated.example.com>"],
,
},
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/legacy-send-verification-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/legacy-send-verification-code.test.ts
index b53440fd44..ee3fe395ec 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/legacy-send-verification-code.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/legacy-send-verification-code.test.ts
@@ -52,13 +52,13 @@ it("should send a verification code per e-mail", async ({ expect }) => {
[
MailboxMessage {
"from": "Stack Dashboard ",
- "subject": "Mock subject, ",
+ "subject": "Verify your email at Stack Dashboard",
"to": ["@stack-generated.example.com>"],
,
},
MailboxMessage {
"from": "Stack Dashboard ",
- "subject": "Mock subject, ",
+ "subject": "Verify your email at Stack Dashboard",
"to": ["@stack-generated.example.com>"],
,
},
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/send-verification-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/send-verification-code.test.ts
index 48a612c62d..b08fc3297e 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/send-verification-code.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/send-verification-code.test.ts
@@ -39,13 +39,13 @@ it("should send a verification code per e-mail", async ({ expect }) => {
[
MailboxMessage {
"from": "Stack Dashboard ",
- "subject": "Mock subject, ",
+ "subject": "Verify your email at Stack Dashboard",
"to": ["@stack-generated.example.com>"],
,
},
MailboxMessage {
"from": "Stack Dashboard ",
- "subject": "Mock subject, ",
+ "subject": "Verify your email at Stack Dashboard",
"to": ["@stack-generated.example.com>"],
,
},
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/verify.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/verify.test.ts
index e8a37af055..b1d21a80a0 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/verify.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/contact-channels/verify.test.ts
@@ -18,7 +18,7 @@ it("each verification code that was already requested can be used exactly once",
await ContactChannels.sendVerificationCode();
const mailbox = backendContext.value.mailbox;
const messages = await mailbox.fetchMessages();
- const verifyMessages = messages.filter((message) => message.subject === "Mock subject, ");
+ const verifyMessages = messages.filter((message) => message.subject === "Verify your email at Stack Dashboard");
const verificationCodes = verifyMessages.map((message) => message.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1] ?? throwErr("Verification code not found"));
expect(verificationCodes).toHaveLength(3);
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts
index 02179cdd69..1add8359f7 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts
@@ -91,9 +91,10 @@ describe("get email theme", () => {
"body": {
"display_name": "Default Light",
"tsx_source": deindent\`
- import { Html, Head, Tailwind, Body, Container } from '@react-email/components';
+ import { Html, Head, Tailwind, Body, Container, Link } from '@react-email/components';
+ import { ThemeProps } from "@stackframe/emails"
- export function EmailTheme({ children }: { children: React.ReactNode }) {
+ export function EmailTheme({ children, unsubscribeLink }: ThemeProps) {
return (
@@ -102,12 +103,22 @@ describe("get email theme", () => {
{children}
+ {unsubscribeLink && (
+
+ Click here{" "}
+ to unsubscribe from these emails
+
+ )}
);
}
- \`,
+
+ EmailTheme.PreviewProps = {
+ unsubscribeLink: "https://example.com"
+ } satisfies Partial
+ \` + "\\n",
},
"headers": Headers { },
}
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts
index dc2f8d23f3..89df16157d 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts
@@ -15,7 +15,17 @@ it("should not allow updating email templates when using shared email config", a
'x-stack-admin-access-token': adminAccessToken,
},
body: {
- tsx_source: "mock_tsx_source",
+ tsx_source: `
+ import { Subject, NotificationCategory } from '@stackframe/emails';
+ export const variablesSchema = (v) => v;
+ export function EmailTemplate() {
+ return <>
+
+
+ Mock email template
+ >;
+ }
+ `,
},
});
@@ -56,39 +66,24 @@ it("should allow adding and updating email templates with custom email config",
method: "PATCH",
accessType: "admin",
body: {
- tsx_source: "mock_tsx_source",
+ tsx_source: `
+ import { Subject, NotificationCategory } from '@stackframe/emails';
+ export const variablesSchema = (v) => v;
+ export function EmailTemplate() {
+ return <>
+
+
+ Mock email template
+ >;
+ }
+ `,
},
});
expect(updateResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
- "body": {
- "rendered_html": deindent\`
- Mock api key detected,
-
- templateComponent: mock_tsx_source
-
- themeComponent: import { Html, Head, Tailwind, Body, Container } from '@react-email/components';
-
- export function EmailTheme({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
-
- {children}
-
-
-
-
- );
- }
-
- variables: {"projectDisplayName":"New Project"}
- \`,
- },
+ "body": { "rendered_html": " " },
"headers": Headers { },
}
`);
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/email.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/email.test.ts
index a367bebf8c..f76bda324d 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/internal/email.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/email.test.ts
@@ -40,7 +40,7 @@ it("should list sent emails for the current project", async ({ expect }) => {
"username": "does not matter, ignored by Inbucket",
},
"sent_at_millis": ,
- "subject": "Mock subject, ",
+ "subject": "Sign in to New Project: Your code is ",
"to": ["default-mailbox--@stack-generated.example.com"],
},
{
@@ -55,7 +55,7 @@ it("should list sent emails for the current project", async ({ expect }) => {
"username": "does not matter, ignored by Inbucket",
},
"sent_at_millis": ,
- "subject": "Mock subject, ",
+ "subject": "Sign in to New Project: Your code is ",
"to": ["default-mailbox--@stack-generated.example.com"],
},
],
@@ -102,7 +102,7 @@ it("should not allow two different projects to see the same send log", async ({
"username": "does not matter, ignored by Inbucket",
},
"sent_at_millis": ,
- "subject": "Mock subject, ",
+ "subject": "Sign in to New Project: Your code is ",
"to": ["default-mailbox--@stack-generated.example.com"],
},
],
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts
index 9d5dfefaba..b862e5e10e 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts
@@ -8,7 +8,7 @@ it("should return 400 when theme is not found", async ({ expect }) => {
accessType: "admin",
body: {
theme_id: generateUuid(),
- template_tsx_source: "export default function EmailTemplate() { return Test email
; }",
+ template_tsx_source: "import { type } from 'arktype'; export function EmailTemplate() { return Test email
; } export const variablesSchema = type({});",
},
});
expect(response).toMatchInlineSnapshot(`
@@ -27,7 +27,7 @@ it("should return 400 when template is not found", async ({ expect }) => {
body: {
theme_tsx_source: `
import { Html, Tailwind, Body } from '@react-email/components';
- function EmailTheme({ children }: { children: React.ReactNode }) {
+ export function EmailTheme({ children }: { children: React.ReactNode }) {
return (
@@ -40,7 +40,6 @@ it("should return 400 when template is not found", async ({ expect }) => {
);
}
- export default EmailTheme;
`,
template_id: generateUuid(),
},
@@ -61,7 +60,7 @@ it("should return 400 when both theme_id and theme_tsx_source are provided", asy
body: {
theme_id: "1df07ae6-abf3-4a40-83a5-a1a2cbe336ac",
theme_tsx_source: "export default function Theme() { return {children}
; }",
- template_tsx_source: "export default function EmailTemplate() { return Test email
; }",
+ template_tsx_source: "import { type } from 'arktype'; export function EmailTemplate() { return Test email
; } export const variablesSchema = type({});",
},
});
expect(response).toMatchInlineSnapshot(`
@@ -73,14 +72,14 @@ it("should return 400 when both theme_id and theme_tsx_source are provided", asy
`);
});
-it("should return 400 when both template_id and template_tsx_source are provided", async ({ expect }) => {
+it("should render email when valid theme and template TSX sources are provided", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/emails/render-email", {
method: "POST",
accessType: "admin",
body: {
theme_tsx_source: `
import { Html, Tailwind, Body } from '@react-email/components';
- function EmailTheme({ children }: { children: React.ReactNode }) {
+ export function EmailTheme({ children }: { children: React.ReactNode }) {
return (
@@ -93,78 +92,20 @@ it("should return 400 when both template_id and template_tsx_source are provided
);
}
- export default EmailTheme;
`,
- template_id: "some-template-id",
- template_tsx_source: "export default function EmailTemplate() { return Test email
; }",
- },
- });
- expect(response).toMatchInlineSnapshot(`
- NiceResponse {
- "status": 400,
- "body": "Exactly one of template_id or template_tsx_source must be provided",
- "headers": Headers { },
- }
- `);
-});
-
-it("should render email when valid theme and template TSX sources are provided", async ({ expect }) => {
- const response = await niceBackendFetch("/api/v1/emails/render-email", {
- method: "POST",
- accessType: "admin",
- body: {
- theme_tsx_source: `
- import { Html, Tailwind, Body } from '@react-email/components';
- function EmailTheme({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
- {children}
-
-
-
-
- );
+ template_tsx_source: `
+ import { type } from "arktype";
+ export function EmailTemplate() {
+ return Test email content
;
}
- export default EmailTheme;
+ export const variablesSchema = type({});
`,
- template_tsx_source: "export default function EmailTemplate() { return Test email content
; }",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
- "body": {
- "html": deindent\`
- Mock api key detected,
-
- templateComponent: export default function EmailTemplate() { return
Test email content
; }
-
- themeComponent:
- import { Html, Tailwind, Body } from '@react-email/components';
- function EmailTheme({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
- {children}
-
-
-
-
- );
- }
- export default EmailTheme;
-
-
- variables: {}
- \`,
- "notification_category": "mock notification category",
- "subject": "Mock subject, undefined",
- },
+ "body": { "html": "" },
"headers": Headers { },
}
`);
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts
index 38e9142a43..ccf51faf83 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts
@@ -56,14 +56,14 @@ describe("invalid requests", () => {
email_config: testEmailConfig,
},
});
- const userId = randomUUID();
+ const user = await User.create();
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
- user_ids: [userId],
+ user_ids: [user.userId],
html: "Test email
",
subject: "Test Subject",
notification_category_name: "Marketing",
@@ -76,8 +76,7 @@ describe("invalid requests", () => {
"body": {
"results": [
{
- "error": "User not found",
- "success": false,
+ "user_email": "unindexed-mailbox--@stack-generated.example.com",
"user_id": "",
},
],
@@ -111,13 +110,19 @@ describe("invalid requests", () => {
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
- "body": "Cannot send custom emails when using shared email config",
- "headers": Headers { },
+ "body": {
+ "code": "REQUIRES_CUSTOM_EMAIL_SERVER",
+ "error": "This action requires a custom SMTP server. Please edit your email server configuration and try again.",
+ },
+ "headers": Headers {
+ "x-stack-known-error": "REQUIRES_CUSTOM_EMAIL_SERVER",
+ ,
+ },
}
`);
});
- it("should return 404 when invalid notification category name is provided", async ({ expect }) => {
+ it("should return 400 when invalid notification category name is provided", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
config: {
@@ -146,8 +151,8 @@ describe("invalid requests", () => {
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
- "status": 404,
- "body": "Notification category not found",
+ "status": 400,
+ "body": "Notification category not found with given name",
"headers": Headers { },
}
`);
@@ -203,8 +208,7 @@ it("should return 200 with disabled notifications error in results when user has
"body": {
"results": [
{
- "error": "User has disabled notifications for this category",
- "success": false,
+ "user_email": "unindexed-mailbox--@stack-generated.example.com",
"user_id": "",
},
],
@@ -244,15 +248,7 @@ it("should return 200 with no primary email error in results when user does not
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
- "body": {
- "results": [
- {
- "error": "User does not have a primary email",
- "success": false,
- "user_id": "",
- },
- ],
- },
+ "body": { "results": [{ "user_id": "" }] },
"headers": Headers { },
}
`);
@@ -281,20 +277,19 @@ it("should return 200 and send email successfully", async ({ expect }) => {
);
expect(response).toMatchInlineSnapshot(`
- NiceResponse {
- "status": 200,
- "body": {
- "results": [
- {
- "success": true,
- "user_email": "unindexed-mailbox--@stack-generated.example.com",
- "user_id": "",
- },
- ],
- },
- "headers": Headers { },
- }
- `);
+ NiceResponse {
+ "status": 200,
+ "body": {
+ "results": [
+ {
+ "user_email": "unindexed-mailbox--@stack-generated.example.com",
+ "user_id": "",
+ },
+ ],
+ },
+ "headers": Headers { },
+ }
+ `);
// Verify the email was actually sent by checking the mailbox
const messages = await user.mailbox.fetchMessages();
@@ -303,7 +298,7 @@ it("should return 200 and send email successfully", async ({ expect }) => {
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"http://localhost:8102/api/v1/emails/unsubscribe-link?code=%3Cstripped+query+param%3E"`);
});
-it("should handle mixed results for multiple users", async ({ expect }) => {
+it("should handle user that does not exist", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Mixed Results Project",
config: {
@@ -337,38 +332,246 @@ it("should handle mixed results for multiple users", async ({ expect }) => {
expect(response).toMatchInlineSnapshot(`
NiceResponse {
- "status": 200,
+ "status": 400,
"body": {
- "results": [
- {
- "error": "User has disabled notifications for this category",
- "success": false,
- "user_id": "",
- },
- {
- "error": "User not found",
- "success": false,
- "user_id": "",
- },
- {
- "success": true,
- "user_email": "unindexed-mailbox--@stack-generated.example.com",
- "user_id": "",
- },
- ],
+ "code": "USER_ID_DOES_NOT_EXIST",
+ "details": { "user_id": "" },
+ "error": "The given user with the ID does not exist.",
+ },
+ "headers": Headers {
+ "x-stack-known-error": "USER_ID_DOES_NOT_EXIST",
+ ,
},
- "headers": Headers { },
}
`);
+});
- // Verify only the successful user received the email
- const successfulUserMessages = await successfulUser.mailbox.fetchMessages();
- const sentEmail = successfulUserMessages.find(msg => msg.subject === "Bulk Test Email Subject");
- expect(sentEmail).toBeDefined();
- expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"http://localhost:8102/api/v1/emails/unsubscribe-link?code=%3Cstripped+query+param%3E"`);
+describe("validation errors", () => {
+ it("should return 400 when neither html nor template_id is provided", async ({ expect }) => {
+ await Project.createAndSwitch({
+ display_name: "Test Validation Project",
+ config: {
+ email_config: testEmailConfig,
+ },
+ });
+ const user = await User.create();
+ const response = await niceBackendFetch(
+ "/api/v1/emails/send-email",
+ {
+ method: "POST",
+ accessType: "server",
+ body: {
+ user_ids: [user.userId],
+ subject: "Test Subject",
+ notification_category_name: "Marketing",
+ }
+ }
+ );
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 400,
+ "body": {
+ "code": "SCHEMA_ERROR",
+ "details": { "message": "Either html or template_id must be provided" },
+ "error": "Either html or template_id must be provided",
+ },
+ "headers": Headers {
+ "x-stack-known-error": "SCHEMA_ERROR",
+ ,
+ },
+ }
+ `);
+ });
+
+ it("should return 200 when empty user_ids array is provided", async ({ expect }) => {
+ await Project.createAndSwitch({
+ display_name: "Test Empty UserIds Project",
+ config: {
+ email_config: testEmailConfig,
+ },
+ });
+ const response = await niceBackendFetch(
+ "/api/v1/emails/send-email",
+ {
+ method: "POST",
+ accessType: "server",
+ body: {
+ user_ids: [],
+ html: "Test email
",
+ subject: "Test Subject",
+ notification_category_name: "Marketing",
+ }
+ }
+ );
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 200,
+ "body": { "results": [] },
+ "headers": Headers { },
+ }
+ `);
+ });
+});
+
+describe("template-based emails", () => {
+ it("should return 400 when invalid template_id is provided", async ({ expect }) => {
+ await Project.createAndSwitch({
+ display_name: "Test Invalid Template Project",
+ config: {
+ email_config: testEmailConfig,
+ },
+ });
+ const user = await User.create();
+ const response = await niceBackendFetch(
+ "/api/v1/emails/send-email",
+ {
+ method: "POST",
+ accessType: "server",
+ body: {
+ user_ids: [user.userId],
+ template_id: "non-existent-template",
+ variables: { name: "Test User" },
+ notification_category_name: "Marketing",
+ }
+ }
+ );
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 400,
+ "body": "Template not found with given id",
+ "headers": Headers { },
+ }
+ `);
+ });
+
+ it("should return 400 when invalid theme_id is provided", async ({ expect }) => {
+ await Project.createAndSwitch({
+ display_name: "Test Invalid Theme Project",
+ config: {
+ email_config: testEmailConfig,
+ },
+ });
+ const user = await User.create();
+ const response = await niceBackendFetch(
+ "/api/v1/emails/send-email",
+ {
+ method: "POST",
+ accessType: "server",
+ body: {
+ user_ids: [user.userId],
+ html: "Test email with invalid theme
",
+ subject: "Test Subject",
+ theme_id: "non-existent-theme",
+ notification_category_name: "Marketing",
+ }
+ }
+ );
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 400,
+ "body": {
+ "code": "SCHEMA_ERROR",
+ "details": {
+ "message": deindent\`
+ Request validation failed on POST /api/v1/emails/send-email:
+ - body.theme_id is invalid
+ \`,
+ },
+ "error": deindent\`
+ Request validation failed on POST /api/v1/emails/send-email:
+ - body.theme_id is invalid
+ \`,
+ },
+ "headers": Headers {
+ "x-stack-known-error": "SCHEMA_ERROR",
+ ,
+ },
+ }
+ `);
+ });
+});
+
+describe("notification categories", () => {
+ it("should return 200 and send email successfully with Transactional category", async ({ expect }) => {
+ await Project.createAndSwitch({
+ display_name: "Test Transactional Project",
+ config: {
+ email_config: testEmailConfig,
+ },
+ });
+ const user = await User.create();
+ const response = await niceBackendFetch(
+ "/api/v1/emails/send-email",
+ {
+ method: "POST",
+ accessType: "server",
+ body: {
+ user_ids: [user.userId],
+ html: "Transactional email
",
+ subject: "Transactional Test Subject",
+ notification_category_name: "Transactional",
+ }
+ }
+ );
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 200,
+ "body": {
+ "results": [
+ {
+ "user_email": "unindexed-mailbox--@stack-generated.example.com",
+ "user_id": "",
+ },
+ ],
+ },
+ "headers": Headers { },
+ }
+ `);
- // Verify the user with disabled notifications did not receive the email
- const disabledUserMessages = await userWithDisabledNotifications.mailbox.fetchMessages();
- const disabledUserEmail = disabledUserMessages.find(msg => msg.subject === "Bulk Test Email Subject");
- expect(disabledUserEmail).toBeUndefined();
+ // Verify the email was sent
+ const messages = await user.mailbox.fetchMessages();
+ const sentEmail = messages.find(msg => msg.subject === "Transactional Test Subject");
+ expect(sentEmail).toBeDefined();
+ });
+
+ it("should default to Transactional category when notification_category_name is not provided", async ({ expect }) => {
+ await Project.createAndSwitch({
+ display_name: "Test Default Category Project",
+ config: {
+ email_config: testEmailConfig,
+ },
+ });
+ const user = await User.create();
+ const response = await niceBackendFetch(
+ "/api/v1/emails/send-email",
+ {
+ method: "POST",
+ accessType: "server",
+ body: {
+ user_ids: [user.userId],
+ html: "Default category email
",
+ subject: "Default Category Test Subject",
+ }
+ }
+ );
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 200,
+ "body": {
+ "results": [
+ {
+ "user_email": "unindexed-mailbox--@stack-generated.example.com",
+ "user_id": "",
+ },
+ ],
+ },
+ "headers": Headers { },
+ }
+ `);
+
+ // Verify the email was sent
+ const messages = await user.mailbox.fetchMessages();
+ const sentEmail = messages.find(msg => msg.subject === "Default Category Test Subject");
+ expect(sentEmail).toBeDefined();
+ });
});
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts
index 546db73112..34fb91ad43 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts
@@ -37,7 +37,6 @@ it("unsubscribe link should be sent and update notification preference", async (
"body": {
"results": [
{
- "success": true,
"user_email": "unindexed-mailbox--@stack-generated.example.com",
"user_id": "",
},
@@ -132,7 +131,6 @@ it("unsubscribe link should not be sent for emails with transactional notificati
"body": {
"results": [
{
- "success": true,
"user_email": "unindexed-mailbox--@stack-generated.example.com",
"user_id": "",
},
@@ -145,35 +143,5 @@ it("unsubscribe link should not be sent for emails with transactional notificati
const messages = await user.mailbox.fetchMessages();
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
expect(sentEmail).toBeDefined();
- expect(sentEmail!.body?.html).toMatchInlineSnapshot(`
- deindent\`
- Mock api key detected,
-
- templateComponent: export function EmailTemplate() {
- return <>
-
Test Email
This is a test email with HTML content.
"}} />
-
- >
- };
-
- themeComponent: import { Html, Head, Tailwind, Body, Container } from '@react-email/components';
-
- export function EmailTheme({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
-
- {children}
-
-
-
-
- );
- }
-
- variables: {}
- \`
- `);
+ expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"Test Email This is a test email with HTML content.
"`);
});
diff --git a/apps/e2e/tests/js/email.test.ts b/apps/e2e/tests/js/email.test.ts
index 11f97104e0..62957e9374 100644
--- a/apps/e2e/tests/js/email.test.ts
+++ b/apps/e2e/tests/js/email.test.ts
@@ -50,6 +50,10 @@ it("should successfully send email with template", async ({ expect }) => {
const result = await serverApp.sendEmail({
userIds: [user.id],
templateId: DEFAULT_TEMPLATE_IDS.sign_in_invitation,
+ variables: {
+ teamDisplayName: "Test Team",
+ signInInvitationLink: "https://example.com",
+ },
subject: "Welcome!",
});
diff --git a/docker/dependencies/docker.compose.yaml b/docker/dependencies/docker.compose.yaml
index a333684a4d..5b84c95ac1 100644
--- a/docker/dependencies/docker.compose.yaml
+++ b/docker/dependencies/docker.compose.yaml
@@ -160,7 +160,7 @@ services:
image: freestyle-mock
container_name: freestyle-mock
ports:
- - "8119:8080" # POST http://localhost:8119/execute/v1/script
+ - "8122:8080" # POST http://localhost:8119/execute/v1/script
environment:
DENO_DIR: /deno-cache
volumes:
diff --git a/docker/dependencies/freestyle-mock/Dockerfile b/docker/dependencies/freestyle-mock/Dockerfile
index 1b336a082c..ef95b8b958 100644
--- a/docker/dependencies/freestyle-mock/Dockerfile
+++ b/docker/dependencies/freestyle-mock/Dockerfile
@@ -1,90 +1,177 @@
-FROM denoland/deno:1.46.1
+FROM oven/bun:1
# ---- app setup --------------------------------------------------------------
WORKDIR /app
+# Create package.json for global dependencies
+RUN cat <<'EOF' > package.json
+{
+ "name": "freestyle-mock",
+ "dependencies": {
+ "arktype": "2.1.2",
+ "react": "19.1.1",
+ "@react-email/components": "0.1.1"
+ }
+}
+EOF
+
+# Install global dependencies
+RUN bun install
+
# Drop the whole server inline
RUN cat <<'EOF' > server.ts
-import { serve } from "https://deno.land/std@0.224.0/http/server.ts";
-import { ensureDir } from "https://deno.land/std@0.224.0/fs/ensure_dir.ts";
-import { join } from "https://deno.land/std@0.224.0/path/mod.ts";
+import { serve } from "bun";
+import { mkdir, writeFile, rm } from "fs/promises";
+import { join } from "path";
+import { spawn } from "child_process";
type LogLine = { message: string; type: string };
-serve(async (req) => {
- const url = new URL(req.url);
- if (!(req.method === "POST" && url.pathname === "/execute/v1/script")) {
- return new Response("Not found", { status: 404 });
- }
-
- const { script, config = {} } = await req.json();
-
- // 1. temp dir --------------------------------------------------------------
- const workDir = join("/tmp", "job-" + crypto.randomUUID());
- await ensureDir(workDir);
-
- // 2. write user script -----------------------------------------------------
- const scriptFile = join(workDir, "user_script.ts");
- await Deno.writeTextFile(scriptFile, script);
-
- // 3. (optional) pre-cache npm deps ----------------------------------------
- if (config.nodeModules && Object.keys(config.nodeModules).length) {
- const pkgs = Object.entries(config.nodeModules).map(
- ([name, ver]) => `npm:${name}@${ver}`,
- );
- await new Deno.Command("deno", {
- cwd: workDir,
- args: ["cache", "--unstable", "--node-modules-dir", ...pkgs],
- }).output();
- }
+serve({
+ port: 8080,
+ async fetch(req) {
+ const url = new URL(req.url);
+ if (!(req.method === "POST" && url.pathname === "/execute/v1/script")) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const { script, config = {} } = await req.json();
+
+ // 1. temp dir --------------------------------------------------------------
+ const workDir = join("/tmp", "job-" + crypto.randomUUID());
+ await mkdir(workDir, { recursive: true });
+
+ // 2. write user script and runner files -----------------------------------
+ // Write the user script as-is
+ const scriptFile = join(workDir, "script.ts");
+ await writeFile(scriptFile, script);
+
+ // Write a runner that imports from the user script
+ const runnerScript = `
+const logs: Array<{ message: string; type: string }> = [];
+
+// Capture console output
+const originalConsole = { ...console };
+const logMethods = ['log', 'info', 'warn', 'error', 'debug'];
+logMethods.forEach(method => {
+ (console as any)[method] = (...args: any[]) => {
+ logs.push({ message: args.map(String).join(' '), type: method });
+ (originalConsole as any)[method](...args);
+ };
+});
+
+// Import the user function
+import userFunction from './script.ts';
+
+try {
+ const result = await (typeof userFunction === 'function' ? userFunction() : userFunction);
+ console.log(JSON.stringify({ result, logs }));
+} catch (error) {
+ console.error(JSON.stringify({ error: error.message, logs }));
+ process.exit(1);
+}
+`;
+ const runnerFile = join(workDir, "runner.ts");
+ await writeFile(runnerFile, runnerScript);
+
+ // 2.1. create package.json for dependencies -------------------------------
+ const packageJson = {
+ type: "module",
+ dependencies: config.nodeModules || {}
+ };
+ const packageJsonFile = join(workDir, "package.json");
+ await writeFile(packageJsonFile, JSON.stringify(packageJson, null, 2));
+
+ // 3. install dependencies -------------------------------------------------
+ if (config.nodeModules && Object.keys(config.nodeModules).length) {
+ const installProcess = spawn("bun", ["install"], {
+ cwd: workDir,
+ stdio: "pipe"
+ });
+
+ await new Promise((resolve, reject) => {
+ installProcess.on("close", (code) => {
+ if (code === 0) resolve(void 0);
+ else reject(new Error(`bun install failed with code ${code}`));
+ });
+ });
+ }
+
+ // 4. run user script & capture logs ---------------------------------------
+ const logs: LogLine[] = [];
+
+ let result: unknown = null;
+ try {
+ // Set environment variables
+ const env = { ...process.env, ...(config.envVars ?? {}) };
+
+ // ── spawn a new Bun process ──
+ const bunProcess = spawn("bun", ["run", runnerFile], {
+ cwd: workDir,
+ env,
+ stdio: "pipe"
+ });
+
+ let stdout = "";
+ let stderr = "";
+
+ bunProcess.stdout?.on("data", (data) => {
+ stdout += data.toString();
+ });
+
+ bunProcess.stderr?.on("data", (data) => {
+ stderr += data.toString();
+ });
+
+ await new Promise((resolve, reject) => {
+ bunProcess.on("close", (code) => {
+ if (code === 0) resolve(void 0);
+ else reject(new Error(stderr || `Process exited with code ${code}`));
+ });
+ });
+
+ if (stderr) {
+ throw new Error(stderr);
+ }
- // 4. run user script & capture logs ---------------------------------------
- const logs: LogLine[] = [];
- const proxied = new Proxy(console, {
- get(t, p) {
- if (typeof p === "string" && typeof t[p as keyof Console] === "function") {
- return (...args: unknown[]) => {
- logs.push({ message: args.map(String).join(" "), type: p });
- // @ts-ignore - let it still log to container stdout
- t[p](...args);
- };
+ // Parse the wrapped script output
+ try {
+ const lines = stdout.trim().split('\n');
+ const lastLine = lines[lines.length - 1];
+ const parsed = JSON.parse(lastLine);
+
+ if (parsed && typeof parsed === "object") {
+ if ("error" in parsed) {
+ throw new Error(parsed.error);
+ }
+ result = parsed.result;
+ logs.push(...(parsed.logs || []));
+ } else {
+ result = parsed;
+ }
+ } catch (parseError) {
+ // If JSON parsing fails, treat stdout as the result
+ result = stdout.trim();
}
- // @ts-ignore
- return t[p];
- },
- });
-
- let result: unknown = null;
- try {
- const original = globalThis.console;
- // @ts-ignore
- globalThis.console = proxied;
-
- for (const [k, v] of Object.entries(config.envVars ?? {})) Deno.env.set(k, v);
-
- const mod = await import(`file://${scriptFile}?t=${Date.now()}`);
- if (typeof mod.default !== "function") throw new Error("default export missing");
- result = await mod.default();
-
- // @ts-ignore
- globalThis.console = original;
- } catch (err) {
- return new Response(JSON.stringify({ error: err.message, logs }), {
- status: 500,
+
+ } catch (err: any) {
+ return new Response(JSON.stringify({ error: err.message, logs }), {
+ status: 500,
+ headers: { "Content-Type": "application/json" },
+ });
+ } finally {
+ try { await rm(workDir, { recursive: true }); } catch { /* ignore */ }
+ }
+
+ return new Response(JSON.stringify({ result, logs }), {
headers: { "Content-Type": "application/json" },
});
- } finally {
- try { await Deno.remove(workDir, { recursive: true }); } catch { /* ignore */ }
- }
-
- return new Response(JSON.stringify({ result, logs }), {
- headers: { "Content-Type": "application/json" },
- });
-}, { port: 8080 });
+ },
+});
EOF
# ---- network ----------------------------------------------------------------
EXPOSE 8080
# ---- launch -----------------------------------------------------------------
-CMD ["deno", "run", "--unstable", "-A", "server.ts"]
+CMD ["bun", "run", "server.ts"]
diff --git a/packages/stack-shared/src/helpers/emails.ts b/packages/stack-shared/src/helpers/emails.ts
index 23c49d22a2..e48e578c1d 100644
--- a/packages/stack-shared/src/helpers/emails.ts
+++ b/packages/stack-shared/src/helpers/emails.ts
@@ -98,7 +98,7 @@ export function EmailTheme({ children, unsubscribeLink }: ThemeProps) {
EmailTheme.PreviewProps = {
unsubscribeLink: "https://example.com"
-} satisfies Partial
+} satisfies Partial
`;
diff --git a/packages/stack-shared/src/utils/esbuild.tsx b/packages/stack-shared/src/utils/esbuild.tsx
index 443a787800..0a08a6b3c2 100644
--- a/packages/stack-shared/src/utils/esbuild.tsx
+++ b/packages/stack-shared/src/utils/esbuild.tsx
@@ -5,18 +5,20 @@ import { StackAssertionError, throwErr } from "./errors";
import { Result } from "./results";
import { traceSpan, withTraceSpan } from './telemetry';
+const esbuildWasmUrl = `https://unpkg.com/esbuild-wasm@${esbuild.version}/esbuild.wasm`;
+
let esbuildInitializePromise: Promise | null = null;
// esbuild requires self property to be set, and it is not set by default in nodejs
(globalThis.self as any) ??= globalThis as any;
-export async function initializeEsbuild() {
+export function initializeEsbuild(): Promise {
if (!esbuildInitializePromise) {
esbuildInitializePromise = withTraceSpan('initializeEsbuild', async () => {
await esbuild.initialize(isBrowserLike() ? {
- wasmURL: `https://unpkg.com/esbuild-wasm@${esbuild.version}/esbuild.wasm`,
+ wasmURL: esbuildWasmUrl,
} : {
wasmModule: (
- await fetch(`https://unpkg.com/esbuild-wasm@${esbuild.version}/esbuild.wasm`)
+ await fetch(esbuildWasmUrl)
.then(wasm => wasm.arrayBuffer())
.then(wasm => new WebAssembly.Module(wasm))
),
@@ -24,7 +26,8 @@ export async function initializeEsbuild() {
});
})();
}
- await esbuildInitializePromise;
+
+ return esbuildInitializePromise;
}
export async function bundleJavaScript(sourceFiles: Record & { '/entry.js': string }, options: {
diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
index b6e9a3ef1c..8a6cb347ed 100644
--- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
+++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
@@ -943,7 +943,7 @@ export class _StackServerAppImplIncomplete> {
- return this._interface.sendEmail(options);
+ return await this._interface.sendEmail(options);
}
protected override async _refreshSession(session: InternalSession) {