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:
Waleed Latif
2025-08-08 17:01:41 -07:00
committed by GitHub
parent 7f39cd0f23
commit 43a3416347
18 changed files with 469 additions and 277 deletions

View File

@@ -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')

View File

@@ -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)

View File

@@ -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')
})
})

View File

@@ -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)

View File

@@ -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)

View File

@@ -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: {

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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')
})
})
})

View File

@@ -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,
}
}

View 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')
})
})
})

View 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,
}
}

View File

@@ -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()
}

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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=="],