SmtpForm.tsx 19 KB

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