2022-08-07 11:38:07 +02:00
|
|
|
import * as plugins from './smartmail.plugins.js';
|
|
|
|
import * as paths from './smartmail.paths.js';
|
2020-06-18 15:34:05 +00:00
|
|
|
|
|
|
|
export interface IEmailValidationResult {
|
|
|
|
valid: boolean;
|
2020-06-18 21:04:52 +00:00
|
|
|
disposable: boolean;
|
|
|
|
freemail: boolean;
|
2020-06-18 15:34:05 +00:00
|
|
|
reason: string;
|
2025-05-07 13:18:41 +00:00
|
|
|
formatValid: boolean;
|
|
|
|
mxValid: boolean;
|
|
|
|
localPartValid: boolean;
|
|
|
|
domainPartValid: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface IEmailAddressValidatorOptions {
|
|
|
|
skipOnlineDomainFetch?: boolean;
|
|
|
|
cacheDnsResults?: boolean;
|
|
|
|
cacheExpiryMs?: number;
|
2020-06-18 15:34:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export class EmailAddressValidator {
|
2022-08-07 11:38:07 +02:00
|
|
|
public domainMap: { [key: string]: 'disposable' | 'freemail' };
|
2020-06-18 15:34:05 +00:00
|
|
|
public smartdns = new plugins.smartdns.Smartdns({});
|
2025-05-07 13:18:41 +00:00
|
|
|
private dnsCache: Map<string, { result: any; timestamp: number }> = new Map();
|
|
|
|
private options: IEmailAddressValidatorOptions;
|
2020-06-18 15:34:05 +00:00
|
|
|
|
2025-05-07 13:18:41 +00:00
|
|
|
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;
|
|
|
|
}
|
2025-05-07 14:57:50 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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<string[]> {
|
|
|
|
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<boolean> {
|
|
|
|
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);
|
|
|
|
}
|
2025-05-07 13:18:41 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Validates an email address
|
|
|
|
* @param emailArg The email address to validate
|
|
|
|
* @returns Validation result with details
|
|
|
|
*/
|
2020-06-18 15:34:05 +00:00
|
|
|
public async validate(emailArg: string): Promise<IEmailValidationResult> {
|
2020-06-18 21:04:52 +00:00
|
|
|
await this.fetchDomains();
|
2025-05-07 13:18:41 +00:00
|
|
|
|
|
|
|
// Initialize result
|
|
|
|
const result: IEmailValidationResult = {
|
|
|
|
valid: false,
|
|
|
|
reason: '',
|
|
|
|
disposable: false,
|
|
|
|
freemail: false,
|
|
|
|
formatValid: false,
|
|
|
|
mxValid: false,
|
|
|
|
localPartValid: false,
|
|
|
|
domainPartValid: false
|
2020-06-18 15:34:05 +00:00
|
|
|
};
|
2025-05-07 13:18:41 +00:00
|
|
|
|
|
|
|
// 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;
|
2020-06-18 15:34:05 +00:00
|
|
|
}
|
2020-06-18 21:04:52 +00:00
|
|
|
|
2025-05-07 13:18:41 +00:00
|
|
|
/**
|
|
|
|
* Fetches the domain list for checking disposable and free email providers
|
|
|
|
*/
|
2020-06-18 21:04:52 +00:00
|
|
|
public async fetchDomains() {
|
|
|
|
if (!this.domainMap) {
|
|
|
|
const localFileString = plugins.smartfile.fs.toStringSync(
|
|
|
|
plugins.path.join(paths.assetDir, 'domains.json')
|
|
|
|
);
|
|
|
|
const localFileObject = JSON.parse(localFileString);
|
|
|
|
|
2025-05-07 13:18:41 +00:00
|
|
|
if (this.options.skipOnlineDomainFetch) {
|
|
|
|
this.domainMap = localFileObject;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-06-18 21:04:52 +00:00
|
|
|
try {
|
2025-05-07 13:18:41 +00:00
|
|
|
const onlineFileObject = (
|
2020-06-18 21:04:52 +00:00
|
|
|
await plugins.smartrequest.getJson(
|
|
|
|
'https://raw.githubusercontent.com/romainsimon/emailvalid/master/domains.json'
|
|
|
|
)
|
|
|
|
).body;
|
|
|
|
this.domainMap = onlineFileObject;
|
|
|
|
} catch (e) {
|
|
|
|
this.domainMap = localFileObject;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2020-06-18 15:34:05 +00:00
|
|
|
}
|