Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
772f7ec
wip drafts
BilalG1 Aug 16, 2025
83fd955
wip on send-email route
BilalG1 Aug 18, 2025
060335c
email drafts
BilalG1 Aug 18, 2025
2a08ae7
lint fixes
BilalG1 Aug 18, 2025
8bab612
small fixes
BilalG1 Aug 18, 2025
0f1c9a3
fix tests
BilalG1 Aug 18, 2025
7461754
Merge branch 'dev' into email-drafts
BilalG1 Aug 18, 2025
e654986
small fixes
BilalG1 Aug 18, 2025
e5eb478
Merge dev into email-drafts
N2D4 Aug 19, 2025
d5fa738
resend api
BilalG1 Aug 19, 2025
773d5e5
Merge branch 'email-drafts' into resend-api-key-config
BilalG1 Aug 19, 2025
3e4262d
fix default
BilalG1 Aug 19, 2025
83cf1c4
fix email config dialog schema
BilalG1 Aug 19, 2025
e48d423
Merge branch 'dev' into email-drafts
BilalG1 Aug 19, 2025
0e6ecc6
Merge branch 'email-drafts' into resend-api-key-config
BilalG1 Aug 19, 2025
fa0ee35
merge dev
BilalG1 Aug 26, 2025
33f8d6d
email-draft tests
BilalG1 Aug 26, 2025
c7985ff
fix import
BilalG1 Aug 26, 2025
8fc939d
merge
BilalG1 Aug 27, 2025
b9e0630
Merge branch 'dev' into email-drafts
N2D4 Aug 28, 2025
76c45cf
Fix Supabase example ports
N2D4 Aug 28, 2025
856c0c5
Merge branch 'dev' into email-drafts
N2D4 Aug 28, 2025
fa1bc34
merge dev
BilalG1 Sep 2, 2025
3c34140
Merge branch 'dev' into email-drafts
BilalG1 Sep 2, 2025
88a10cf
Merge branch 'email-drafts' into resend-api-key-config
BilalG1 Sep 2, 2025
9594392
merge dev
BilalG1 Sep 10, 2025
c7ebb00
fix type issue
BilalG1 Sep 10, 2025
615fd72
Merge remote-tracking branch 'origin/email-drafts' into resend-api-ke…
BilalG1 Sep 10, 2025
0b57f9e
Merge dev into resend-api-key-config
N2D4 Sep 11, 2025
795c404
Merge branch 'dev' into resend-api-key-config
N2D4 Sep 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/src/lib/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =======================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<PageLayout
Expand All @@ -30,7 +31,7 @@ export default function PageClient() {
actions={
<SendEmailDialog
trigger={<Button>Send Email</Button>}
emailConfigType={emailConfig?.type}
emailConfig={emailConfig}
/>
}
>
Expand All @@ -51,21 +52,21 @@ export default function PageClient() {
description="Configure the email server and sender address for outgoing emails"
actions={
<div className="flex items-center gap-2">
{emailConfig?.type === 'standard' && <TestSendingDialog trigger={<Button variant='secondary' className="w-full">Send Test Email</Button>} />}
{!emailConfig.isShared && <TestSendingDialog trigger={<Button variant='secondary' className="w-full">Send Test Email</Button>} />}
<EditEmailServerDialog trigger={<Button variant='secondary' className="w-full">Configure</Button>} />
</div>
}
>
<SettingText label="Server">
<div className="flex items-center gap-2">
{emailConfig?.type === 'standard' ?
'Custom SMTP server' :
{emailConfig.isShared ?
<>Shared <SimpleTooltip tooltip="When you use the shared email server, all the emails are sent from Stack's email address" type='info' /></>
: (emailConfig.provider === 'resend' ? "Resend" : "Custom SMTP server")
}
</div>
</SettingText>
<SettingText label="Sender Email">
{emailConfig?.type === 'standard' ? emailConfig.senderEmail : '[email protected]'}
{emailConfig.isShared ? '[email protected]' : emailConfig.senderEmail}
</SettingText>
</SettingCard>
)}
Expand All @@ -76,19 +77,26 @@ export default function PageClient() {
);
}

function definedWhenNotShared<S extends yup.AnyObject>(schema: S, message: string): S {
function definedWhenTypeIsOneOf<S extends yup.AnyObject>(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()
});
}
Comment on lines +80 to 86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix Biome “noThenProperty” lint error and broaden generic.

Use the functional when builder to avoid a then property and accept any Yup schema, not just objects.

-function definedWhenTypeIsOneOf<S extends yup.AnyObject>(schema: S, types: string[], message: string): S {
-  return schema.when('type', {
-    is: (t: string) => types.includes(t),
-    then: (schema: S) => schema.defined(message),
-    otherwise: (schema: S) => schema.optional()
-  });
-}
+function definedWhenTypeIsOneOf<S extends yup.AnySchema>(schema: S, types: string[], message: string): S {
+  // Avoid "then" property (thenable) objects per lint rule.
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  return (schema as any).when('type', (val: unknown, s: S) =>
+    types.includes(String(val)) ? s.defined(message) : s.optional()
+  );
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function definedWhenTypeIsOneOf<S extends yup.AnyObject>(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()
});
}
function definedWhenTypeIsOneOf<S extends yup.AnySchema>(schema: S, types: string[], message: string): S {
// Avoid "then" property (thenable) objects per lint rule.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (schema as any).when('type', (val: unknown, s: S) =>
types.includes(String(val)) ? s.defined(message) : s.optional()
);
}
🧰 Tools
🪛 Biome (2.1.2)

[error] 83-83: Do not add then to an object.

(lint/suspicious/noThenProperty)

🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx
around lines 80 to 86, the helper currently uses the object form of yup.when
(causing the Biome noThenProperty lint error) and constrains the generic to
yup.AnyObject; change the signature to accept any Yup schema (e.g. S extends
yup.Schema<any>) and switch to the functional when builder: call
schema.when('type', (typeValue, currentSchema) => types.includes(typeValue as
string) ? currentSchema.defined(message) : currentSchema.optional()), removing
the object with then/otherwise to satisfy the linter and broaden the accepted
schema type.


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',
Expand All @@ -103,25 +111,59 @@ 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: {
trigger: React.ReactNode,
}) {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProject();
const config = project.useConfig();
const [error, setError] = useState<string | null>(null);
const [formValues, setFormValues] = useState<any>(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: '[email protected]',
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 <FormDialog
trigger={props.trigger}
title="Edit Email Server"
Expand All @@ -135,45 +177,31 @@ function EditEmailServerDialog(props: {
emailConfig: { type: 'shared' }
}
});
} else if (values.type === 'resend') {
if (!values.password || !values.senderEmail || !values.senderName) {
throwErr("Missing email server config for Resend");
}
return await testEmailAndUpdateConfig({
type: 'resend',
host: 'smtp.resend.com',
port: 465,
username: 'resend',
password: values.password,
senderEmail: values.senderEmail,
senderName: values.senderName,
});
} else {
if (!values.host || !values.port || !values.username || !values.password || !values.senderEmail || !values.senderName) {
throwErr("Missing email server config for custom SMTP server");
}

const emailConfig = {
return await testEmailAndUpdateConfig({
type: 'standard',
host: values.host,
port: values.port,
username: values.username,
password: values.password,
senderEmail: values.senderEmail,
senderName: values.senderName,
};

const testResult = await stackAdminApp.sendTestEmail({
recipientEmail: '[email protected]',
emailConfig: emailConfig,
});

if (testResult.status === 'error') {
setError(testResult.error.errorMessage);
return 'prevent-close-and-prevent-reset';
} else {
setError(null);
}

await project.update({
config: {
emailConfig: {
type: 'standard',
...emailConfig,
}
}
});

toast({
title: "Email server updated",
description: "The email server has been updated. You can now send test emails to verify the configuration.",
variant: 'success',
senderName: values.senderName
});
}
}}
Expand All @@ -193,9 +221,26 @@ function EditEmailServerDialog(props: {
control={form.control}
options={[
{ label: "Shared ([email protected])", value: 'shared' },
{ label: "Resend (your own email address)", value: 'resend' },
{ label: "Custom SMTP server (your own email address)", value: 'standard' },
]}
/>
{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) => (
<InputField
key={field.name}
label={field.label}
name={field.name}
control={form.control}
type={field.type}
required
/>
))}
</>}
{form.watch('type') === 'standard' && <>
{([
{ label: "Host", name: "host", type: 'text' },
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -420,7 +465,7 @@ function SendEmailDialog(props: {
<>
<div
onClick={() => {
if (props.emailConfigType === 'standard') {
if (!props.emailConfig.isShared) {
setOpen(true);
} else {
setSharedSmtpDialogOpen(true);
Expand Down
2 changes: 2 additions & 0 deletions packages/stack-shared/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -449,6 +450,7 @@ const organizationConfigDefaults = {
emails: {
server: {
isShared: true,
provider: "smtp",
host: undefined,
port: undefined,
username: undefined,
Expand Down
20 changes: 10 additions & 10 deletions packages/template/src/lib/stack-app/project-configs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export type AdminProjectConfig = {

export type AdminEmailConfig = (
{
type: "standard",
type: "standard" | "resend",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Adding 'resend' to the type union but requiring SMTP fields (host, port, username, password) creates a configuration mismatch since Resend uses API keys, not SMTP credentials

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea shouldn't this be another union option?

senderName: string,
senderEmail: string,
host: string,
Expand All @@ -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?: {
Expand Down
Loading