diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx index 4ba3c57574..77683b475a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx @@ -155,7 +155,7 @@ function EditEmailServerDialog(props: { senderEmail: emailConfig.senderEmail, senderName: emailConfig.senderName, provider: emailConfig.type === 'resend' ? 'resend' : 'smtp', - }, + } satisfies CompleteConfig['emails']['server'] }); toast({ diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts index aaa7e6fe4f..d1f796a499 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts @@ -1,3 +1,4 @@ +import { DEFAULT_EMAIL_THEME_ID } from "@stackframe/stack-shared/dist/helpers/emails"; import { pick } from "@stackframe/stack-shared/dist/utils/objects"; import { it } from "../../../../../helpers"; import { Project, niceBackendFetch } from "../../../../backend-helpers"; @@ -531,3 +532,102 @@ it("adds, updates, and removes domains", async ({ expect }) => { // Second domain should still be there expect(configWithUpdatedDomain.domains.trustedDomains['domain-2']).toBeDefined(); }); + +it("only keeps custom email templates when using a dedicated email server", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + magic_link_enabled: true, + } + }); + + const customTemplate = { + displayName: "Custom Reset", + tsxSource: "export const EmailTemplate = () => null;", + themeId: DEFAULT_EMAIL_THEME_ID, + }; + const customTemplateId = "11111111-1111-4111-8111-111111111111"; + + const configureServer = (server: Record) => niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'emails.server': server, + }), + }, + }); + const upsertTemplate = (template: typeof customTemplate | null) => niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + [`emails.templates.${customTemplateId}`]: template, + }), + }, + }); + + const dedicatedServer = { + isShared: false, + provider: 'smtp', + host: 'smtp.example.com', + port: 587, + username: 'smtp-user', + password: 'smtp-pass', + senderName: 'Stack', + senderEmail: 'noreply@example.com', + }; + + const setDedicatedResponse = await configureServer(dedicatedServer); + expect(setDedicatedResponse.status).toBe(200); + + const addTemplateResponse = await upsertTemplate(customTemplate); + expect(addTemplateResponse.status).toBe(200); + + const initialConfigResponse = await niceBackendFetch("/api/v1/internal/config", { + method: "GET", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + }); + const initialConfig = JSON.parse(initialConfigResponse.body.config_string); + expect(initialConfig.emails.server.isShared).toBe(false); + expect(initialConfig.emails.templates[customTemplateId]).toEqual(customTemplate); + + const setSharedResponse = await configureServer({ + isShared: true, + provider: 'smtp', + }); + expect(setSharedResponse.status).toBe(200); + + const sharedConfigResponse = await niceBackendFetch("/api/v1/internal/config", { + method: "GET", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + }); + const sharedConfig = JSON.parse(sharedConfigResponse.body.config_string); + expect(sharedConfig.emails.server.isShared).toBe(true); + expect(sharedConfig.emails.templates[customTemplateId]).toBeUndefined(); + + const restoreDedicatedResponse = await configureServer(dedicatedServer); + expect(restoreDedicatedResponse.status).toBe(200); + + const dedicatedConfigResponse = await niceBackendFetch("/api/v1/internal/config", { + method: "GET", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + }); + const dedicatedConfig = JSON.parse(dedicatedConfigResponse.body.config_string); + expect(dedicatedConfig.emails.server.isShared).toBe(false); + expect(dedicatedConfig.emails.templates[customTemplateId]).toEqual(customTemplate); +}); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 37314f66c5..8835d5953d 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -752,7 +752,7 @@ export async function sanitizeOrganizationConfig(config: OrganizationRenderedCon }; const templates: typeof prepared.emails.templates = { ...DEFAULT_EMAIL_TEMPLATES, - ...prepared.emails.templates, + ...(config.emails.server.isShared ? {} : prepared.emails.templates), }; const products = typedFromEntries(typedEntries(prepared.payments.products).map(([key, product]) => { const isAddOnTo = product.isAddOnTo === false ?