Skip to content

Commit 39a4b05

Browse files
BilalG1N2D4
andauthored
resend api key config (#851)
https://www.loom.com/share/11affd2a119549c18a4056ad5db34cb6?sid=c86dd093-b8ca-4600-afb1-dda78e40b6a5 <!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Adds support for 'resend' email provider, updating configurations, schemas, and UI components to handle the new provider. > > - **Behavior**: > - Adds support for 'resend' email provider in `createOrUpdateProjectWithLegacyConfig()` in `projects.tsx`. > - Updates `PageClient` in `page-client.tsx` to handle 'resend' provider in email configuration. > - Modifies `EditEmailServerDialog` to include 'resend' option and handle its configuration. > - **Schema**: > - Updates `environmentConfigSchema` in `schema.ts` to include 'resend' as a valid provider. > - Modifies `AdminEmailConfig` type to include 'resend' in `project-configs/index.ts`. > - **UI**: > - Updates `SendEmailDialog` and `TestSendingDialog` in `page-client.tsx` to handle 'resend' provider. > - Adjusts form fields in `EditEmailServerDialog` to support 'resend' specific fields like API Key. > > <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 615fd72. 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 - Added support for Resend email provider alongside Shared and Custom SMTP. - Introduced an in-app test to verify email settings before saving. - New Shared Email Server dialog with guidance and warnings. - Improvements - Streamlined email configuration with a type dropdown and conditional fields. - Clearer defaults and display text, including noreply@stackframe.co for Shared setups. - Enhanced validation tailored to each email mode. - Chores - Updated configuration schema to include a provider field for email servers. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
1 parent 32b42ad commit 39a4b05

4 files changed

Lines changed: 109 additions & 61 deletions

File tree

apps/backend/src/lib/projects.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export async function createOrUpdateProjectWithLegacyConfig(
216216
password: dataOptions.email_config.password,
217217
senderName: dataOptions.email_config.sender_name,
218218
senderEmail: dataOptions.email_config.sender_email,
219+
provider: "smtp",
219220
} satisfies CompleteConfig['emails']['server'] : undefined,
220221
'emails.selectedThemeId': dataOptions.email_theme,
221222
// ======================= rbac =======================

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

Lines changed: 96 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,19 @@ import { strictEmailSchema } from "@stackframe/stack-shared/dist/schema-fields";
1010
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
1111
import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects";
1212
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
13-
import { ActionDialog, Alert, Button, DataTable, SimpleTooltip, Typography, useToast, Input, Textarea, TooltipProvider, TooltipTrigger, TooltipContent, Tooltip, AlertDescription, AlertTitle } from "@stackframe/stack-ui";
13+
import { ActionDialog, Alert, Button, DataTable, SimpleTooltip, Typography, useToast, TooltipProvider, TooltipTrigger, TooltipContent, Tooltip, AlertDescription, AlertTitle } from "@stackframe/stack-ui";
1414
import { ColumnDef } from "@tanstack/react-table";
1515
import { AlertCircle, X } from "lucide-react";
1616
import { useEffect, useMemo, useState } from "react";
1717
import * as yup from "yup";
1818
import { PageLayout } from "../page-layout";
1919
import { useAdminApp } from "../use-admin-app";
20+
import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema";
2021

2122
export default function PageClient() {
2223
const stackAdminApp = useAdminApp();
2324
const project = stackAdminApp.useProject();
24-
const emailConfig = project.config.emailConfig;
25+
const emailConfig = project.useConfig().emails.server;
2526

2627
return (
2728
<PageLayout
@@ -30,7 +31,7 @@ export default function PageClient() {
3031
actions={
3132
<SendEmailDialog
3233
trigger={<Button>Send Email</Button>}
33-
emailConfigType={emailConfig?.type}
34+
emailConfig={emailConfig}
3435
/>
3536
}
3637
>
@@ -51,21 +52,21 @@ export default function PageClient() {
5152
description="Configure the email server and sender address for outgoing emails"
5253
actions={
5354
<div className="flex items-center gap-2">
54-
{emailConfig?.type === 'standard' && <TestSendingDialog trigger={<Button variant='secondary' className="w-full">Send Test Email</Button>} />}
55+
{!emailConfig.isShared && <TestSendingDialog trigger={<Button variant='secondary' className="w-full">Send Test Email</Button>} />}
5556
<EditEmailServerDialog trigger={<Button variant='secondary' className="w-full">Configure</Button>} />
5657
</div>
5758
}
5859
>
5960
<SettingText label="Server">
6061
<div className="flex items-center gap-2">
61-
{emailConfig?.type === 'standard' ?
62-
'Custom SMTP server' :
62+
{emailConfig.isShared ?
6363
<>Shared <SimpleTooltip tooltip="When you use the shared email server, all the emails are sent from Stack's email address" type='info' /></>
64+
: (emailConfig.provider === 'resend' ? "Resend" : "Custom SMTP server")
6465
}
6566
</div>
6667
</SettingText>
6768
<SettingText label="Sender Email">
68-
{emailConfig?.type === 'standard' ? emailConfig.senderEmail : 'noreply@stackframe.co'}
69+
{emailConfig.isShared ? 'noreply@stackframe.co' : emailConfig.senderEmail}
6970
</SettingText>
7071
</SettingCard>
7172
)}
@@ -76,19 +77,26 @@ export default function PageClient() {
7677
);
7778
}
7879

79-
function definedWhenNotShared<S extends yup.AnyObject>(schema: S, message: string): S {
80+
function definedWhenTypeIsOneOf<S extends yup.AnyObject>(schema: S, types: string[], message: string): S {
8081
return schema.when('type', {
81-
is: 'standard',
82+
is: (t: string) => types.includes(t),
8283
then: (schema: S) => schema.defined(message),
8384
otherwise: (schema: S) => schema.optional()
8485
});
8586
}
8687

87-
const getDefaultValues = (emailConfig: AdminEmailConfig | undefined, project: AdminProject) => {
88+
const getDefaultValues = (emailConfig: CompleteConfig['emails']['server'] | undefined, project: AdminProject) => {
8889
if (!emailConfig) {
8990
return { type: 'shared', senderName: project.displayName } as const;
90-
} else if (emailConfig.type === 'shared') {
91+
} else if (emailConfig.isShared) {
9192
return { type: 'shared' } as const;
93+
} else if (emailConfig.provider === 'resend') {
94+
return {
95+
type: 'resend',
96+
senderEmail: emailConfig.senderEmail,
97+
senderName: emailConfig.senderName,
98+
password: emailConfig.password,
99+
} as const;
92100
} else {
93101
return {
94102
type: 'standard',
@@ -103,25 +111,59 @@ const getDefaultValues = (emailConfig: AdminEmailConfig | undefined, project: Ad
103111
};
104112

105113
const emailServerSchema = yup.object({
106-
type: yup.string().oneOf(['shared', 'standard']).defined(),
107-
host: definedWhenNotShared(yup.string(), "Host is required"),
108-
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"),
109-
username: definedWhenNotShared(yup.string(), "Username is required"),
110-
password: definedWhenNotShared(yup.string(), "Password is required"),
111-
senderEmail: definedWhenNotShared(strictEmailSchema("Sender email must be a valid email"), "Sender email is required"),
112-
senderName: definedWhenNotShared(yup.string(), "Email sender name is required"),
114+
type: yup.string().oneOf(['shared', 'standard', 'resend']).defined(),
115+
host: definedWhenTypeIsOneOf(yup.string(), ["standard"], "Host is required"),
116+
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"),
117+
username: definedWhenTypeIsOneOf(yup.string(), ["standard"], "Username is required"),
118+
password: definedWhenTypeIsOneOf(yup.string(), ["standard", "resend"], "Password is required"),
119+
senderEmail: definedWhenTypeIsOneOf(strictEmailSchema("Sender email must be a valid email"), ["standard", "resend"], "Sender email is required"),
120+
senderName: definedWhenTypeIsOneOf(yup.string(), ["standard", "resend"], "Email sender name is required"),
113121
});
114122

115123
function EditEmailServerDialog(props: {
116124
trigger: React.ReactNode,
117125
}) {
118126
const stackAdminApp = useAdminApp();
119127
const project = stackAdminApp.useProject();
128+
const config = project.useConfig();
120129
const [error, setError] = useState<string | null>(null);
121130
const [formValues, setFormValues] = useState<any>(null);
122-
const defaultValues = useMemo(() => getDefaultValues(project.config.emailConfig, project), [project]);
131+
const defaultValues = useMemo(() => getDefaultValues(config.emails.server, project), [config, project]);
123132
const { toast } = useToast();
124133

134+
async function testEmailAndUpdateConfig(emailConfig: AdminEmailConfig & { type: "standard" | "resend" }) {
135+
const testResult = await stackAdminApp.sendTestEmail({
136+
recipientEmail: 'test-email-recipient@stackframe.co',
137+
emailConfig,
138+
});
139+
140+
if (testResult.status === 'error') {
141+
setError(testResult.error.errorMessage);
142+
return 'prevent-close-and-prevent-reset';
143+
}
144+
setError(null);
145+
await project.updateConfig({
146+
emails: {
147+
server: {
148+
isShared: false,
149+
host: emailConfig.host,
150+
port: emailConfig.port,
151+
username: emailConfig.username,
152+
password: emailConfig.password,
153+
senderEmail: emailConfig.senderEmail,
154+
senderName: emailConfig.senderName,
155+
provider: emailConfig.type === 'resend' ? 'resend' : 'smtp',
156+
}
157+
}
158+
});
159+
160+
toast({
161+
title: "Email server updated",
162+
description: "The email server has been updated. You can now send test emails to verify the configuration.",
163+
variant: 'success',
164+
});
165+
}
166+
125167
return <FormDialog
126168
trigger={props.trigger}
127169
title="Edit Email Server"
@@ -135,45 +177,31 @@ function EditEmailServerDialog(props: {
135177
emailConfig: { type: 'shared' }
136178
}
137179
});
180+
} else if (values.type === 'resend') {
181+
if (!values.password || !values.senderEmail || !values.senderName) {
182+
throwErr("Missing email server config for Resend");
183+
}
184+
return await testEmailAndUpdateConfig({
185+
type: 'resend',
186+
host: 'smtp.resend.com',
187+
port: 465,
188+
username: 'resend',
189+
password: values.password,
190+
senderEmail: values.senderEmail,
191+
senderName: values.senderName,
192+
});
138193
} else {
139194
if (!values.host || !values.port || !values.username || !values.password || !values.senderEmail || !values.senderName) {
140195
throwErr("Missing email server config for custom SMTP server");
141196
}
142-
143-
const emailConfig = {
197+
return await testEmailAndUpdateConfig({
198+
type: 'standard',
144199
host: values.host,
145200
port: values.port,
146201
username: values.username,
147202
password: values.password,
148203
senderEmail: values.senderEmail,
149-
senderName: values.senderName,
150-
};
151-
152-
const testResult = await stackAdminApp.sendTestEmail({
153-
recipientEmail: 'test-email-recipient@stackframe.co',
154-
emailConfig: emailConfig,
155-
});
156-
157-
if (testResult.status === 'error') {
158-
setError(testResult.error.errorMessage);
159-
return 'prevent-close-and-prevent-reset';
160-
} else {
161-
setError(null);
162-
}
163-
164-
await project.update({
165-
config: {
166-
emailConfig: {
167-
type: 'standard',
168-
...emailConfig,
169-
}
170-
}
171-
});
172-
173-
toast({
174-
title: "Email server updated",
175-
description: "The email server has been updated. You can now send test emails to verify the configuration.",
176-
variant: 'success',
204+
senderName: values.senderName
177205
});
178206
}
179207
}}
@@ -193,9 +221,26 @@ function EditEmailServerDialog(props: {
193221
control={form.control}
194222
options={[
195223
{ label: "Shared (noreply@stackframe.co)", value: 'shared' },
224+
{ label: "Resend (your own email address)", value: 'resend' },
196225
{ label: "Custom SMTP server (your own email address)", value: 'standard' },
197226
]}
198227
/>
228+
{form.watch('type') === 'resend' && <>
229+
{([
230+
{ label: "Resend API Key", name: "password", type: 'password' },
231+
{ label: "Sender Email", name: "senderEmail", type: 'email' },
232+
{ label: "Sender Name", name: "senderName", type: 'text' },
233+
] as const).map((field) => (
234+
<InputField
235+
key={field.name}
236+
label={field.label}
237+
name={field.name}
238+
control={form.control}
239+
type={field.type}
240+
required
241+
/>
242+
))}
243+
</>}
199244
{form.watch('type') === 'standard' && <>
200245
{([
201246
{ label: "Host", name: "host", type: 'text' },
@@ -327,7 +372,7 @@ function EmailSendDataTable() {
327372

328373
function SendEmailDialog(props: {
329374
trigger: React.ReactNode,
330-
emailConfigType?: AdminEmailConfig['type'],
375+
emailConfig: CompleteConfig['emails']['server'],
331376
}) {
332377
const stackAdminApp = useAdminApp();
333378
const { toast } = useToast();
@@ -420,7 +465,7 @@ function SendEmailDialog(props: {
420465
<>
421466
<div
422467
onClick={() => {
423-
if (props.emailConfigType === 'standard') {
468+
if (!props.emailConfig.isShared) {
424469
setOpen(true);
425470
} else {
426471
setSharedSmtpDialogOpen(true);

packages/stack-shared/src/config/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({
212212
emails: branchConfigSchema.getNested("emails").concat(yupObject({
213213
server: yupObject({
214214
isShared: yupBoolean(),
215+
provider: yupString().oneOf(['resend', 'smtp']).optional(),
215216
host: schemaFields.emailHostSchema.optional().nonEmpty(),
216217
port: schemaFields.emailPortSchema.optional(),
217218
username: schemaFields.emailUsernameSchema.optional().nonEmpty(),
@@ -449,6 +450,7 @@ const organizationConfigDefaults = {
449450
emails: {
450451
server: {
451452
isShared: true,
453+
provider: "smtp",
452454
host: undefined,
453455
port: undefined,
454456
username: undefined,

packages/template/src/lib/stack-app/project-configs/index.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export type AdminProjectConfig = {
3939

4040
export type AdminEmailConfig = (
4141
{
42-
type: "standard",
42+
type: "standard" | "resend",
4343
senderName: string,
4444
senderEmail: string,
4545
host: string,
@@ -60,15 +60,15 @@ export type AdminDomainConfig = {
6060
export type AdminOAuthProviderConfig = {
6161
id: string,
6262
} & (
63-
| { type: 'shared' }
64-
| {
65-
type: 'standard',
66-
clientId: string,
67-
clientSecret: string,
68-
facebookConfigId?: string,
69-
microsoftTenantId?: string,
70-
}
71-
) & OAuthProviderConfig;
63+
| { type: 'shared' }
64+
| {
65+
type: 'standard',
66+
clientId: string,
67+
clientSecret: string,
68+
facebookConfigId?: string,
69+
microsoftTenantId?: string,
70+
}
71+
) & OAuthProviderConfig;
7272

7373
export type AdminProjectConfigUpdateOptions = {
7474
domains?: {

0 commit comments

Comments
 (0)