mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 14:43:54 -05:00
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
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
75
apps/sim/lib/email/validation.test.ts
Normal file
75
apps/sim/lib/email/validation.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
262
apps/sim/lib/email/validation.ts
Normal file
262
apps/sim/lib/email/validation.ts
Normal file
@@ -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<boolean> {
|
||||
// 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<EmailValidationResult> {
|
||||
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<boolean>((_, 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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
6
bun.lock
6
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=="],
|
||||
|
||||
Reference in New Issue
Block a user