From 43a3416347ee7881d2eeb06fb0247856ff74b983 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 8 Aug 2025 17:01:41 -0700 Subject: [PATCH] fix(email-validation): add email validation to prevent bouncing, fixed OTP validation (#916) * feat(email-validation): add email validation to prevent bouncing * removed suspicious patterns * fix(verification): fixed OTP verification * fix failing tests, cleanup --- apps/sim/app/(auth)/login/login-form.test.tsx | 16 +- apps/sim/app/(auth)/login/login-form.tsx | 53 ++-- .../app/(auth)/signup/signup-form.test.tsx | 32 +-- apps/sim/app/(auth)/signup/signup-form.tsx | 105 ++----- .../sim/app/(auth)/verify/use-verification.ts | 11 +- .../organizations/[id]/invitations/route.ts | 9 +- .../api/organizations/[id]/members/route.ts | 12 +- .../components/invite-modal/invite-modal.tsx | 6 +- apps/sim/lib/auth.ts | 18 +- .../lib/billing/validation/seat-management.ts | 6 +- apps/sim/lib/email/utils.test.ts | 113 -------- apps/sim/lib/email/utils.ts | 10 - apps/sim/lib/email/validation.test.ts | 75 +++++ apps/sim/lib/email/validation.ts | 262 ++++++++++++++++++ apps/sim/middleware.ts | 7 + apps/sim/package.json | 2 +- apps/sim/stores/organization/utils.ts | 3 +- bun.lock | 6 +- 18 files changed, 469 insertions(+), 277 deletions(-) delete mode 100644 apps/sim/lib/email/utils.test.ts delete mode 100644 apps/sim/lib/email/utils.ts create mode 100644 apps/sim/lib/email/validation.test.ts create mode 100644 apps/sim/lib/email/validation.ts diff --git a/apps/sim/app/(auth)/login/login-form.test.tsx b/apps/sim/app/(auth)/login/login-form.test.tsx index 6ab8cf5c9..8f6fa4af9 100644 --- a/apps/sim/app/(auth)/login/login-form.test.tsx +++ b/apps/sim/app/(auth)/login/login-form.test.tsx @@ -94,10 +94,10 @@ describe('LoginPage', () => { const emailInput = screen.getByPlaceholderText(/enter your email/i) const passwordInput = screen.getByPlaceholderText(/enter your password/i) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'password123' } }) - expect(emailInput).toHaveValue('test@example.com') + expect(emailInput).toHaveValue('user@company.com') expect(passwordInput).toHaveValue('password123') }) @@ -117,7 +117,7 @@ describe('LoginPage', () => { const submitButton = screen.getByRole('button', { name: /sign in/i }) await act(async () => { - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'password123' } }) fireEvent.click(submitButton) }) @@ -140,14 +140,14 @@ describe('LoginPage', () => { const passwordInput = screen.getByPlaceholderText(/enter your password/i) const submitButton = screen.getByRole('button', { name: /sign in/i }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'password123' } }) fireEvent.click(submitButton) await waitFor(() => { expect(mockSignIn).toHaveBeenCalledWith( { - email: 'test@example.com', + email: 'user@company.com', password: 'password123', callbackURL: '/workspace', }, @@ -181,7 +181,7 @@ describe('LoginPage', () => { const passwordInput = screen.getByPlaceholderText(/enter your password/i) const submitButton = screen.getByRole('button', { name: /sign in/i }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } }) fireEvent.click(submitButton) @@ -242,13 +242,13 @@ describe('LoginPage', () => { const passwordInput = screen.getByPlaceholderText(/enter your password/i) const submitButton = screen.getByRole('button', { name: /sign in/i }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'password123' } }) fireEvent.click(submitButton) await waitFor(() => { expect(mockSendOtp).toHaveBeenCalledWith({ - email: 'test@example.com', + email: 'user@company.com', type: 'email-verification', }) expect(mockRouter.push).toHaveBeenCalledWith('/verify') diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index f6ca4aa45..6fcaadf63 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -15,25 +15,27 @@ import { import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { client } from '@/lib/auth-client' +import { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' const logger = createLogger('LoginForm') -const EMAIL_VALIDATIONS = { - required: { - test: (value: string) => Boolean(value && typeof value === 'string'), - message: 'Email is required.', - }, - notEmpty: { - test: (value: string) => value.trim().length > 0, - message: 'Email cannot be empty.', - }, - basicFormat: { - regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, - message: 'Please enter a valid email address.', - }, +const validateEmailField = (emailValue: string): string[] => { + const errors: string[] = [] + + if (!emailValue || !emailValue.trim()) { + errors.push('Email is required.') + return errors + } + + const validation = quickValidateEmail(emailValue.trim().toLowerCase()) + if (!validation.isValid) { + errors.push(validation.reason || 'Please enter a valid email address.') + } + + return errors } const PASSWORD_VALIDATIONS = { @@ -68,27 +70,6 @@ const validateCallbackUrl = (url: string): boolean => { } } -// Validate email and return array of error messages -const validateEmail = (emailValue: string): string[] => { - const errors: string[] = [] - - if (!EMAIL_VALIDATIONS.required.test(emailValue)) { - errors.push(EMAIL_VALIDATIONS.required.message) - return errors // Return early for required field - } - - if (!EMAIL_VALIDATIONS.notEmpty.test(emailValue)) { - errors.push(EMAIL_VALIDATIONS.notEmpty.message) - return errors // Return early for empty field - } - - if (!EMAIL_VALIDATIONS.basicFormat.regex.test(emailValue)) { - errors.push(EMAIL_VALIDATIONS.basicFormat.message) - } - - return errors -} - // Validate password and return array of error messages const validatePassword = (passwordValue: string): string[] => { const errors: string[] = [] @@ -182,7 +163,7 @@ export default function LoginPage({ setEmail(newEmail) // Silently validate but don't show errors until submit - const errors = validateEmail(newEmail) + const errors = validateEmailField(newEmail) setEmailErrors(errors) setShowEmailValidationError(false) } @@ -205,7 +186,7 @@ export default function LoginPage({ const email = formData.get('email') as string // Validate email on submit - const emailValidationErrors = validateEmail(email) + const emailValidationErrors = validateEmailField(email) setEmailErrors(emailValidationErrors) setShowEmailValidationError(emailValidationErrors.length > 0) diff --git a/apps/sim/app/(auth)/signup/signup-form.test.tsx b/apps/sim/app/(auth)/signup/signup-form.test.tsx index adb4b025f..014acba8f 100644 --- a/apps/sim/app/(auth)/signup/signup-form.test.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.test.tsx @@ -96,11 +96,11 @@ describe('SignupPage', () => { const passwordInput = screen.getByPlaceholderText(/enter your password/i) fireEvent.change(nameInput, { target: { value: 'John Doe' } }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'Password123!' } }) expect(nameInput).toHaveValue('John Doe') - expect(emailInput).toHaveValue('test@example.com') + expect(emailInput).toHaveValue('user@company.com') expect(passwordInput).toHaveValue('Password123!') }) @@ -118,7 +118,7 @@ describe('SignupPage', () => { const submitButton = screen.getByRole('button', { name: /create account/i }) fireEvent.change(nameInput, { target: { value: 'John Doe' } }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'Password123!' } }) fireEvent.click(submitButton) @@ -144,14 +144,14 @@ describe('SignupPage', () => { // Use valid input that passes all validation rules fireEvent.change(nameInput, { target: { value: 'John Doe' } }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'Password123!' } }) fireEvent.click(submitButton) await waitFor(() => { expect(mockSignUp).toHaveBeenCalledWith( { - email: 'test@example.com', + email: 'user@company.com', password: 'Password123!', name: 'John Doe', }, @@ -174,7 +174,7 @@ describe('SignupPage', () => { // Use name with leading/trailing spaces which should fail validation fireEvent.change(nameInput, { target: { value: ' John Doe ' } }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'Password123!' } }) fireEvent.click(submitButton) @@ -206,15 +206,13 @@ describe('SignupPage', () => { const submitButton = screen.getByRole('button', { name: /create account/i }) fireEvent.change(nameInput, { target: { value: 'John Doe' } }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'Password123!' } }) fireEvent.click(submitButton) await waitFor(() => { - expect(mockSendOtp).toHaveBeenCalledWith({ - email: 'test@example.com', - type: 'email-verification', - }) + // With sendVerificationOnSignUp: true, OTP is sent automatically by Better Auth + // No manual OTP sending in the component anymore expect(mockRouter.push).toHaveBeenCalledWith('/verify?fromSignup=true') }) }) @@ -267,7 +265,7 @@ describe('SignupPage', () => { const submitButton = screen.getByRole('button', { name: /create account/i }) fireEvent.change(nameInput, { target: { value: longName } }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } }) fireEvent.click(submitButton) @@ -295,7 +293,7 @@ describe('SignupPage', () => { const submitButton = screen.getByRole('button', { name: /create account/i }) fireEvent.change(nameInput, { target: { value: exactLengthName } }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } }) fireEvent.click(submitButton) @@ -308,7 +306,7 @@ describe('SignupPage', () => { await waitFor(() => { expect(mockSignUp).toHaveBeenCalledWith( { - email: 'test@example.com', + email: 'user@company.com', password: 'ValidPass123!', name: exactLengthName, }, @@ -343,7 +341,7 @@ describe('SignupPage', () => { await act(async () => { fireEvent.change(nameInput, { target: { value: 'John Doe' } }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'Password123!' } }) fireEvent.click(submitButton) }) @@ -385,12 +383,12 @@ describe('SignupPage', () => { const submitButton = screen.getByRole('button', { name: /create account/i }) fireEvent.change(nameInput, { target: { value: 'John Doe' } }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(emailInput, { target: { value: 'user@company.com' } }) fireEvent.change(passwordInput, { target: { value: 'Password123!' } }) fireEvent.click(submitButton) await waitFor(() => { - expect(mockRouter.push).toHaveBeenCalledWith('/invite/123') + expect(mockRouter.push).toHaveBeenCalledWith('/verify?fromSignup=true') }) }) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 8b5e88dc8..ba544ca84 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { client } from '@/lib/auth-client' +import { quickValidateEmail } from '@/lib/email/validation' import { cn } from '@/lib/utils' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' @@ -51,31 +52,20 @@ const NAME_VALIDATIONS = { }, } -const EMAIL_VALIDATIONS = { - required: { - test: (value: string) => Boolean(value && typeof value === 'string'), - message: 'Email is required.', - }, - notEmpty: { - test: (value: string) => value.trim().length > 0, - message: 'Email cannot be empty.', - }, - maxLength: { - test: (value: string) => value.length <= 254, - message: 'Email must be less than 254 characters.', - }, - basicFormat: { - regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, - message: 'Please enter a valid email address.', - }, - noSpaces: { - regex: /^[^\s]*$/, - message: 'Email cannot contain spaces.', - }, - validStart: { - regex: /^[a-zA-Z0-9]/, - message: 'Email must start with a letter or number.', - }, +const validateEmailField = (emailValue: string): string[] => { + const errors: string[] = [] + + if (!emailValue || !emailValue.trim()) { + errors.push('Email is required.') + return errors + } + + const validation = quickValidateEmail(emailValue.trim().toLowerCase()) + if (!validation.isValid) { + errors.push(validation.reason || 'Please enter a valid email address.') + } + + return errors } function SignupFormContent({ @@ -188,39 +178,6 @@ function SignupFormContent({ return errors } - // Validate email and return array of error messages - const validateEmail = (emailValue: string): string[] => { - const errors: string[] = [] - - if (!EMAIL_VALIDATIONS.required.test(emailValue)) { - errors.push(EMAIL_VALIDATIONS.required.message) - return errors // Return early for required field - } - - if (!EMAIL_VALIDATIONS.notEmpty.test(emailValue)) { - errors.push(EMAIL_VALIDATIONS.notEmpty.message) - return errors // Return early for empty field - } - - if (!EMAIL_VALIDATIONS.maxLength.test(emailValue)) { - errors.push(EMAIL_VALIDATIONS.maxLength.message) - } - - if (!EMAIL_VALIDATIONS.noSpaces.regex.test(emailValue)) { - errors.push(EMAIL_VALIDATIONS.noSpaces.message) - } - - if (!EMAIL_VALIDATIONS.validStart.regex.test(emailValue)) { - errors.push(EMAIL_VALIDATIONS.validStart.message) - } - - if (!EMAIL_VALIDATIONS.basicFormat.regex.test(emailValue)) { - errors.push(EMAIL_VALIDATIONS.basicFormat.message) - } - - return errors - } - const handlePasswordChange = (e: React.ChangeEvent) => { const newPassword = e.target.value setPassword(newPassword) @@ -246,7 +203,7 @@ function SignupFormContent({ setEmail(newEmail) // Silently validate but don't show errors until submit - const errors = validateEmail(newEmail) + const errors = validateEmailField(newEmail) setEmailErrors(errors) setShowEmailValidationError(false) @@ -271,7 +228,7 @@ function SignupFormContent({ setShowNameValidationError(nameValidationErrors.length > 0) // Validate email on submit - const emailValidationErrors = validateEmail(emailValue) + const emailValidationErrors = validateEmailField(emailValue) setEmailErrors(emailValidationErrors) setShowEmailValidationError(emailValidationErrors.length > 0) @@ -370,27 +327,23 @@ function SignupFormContent({ return } - // Handle invitation flow redirect - if (isInviteFlow && redirectUrl) { - router.push(redirectUrl) - return - } - - try { - await client.emailOtp.sendVerificationOtp({ - email: emailValue, - type: 'email-verification', - }) - } catch (err) { - console.error('Failed to send verification OTP:', err) - } - + // For new signups, always require verification if (typeof window !== 'undefined') { sessionStorage.setItem('verificationEmail', emailValue) localStorage.setItem('has_logged_in_before', 'true') - document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' // 1 year expiry + + // Set cookie flag for middleware check + document.cookie = 'requiresEmailVerification=true; path=/; max-age=900; SameSite=Lax' // 15 min expiry + document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' + + // Store invitation flow state if applicable + if (isInviteFlow && redirectUrl) { + sessionStorage.setItem('inviteRedirectUrl', redirectUrl) + sessionStorage.setItem('isInviteFlow', 'true') + } } + // Always redirect to verification for new signups router.push('/verify?fromSignup=true') } catch (error) { console.error('Signup error:', error) diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index 549fd675e..139ffbcc3 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -121,10 +121,14 @@ export function useVerification({ if (response && !response.error) { setIsVerified(true) - // Clear email from sessionStorage after successful verification + // Clear verification requirements and session storage if (typeof window !== 'undefined') { sessionStorage.removeItem('verificationEmail') + // Clear the verification requirement flag + document.cookie = + 'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT' + // Also clear invite-related items if (isInviteFlow) { sessionStorage.removeItem('inviteRedirectUrl') @@ -223,6 +227,11 @@ export function useVerification({ // Auto-verify and redirect in development/docker environments if (isDevOrDocker || !hasResendKey) { setIsVerified(true) + + // Clear verification requirement cookie (same as manual verification) + document.cookie = + 'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT' + const timeoutId = setTimeout(() => { router.push('/workspace') }, 1000) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 1acae5939..6537061a7 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -12,7 +12,7 @@ import { validateSeatAvailability, } from '@/lib/billing/validation/seat-management' import { sendEmail } from '@/lib/email/mailer' -import { validateAndNormalizeEmail } from '@/lib/email/utils' +import { quickValidateEmail } from '@/lib/email/validation' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' @@ -201,8 +201,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Validate and normalize emails const processedEmails = invitationEmails .map((email: string) => { - const result = validateAndNormalizeEmail(email) - return result.isValid ? result.normalized : null + const normalized = email.trim().toLowerCase() + const validation = quickValidateEmail(normalized) + return validation.isValid ? normalized : null }) .filter(Boolean) as string[] @@ -401,7 +402,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ pendingEmails.includes(email) ), invalidEmails: invitationEmails.filter( - (email: string) => !validateAndNormalizeEmail(email) + (email: string) => !quickValidateEmail(email.trim().toLowerCase()).isValid ), workspaceInvitations: isBatch ? validWorkspaceInvitations.length : 0, seatInfo: { diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 484d0878f..9f40f187c 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -5,7 +5,7 @@ import { getEmailSubject, renderInvitationEmail } from '@/components/emails/rend import { getSession } from '@/lib/auth' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { sendEmail } from '@/lib/email/mailer' -import { validateAndNormalizeEmail } from '@/lib/email/utils' +import { quickValidateEmail } from '@/lib/email/validation' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' @@ -139,9 +139,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } // Validate and normalize email - const { isValid, normalized: normalizedEmail } = validateAndNormalizeEmail(email) - if (!isValid) { - return NextResponse.json({ error: 'Invalid email format' }, { status: 400 }) + const normalizedEmail = email.trim().toLowerCase() + const validation = quickValidateEmail(normalizedEmail) + if (!validation.isValid) { + return NextResponse.json( + { error: validation.reason || 'Invalid email format' }, + { status: 400 } + ) } // Verify user has admin access diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx index 7389896a3..4e47cb20d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx @@ -18,7 +18,7 @@ import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' -import { validateAndNormalizeEmail } from '@/lib/email/utils' +import { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' import type { PermissionType } from '@/lib/permissions/utils' import { cn } from '@/lib/utils' @@ -475,7 +475,9 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr (email: string) => { if (!email.trim()) return false - const { isValid, normalized } = validateAndNormalizeEmail(email) + const normalized = email.trim().toLowerCase() + const validation = quickValidateEmail(normalized) + const isValid = validation.isValid if (emails.includes(normalized) || invalidEmails.includes(normalized)) { return false diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index fdce33cd6..4c0580474 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -21,6 +21,7 @@ import { } from '@/components/emails/render-email' import { getBaseURL } from '@/lib/auth-client' import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants' +import { quickValidateEmail } from '@/lib/email/validation' import { env, isTruthy } from '@/lib/env' import { isProd } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' @@ -156,7 +157,7 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, requireEmailVerification: false, - sendVerificationOnSignUp: false, + sendVerificationOnSignUp: true, throwOnMissingCredentials: true, throwOnInvalidCredentials: true, sendResetPassword: async ({ user, url, token }, request) => { @@ -237,12 +238,27 @@ export const auth = betterAuth({ throw new Error('Email is required') } + // Validate email before sending OTP + const validation = quickValidateEmail(data.email) + if (!validation.isValid) { + logger.warn('Email validation failed', { + email: data.email, + reason: validation.reason, + checks: validation.checks, + }) + throw new Error( + validation.reason || + "We are unable to deliver the verification email to that address. Please make sure it's valid and able to receive emails." + ) + } + // In development with no RESEND_API_KEY, log verification code if (!validResendAPIKEY) { logger.info('🔑 VERIFICATION CODE FOR LOGIN/SIGNUP', { email: data.email, otp: data.otp, type: data.type, + validation: validation.checks, }) return } diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts index 5516fd45e..988bb59df 100644 --- a/apps/sim/lib/billing/validation/seat-management.ts +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -1,5 +1,6 @@ import { and, count, eq } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' import { invitation, member, organization, subscription, user, userStats } from '@/db/schema' @@ -208,8 +209,9 @@ export async function validateBulkInvitations( try { // Remove duplicates and validate email format const uniqueEmails = [...new Set(emailList)] - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - const validEmails = uniqueEmails.filter((email) => emailRegex.test(email)) + const validEmails = uniqueEmails.filter( + (email) => quickValidateEmail(email.trim().toLowerCase()).isValid + ) const duplicateEmails = emailList.filter((email, index) => emailList.indexOf(email) !== index) // Check for existing members diff --git a/apps/sim/lib/email/utils.test.ts b/apps/sim/lib/email/utils.test.ts deleted file mode 100644 index c8c44d9e5..000000000 --- a/apps/sim/lib/email/utils.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { validateAndNormalizeEmail } from '@/lib/email/utils' - -describe('validateAndNormalizeEmail', () => { - describe('valid emails', () => { - it.concurrent('should validate simple email addresses', () => { - const result = validateAndNormalizeEmail('test@example.com') - expect(result.isValid).toBe(true) - expect(result.normalized).toBe('test@example.com') - }) - - it.concurrent('should validate emails with subdomains', () => { - const result = validateAndNormalizeEmail('user@mail.example.com') - expect(result.isValid).toBe(true) - expect(result.normalized).toBe('user@mail.example.com') - }) - - it.concurrent('should validate emails with numbers and hyphens', () => { - const result = validateAndNormalizeEmail('user123@test-domain.co.uk') - expect(result.isValid).toBe(true) - expect(result.normalized).toBe('user123@test-domain.co.uk') - }) - - it.concurrent('should validate emails with special characters in local part', () => { - const result = validateAndNormalizeEmail('user.name+tag@example.com') - expect(result.isValid).toBe(true) - expect(result.normalized).toBe('user.name+tag@example.com') - }) - }) - - describe('invalid emails', () => { - it.concurrent('should reject emails without @ symbol', () => { - const result = validateAndNormalizeEmail('testexample.com') - expect(result.isValid).toBe(false) - expect(result.normalized).toBe('testexample.com') - }) - - it.concurrent('should reject emails without domain', () => { - const result = validateAndNormalizeEmail('test@') - expect(result.isValid).toBe(false) - expect(result.normalized).toBe('test@') - }) - - it.concurrent('should reject emails without local part', () => { - const result = validateAndNormalizeEmail('@example.com') - expect(result.isValid).toBe(false) - expect(result.normalized).toBe('@example.com') - }) - - it.concurrent('should reject emails without TLD', () => { - const result = validateAndNormalizeEmail('test@domain') - expect(result.isValid).toBe(false) - expect(result.normalized).toBe('test@domain') - }) - - it.concurrent('should reject empty strings', () => { - const result = validateAndNormalizeEmail('') - expect(result.isValid).toBe(false) - expect(result.normalized).toBe('') - }) - - it.concurrent('should reject emails with spaces', () => { - const result = validateAndNormalizeEmail('test @example.com') - expect(result.isValid).toBe(false) - expect(result.normalized).toBe('test @example.com') - }) - - it.concurrent('should reject emails with multiple @ symbols', () => { - const result = validateAndNormalizeEmail('test@@example.com') - expect(result.isValid).toBe(false) - expect(result.normalized).toBe('test@@example.com') - }) - }) - - describe('normalization', () => { - it.concurrent('should trim whitespace from email', () => { - const result = validateAndNormalizeEmail(' test@example.com ') - expect(result.isValid).toBe(true) - expect(result.normalized).toBe('test@example.com') - }) - - it.concurrent('should convert email to lowercase', () => { - const result = validateAndNormalizeEmail('Test.User@EXAMPLE.COM') - expect(result.isValid).toBe(true) - expect(result.normalized).toBe('test.user@example.com') - }) - - it.concurrent('should trim and convert to lowercase together', () => { - const result = validateAndNormalizeEmail(' Test.User@EXAMPLE.COM ') - expect(result.isValid).toBe(true) - expect(result.normalized).toBe('test.user@example.com') - }) - - it.concurrent('should normalize invalid emails as well', () => { - const result = validateAndNormalizeEmail(' INVALID EMAIL ') - expect(result.isValid).toBe(false) - expect(result.normalized).toBe('invalid email') - }) - }) - - describe('edge cases', () => { - it.concurrent('should handle only whitespace', () => { - const result = validateAndNormalizeEmail(' ') - expect(result.isValid).toBe(false) - expect(result.normalized).toBe('') - }) - - it.concurrent('should handle tab and newline characters', () => { - const result = validateAndNormalizeEmail('\t\ntest@example.com\t\n') - expect(result.isValid).toBe(true) - expect(result.normalized).toBe('test@example.com') - }) - }) -}) diff --git a/apps/sim/lib/email/utils.ts b/apps/sim/lib/email/utils.ts deleted file mode 100644 index 8816f688b..000000000 --- a/apps/sim/lib/email/utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const validateAndNormalizeEmail = ( - email: string -): { isValid: boolean; normalized: string } => { - const normalized = email.trim().toLowerCase() - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - return { - isValid: emailRegex.test(normalized), - normalized, - } -} diff --git a/apps/sim/lib/email/validation.test.ts b/apps/sim/lib/email/validation.test.ts new file mode 100644 index 000000000..e179abb83 --- /dev/null +++ b/apps/sim/lib/email/validation.test.ts @@ -0,0 +1,75 @@ +import { quickValidateEmail, validateEmail } from './validation' + +describe('Email Validation', () => { + describe('validateEmail', () => { + it.concurrent('should validate a correct email', async () => { + const result = await validateEmail('user@example.com') + expect(result.isValid).toBe(true) + expect(result.checks.syntax).toBe(true) + expect(result.checks.disposable).toBe(true) + }) + + it.concurrent('should reject invalid syntax', async () => { + const result = await validateEmail('invalid-email') + expect(result.isValid).toBe(false) + expect(result.reason).toBe('Invalid email format') + expect(result.checks.syntax).toBe(false) + }) + + it.concurrent('should reject disposable email addresses', async () => { + const result = await validateEmail('test@10minutemail.com') + expect(result.isValid).toBe(false) + expect(result.reason).toBe('Disposable email addresses are not allowed') + expect(result.checks.disposable).toBe(false) + }) + + it.concurrent('should accept legitimate business emails', async () => { + const legitimateEmails = [ + 'test@gmail.com', + 'noreply@gmail.com', + 'no-reply@yahoo.com', + 'user12345@outlook.com', + 'longusernamehere@gmail.com', + ] + + for (const email of legitimateEmails) { + const result = await validateEmail(email) + expect(result.isValid).toBe(true) + } + }) + + it.concurrent('should reject consecutive dots (RFC violation)', async () => { + const result = await validateEmail('user..name@example.com') + expect(result.isValid).toBe(false) + expect(result.reason).toBe('Email contains suspicious patterns') + }) + + it.concurrent('should reject very long local parts (RFC violation)', async () => { + const longLocalPart = 'a'.repeat(65) + const result = await validateEmail(`${longLocalPart}@example.com`) + expect(result.isValid).toBe(false) + expect(result.reason).toBe('Email contains suspicious patterns') + }) + }) + + describe('quickValidateEmail', () => { + it.concurrent('should validate quickly without MX check', () => { + const result = quickValidateEmail('user@example.com') + expect(result.isValid).toBe(true) + expect(result.checks.mxRecord).toBe(true) // Skipped, so assumed true + expect(result.confidence).toBe('medium') + }) + + it.concurrent('should reject invalid emails quickly', () => { + const result = quickValidateEmail('invalid-email') + expect(result.isValid).toBe(false) + expect(result.reason).toBe('Invalid email format') + }) + + it.concurrent('should reject disposable emails quickly', () => { + const result = quickValidateEmail('test@tempmail.org') + expect(result.isValid).toBe(false) + expect(result.reason).toBe('Disposable email addresses are not allowed') + }) + }) +}) diff --git a/apps/sim/lib/email/validation.ts b/apps/sim/lib/email/validation.ts new file mode 100644 index 000000000..a9e5bc5f0 --- /dev/null +++ b/apps/sim/lib/email/validation.ts @@ -0,0 +1,262 @@ +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('EmailValidation') + +export interface EmailValidationResult { + isValid: boolean + reason?: string + confidence: 'high' | 'medium' | 'low' + checks: { + syntax: boolean + domain: boolean + mxRecord: boolean + disposable: boolean + } +} + +// Common disposable email domains (subset - can be expanded) +const DISPOSABLE_DOMAINS = new Set([ + '10minutemail.com', + 'tempmail.org', + 'guerrillamail.com', + 'mailinator.com', + 'yopmail.com', + 'temp-mail.org', + 'throwaway.email', + 'getnada.com', + '10minutemail.net', + 'temporary-mail.net', + 'fakemailgenerator.com', + 'sharklasers.com', + 'guerrillamailblock.com', + 'pokemail.net', + 'spam4.me', + 'tempail.com', + 'tempr.email', + 'dispostable.com', + 'emailondeck.com', +]) + +/** + * Validates email syntax using RFC 5322 compliant regex + */ +function validateEmailSyntax(email: string): boolean { + const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + return emailRegex.test(email) && email.length <= 254 +} + +/** + * Checks if domain has valid MX records (server-side only) + */ +async function checkMXRecord(domain: string): Promise { + // Skip MX check on client-side (browser) + if (typeof window !== 'undefined') { + return true // Assume valid on client-side + } + + try { + const { promisify } = await import('util') + const dns = await import('dns') + const resolveMx = promisify(dns.resolveMx) + + const mxRecords = await resolveMx(domain) + return mxRecords && mxRecords.length > 0 + } catch (error) { + logger.debug('MX record check failed', { domain, error }) + return false + } +} + +/** + * Checks if email is from a known disposable email provider + */ +function isDisposableEmail(email: string): boolean { + const domain = email.split('@')[1]?.toLowerCase() + return domain ? DISPOSABLE_DOMAINS.has(domain) : false +} + +/** + * Checks for obvious patterns that indicate invalid emails + */ +function hasInvalidPatterns(email: string): boolean { + // Check for consecutive dots (RFC violation) + if (email.includes('..')) return true + + // Check for local part length (RFC limit is 64 characters) + const localPart = email.split('@')[0] + if (localPart && localPart.length > 64) return true + + return false +} + +/** + * Validates an email address comprehensively + */ +export async function validateEmail(email: string): Promise { + const checks = { + syntax: false, + domain: false, + mxRecord: false, + disposable: false, + } + + try { + // 1. Basic syntax validation + checks.syntax = validateEmailSyntax(email) + if (!checks.syntax) { + return { + isValid: false, + reason: 'Invalid email format', + confidence: 'high', + checks, + } + } + + const domain = email.split('@')[1]?.toLowerCase() + if (!domain) { + return { + isValid: false, + reason: 'Missing domain', + confidence: 'high', + checks, + } + } + + // 2. Check for disposable email first (more specific) + checks.disposable = !isDisposableEmail(email) + if (!checks.disposable) { + return { + isValid: false, + reason: 'Disposable email addresses are not allowed', + confidence: 'high', + checks, + } + } + + // 3. Check for invalid patterns + if (hasInvalidPatterns(email)) { + return { + isValid: false, + reason: 'Email contains suspicious patterns', + confidence: 'high', + checks, + } + } + + // 4. Domain validation - check for obvious invalid domains + checks.domain = domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.') + if (!checks.domain) { + return { + isValid: false, + reason: 'Invalid domain format', + confidence: 'high', + checks, + } + } + + // 5. MX record check (with timeout) + try { + const mxCheckPromise = checkMXRecord(domain) + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('MX check timeout')), 5000) + ) + + checks.mxRecord = await Promise.race([mxCheckPromise, timeoutPromise]) + } catch (error) { + logger.debug('MX record check failed or timed out', { domain, error }) + checks.mxRecord = false + } + + // Determine overall validity and confidence + if (!checks.mxRecord) { + return { + isValid: false, + reason: 'Domain does not accept emails (no MX records)', + confidence: 'high', + checks, + } + } + + return { + isValid: true, + confidence: 'high', + checks, + } + } catch (error) { + logger.error('Email validation error', { email, error }) + return { + isValid: false, + reason: 'Validation service temporarily unavailable', + confidence: 'low', + checks, + } + } +} + +/** + * Quick validation for high-volume scenarios (skips MX check) + */ +export function quickValidateEmail(email: string): EmailValidationResult { + const checks = { + syntax: false, + domain: false, + mxRecord: true, // Skip MX check for performance + disposable: false, + } + + checks.syntax = validateEmailSyntax(email) + if (!checks.syntax) { + return { + isValid: false, + reason: 'Invalid email format', + confidence: 'high', + checks, + } + } + + const domain = email.split('@')[1]?.toLowerCase() + if (!domain) { + return { + isValid: false, + reason: 'Missing domain', + confidence: 'high', + checks, + } + } + + checks.disposable = !isDisposableEmail(email) + if (!checks.disposable) { + return { + isValid: false, + reason: 'Disposable email addresses are not allowed', + confidence: 'high', + checks, + } + } + + if (hasInvalidPatterns(email)) { + return { + isValid: false, + reason: 'Email contains suspicious patterns', + confidence: 'medium', + checks, + } + } + + checks.domain = domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.') + if (!checks.domain) { + return { + isValid: false, + reason: 'Invalid domain format', + confidence: 'high', + checks, + } + } + + return { + isValid: true, + confidence: 'medium', + checks, + } +} diff --git a/apps/sim/middleware.ts b/apps/sim/middleware.ts index 7f6b35409..d69f97095 100644 --- a/apps/sim/middleware.ts +++ b/apps/sim/middleware.ts @@ -93,6 +93,13 @@ export async function middleware(request: NextRequest) { if (!hasActiveSession) { return NextResponse.redirect(new URL('/login', request.url)) } + + // Check if user needs email verification + const requiresVerification = request.cookies.get('requiresEmailVerification') + if (requiresVerification?.value === 'true') { + return NextResponse.redirect(new URL('/verify', request.url)) + } + return NextResponse.next() } diff --git a/apps/sim/package.json b/apps/sim/package.json index aa39154b9..da697019a 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -133,7 +133,7 @@ "@types/js-yaml": "4.0.9", "@types/jsdom": "21.1.7", "@types/lodash": "^4.17.16", - "@types/node": "^22", + "@types/node": "24.2.1", "@types/prismjs": "^1.26.5", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/apps/sim/stores/organization/utils.ts b/apps/sim/stores/organization/utils.ts index 9ae629b7c..46d3abb6d 100644 --- a/apps/sim/stores/organization/utils.ts +++ b/apps/sim/stores/organization/utils.ts @@ -1,3 +1,4 @@ +import { quickValidateEmail } from '@/lib/email/validation' import type { Organization } from '@/stores/organization/types' /** @@ -32,5 +33,5 @@ export function validateSlug(slug: string): boolean { * Validate email format */ export function validateEmail(email: string): boolean { - return email.includes('@') && email.trim().length > 0 + return quickValidateEmail(email.trim().toLowerCase()).isValid } diff --git a/bun.lock b/bun.lock index 78bda3355..24854fada 100644 --- a/bun.lock +++ b/bun.lock @@ -162,7 +162,7 @@ "@types/js-yaml": "4.0.9", "@types/jsdom": "21.1.7", "@types/lodash": "^4.17.16", - "@types/node": "^22", + "@types/node": "24.2.1", "@types/prismjs": "^1.26.5", "@types/react": "^19", "@types/react-dom": "^19", @@ -3727,6 +3727,8 @@ "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "sim/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], "sim/tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], @@ -4081,6 +4083,8 @@ "restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "sim/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "sim/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "sim/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],