BREAKING CHANGE(smartmail): Improve email validation and Smartmail features: add detailed validation for email parts, caching for MX lookups, multi-recipient support, custom headers, and update dependency imports and build scripts.

This commit is contained in:
2025-05-07 13:18:41 +00:00
parent 442bc5a9d9
commit e395a059a6
14 changed files with 9695 additions and 3546 deletions

View File

@ -1,8 +1,8 @@
/**
* autocreated commitinfo by @pushrocks/commitinfo
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@push.rocks/smartmail',
version: '1.0.24',
description: 'a unified format for representing and dealing with mails'
version: '2.0.0',
description: 'A unified format for representing and dealing with emails, with support for attachments and email validation.'
}

View File

@ -6,27 +6,190 @@ export interface IEmailValidationResult {
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;
public async validate(emailArg: string): Promise<IEmailValidationResult> {
await this.fetchDomains();
const emailArray = emailArg.split('@');
const result = await this.smartdns.getRecords(emailArray[1], 'MX');
// console.log(emailArray);
// console.log(this.domainMap[emailArray[1]]);
return {
valid: !!result,
reason: 'todo',
disposable: this.domainMap[emailArray[1]] === 'disposable',
freemail: this.domainMap[emailArray[1]] === 'freemail',
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(
@ -34,21 +197,20 @@ export class EmailAddressValidator {
);
const localFileObject = JSON.parse(localFileString);
let onlineFileObject: any;
if (this.options.skipOnlineDomainFetch) {
this.domainMap = localFileObject;
return;
}
try {
onlineFileObject = (
const onlineFileObject = (
await plugins.smartrequest.getJson(
'https://raw.githubusercontent.com/romainsimon/emailvalid/master/domains.json'
)
).body;
this.domainMap = onlineFileObject;
console.log(
'smartmail EmailAddressValidator: Using online email list for email validation'
);
} catch (e) {
this.domainMap = localFileObject;
console.log(e);
console.log('smartmail EmailAddressValidator: Using local email list for email validation');
}
}
}

View File

@ -1,38 +1,257 @@
import * as plugins from './smartmail.plugins.js';
import { EmailAddressValidator } from './smartmail.classes.emailaddressvalidator.js';
export type EmailAddress = string;
export type EmailAddressList = EmailAddress[];
export interface ISmartmailOptions<T> {
from: string;
from: EmailAddress;
to?: EmailAddressList;
cc?: EmailAddressList;
bcc?: EmailAddressList;
replyTo?: EmailAddress;
subject: string;
body: string;
htmlBody?: string;
creationObjectRef?: T;
headers?: Record<string, string>;
priority?: 'high' | 'normal' | 'low';
validateEmails?: boolean;
}
export interface IMimeAttachment {
filename: string;
content: Buffer;
contentType: string;
}
/**
* a standard representation for mails
* A standard representation for emails with advanced features
*/
export class Smartmail<T> {
public options: ISmartmailOptions<T>;
public attachments: plugins.smartfile.Smartfile[] = [];
public attachments: plugins.smartfile.SmartFile[] = [];
private emailValidator: EmailAddressValidator;
constructor(optionsArg: ISmartmailOptions<T>) {
this.options = optionsArg;
// Set default options
this.options = {
validateEmails: false,
to: [],
cc: [],
bcc: [],
headers: {},
priority: 'normal',
...optionsArg
};
this.emailValidator = new EmailAddressValidator();
}
public addAttachment(smartfileArg: plugins.smartfile.Smartfile) {
/**
* Adds an attachment to the email
* @param smartfileArg The file to attach
*/
public addAttachment(smartfileArg: plugins.smartfile.SmartFile) {
this.attachments.push(smartfileArg);
}
/**
* Gets the creation object reference
* @returns The creation object reference
*/
public getCreationObject(): T {
return this.options.creationObjectRef;
}
public getSubject(dataArg: any = {}) {
/**
* Gets the processed subject with template variables applied
* @param dataArg Data to apply to the template
* @returns Processed subject
*/
public getSubject(dataArg: any = {}): string {
const smartmustache = new plugins.smartmustache.SmartMustache(this.options.subject);
return smartmustache.applyData(dataArg);
}
public getBody(dataArg: any = {}) {
/**
* Gets the processed plain text body with template variables applied
* @param dataArg Data to apply to the template
* @returns Processed body
*/
public getBody(dataArg: any = {}): string {
const smartmustache = new plugins.smartmustache.SmartMustache(this.options.body);
return smartmustache.applyData(dataArg);
}
/**
* Gets the processed HTML body with template variables applied
* @param dataArg Data to apply to the template
* @returns Processed HTML body or null if not set
*/
public getHtmlBody(dataArg: any = {}): string | null {
if (!this.options.htmlBody) {
return null;
}
const smartmustache = new plugins.smartmustache.SmartMustache(this.options.htmlBody);
return smartmustache.applyData(dataArg);
}
/**
* Adds a recipient to the email
* @param email Email address to add
* @param type Type of recipient (to, cc, bcc)
*/
public addRecipient(email: EmailAddress, type: 'to' | 'cc' | 'bcc' = 'to'): void {
if (!this.options[type]) {
this.options[type] = [];
}
this.options[type]!.push(email);
}
/**
* Adds multiple recipients to the email
* @param emails Email addresses to add
* @param type Type of recipients (to, cc, bcc)
*/
public addRecipients(emails: EmailAddressList, type: 'to' | 'cc' | 'bcc' = 'to'): void {
if (!this.options[type]) {
this.options[type] = [];
}
this.options[type] = [...this.options[type]!, ...emails];
}
/**
* Sets the reply-to address
* @param email Email address for reply-to
*/
public setReplyTo(email: EmailAddress): void {
this.options.replyTo = email;
}
/**
* Sets the priority of the email
* @param priority Priority level
*/
public setPriority(priority: 'high' | 'normal' | 'low'): void {
this.options.priority = priority;
}
/**
* Adds a custom header to the email
* @param name Header name
* @param value Header value
*/
public addHeader(name: string, value: string): void {
if (!this.options.headers) {
this.options.headers = {};
}
this.options.headers[name] = value;
}
/**
* Validates all email addresses in the email
* @returns Promise resolving to validation results
*/
public async validateAllEmails(): Promise<Record<string, boolean>> {
const results: Record<string, boolean> = {};
const emails: EmailAddress[] = [];
// Collect all emails
if (this.options.from) emails.push(this.options.from);
if (this.options.replyTo) emails.push(this.options.replyTo);
if (this.options.to) emails.push(...this.options.to);
if (this.options.cc) emails.push(...this.options.cc);
if (this.options.bcc) emails.push(...this.options.bcc);
// Validate each email
for (const email of emails) {
const validationResult = await this.emailValidator.validate(email);
results[email] = validationResult.valid;
}
return results;
}
/**
* Converts the email to a MIME format object for sending
* @param dataArg Data to apply to templates
* @returns MIME format object
*/
public async toMimeFormat(dataArg: any = {}): Promise<any> {
// Validate emails if option is enabled
if (this.options.validateEmails) {
const validationResults = await this.validateAllEmails();
const invalidEmails = Object.entries(validationResults)
.filter(([_, valid]) => !valid)
.map(([email]) => email);
if (invalidEmails.length > 0) {
throw new Error(`Invalid email addresses: ${invalidEmails.join(', ')}`);
}
}
// Build MIME parts
const subject = this.getSubject(dataArg);
const textBody = this.getBody(dataArg);
const htmlBody = this.getHtmlBody(dataArg);
// Convert attachments to MIME format
const mimeAttachments: IMimeAttachment[] = await Promise.all(
this.attachments.map(async (file) => {
return {
filename: file.path.split('/').pop()!,
content: file.contentBuffer,
contentType: 'application/octet-stream'
};
})
);
// Build email format object
const mimeObj: any = {
from: this.options.from,
subject,
text: textBody,
attachments: mimeAttachments,
headers: { ...this.options.headers }
};
// Add optional fields
if (this.options.to && this.options.to.length > 0) {
mimeObj.to = this.options.to;
}
if (this.options.cc && this.options.cc.length > 0) {
mimeObj.cc = this.options.cc;
}
if (this.options.bcc && this.options.bcc.length > 0) {
mimeObj.bcc = this.options.bcc;
}
if (this.options.replyTo) {
mimeObj.replyTo = this.options.replyTo;
}
if (htmlBody) {
mimeObj.html = htmlBody;
}
// Add priority headers if specified
if (this.options.priority === 'high') {
mimeObj.headers['X-Priority'] = '1';
mimeObj.headers['X-MSMail-Priority'] = 'High';
mimeObj.headers['Importance'] = 'High';
} else if (this.options.priority === 'low') {
mimeObj.headers['X-Priority'] = '5';
mimeObj.headers['X-MSMail-Priority'] = 'Low';
mimeObj.headers['Importance'] = 'Low';
}
return mimeObj;
}
}

View File

@ -4,7 +4,7 @@ import * as path from 'path';
export { path };
// pushrocks scope
import * as smartdns from '@push.rocks/smartdns';
import * as smartdns from '@push.rocks/smartdns/client';
import * as smartfile from '@push.rocks/smartfile';
import * as smartmustache from '@push.rocks/smartmustache';
import * as smartpath from '@push.rocks/smartpath';