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:
2025-05-07 17:41:04 +00:00
parent 2ee66ef967
commit c852e954c9
17 changed files with 2566 additions and 625 deletions

View File

@ -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}`);
}
}

View File

@ -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

View 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);
}
}

View File

@ -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;
}
}
}

View File

@ -1,3 +1,3 @@
import { EmailService } from './email.classes.emailservice.js';
import { EmailService } from './classes.emailservice.js';
export { EmailService as Email };