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 = 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 { 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; } /** * Gets the MX records for a domain * @param domain The domain to get MX records for * @returns Array of MX records as strings */ public async getMxRecords(domain: string): Promise { const mxRecords = await this.checkMxRecords(domain); if (!mxRecords || !Array.isArray(mxRecords)) { return []; } // Extract exchange values from MX records return mxRecords.map((record: any) => { if (record && record.exchange) { return record.exchange; } return ''; }).filter(Boolean); } /** * Checks if an email is from a disposable domain * @param email The email address to check * @returns True if the email is from a disposable domain */ public async isDisposableEmail(email: string): Promise { await this.fetchDomains(); if (!this.isValidEmailFormat(email)) { return false; } const domainPart = email.split('@')[1]; return this.domainMap[domainPart] === 'disposable'; } /** * Checks if an email is a role account (e.g. info@, support@, etc.) * @param email The email address to check * @returns True if the email is a role account */ public isRoleAccount(email: string): boolean { if (!this.isValidEmailFormat(email)) { return false; } const localPart = email.split('@')[0].toLowerCase(); const roleAccounts = [ 'admin', 'administrator', 'webmaster', 'hostmaster', 'postmaster', 'info', 'support', 'sales', 'marketing', 'contact', 'help', 'abuse', 'noc', 'security', 'billing', 'donations', 'donate', 'staff', 'office', 'hr', 'jobs', 'careers', 'team', 'enquiry', 'enquiries', 'feedback', 'no-reply', 'noreply' ]; return roleAccounts.includes(localPart); } /** * Validates an email address * @param emailArg The email address to validate * @returns Validation result with details */ public async validate(emailArg: string): Promise { 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; } } } }