Skip to content
90 changes: 90 additions & 0 deletions __tests__/email-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { EmailValidator } from '../lib/email-validation';

describe('Email Validation', () => {
// Comprehensive test scenarios covering 10+ email format cases
const validEmailScenarios = [
// Standard email formats
'user@example.com',
'firstname.lastname@example.com',
'email@subdomain.example.com',

// Special character handling
'firstname+lastname@example.com',
'email.with.dots@example.com',
'email-with-hyphen@example.com',

// Numeric and special domain cases
'email@123.123.123.123', // IP address domain
'email@[123.123.123.123]', // IP in square brackets
'1234567890@example.com', // Numeric local part

// Complex valid emails
'"email with spaces"@example.com',
'very.common@example.com',
'disposable.style.email.with+symbol@example.com',
'other.email-with-hyphen@example.com',
'fully-qualified-domain@example.com',
'user.name+tag@example.com'
];

const invalidEmailScenarios = [
// Empty and whitespace inputs
'',
' ',
null as any,
undefined as any,

// Invalid formats
'invalid',
'@invalid.com',
'invalid@',
'invalid@.com',
'invalid@domain',
'invalid@domain.',

// Problematic patterns
'email@example',
'email.@example.com',
'.email@example.com',
'email..email@example.com',

// Special character issues
'あいうえお@example.com', // Non-ASCII characters
'email@-example.com',
'email@example-.com',

// Length and dot issues
'a'.repeat(65) + '@example.com', // Too long local part
'email@' + 'a'.repeat(256) + '.com' // Too long domain
];

// Validate correct email formats
test.each(validEmailScenarios)('validates valid email: %s', (email) => {
expect(EmailValidator.validate(email)).toBe(true);
expect(EmailValidator.getValidationError(email)).toBeNull();
});

// Invalidate incorrect email formats
test.each(invalidEmailScenarios)('invalidates invalid email: %s', (email) => {
expect(EmailValidator.validate(email)).toBe(false);
expect(EmailValidator.getValidationError(email)).not.toBeNull();
});

// Normalization tests
describe('Email Normalization', () => {
test('normalizes emails consistently', () => {
expect(EmailValidator.normalize(' User@Example.com ')).toBe('user@example.com');
expect(EmailValidator.normalize('USER@EXAMPLE.COM')).toBe('user@example.com');
expect(EmailValidator.normalize('')).toBe('');
});
});

// Error message tests
describe('Validation Error Messages', () => {
test('provides appropriate error messages', () => {
expect(EmailValidator.getValidationError('')).toBe('Email is required');
expect(EmailValidator.getValidationError('a@b')).toBe('Invalid email format');
expect(EmailValidator.getValidationError('a'.repeat(300) + '@example.com')).toBe('Email is too long');
});
});
});
65 changes: 65 additions & 0 deletions lib/email-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Comprehensive email validation utility
*/
export class EmailValidator {
/**
* Validate email format with comprehensive checks
* @param email - Email address to validate
* @returns boolean indicating email validity
*/
static validate(email: string): boolean {
// Check for null, undefined, or empty input
if (!email) return false;

// Trim and convert to lowercase
const trimmedEmail = email.trim().toLowerCase();

// Length check (RFC 5321 limits)
if (trimmedEmail.length < 3 || trimmedEmail.length > 254) return false;

// Comprehensive email regex with RFC 5322 standard
const emailRegex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

// Additional validation checks
if (!emailRegex.test(trimmedEmail)) return false;

// Local part length check
const [local, domain] = trimmedEmail.split('@');
if (local.length > 64 || domain.length > 255) return false;

// Disallow consecutive dots
if (/\.{2,}/.test(trimmedEmail)) return false;

// Disallow leading/trailing dots in local part
if (/^\.|\.@|\.$/.test(local)) return false;

return true;
}

/**
* Normalize email for consistent comparison
* @param email - Email to normalize
* @returns Normalized email
*/
static normalize(email: string): string {
return email ? email.trim().toLowerCase() : '';
}

/**
* Get detailed validation error message
* @param email - Email to validate
* @returns Error message or null if valid
*/
static getValidationError(email: string): string | null {
if (!email) return 'Email is required';

const trimmedEmail = email.trim().toLowerCase();

if (trimmedEmail.length < 3) return 'Email is too short';
if (trimmedEmail.length > 254) return 'Email is too long';

if (!this.validate(trimmedEmail)) return 'Invalid email format';

return null;
}
}
55 changes: 55 additions & 0 deletions models/User.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import mongoose from 'mongoose';
import { EmailValidator } from '../lib/email-validation';

// Define the User schema interface
export interface IUser extends mongoose.Document {
email: string;
password: string;
// Add other user fields as needed
}

// Create the User schema
const UserSchema = new mongoose.Schema<IUser>({
email: {
type: String,
required: [true, 'Email is required'],
unique: true, // Database-level unique constraint
trim: true,
lowercase: true,
validate: {
validator: function(value: string) {
return EmailValidator.validate(value);
},
message: 'Invalid email format'
}
},
password: {
type: String,
required: [true, 'Password is required'],
// Add password complexity validation if needed
}
}, {
timestamps: true,
// Ensure case-insensitive unique index
indexes: [{
email: 1
}]
});

// Pre-save hook to normalize email
UserSchema.pre('save', function(next) {
if (this.isModified('email')) {
this.email = EmailValidator.normalize(this.email);
}
next();
});

// Custom method to find user by email (case-insensitive)
UserSchema.statics.findByEmail = function(email: string) {
return this.findOne({ email: EmailValidator.normalize(email) });
};

// Create the User model
export const User = mongoose.models.User || mongoose.model<IUser>('User', UserSchema);

export default User;