diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 8eee2ad6ea..3b94102f54 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -216,6 +216,7 @@ export async function createOrUpdateProjectWithLegacyConfig( password: dataOptions.email_config.password, senderName: dataOptions.email_config.sender_name, senderEmail: dataOptions.email_config.sender_email, + provider: "smtp", } satisfies CompleteConfig['emails']['server'] : undefined, 'emails.selectedThemeId': dataOptions.email_theme, // ======================= rbac ======================= 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 9a00567cd6..0626b26636 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 @@ -10,18 +10,19 @@ import { strictEmailSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; -import { ActionDialog, Alert, Button, DataTable, SimpleTooltip, Typography, useToast, Input, Textarea, TooltipProvider, TooltipTrigger, TooltipContent, Tooltip, AlertDescription, AlertTitle } from "@stackframe/stack-ui"; +import { ActionDialog, Alert, Button, DataTable, SimpleTooltip, Typography, useToast, TooltipProvider, TooltipTrigger, TooltipContent, Tooltip, AlertDescription, AlertTitle } from "@stackframe/stack-ui"; import { ColumnDef } from "@tanstack/react-table"; import { AlertCircle, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import * as yup from "yup"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; +import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; export default function PageClient() { const stackAdminApp = useAdminApp(); const project = stackAdminApp.useProject(); - const emailConfig = project.config.emailConfig; + const emailConfig = project.useConfig().emails.server; return ( Send Email} - emailConfigType={emailConfig?.type} + emailConfig={emailConfig} /> } > @@ -51,21 +52,21 @@ export default function PageClient() { description="Configure the email server and sender address for outgoing emails" actions={
- {emailConfig?.type === 'standard' && Send Test Email} />} + {!emailConfig.isShared && Send Test Email} />} Configure} />
} >
- {emailConfig?.type === 'standard' ? - 'Custom SMTP server' : + {emailConfig.isShared ? <>Shared + : (emailConfig.provider === 'resend' ? "Resend" : "Custom SMTP server") }
- {emailConfig?.type === 'standard' ? emailConfig.senderEmail : 'noreply@stackframe.co'} + {emailConfig.isShared ? 'noreply@stackframe.co' : emailConfig.senderEmail} )} @@ -76,19 +77,26 @@ export default function PageClient() { ); } -function definedWhenNotShared(schema: S, message: string): S { +function definedWhenTypeIsOneOf(schema: S, types: string[], message: string): S { return schema.when('type', { - is: 'standard', + is: (t: string) => types.includes(t), then: (schema: S) => schema.defined(message), otherwise: (schema: S) => schema.optional() }); } -const getDefaultValues = (emailConfig: AdminEmailConfig | undefined, project: AdminProject) => { +const getDefaultValues = (emailConfig: CompleteConfig['emails']['server'] | undefined, project: AdminProject) => { if (!emailConfig) { return { type: 'shared', senderName: project.displayName } as const; - } else if (emailConfig.type === 'shared') { + } else if (emailConfig.isShared) { return { type: 'shared' } as const; + } else if (emailConfig.provider === 'resend') { + return { + type: 'resend', + senderEmail: emailConfig.senderEmail, + senderName: emailConfig.senderName, + password: emailConfig.password, + } as const; } else { return { type: 'standard', @@ -103,13 +111,13 @@ const getDefaultValues = (emailConfig: AdminEmailConfig | undefined, project: Ad }; const emailServerSchema = yup.object({ - type: yup.string().oneOf(['shared', 'standard']).defined(), - host: definedWhenNotShared(yup.string(), "Host is required"), - port: definedWhenNotShared(yup.number().min(0, "Port must be a number between 0 and 65535").max(65535, "Port must be a number between 0 and 65535"), "Port is required"), - username: definedWhenNotShared(yup.string(), "Username is required"), - password: definedWhenNotShared(yup.string(), "Password is required"), - senderEmail: definedWhenNotShared(strictEmailSchema("Sender email must be a valid email"), "Sender email is required"), - senderName: definedWhenNotShared(yup.string(), "Email sender name is required"), + type: yup.string().oneOf(['shared', 'standard', 'resend']).defined(), + host: definedWhenTypeIsOneOf(yup.string(), ["standard"], "Host is required"), + port: definedWhenTypeIsOneOf(yup.number().min(0, "Port must be a number between 0 and 65535").max(65535, "Port must be a number between 0 and 65535"), ["standard"], "Port is required"), + username: definedWhenTypeIsOneOf(yup.string(), ["standard"], "Username is required"), + password: definedWhenTypeIsOneOf(yup.string(), ["standard", "resend"], "Password is required"), + senderEmail: definedWhenTypeIsOneOf(strictEmailSchema("Sender email must be a valid email"), ["standard", "resend"], "Sender email is required"), + senderName: definedWhenTypeIsOneOf(yup.string(), ["standard", "resend"], "Email sender name is required"), }); function EditEmailServerDialog(props: { @@ -117,11 +125,45 @@ function EditEmailServerDialog(props: { }) { const stackAdminApp = useAdminApp(); const project = stackAdminApp.useProject(); + const config = project.useConfig(); const [error, setError] = useState(null); const [formValues, setFormValues] = useState(null); - const defaultValues = useMemo(() => getDefaultValues(project.config.emailConfig, project), [project]); + const defaultValues = useMemo(() => getDefaultValues(config.emails.server, project), [config, project]); const { toast } = useToast(); + async function testEmailAndUpdateConfig(emailConfig: AdminEmailConfig & { type: "standard" | "resend" }) { + const testResult = await stackAdminApp.sendTestEmail({ + recipientEmail: 'test-email-recipient@stackframe.co', + emailConfig, + }); + + if (testResult.status === 'error') { + setError(testResult.error.errorMessage); + return 'prevent-close-and-prevent-reset'; + } + setError(null); + await project.updateConfig({ + emails: { + server: { + isShared: false, + host: emailConfig.host, + port: emailConfig.port, + username: emailConfig.username, + password: emailConfig.password, + senderEmail: emailConfig.senderEmail, + senderName: emailConfig.senderName, + provider: emailConfig.type === 'resend' ? 'resend' : 'smtp', + } + } + }); + + toast({ + title: "Email server updated", + description: "The email server has been updated. You can now send test emails to verify the configuration.", + variant: 'success', + }); + } + return + {form.watch('type') === 'resend' && <> + {([ + { label: "Resend API Key", name: "password", type: 'password' }, + { label: "Sender Email", name: "senderEmail", type: 'email' }, + { label: "Sender Name", name: "senderName", type: 'text' }, + ] as const).map((field) => ( + + ))} + } {form.watch('type') === 'standard' && <> {([ { label: "Host", name: "host", type: 'text' }, @@ -327,7 +372,7 @@ function EmailSendDataTable() { function SendEmailDialog(props: { trigger: React.ReactNode, - emailConfigType?: AdminEmailConfig['type'], + emailConfig: CompleteConfig['emails']['server'], }) { const stackAdminApp = useAdminApp(); const { toast } = useToast(); @@ -420,7 +465,7 @@ function SendEmailDialog(props: { <>
{ - if (props.emailConfigType === 'standard') { + if (!props.emailConfig.isShared) { setOpen(true); } else { setSharedSmtpDialogOpen(true); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index bee1837aca..7c917e1ee6 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -212,6 +212,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ emails: branchConfigSchema.getNested("emails").concat(yupObject({ server: yupObject({ isShared: yupBoolean(), + provider: yupString().oneOf(['resend', 'smtp']).optional(), host: schemaFields.emailHostSchema.optional().nonEmpty(), port: schemaFields.emailPortSchema.optional(), username: schemaFields.emailUsernameSchema.optional().nonEmpty(), @@ -449,6 +450,7 @@ const organizationConfigDefaults = { emails: { server: { isShared: true, + provider: "smtp", host: undefined, port: undefined, username: undefined, diff --git a/packages/template/src/lib/stack-app/project-configs/index.ts b/packages/template/src/lib/stack-app/project-configs/index.ts index ad4d84695e..5bc3f91e37 100644 --- a/packages/template/src/lib/stack-app/project-configs/index.ts +++ b/packages/template/src/lib/stack-app/project-configs/index.ts @@ -39,7 +39,7 @@ export type AdminProjectConfig = { export type AdminEmailConfig = ( { - type: "standard", + type: "standard" | "resend", senderName: string, senderEmail: string, host: string, @@ -60,15 +60,15 @@ export type AdminDomainConfig = { export type AdminOAuthProviderConfig = { id: string, } & ( - | { type: 'shared' } - | { - type: 'standard', - clientId: string, - clientSecret: string, - facebookConfigId?: string, - microsoftTenantId?: string, - } -) & OAuthProviderConfig; + | { type: 'shared' } + | { + type: 'standard', + clientId: string, + clientSecret: string, + facebookConfigId?: string, + microsoftTenantId?: string, + } + ) & OAuthProviderConfig; export type AdminProjectConfigUpdateOptions = { domains?: {