SmtpForm.tsx497 lines · main
1// @ts-nocheck
2import { zodResolver } from '@hookform/resolvers/zod'
3import { PermissionAction } from '@supabase/shared-types/out/constants'
4import { useParams } from 'common'
5import { useEffect, useState } from 'react'
6import { SubmitHandler, useForm } from 'react-hook-form'
7import { toast } from 'sonner'
8import {
9 Button,
10 Card,
11 CardContent,
12 CardFooter,
13 cn,
14 Form,
15 FormControl,
16 FormField,
17 FormInputGroupInput,
18 Input,
19 InputGroup,
20 InputGroupAddon,
21 InputGroupText,
22 Switch,
23} from 'ui'
24import { Admonition, PageSection, PageSectionContent } from 'ui-patterns'
25import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
26import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
27import * as z from 'zod'
28
29import { urlRegex } from '../Auth.constants'
30import { defaultDisabledSmtpFormValues } from './SmtpForm.constants'
31import { generateFormValues, isSmtpEnabled } from './SmtpForm.utils'
32import AlertError from '@/components/ui/AlertError'
33import { InlineLink } from '@/components/ui/InlineLink'
34import NoPermission from '@/components/ui/NoPermission'
35import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
36import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
37import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
38
39const smtpEnabledSchema = z.object({
40 ENABLE_SMTP: z.literal(true),
41 SMTP_ADMIN_EMAIL: z
42 .string()
43 .trim()
44 .min(1, 'Sender email address is required')
45 .email('Must be a valid email'),
46 SMTP_SENDER_NAME: z.string().trim().min(1, 'Sender name is required'),
47 SMTP_HOST: z
48 .string()
49 .trim()
50 .min(1, 'Host URL is required')
51 .regex(urlRegex({ excludeSimpleDomains: false }), 'Must be a valid URL or IP address'),
52 SMTP_PORT: z.preprocess(
53 (val) => (val === '' || val == null ? undefined : val),
54 z.coerce
55 .number({
56 required_error: 'Port number is required',
57 invalid_type_error: 'Port number is required',
58 })
59 .min(1, 'Must be a valid port number more than 0')
60 .max(65535, 'Must be a valid port number no more than 65535')
61 ),
62 SMTP_MAX_FREQUENCY: z.preprocess(
63 (val) => (val === '' || val == null ? undefined : val),
64 z.coerce
65 .number({
66 required_error: 'Rate limit is required',
67 invalid_type_error: 'Rate limit is required',
68 })
69 .min(1, 'Must be more than 0')
70 .max(32767, 'Must not be more than 32,767 an hour')
71 ),
72 SMTP_USER: z.string().trim().min(1, 'SMTP Username is required'),
73 SMTP_PASS: z.string().trim().optional(),
74})
75
76const smtpDisabledSchema = z.object({
77 ENABLE_SMTP: z.literal(false),
78 SMTP_ADMIN_EMAIL: z.string().optional(),
79 SMTP_SENDER_NAME: z.string().optional(),
80 SMTP_HOST: z.string().optional(),
81 SMTP_PORT: z.preprocess(
82 (val) => (val === '' || val == null ? undefined : val),
83 z.coerce.number().optional()
84 ),
85 SMTP_MAX_FREQUENCY: z.preprocess(
86 (val) => (val === '' || val == null ? undefined : val),
87 z.coerce.number().optional()
88 ),
89 SMTP_USER: z.string().optional(),
90 SMTP_PASS: z.string().optional(),
91})
92
93const smtpSchema = z.discriminatedUnion('ENABLE_SMTP', [smtpEnabledSchema, smtpDisabledSchema])
94
95type SmtpFormValues = z.infer<typeof smtpSchema>
96
97export const SmtpForm = () => {
98 const { ref: projectRef } = useParams()
99 const { data: authConfig, error: authConfigError, isError } = useAuthConfigQuery({ projectRef })
100 const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation()
101
102 const [enableSmtp, setEnableSmtp] = useState(false)
103
104 const { can: canReadConfig } = useAsyncCheckPermissions(
105 PermissionAction.READ,
106 'custom_config_gotrue'
107 )
108 const { can: canUpdateConfig } = useAsyncCheckPermissions(
109 PermissionAction.UPDATE,
110 'custom_config_gotrue'
111 )
112
113 const form = useForm<SmtpFormValues>({
114 resolver: zodResolver(
115 smtpSchema.superRefine((data, ctx: any) => {
116 const isEnablingSmtp = data.ENABLE_SMTP && !isSmtpEnabled(authConfig)
117
118 if (isEnablingSmtp && !data.SMTP_PASS) {
119 ctx.addIssue({
120 code: 'custom',
121 message: 'SMTP Password is required',
122 path: ['SMTP_PASS'],
123 })
124 }
125 })
126 ),
127 defaultValues: {
128 SMTP_ADMIN_EMAIL: '',
129 SMTP_SENDER_NAME: '',
130 SMTP_HOST: '',
131 SMTP_PORT: undefined,
132 SMTP_MAX_FREQUENCY: undefined,
133 SMTP_USER: '',
134 SMTP_PASS: '',
135 ENABLE_SMTP: false,
136 },
137 })
138
139 const { isDirty } = form.formState
140
141 // Update form values when auth config is loaded
142 useEffect(() => {
143 if (authConfig) {
144 const formValues = generateFormValues(authConfig)
145 form.reset({
146 ...formValues,
147 ENABLE_SMTP: isSmtpEnabled(authConfig),
148 } as SmtpFormValues)
149 setEnableSmtp(isSmtpEnabled(authConfig))
150 }
151 }, [authConfig, form])
152
153 // Update enableSmtp state when the form field changes
154 useEffect(() => {
155 const subscription = form.watch((value, { name }) => {
156 if (name === 'ENABLE_SMTP') {
157 setEnableSmtp(value.ENABLE_SMTP as boolean)
158 }
159 })
160 return () => subscription.unsubscribe()
161 }, [form])
162
163 const onSubmit: SubmitHandler<SmtpFormValues> = (values) => {
164 const { ENABLE_SMTP, ...rest } = values
165 const basePayload = ENABLE_SMTP ? rest : defaultDisabledSmtpFormValues
166
167 // When enabling SMTP, set RATE_LIMIT_EMAIL_SENT to 30
168 // When disabling, backend will handle resetting to default
169 const isEnablingSmtp = ENABLE_SMTP && !isSmtpEnabled(authConfig)
170 const payload = {
171 ...basePayload,
172 ...(isEnablingSmtp && { RATE_LIMIT_EMAIL_SENT: 30 }),
173 }
174
175 // Format payload: Convert port to string
176 if (payload.SMTP_PORT) {
177 payload.SMTP_PORT = payload.SMTP_PORT.toString() as any
178 }
179
180 // the SMTP_PASS is write-only, it's never shown. If we don't delete it from the payload, it will replace the
181 // previously saved value with an empty one
182 if (payload.SMTP_PASS === '') {
183 delete payload.SMTP_PASS
184 }
185
186 updateAuthConfig(
187 { projectRef: projectRef!, config: payload as any },
188 {
189 onError: (error) => {
190 toast.error(`Failed to update settings: ${error.message}`)
191 },
192 onSuccess: () => {
193 toast.success('Successfully updated settings')
194 },
195 }
196 )
197 }
198
199 if (isError) {
200 return (
201 <PageSection>
202 <PageSectionContent>
203 <AlertError error={authConfigError} subject="Failed to retrieve auth configuration" />
204 </PageSectionContent>
205 </PageSection>
206 )
207 }
208
209 if (!canReadConfig) {
210 return (
211 <PageSection>
212 <PageSectionContent>
213 <NoPermission resourceText="view SMTP settings" />
214 </PageSectionContent>
215 </PageSection>
216 )
217 }
218
219 const showFooterMessage =
220 form.formState.isDirty && ((enableSmtp && !isSmtpEnabled(authConfig)) || !enableSmtp)
221
222 return (
223 <PageSection>
224 <PageSectionContent>
225 <Form {...form}>
226 <form onSubmit={form.handleSubmit(onSubmit)}>
227 <Card>
228 <CardContent>
229 <FormField
230 control={form.control}
231 name="ENABLE_SMTP"
232 render={({ field }) => (
233 <FormItemLayout
234 layout="flex-row-reverse"
235 label="Enable custom SMTP"
236 description={
237 <p className="max-w-full prose text-sm text-foreground-lighter">
238 Emails will be sent using your custom SMTP provider. Email rate limits can
239 be adjusted{' '}
240 <InlineLink href={`/project/${projectRef}/auth/rate-limits`}>
241 here
242 </InlineLink>
243 .
244 </p>
245 }
246 >
247 <FormControl>
248 <Switch
249 checked={field.value}
250 onCheckedChange={field.onChange}
251 disabled={!canUpdateConfig}
252 />
253 </FormControl>
254 </FormItemLayout>
255 )}
256 />
257
258 {enableSmtp && !isSmtpEnabled(form.getValues() as any) && (
259 <Admonition
260 type="warning"
261 title="All fields must be filled"
262 description="Each of the fields below must be filled before custom SMTP can be enabled."
263 className="bg-warning-200 border-warning-400 mt-4"
264 />
265 )}
266 </CardContent>
267
268 {enableSmtp && (
269 <>
270 <CardContent className="py-6">
271 <div className="grid grid-cols-12 gap-6">
272 <div className="col-span-4">
273 <h3 className="text-sm mb-1">Sender details</h3>
274 <p className="text-sm text-foreground-lighter text-balance">
275 Configure the sender information for your emails.
276 </p>
277 </div>
278 <div className="col-span-8 space-y-4">
279 <FormField
280 control={form.control}
281 name="SMTP_ADMIN_EMAIL"
282 render={({ field }) => (
283 <FormItemLayout
284 label="Sender email address"
285 description="The email address the emails are sent from."
286 >
287 <FormControl>
288 <Input
289 {...field}
290 placeholder="noreply@yourdomain.com"
291 disabled={!canUpdateConfig}
292 />
293 </FormControl>
294 </FormItemLayout>
295 )}
296 />
297
298 <FormField
299 control={form.control}
300 name="SMTP_SENDER_NAME"
301 render={({ field }) => (
302 <FormItemLayout
303 label="Sender name"
304 description="Name displayed in the recipient's inbox."
305 >
306 <FormControl>
307 <Input
308 {...field}
309 placeholder="Your Name"
310 disabled={!canUpdateConfig}
311 />
312 </FormControl>
313 </FormItemLayout>
314 )}
315 />
316 </div>
317 </div>
318 </CardContent>
319
320 <CardContent className="py-6">
321 <div className="grid grid-cols-12 gap-6">
322 <div className="col-span-4">
323 <h3 className="text-sm mb-1">SMTP provider settings</h3>
324 <p className="text-sm text-foreground-lighter text-balance">
325 Your SMTP credentials will always be encrypted in our database.
326 </p>
327 </div>
328 <div className="col-span-8 space-y-4">
329 <FormField
330 control={form.control}
331 name="SMTP_HOST"
332 render={({ field }) => (
333 <FormItemLayout
334 label="Host"
335 description="Hostname or IP address of your SMTP server."
336 >
337 <FormControl>
338 <Input
339 {...field}
340 placeholder="your.smtp.host.com"
341 disabled={!canUpdateConfig}
342 />
343 </FormControl>
344 </FormItemLayout>
345 )}
346 />
347
348 {form.watch('SMTP_HOST')?.endsWith('.gmail.com') && (
349 <Admonition
350 type="warning"
351 title="Check your SMTP provider"
352 description="It looks like the SMTP provider you entered is designed
353 for sending personal rather than transactional email messages. Email deliverability may
354 be impacted."
355 className="mb-4 bg-warning-200 border-warning-400"
356 />
357 )}
358
359 <FormField
360 control={form.control}
361 name="SMTP_PORT"
362 render={({ field }) => (
363 <FormItemLayout
364 label="Port number"
365 description={
366 <>
367 <span className="block">
368 Port used by your SMTP server. Common ports include 465 and 587.
369 Avoid using port 25 as it is often blocked by providers to curb
370 spam.
371 </span>
372 </>
373 }
374 >
375 <FormControl>
376 <Input
377 type="number"
378 value={field.value}
379 onChange={(e) => field.onChange(e.target.value)}
380 placeholder="587"
381 disabled={!canUpdateConfig}
382 />
383 </FormControl>
384 </FormItemLayout>
385 )}
386 />
387
388 <FormField
389 control={form.control}
390 name="SMTP_MAX_FREQUENCY"
391 render={({ field }) => (
392 <FormItemLayout
393 label="Minimum interval per user"
394 description="The minimum time in seconds between emails before another email can be sent to the same user."
395 >
396 <FormControl>
397 <InputGroup>
398 <FormInputGroupInput
399 type="number"
400 value={field.value}
401 onChange={(e) => field.onChange(e.target.value)}
402 disabled={!canUpdateConfig}
403 />
404 <InputGroupAddon align="inline-end">
405 <InputGroupText>seconds</InputGroupText>
406 </InputGroupAddon>
407 </InputGroup>
408 </FormControl>
409 </FormItemLayout>
410 )}
411 />
412
413 <FormField
414 control={form.control}
415 name="SMTP_USER"
416 render={({ field }) => (
417 <FormItemLayout
418 label="Username"
419 description="Username for your SMTP server."
420 >
421 <FormControl>
422 <Input
423 {...field}
424 placeholder="SMTP Username"
425 disabled={!canUpdateConfig}
426 />
427 </FormControl>
428 </FormItemLayout>
429 )}
430 />
431
432 <FormField
433 control={form.control}
434 name="SMTP_PASS"
435 render={({ field }) => (
436 <FormItemLayout
437 label="Password"
438 description="Password for your SMTP server. For security reasons, this password cannot be viewed once saved."
439 >
440 <FormControl>
441 <PasswordInput {...field} reveal copy disabled={!canUpdateConfig} />
442 </FormControl>
443 </FormItemLayout>
444 )}
445 />
446 </div>
447 </div>
448 </CardContent>
449 </>
450 )}
451
452 <CardFooter
453 className={cn(showFooterMessage ? 'justify-between' : 'justify-end', 'gap-x-2')}
454 >
455 {showFooterMessage &&
456 (enableSmtp ? (
457 <p className="text-sm text-foreground-light">
458 Rate limit for sending emails will be increased to 30 and{' '}
459 <InlineLink href={`/project/${projectRef}/auth/rate-limits`}>
460 can be adjusted
461 </InlineLink>{' '}
462 after enabling custom SMTP
463 </p>
464 ) : (
465 <p className="text-sm text-foreground-light">
466 Rate limit for sending emails will be reduced to 2 after disabling custom SMTP
467 </p>
468 ))}
469 <div className="flex items-center gap-x-2">
470 {isDirty && (
471 <Button
472 type="default"
473 onClick={() => {
474 form.reset()
475 setEnableSmtp(isSmtpEnabled(authConfig))
476 }}
477 >
478 Cancel
479 </Button>
480 )}
481 <Button
482 type="primary"
483 htmlType="submit"
484 loading={isUpdatingConfig}
485 disabled={!canUpdateConfig || !isDirty}
486 >
487 Save changes
488 </Button>
489 </div>
490 </CardFooter>
491 </Card>
492 </form>
493 </Form>
494 </PageSectionContent>
495 </PageSection>
496 )
497}