smartmail/ts/smartmail.classes.emailaddressvalidator.ts

218 lines
6.4 KiB
TypeScript

import * as plugins from './smartmail.plugins.js';
import * as paths from './smartmail.paths.js';
export interface IEmailValidationResult {
valid: boolean;
disposable: boolean;
freemail: boolean;
reason: string;
formatValid: boolean;
mxValid: boolean;
localPartValid: boolean;
domainPartValid: boolean;
}
export interface IEmailAddressValidatorOptions {
skipOnlineDomainFetch?: boolean;
cacheDnsResults?: boolean;
cacheExpiryMs?: number;
}
export class EmailAddressValidator {
public domainMap: { [key: string]: 'disposable' | 'freemail' };
public smartdns = new plugins.smartdns.Smartdns({});
private dnsCache: Map<string, { result: any; timestamp: number }> = new Map();
private options: IEmailAddressValidatorOptions;
constructor(optionsArg: IEmailAddressValidatorOptions = {}) {
this.options = {
skipOnlineDomainFetch: false,
cacheDnsResults: true,
cacheExpiryMs: 3600000, // 1 hour
...optionsArg
};
}
/**
* Validates an email address format according to RFC 5322
* @param emailArg The email address to validate
* @returns True if the format is valid
*/
public isValidEmailFormat(emailArg: string): boolean {
if (!emailArg) return false;
// RFC 5322 compliant regex pattern
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,}))$/;
return emailRegex.test(emailArg);
}
/**
* Validates the local part of an email address (before the @)
* @param localPart The local part of the email address
* @returns True if the local part is valid
*/
public isValidLocalPart(localPart: string): boolean {
if (!localPart) return false;
if (localPart.length > 64) return false;
// Check for illegal characters and patterns
const illegalChars = /[^\w.!#$%&'*+/=?^`{|}~-]/;
if (illegalChars.test(localPart)) return false;
// Check for consecutive dots or leading/trailing dots
if (localPart.includes('..') || localPart.startsWith('.') || localPart.endsWith('.')) return false;
return true;
}
/**
* Validates the domain part of an email address (after the @)
* @param domainPart The domain part of the email address
* @returns True if the domain part is valid
*/
public isValidDomainPart(domainPart: string): boolean {
if (!domainPart) return false;
if (domainPart.length > 255) return false;
// Domain name validation regex
const domainRegex = /^[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])?)*$/;
// Must have at least one dot
if (!domainPart.includes('.')) return false;
// Must end with a valid TLD (at least 2 chars)
const parts = domainPart.split('.');
const tld = parts[parts.length - 1];
if (tld.length < 2) return false;
return domainRegex.test(domainPart);
}
/**
* Performs DNS MX record lookup for a domain
* @param domain The domain to check
* @returns MX records or null if none exist
*/
public async checkMxRecords(domain: string): Promise<any> {
if (this.options.cacheDnsResults) {
const cached = this.dnsCache.get(domain);
if (cached && (Date.now() - cached.timestamp) < this.options.cacheExpiryMs!) {
return cached.result;
}
}
const result = await this.smartdns.getRecords(domain, 'MX');
if (this.options.cacheDnsResults) {
this.dnsCache.set(domain, { result, timestamp: Date.now() });
}
return result;
}
/**
* Validates an email address
* @param emailArg The email address to validate
* @returns Validation result with details
*/
public async validate(emailArg: string): Promise<IEmailValidationResult> {
await this.fetchDomains();
// Initialize result
const result: IEmailValidationResult = {
valid: false,
reason: '',
disposable: false,
freemail: false,
formatValid: false,
mxValid: false,
localPartValid: false,
domainPartValid: false
};
// Check overall email format
const formatValid = this.isValidEmailFormat(emailArg);
result.formatValid = formatValid;
if (!formatValid) {
result.reason = 'Invalid email format';
return result;
}
// Split email into local and domain parts
const [localPart, domainPart] = emailArg.split('@');
// Validate local part
const localPartValid = this.isValidLocalPart(localPart);
result.localPartValid = localPartValid;
if (!localPartValid) {
result.reason = 'Invalid local part (username)';
return result;
}
// Validate domain part
const domainPartValid = this.isValidDomainPart(domainPart);
result.domainPartValid = domainPartValid;
if (!domainPartValid) {
result.reason = 'Invalid domain part';
return result;
}
// Check MX records
const mxRecords = await this.checkMxRecords(domainPart);
result.mxValid = !!mxRecords;
if (!mxRecords) {
result.reason = 'Domain does not have valid MX records';
return result;
}
// Check if domain is disposable or free
result.disposable = this.domainMap[domainPart] === 'disposable';
result.freemail = this.domainMap[domainPart] === 'freemail';
if (result.disposable) {
result.reason = 'Domain is a disposable email provider';
} else if (result.freemail) {
result.reason = 'Domain is a free email provider';
} else {
result.reason = 'Email is valid';
}
// Email is valid if it has proper format and MX records
result.valid = result.formatValid && result.mxValid;
return result;
}
/**
* Fetches the domain list for checking disposable and free email providers
*/
public async fetchDomains() {
if (!this.domainMap) {
const localFileString = plugins.smartfile.fs.toStringSync(
plugins.path.join(paths.assetDir, 'domains.json')
);
const localFileObject = JSON.parse(localFileString);
if (this.options.skipOnlineDomainFetch) {
this.domainMap = localFileObject;
return;
}
try {
const onlineFileObject = (
await plugins.smartrequest.getJson(
'https://raw.githubusercontent.com/romainsimon/emailvalid/master/domains.json'
)
).body;
this.domainMap = onlineFileObject;
} catch (e) {
this.domainMap = localFileObject;
}
}
}
}