feat(email): Enhance email integration by updating @push.rocks/smartmail to ^2.1.0 and improving the entire email stack including validation, DKIM verification, templating, MIME conversion, and attachment handling.
This commit is contained in:
@ -27,7 +27,7 @@ export class MtaConnector {
|
||||
* @param options Additional options
|
||||
*/
|
||||
public async sendEmail(
|
||||
smartmail: plugins.smartmail.Smartmail<any>, // TODO: look at type
|
||||
smartmail: plugins.smartmail.Smartmail<any>,
|
||||
toAddresses: string | string[],
|
||||
options: any = {}
|
||||
): Promise<string> {
|
||||
@ -37,36 +37,36 @@ export class MtaConnector {
|
||||
? toAddresses
|
||||
: toAddresses.split(',').map(addr => addr.trim());
|
||||
|
||||
// Map SmartMail attachments to MTA attachments
|
||||
const attachments: IAttachment[] = smartmail.attachments.map(attachment => {
|
||||
return {
|
||||
filename: attachment.parsedPath.base,
|
||||
content: Buffer.from(attachment.contentBuffer),
|
||||
contentType: (attachment as any)?.getContentType?.() || 'application/octet-stream' // TODO: revisit after smartfile has been updated
|
||||
};
|
||||
});
|
||||
// Add recipients to smartmail if they're not already added
|
||||
if (!smartmail.options.to || smartmail.options.to.length === 0) {
|
||||
for (const recipient of toArray) {
|
||||
smartmail.addRecipient(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
// Create MTA Email
|
||||
const mtaEmail = new MtaEmail({
|
||||
from: smartmail.options.from,
|
||||
to: toArray,
|
||||
subject: smartmail.getSubject(),
|
||||
text: smartmail.getBody(false), // Plain text version
|
||||
html: smartmail.getBody(true), // HTML version
|
||||
attachments
|
||||
});
|
||||
|
||||
// Send using MTA
|
||||
const emailId = await this.mtaService.send(mtaEmail);
|
||||
// Handle options
|
||||
const emailOptions: Record<string, any> = { ...options };
|
||||
|
||||
logger.log('info', `Email sent via MTA to ${toAddresses}`, {
|
||||
eventType: 'sentEmail',
|
||||
provider: 'mta',
|
||||
emailId,
|
||||
to: toAddresses
|
||||
});
|
||||
|
||||
return emailId;
|
||||
// Check if we should use MIME format
|
||||
const useMimeFormat = options.useMimeFormat ?? true;
|
||||
|
||||
if (useMimeFormat) {
|
||||
// Use smartmail's MIME conversion for improved handling
|
||||
try {
|
||||
// Convert to MIME format
|
||||
const mimeEmail = await smartmail.toMimeFormat(smartmail.options.creationObjectRef);
|
||||
|
||||
// Parse the MIME email to create an MTA Email
|
||||
return this.sendMimeEmail(mimeEmail, toArray);
|
||||
} catch (mimeError) {
|
||||
logger.log('warn', `Failed to use MIME format, falling back to direct conversion: ${mimeError.message}`);
|
||||
// Fall back to direct conversion
|
||||
return this.sendDirectEmail(smartmail, toArray);
|
||||
}
|
||||
} else {
|
||||
// Use direct conversion
|
||||
return this.sendDirectEmail(smartmail, toArray);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to send email via MTA: ${error.message}`, {
|
||||
eventType: 'emailError',
|
||||
@ -76,13 +76,176 @@ export class MtaConnector {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a MIME-formatted email
|
||||
* @param mimeEmail The MIME-formatted email content
|
||||
* @param recipients The email recipients
|
||||
*/
|
||||
private async sendMimeEmail(mimeEmail: string, recipients: string[]): Promise<string> {
|
||||
try {
|
||||
// Parse the MIME email
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(mimeEmail);
|
||||
|
||||
// Extract necessary information for MTA Email
|
||||
const mtaEmail = new MtaEmail({
|
||||
from: parsedEmail.from?.text || '',
|
||||
to: recipients,
|
||||
subject: parsedEmail.subject || '',
|
||||
text: parsedEmail.text || '',
|
||||
html: parsedEmail.html || undefined,
|
||||
attachments: parsedEmail.attachments?.map(attachment => ({
|
||||
filename: attachment.filename || 'attachment',
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType || 'application/octet-stream',
|
||||
contentId: attachment.contentId
|
||||
})) || [],
|
||||
headers: Object.fromEntries([...parsedEmail.headers].map(([key, value]) => [key, String(value)]))
|
||||
});
|
||||
|
||||
// Send using MTA
|
||||
const emailId = await this.mtaService.send(mtaEmail);
|
||||
|
||||
logger.log('info', `MIME email sent via MTA to ${recipients.join(', ')}`, {
|
||||
eventType: 'sentEmail',
|
||||
provider: 'mta',
|
||||
emailId,
|
||||
to: recipients
|
||||
});
|
||||
|
||||
return emailId;
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to send MIME email: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using direct conversion (fallback method)
|
||||
* @param smartmail The Smartmail instance
|
||||
* @param recipients The email recipients
|
||||
*/
|
||||
private async sendDirectEmail(
|
||||
smartmail: plugins.smartmail.Smartmail<any>,
|
||||
recipients: string[]
|
||||
): Promise<string> {
|
||||
// Map SmartMail attachments to MTA attachments with improved content type handling
|
||||
const attachments: IAttachment[] = smartmail.attachments.map(attachment => {
|
||||
// Try to determine content type from file extension if not explicitly set
|
||||
let contentType = (attachment as any)?.contentType;
|
||||
|
||||
if (!contentType) {
|
||||
const extension = attachment.parsedPath.ext.toLowerCase();
|
||||
contentType = this.getContentTypeFromExtension(extension);
|
||||
}
|
||||
|
||||
return {
|
||||
filename: attachment.parsedPath.base,
|
||||
content: Buffer.from(attachment.contentBuffer),
|
||||
contentType: contentType || 'application/octet-stream',
|
||||
// Add content ID for inline images if available
|
||||
contentId: (attachment as any)?.contentId
|
||||
};
|
||||
});
|
||||
|
||||
// Create MTA Email
|
||||
const mtaEmail = new MtaEmail({
|
||||
from: smartmail.options.from,
|
||||
to: recipients,
|
||||
subject: smartmail.getSubject(),
|
||||
text: smartmail.getBody(false), // Plain text version
|
||||
html: smartmail.getBody(true), // HTML version
|
||||
attachments
|
||||
});
|
||||
|
||||
// Prepare arrays for CC and BCC recipients
|
||||
let ccRecipients: string[] = [];
|
||||
let bccRecipients: string[] = [];
|
||||
|
||||
// Add CC recipients if present
|
||||
if (smartmail.options.cc?.length > 0) {
|
||||
// Handle CC recipients - smartmail options may contain email objects
|
||||
ccRecipients = smartmail.options.cc.map(r => {
|
||||
if (typeof r === 'string') return r;
|
||||
return typeof (r as any).address === 'string' ? (r as any).address :
|
||||
typeof (r as any).email === 'string' ? (r as any).email : '';
|
||||
});
|
||||
mtaEmail.cc = ccRecipients;
|
||||
}
|
||||
|
||||
// Add BCC recipients if present
|
||||
if (smartmail.options.bcc?.length > 0) {
|
||||
// Handle BCC recipients - smartmail options may contain email objects
|
||||
bccRecipients = smartmail.options.bcc.map(r => {
|
||||
if (typeof r === 'string') return r;
|
||||
return typeof (r as any).address === 'string' ? (r as any).address :
|
||||
typeof (r as any).email === 'string' ? (r as any).email : '';
|
||||
});
|
||||
mtaEmail.bcc = bccRecipients;
|
||||
}
|
||||
|
||||
// Send using MTA
|
||||
const emailId = await this.mtaService.send(mtaEmail);
|
||||
|
||||
logger.log('info', `Email sent via MTA to ${recipients.join(', ')}`, {
|
||||
eventType: 'sentEmail',
|
||||
provider: 'mta',
|
||||
emailId,
|
||||
to: recipients
|
||||
});
|
||||
|
||||
return emailId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type from file extension
|
||||
* @param extension The file extension (with or without dot)
|
||||
* @returns The content type or undefined if unknown
|
||||
*/
|
||||
private getContentTypeFromExtension(extension: string): string | undefined {
|
||||
// Remove dot if present
|
||||
const ext = extension.startsWith('.') ? extension.substring(1) : extension;
|
||||
|
||||
// Common content types
|
||||
const contentTypes: Record<string, string> = {
|
||||
'pdf': 'application/pdf',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'svg': 'image/svg+xml',
|
||||
'webp': 'image/webp',
|
||||
'txt': 'text/plain',
|
||||
'html': 'text/html',
|
||||
'csv': 'text/csv',
|
||||
'json': 'application/json',
|
||||
'xml': 'application/xml',
|
||||
'zip': 'application/zip',
|
||||
'doc': 'application/msword',
|
||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xls': 'application/vnd.ms-excel',
|
||||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'ppt': 'application/vnd.ms-powerpoint',
|
||||
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
};
|
||||
|
||||
return contentTypes[ext.toLowerCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve and process an incoming email
|
||||
* For MTA, this would handle an email already received by the SMTP server
|
||||
* @param emailData The raw email data or identifier
|
||||
* @param options Additional processing options
|
||||
*/
|
||||
public async receiveEmail(emailData: string): Promise<plugins.smartmail.Smartmail<any>> {
|
||||
public async receiveEmail(
|
||||
emailData: string,
|
||||
options: {
|
||||
preserveHeaders?: boolean;
|
||||
includeRawData?: boolean;
|
||||
validateSender?: boolean;
|
||||
} = {}
|
||||
): Promise<plugins.smartmail.Smartmail<any>> {
|
||||
try {
|
||||
// In a real implementation, this would retrieve an email from the MTA storage
|
||||
// For now, we can use a simplified approach:
|
||||
@ -90,27 +253,180 @@ export class MtaConnector {
|
||||
// Parse the email (assuming emailData is a raw email or a file path)
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
||||
|
||||
// Extract sender information
|
||||
const sender = parsedEmail.from?.text || '';
|
||||
let senderName = '';
|
||||
let senderEmail = sender;
|
||||
|
||||
// Try to extract name and email from "Name <email>" format
|
||||
const senderMatch = sender.match(/(.*?)\s*<([^>]+)>/);
|
||||
if (senderMatch) {
|
||||
senderName = senderMatch[1].trim();
|
||||
senderEmail = senderMatch[2].trim();
|
||||
}
|
||||
|
||||
// Extract recipients
|
||||
const recipients = [];
|
||||
if (parsedEmail.to) {
|
||||
// Extract recipients safely
|
||||
try {
|
||||
// Handle AddressObject or AddressObject[]
|
||||
if (parsedEmail.to && typeof parsedEmail.to === 'object' && 'value' in parsedEmail.to) {
|
||||
const addressList = Array.isArray(parsedEmail.to.value)
|
||||
? parsedEmail.to.value
|
||||
: [parsedEmail.to.value];
|
||||
|
||||
for (const addr of addressList) {
|
||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
||||
recipients.push({
|
||||
name: addr.name || '',
|
||||
email: addr.address || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, try to extract as string
|
||||
let toStr = '';
|
||||
if (parsedEmail.to && typeof parsedEmail.to === 'object' && 'text' in parsedEmail.to) {
|
||||
toStr = String(parsedEmail.to.text || '');
|
||||
}
|
||||
if (toStr) {
|
||||
recipients.push({
|
||||
name: '',
|
||||
email: toStr
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a more comprehensive creation object reference
|
||||
const creationObjectRef: Record<string, any> = {
|
||||
sender: {
|
||||
name: senderName,
|
||||
email: senderEmail
|
||||
},
|
||||
recipients: recipients,
|
||||
subject: parsedEmail.subject || '',
|
||||
date: parsedEmail.date || new Date(),
|
||||
messageId: parsedEmail.messageId || '',
|
||||
inReplyTo: parsedEmail.inReplyTo || null,
|
||||
references: parsedEmail.references || []
|
||||
};
|
||||
|
||||
// Include headers if requested
|
||||
if (options.preserveHeaders) {
|
||||
creationObjectRef.headers = parsedEmail.headers;
|
||||
}
|
||||
|
||||
// Include raw data if requested
|
||||
if (options.includeRawData) {
|
||||
creationObjectRef.rawData = emailData;
|
||||
}
|
||||
|
||||
// Create a Smartmail from the parsed email
|
||||
const smartmail = new plugins.smartmail.Smartmail({
|
||||
from: parsedEmail.from?.text || '',
|
||||
from: senderEmail,
|
||||
subject: parsedEmail.subject || '',
|
||||
body: parsedEmail.html || parsedEmail.text || '',
|
||||
creationObjectRef: {
|
||||
From: parsedEmail.from?.text || '',
|
||||
To: parsedEmail.to,
|
||||
Subject: parsedEmail.subject || ''
|
||||
}
|
||||
creationObjectRef
|
||||
});
|
||||
|
||||
// Add recipients
|
||||
if (recipients.length > 0) {
|
||||
for (const recipient of recipients) {
|
||||
smartmail.addRecipient(recipient.email);
|
||||
}
|
||||
}
|
||||
|
||||
// Add CC recipients if present
|
||||
if (parsedEmail.cc) {
|
||||
try {
|
||||
// Extract CC recipients safely
|
||||
if (parsedEmail.cc && typeof parsedEmail.cc === 'object' && 'value' in parsedEmail.cc) {
|
||||
const ccList = Array.isArray(parsedEmail.cc.value)
|
||||
? parsedEmail.cc.value
|
||||
: [parsedEmail.cc.value];
|
||||
|
||||
for (const addr of ccList) {
|
||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
||||
smartmail.addRecipient(addr.address, 'cc');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, try to extract as string
|
||||
let ccStr = '';
|
||||
if (parsedEmail.cc && typeof parsedEmail.cc === 'object' && 'text' in parsedEmail.cc) {
|
||||
ccStr = String(parsedEmail.cc.text || '');
|
||||
}
|
||||
if (ccStr) {
|
||||
smartmail.addRecipient(ccStr, 'cc');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add BCC recipients if present (usually not in received emails, but just in case)
|
||||
if (parsedEmail.bcc) {
|
||||
try {
|
||||
// Extract BCC recipients safely
|
||||
if (parsedEmail.bcc && typeof parsedEmail.bcc === 'object' && 'value' in parsedEmail.bcc) {
|
||||
const bccList = Array.isArray(parsedEmail.bcc.value)
|
||||
? parsedEmail.bcc.value
|
||||
: [parsedEmail.bcc.value];
|
||||
|
||||
for (const addr of bccList) {
|
||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
||||
smartmail.addRecipient(addr.address, 'bcc');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, try to extract as string
|
||||
let bccStr = '';
|
||||
if (parsedEmail.bcc && typeof parsedEmail.bcc === 'object' && 'text' in parsedEmail.bcc) {
|
||||
bccStr = String(parsedEmail.bcc.text || '');
|
||||
}
|
||||
if (bccStr) {
|
||||
smartmail.addRecipient(bccStr, 'bcc');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add attachments if present
|
||||
if (parsedEmail.attachments && parsedEmail.attachments.length > 0) {
|
||||
for (const attachment of parsedEmail.attachments) {
|
||||
smartmail.addAttachment(
|
||||
await plugins.smartfile.SmartFile.fromBuffer(
|
||||
attachment.filename || 'attachment',
|
||||
attachment.content
|
||||
)
|
||||
);
|
||||
// Create smartfile with proper constructor options
|
||||
const file = new plugins.smartfile.SmartFile({
|
||||
path: attachment.filename || 'attachment',
|
||||
contentBuffer: attachment.content,
|
||||
base: ''
|
||||
});
|
||||
|
||||
// Set content type and content ID for proper MIME handling
|
||||
if (attachment.contentType) {
|
||||
(file as any).contentType = attachment.contentType;
|
||||
}
|
||||
|
||||
if (attachment.contentId) {
|
||||
(file as any).contentId = attachment.contentId;
|
||||
}
|
||||
|
||||
smartmail.addAttachment(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate sender if requested
|
||||
if (options.validateSender && this.emailRef.emailValidator) {
|
||||
try {
|
||||
const validationResult = await this.emailRef.emailValidator.validate(senderEmail, {
|
||||
checkSyntaxOnly: true // Use syntax-only for performance
|
||||
});
|
||||
|
||||
// Add validation info to the creation object
|
||||
creationObjectRef.senderValidation = validationResult;
|
||||
} catch (validationError) {
|
||||
logger.log('warn', `Sender validation error: ${validationError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,8 @@ import * as paths from '../paths.js';
|
||||
import { MtaConnector } from './classes.connector.mta.js';
|
||||
import { RuleManager } from './classes.rulemanager.js';
|
||||
import { ApiManager } from './classes.apimanager.js';
|
||||
import { TemplateManager } from './classes.templatemanager.js';
|
||||
import { EmailValidator } from './classes.emailvalidator.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { SzPlatformService } from '../platformservice.js';
|
||||
|
||||
@ -12,6 +14,13 @@ import { MtaService, type IMtaConfig } from '../mta/index.js';
|
||||
export interface IEmailConstructorOptions {
|
||||
useMta?: boolean;
|
||||
mtaConfig?: IMtaConfig;
|
||||
templateConfig?: {
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
footerHtml?: string;
|
||||
footerText?: string;
|
||||
};
|
||||
loadTemplatesFromDir?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -33,6 +42,8 @@ export class EmailService {
|
||||
// services
|
||||
public apiManager: ApiManager;
|
||||
public ruleManager: RuleManager;
|
||||
public templateManager: TemplateManager;
|
||||
public emailValidator: EmailValidator;
|
||||
|
||||
// configuration
|
||||
private config: IEmailConstructorOptions;
|
||||
@ -44,9 +55,17 @@ export class EmailService {
|
||||
// Set default options
|
||||
this.config = {
|
||||
useMta: options.useMta ?? true,
|
||||
mtaConfig: options.mtaConfig || {}
|
||||
mtaConfig: options.mtaConfig || {},
|
||||
templateConfig: options.templateConfig || {},
|
||||
loadTemplatesFromDir: options.loadTemplatesFromDir ?? true
|
||||
};
|
||||
|
||||
// Initialize validator
|
||||
this.emailValidator = new EmailValidator();
|
||||
|
||||
// Initialize template manager
|
||||
this.templateManager = new TemplateManager(this.config.templateConfig);
|
||||
|
||||
if (this.config.useMta) {
|
||||
// Initialize MTA service
|
||||
this.mtaService = new MtaService(platformServiceRefArg, this.config.mtaConfig);
|
||||
@ -72,6 +91,15 @@ export class EmailService {
|
||||
// Initialize rule manager
|
||||
await this.ruleManager.init();
|
||||
|
||||
// Load email templates if configured
|
||||
if (this.config.loadTemplatesFromDir) {
|
||||
try {
|
||||
await this.templateManager.loadTemplatesFromDirectory(paths.emailTemplatesDir);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load email templates: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start MTA service if enabled
|
||||
if (this.config.useMta && this.mtaService) {
|
||||
await this.mtaService.start();
|
||||
@ -101,7 +129,7 @@ export class EmailService {
|
||||
* @param options Additional options
|
||||
*/
|
||||
public async sendEmail(
|
||||
email: plugins.smartmail.Smartmail<>,
|
||||
email: plugins.smartmail.Smartmail<any>,
|
||||
to: string | string[],
|
||||
options: any = {}
|
||||
): Promise<string> {
|
||||
@ -112,6 +140,52 @@ export class EmailService {
|
||||
throw new Error('No email provider configured');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using a template
|
||||
* @param templateId The template ID
|
||||
* @param to Recipient email(s)
|
||||
* @param context The template context data
|
||||
* @param options Additional options
|
||||
*/
|
||||
public async sendTemplateEmail(
|
||||
templateId: string,
|
||||
to: string | string[],
|
||||
context: any = {},
|
||||
options: any = {}
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Get email from template
|
||||
const smartmail = await this.templateManager.prepareEmail(templateId, context);
|
||||
|
||||
// Send the email
|
||||
return this.sendEmail(smartmail, to, options);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to send template email: ${error.message}`, {
|
||||
templateId,
|
||||
to,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an email address
|
||||
* @param email The email address to validate
|
||||
* @param options Validation options
|
||||
* @returns Validation result
|
||||
*/
|
||||
public async validateEmail(
|
||||
email: string,
|
||||
options: {
|
||||
checkMx?: boolean;
|
||||
checkDisposable?: boolean;
|
||||
checkRole?: boolean;
|
||||
} = {}
|
||||
): Promise<any> {
|
||||
return this.emailValidator.validate(email, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email service statistics
|
||||
|
219
ts/email/classes.emailvalidator.ts
Normal file
219
ts/email/classes.emailvalidator.ts
Normal file
@ -0,0 +1,219 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export interface IEmailValidationResult {
|
||||
isValid: boolean;
|
||||
hasMx: boolean;
|
||||
hasSpamMarkings: boolean;
|
||||
score: number;
|
||||
details?: {
|
||||
formatValid?: boolean;
|
||||
mxRecords?: string[];
|
||||
disposable?: boolean;
|
||||
role?: boolean;
|
||||
spamIndicators?: string[];
|
||||
errorMessage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced email validator class using smartmail's capabilities
|
||||
*/
|
||||
export class EmailValidator {
|
||||
private validator: plugins.smartmail.EmailAddressValidator;
|
||||
private dnsCache: Map<string, any> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.validator = new plugins.smartmail.EmailAddressValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an email address using comprehensive checks
|
||||
* @param email The email to validate
|
||||
* @param options Validation options
|
||||
* @returns Validation result with details
|
||||
*/
|
||||
public async validate(
|
||||
email: string,
|
||||
options: {
|
||||
checkMx?: boolean;
|
||||
checkDisposable?: boolean;
|
||||
checkRole?: boolean;
|
||||
checkSyntaxOnly?: boolean;
|
||||
} = {}
|
||||
): Promise<IEmailValidationResult> {
|
||||
try {
|
||||
const result: IEmailValidationResult = {
|
||||
isValid: false,
|
||||
hasMx: false,
|
||||
hasSpamMarkings: false,
|
||||
score: 0,
|
||||
details: {
|
||||
formatValid: false,
|
||||
spamIndicators: []
|
||||
}
|
||||
};
|
||||
|
||||
// Always check basic format
|
||||
result.details.formatValid = this.validator.isValidEmailFormat(email);
|
||||
if (!result.details.formatValid) {
|
||||
result.details.errorMessage = 'Invalid email format';
|
||||
return result;
|
||||
}
|
||||
|
||||
// If syntax-only check is requested, return early
|
||||
if (options.checkSyntaxOnly) {
|
||||
result.isValid = true;
|
||||
result.score = 0.5;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get domain for additional checks
|
||||
const domain = email.split('@')[1];
|
||||
|
||||
// Check MX records
|
||||
if (options.checkMx !== false) {
|
||||
try {
|
||||
const mxRecords = await this.getMxRecords(domain);
|
||||
result.details.mxRecords = mxRecords;
|
||||
result.hasMx = mxRecords && mxRecords.length > 0;
|
||||
|
||||
if (!result.hasMx) {
|
||||
result.details.spamIndicators.push('No MX records');
|
||||
result.details.errorMessage = 'Domain has no MX records';
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking MX records: ${error.message}`);
|
||||
result.details.errorMessage = 'Unable to check MX records';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if domain is disposable
|
||||
if (options.checkDisposable !== false) {
|
||||
result.details.disposable = await this.validator.isDisposableEmail(email);
|
||||
if (result.details.disposable) {
|
||||
result.details.spamIndicators.push('Disposable email');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if email is a role account
|
||||
if (options.checkRole !== false) {
|
||||
result.details.role = this.validator.isRoleAccount(email);
|
||||
if (result.details.role) {
|
||||
result.details.spamIndicators.push('Role account');
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate spam score and final validity
|
||||
result.hasSpamMarkings = result.details.spamIndicators.length > 0;
|
||||
|
||||
// Calculate a score between 0-1 based on checks
|
||||
let scoreFactors = 0;
|
||||
let scoreTotal = 0;
|
||||
|
||||
// Format check (highest weight)
|
||||
scoreFactors += 0.4;
|
||||
if (result.details.formatValid) scoreTotal += 0.4;
|
||||
|
||||
// MX check (high weight)
|
||||
if (options.checkMx !== false) {
|
||||
scoreFactors += 0.3;
|
||||
if (result.hasMx) scoreTotal += 0.3;
|
||||
}
|
||||
|
||||
// Disposable check (medium weight)
|
||||
if (options.checkDisposable !== false) {
|
||||
scoreFactors += 0.2;
|
||||
if (!result.details.disposable) scoreTotal += 0.2;
|
||||
}
|
||||
|
||||
// Role account check (low weight)
|
||||
if (options.checkRole !== false) {
|
||||
scoreFactors += 0.1;
|
||||
if (!result.details.role) scoreTotal += 0.1;
|
||||
}
|
||||
|
||||
// Normalize score based on factors actually checked
|
||||
result.score = scoreFactors > 0 ? scoreTotal / scoreFactors : 0;
|
||||
|
||||
// Email is valid if score is above 0.7 (configurable threshold)
|
||||
result.isValid = result.score >= 0.7;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Email validation error: ${error.message}`);
|
||||
return {
|
||||
isValid: false,
|
||||
hasMx: false,
|
||||
hasSpamMarkings: true,
|
||||
score: 0,
|
||||
details: {
|
||||
formatValid: false,
|
||||
errorMessage: `Validation error: ${error.message}`,
|
||||
spamIndicators: ['Validation error']
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MX records for a domain with caching
|
||||
* @param domain Domain to check
|
||||
* @returns Array of MX records
|
||||
*/
|
||||
private async getMxRecords(domain: string): Promise<string[]> {
|
||||
if (this.dnsCache.has(domain)) {
|
||||
return this.dnsCache.get(domain);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use smartmail's getMxRecords method
|
||||
const records = await this.validator.getMxRecords(domain);
|
||||
this.dnsCache.set(domain, records);
|
||||
|
||||
// Cache expires after 1 hour
|
||||
setTimeout(() => {
|
||||
this.dnsCache.delete(domain);
|
||||
}, 60 * 60 * 1000);
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error fetching MX records for ${domain}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates multiple email addresses in batch
|
||||
* @param emails Array of emails to validate
|
||||
* @param options Validation options
|
||||
* @returns Object with email addresses as keys and validation results as values
|
||||
*/
|
||||
public async validateBatch(
|
||||
emails: string[],
|
||||
options: {
|
||||
checkMx?: boolean;
|
||||
checkDisposable?: boolean;
|
||||
checkRole?: boolean;
|
||||
checkSyntaxOnly?: boolean;
|
||||
} = {}
|
||||
): Promise<Record<string, IEmailValidationResult>> {
|
||||
const results: Record<string, IEmailValidationResult> = {};
|
||||
|
||||
for (const email of emails) {
|
||||
results[email] = await this.validate(email, options);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if an email format is valid (synchronous, no DNS checks)
|
||||
* @param email Email to check
|
||||
* @returns Boolean indicating if format is valid
|
||||
*/
|
||||
public isValidFormat(email: string): boolean {
|
||||
return this.validator.isValidEmailFormat(email);
|
||||
}
|
||||
|
||||
}
|
@ -1,13 +1,325 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export class TemplateManager {
|
||||
public smartmailDefault = new plugins.smartmail.Smartmail({
|
||||
body: `
|
||||
|
||||
`,
|
||||
from: `noreply@mail.lossless.com`,
|
||||
subject: `{{subject}}`,
|
||||
});
|
||||
|
||||
public createSmartmailFromData(tempalteTypeArg: plugins.lointEmail.TTemplates) {}
|
||||
/**
|
||||
* Email template type definition
|
||||
*/
|
||||
export interface IEmailTemplate<T = any> {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
from: string;
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
bodyText?: string;
|
||||
category?: string;
|
||||
sampleData?: T;
|
||||
attachments?: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email template context - data used to render the template
|
||||
*/
|
||||
export interface ITemplateContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Template category definitions
|
||||
*/
|
||||
export enum TemplateCategory {
|
||||
NOTIFICATION = 'notification',
|
||||
TRANSACTIONAL = 'transactional',
|
||||
MARKETING = 'marketing',
|
||||
SYSTEM = 'system'
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced template manager using smartmail's capabilities
|
||||
*/
|
||||
export class TemplateManager {
|
||||
private templates: Map<string, IEmailTemplate> = new Map();
|
||||
private defaultConfig: {
|
||||
from: string;
|
||||
replyTo?: string;
|
||||
footerHtml?: string;
|
||||
footerText?: string;
|
||||
};
|
||||
|
||||
constructor(defaultConfig?: {
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
footerHtml?: string;
|
||||
footerText?: string;
|
||||
}) {
|
||||
// Set default configuration
|
||||
this.defaultConfig = {
|
||||
from: defaultConfig?.from || 'noreply@mail.lossless.com',
|
||||
replyTo: defaultConfig?.replyTo,
|
||||
footerHtml: defaultConfig?.footerHtml || '',
|
||||
footerText: defaultConfig?.footerText || ''
|
||||
};
|
||||
|
||||
// Initialize with built-in templates
|
||||
this.registerBuiltinTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register built-in email templates
|
||||
*/
|
||||
private registerBuiltinTemplates(): void {
|
||||
// Welcome email
|
||||
this.registerTemplate<{
|
||||
firstName: string;
|
||||
accountUrl: string;
|
||||
}>({
|
||||
id: 'welcome',
|
||||
name: 'Welcome Email',
|
||||
description: 'Sent to users when they first sign up',
|
||||
from: this.defaultConfig.from,
|
||||
subject: 'Welcome to {{serviceName}}!',
|
||||
category: TemplateCategory.TRANSACTIONAL,
|
||||
bodyHtml: `
|
||||
<h1>Welcome, {{firstName}}!</h1>
|
||||
<p>Thank you for joining {{serviceName}}. We're excited to have you on board.</p>
|
||||
<p>To get started, <a href="{{accountUrl}}">visit your account</a>.</p>
|
||||
`,
|
||||
bodyText:
|
||||
`Welcome, {{firstName}}!
|
||||
|
||||
Thank you for joining {{serviceName}}. We're excited to have you on board.
|
||||
|
||||
To get started, visit your account: {{accountUrl}}
|
||||
`,
|
||||
sampleData: {
|
||||
firstName: 'John',
|
||||
accountUrl: 'https://example.com/account'
|
||||
}
|
||||
});
|
||||
|
||||
// Password reset
|
||||
this.registerTemplate<{
|
||||
resetUrl: string;
|
||||
expiryHours: number;
|
||||
}>({
|
||||
id: 'password-reset',
|
||||
name: 'Password Reset',
|
||||
description: 'Sent when a user requests a password reset',
|
||||
from: this.defaultConfig.from,
|
||||
subject: 'Password Reset Request',
|
||||
category: TemplateCategory.TRANSACTIONAL,
|
||||
bodyHtml: `
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>You recently requested to reset your password. Click the link below to reset it:</p>
|
||||
<p><a href="{{resetUrl}}">Reset Password</a></p>
|
||||
<p>This link will expire in {{expiryHours}} hours.</p>
|
||||
<p>If you didn't request a password reset, please ignore this email.</p>
|
||||
`,
|
||||
sampleData: {
|
||||
resetUrl: 'https://example.com/reset-password?token=abc123',
|
||||
expiryHours: 24
|
||||
}
|
||||
});
|
||||
|
||||
// System notification
|
||||
this.registerTemplate({
|
||||
id: 'system-notification',
|
||||
name: 'System Notification',
|
||||
description: 'General system notification template',
|
||||
from: this.defaultConfig.from,
|
||||
subject: '{{subject}}',
|
||||
category: TemplateCategory.SYSTEM,
|
||||
bodyHtml: `
|
||||
<h2>{{title}}</h2>
|
||||
<div>{{message}}</div>
|
||||
`,
|
||||
sampleData: {
|
||||
subject: 'Important System Notification',
|
||||
title: 'System Maintenance',
|
||||
message: 'The system will be undergoing maintenance on Saturday from 2-4am UTC.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new email template
|
||||
* @param template The email template to register
|
||||
*/
|
||||
public registerTemplate<T = any>(template: IEmailTemplate<T>): void {
|
||||
if (this.templates.has(template.id)) {
|
||||
logger.log('warn', `Template with ID '${template.id}' already exists and will be overwritten`);
|
||||
}
|
||||
|
||||
// Add footer to templates if configured
|
||||
if (this.defaultConfig.footerHtml && template.bodyHtml) {
|
||||
template.bodyHtml += this.defaultConfig.footerHtml;
|
||||
}
|
||||
|
||||
if (this.defaultConfig.footerText && template.bodyText) {
|
||||
template.bodyText += this.defaultConfig.footerText;
|
||||
}
|
||||
|
||||
this.templates.set(template.id, template);
|
||||
logger.log('info', `Registered email template: ${template.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an email template by ID
|
||||
* @param templateId The template ID
|
||||
* @returns The template or undefined if not found
|
||||
*/
|
||||
public getTemplate<T = any>(templateId: string): IEmailTemplate<T> | undefined {
|
||||
return this.templates.get(templateId) as IEmailTemplate<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available templates
|
||||
* @param category Optional category filter
|
||||
* @returns Array of email templates
|
||||
*/
|
||||
public listTemplates(category?: TemplateCategory): IEmailTemplate[] {
|
||||
const templates = Array.from(this.templates.values());
|
||||
if (category) {
|
||||
return templates.filter(template => template.category === category);
|
||||
}
|
||||
return templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Smartmail instance from a template
|
||||
* @param templateId The template ID
|
||||
* @param context The template context data
|
||||
* @returns A configured Smartmail instance
|
||||
*/
|
||||
public async createSmartmail<T = any>(
|
||||
templateId: string,
|
||||
context?: ITemplateContext
|
||||
): Promise<plugins.smartmail.Smartmail<T>> {
|
||||
const template = this.getTemplate(templateId);
|
||||
|
||||
if (!template) {
|
||||
throw new Error(`Template with ID '${templateId}' not found`);
|
||||
}
|
||||
|
||||
// Create Smartmail instance with template content
|
||||
const smartmail = new plugins.smartmail.Smartmail<T>({
|
||||
from: template.from || this.defaultConfig.from,
|
||||
subject: template.subject,
|
||||
body: template.bodyHtml || template.bodyText || '',
|
||||
creationObjectRef: context as T
|
||||
});
|
||||
|
||||
// Add any template attachments
|
||||
if (template.attachments && template.attachments.length > 0) {
|
||||
for (const attachment of template.attachments) {
|
||||
// Load attachment file
|
||||
try {
|
||||
const attachmentPath = plugins.path.isAbsolute(attachment.path)
|
||||
? attachment.path
|
||||
: plugins.path.join(paths.MtaAttachmentsDir, attachment.path);
|
||||
|
||||
// Use appropriate SmartFile method - either read from file or create with empty buffer
|
||||
// For a file path, use the fromFilePath static method
|
||||
const file = await plugins.smartfile.SmartFile.fromFilePath(attachmentPath);
|
||||
|
||||
// Set content type if specified
|
||||
if (attachment.contentType) {
|
||||
(file as any).contentType = attachment.contentType;
|
||||
}
|
||||
|
||||
smartmail.addAttachment(file);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to add attachment '${attachment.name}': ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply template variables if context provided
|
||||
if (context) {
|
||||
// Use applyVariables from smartmail v2.1.0+
|
||||
smartmail.applyVariables(context);
|
||||
}
|
||||
|
||||
return smartmail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and completely process a Smartmail instance from a template
|
||||
* @param templateId The template ID
|
||||
* @param context The template context data
|
||||
* @returns A complete, processed Smartmail instance ready to send
|
||||
*/
|
||||
public async prepareEmail<T = any>(
|
||||
templateId: string,
|
||||
context: ITemplateContext = {}
|
||||
): Promise<plugins.smartmail.Smartmail<T>> {
|
||||
const smartmail = await this.createSmartmail<T>(templateId, context);
|
||||
|
||||
// Pre-compile all mustache templates (subject, body)
|
||||
smartmail.getSubject();
|
||||
smartmail.getBody();
|
||||
|
||||
return smartmail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MIME-formatted email from a template
|
||||
* @param templateId The template ID
|
||||
* @param context The template context data
|
||||
* @returns A MIME-formatted email string
|
||||
*/
|
||||
public async createMimeEmail(
|
||||
templateId: string,
|
||||
context: ITemplateContext = {}
|
||||
): Promise<string> {
|
||||
const smartmail = await this.prepareEmail(templateId, context);
|
||||
return smartmail.toMimeFormat();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load templates from a directory
|
||||
* @param directory The directory containing template JSON files
|
||||
*/
|
||||
public async loadTemplatesFromDirectory(directory: string): Promise<void> {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
if (!plugins.fs.existsSync(directory)) {
|
||||
logger.log('error', `Template directory does not exist: ${directory}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all JSON files
|
||||
const files = plugins.fs.readdirSync(directory)
|
||||
.filter(file => file.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = plugins.path.join(directory, file);
|
||||
const content = plugins.fs.readFileSync(filePath, 'utf8');
|
||||
const template = JSON.parse(content) as IEmailTemplate;
|
||||
|
||||
// Validate template
|
||||
if (!template.id || !template.subject || (!template.bodyHtml && !template.bodyText)) {
|
||||
logger.log('warn', `Invalid template in ${file}: missing required fields`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.registerTemplate(template);
|
||||
} catch (error) {
|
||||
logger.log('error', `Error loading template from ${file}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `Loaded ${this.templates.size} email templates`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load templates from directory: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,3 @@
|
||||
import { EmailService } from './email.classes.emailservice.js';
|
||||
import { EmailService } from './classes.emailservice.js';
|
||||
|
||||
export { EmailService as Email };
|
Reference in New Issue
Block a user