import * as plugins from '../plugins.js'; import * as paths from '../paths.js'; import { logger } from '../logger.js'; import { LRUCache } from 'lru-cache'; /** * Represents a single stage in the warmup process */ export interface IWarmupStage { /** Stage number (1-based) */ stage: number; /** Maximum daily email volume for this stage */ maxDailyVolume: number; /** Duration of this stage in days */ durationDays: number; /** Target engagement metrics for this stage */ targetMetrics?: { /** Minimum open rate (percentage) */ minOpenRate?: number; /** Maximum bounce rate (percentage) */ maxBounceRate?: number; /** Maximum spam complaint rate (percentage) */ maxComplaintRate?: number; }; } /** * Configuration for IP warmup process */ export interface IIPWarmupConfig { /** Whether the warmup is enabled */ enabled?: boolean; /** List of IP addresses to warm up */ ipAddresses?: string[]; /** Target domains to warm up (e.g. your sending domains) */ targetDomains?: string[]; /** Warmup stages defining volume and duration */ stages?: IWarmupStage[]; /** Date when warmup process started */ startDate?: Date; /** Default hourly distribution for sending (percentage of daily volume per hour) */ hourlyDistribution?: number[]; /** Whether to automatically advance stages based on metrics */ autoAdvanceStages?: boolean; /** Whether to suspend warmup if metrics decline */ suspendOnMetricDecline?: boolean; /** Percentage of traffic to send through fallback provider during warmup */ fallbackPercentage?: number; /** Whether to prioritize engaged subscribers during warmup */ prioritizeEngagedSubscribers?: boolean; } /** * Status for a specific IP's warmup process */ export interface IIPWarmupStatus { /** IP address being warmed up */ ipAddress: string; /** Current warmup stage */ currentStage: number; /** Start date of the warmup process */ startDate: Date; /** Start date of the current stage */ currentStageStartDate: Date; /** Target completion date for entire warmup */ targetCompletionDate: Date; /** Daily volume allocation for current stage */ currentDailyAllocation: number; /** Emails sent in current stage */ sentInCurrentStage: number; /** Total emails sent during warmup process */ totalSent: number; /** Whether the warmup is currently active */ isActive: boolean; /** Daily statistics for the past week */ dailyStats: Array<{ /** Date of the statistics */ date: string; /** Number of emails sent */ sent: number; /** Number of emails opened */ opened: number; /** Number of bounces */ bounces: number; /** Number of spam complaints */ complaints: number; }>; /** Current metrics */ metrics: { /** Open rate percentage */ openRate: number; /** Bounce rate percentage */ bounceRate: number; /** Complaint rate percentage */ complaintRate: number; }; } /** * Defines methods for a policy used to allocate emails to different IPs */ export interface IIPAllocationPolicy { /** Name of the policy */ name: string; /** * Allocate an IP address for sending an email * @param availableIPs List of available IP addresses * @param emailInfo Information about the email being sent * @returns The IP to use, or null if no IP is available */ allocateIP( availableIPs: Array<{ ip: string; priority: number; capacity: number }>, emailInfo: { from: string; to: string[]; domain: string; isTransactional: boolean; isWarmup: boolean; } ): string | null; } /** * Default IP warmup configuration with industry standard stages */ const DEFAULT_WARMUP_CONFIG: Required = { enabled: true, ipAddresses: [], targetDomains: [], stages: [ { stage: 1, maxDailyVolume: 50, durationDays: 2, targetMetrics: { maxBounceRate: 8, minOpenRate: 15 } }, { stage: 2, maxDailyVolume: 100, durationDays: 2, targetMetrics: { maxBounceRate: 7, minOpenRate: 18 } }, { stage: 3, maxDailyVolume: 500, durationDays: 3, targetMetrics: { maxBounceRate: 6, minOpenRate: 20 } }, { stage: 4, maxDailyVolume: 1000, durationDays: 3, targetMetrics: { maxBounceRate: 5, minOpenRate: 20 } }, { stage: 5, maxDailyVolume: 5000, durationDays: 5, targetMetrics: { maxBounceRate: 3, minOpenRate: 22 } }, { stage: 6, maxDailyVolume: 10000, durationDays: 5, targetMetrics: { maxBounceRate: 2, minOpenRate: 25 } }, { stage: 7, maxDailyVolume: 20000, durationDays: 5, targetMetrics: { maxBounceRate: 1, minOpenRate: 25 } }, { stage: 8, maxDailyVolume: 50000, durationDays: 5, targetMetrics: { maxBounceRate: 1, minOpenRate: 25 } }, ], startDate: new Date(), // Default hourly distribution (percentage per hour, sums to 100%) hourlyDistribution: [ 1, 1, 1, 1, 1, 2, 3, 5, 7, 8, 10, 11, 10, 9, 8, 6, 5, 4, 3, 2, 1, 1, 1, 0 ], autoAdvanceStages: true, suspendOnMetricDecline: true, fallbackPercentage: 50, prioritizeEngagedSubscribers: true }; /** * Manages the IP warming process for new sending IPs */ export class IPWarmupManager { private static instance: IPWarmupManager; private config: Required; private warmupStatuses: Map = new Map(); private dailySendCounts: Map = new Map(); private hourlySendCounts: Map = new Map(); private isInitialized: boolean = false; private allocationPolicies: Map = new Map(); private activePolicy: string = 'balanced'; /** * Constructor for IPWarmupManager * @param config Warmup configuration */ constructor(config: IIPWarmupConfig = {}) { this.config = { ...DEFAULT_WARMUP_CONFIG, ...config, stages: config.stages || [...DEFAULT_WARMUP_CONFIG.stages] }; // Register default allocation policies this.registerAllocationPolicy('balanced', new BalancedAllocationPolicy()); this.registerAllocationPolicy('roundRobin', new RoundRobinAllocationPolicy()); this.registerAllocationPolicy('dedicated', new DedicatedDomainPolicy()); this.initialize(); } /** * Get the singleton instance of IPWarmupManager * @param config Warmup configuration * @returns Singleton instance */ public static getInstance(config: IIPWarmupConfig = {}): IPWarmupManager { if (!IPWarmupManager.instance) { IPWarmupManager.instance = new IPWarmupManager(config); } return IPWarmupManager.instance; } /** * Initialize the warmup manager */ private initialize(): void { if (this.isInitialized) return; try { // Load warmup statuses from storage this.loadWarmupStatuses(); // Initialize any new IPs that might have been added to config for (const ip of this.config.ipAddresses) { if (!this.warmupStatuses.has(ip)) { this.initializeIPWarmup(ip); } } // Initialize daily and hourly counters const today = new Date().toISOString().split('T')[0]; for (const ip of this.config.ipAddresses) { this.dailySendCounts.set(ip, 0); this.hourlySendCounts.set(ip, Array(24).fill(0)); } // Schedule daily reset of counters this.scheduleDailyReset(); // Schedule daily evaluation of warmup progress this.scheduleDailyEvaluation(); this.isInitialized = true; logger.log('info', `IP Warmup Manager initialized with ${this.config.ipAddresses.length} IPs`); } catch (error) { logger.log('error', `Failed to initialize IP Warmup Manager: ${error.message}`, { stack: error.stack }); } } /** * Initialize warmup status for a new IP address * @param ipAddress IP address to initialize */ private initializeIPWarmup(ipAddress: string): void { const startDate = new Date(); let targetCompletionDate = new Date(startDate); // Calculate target completion date based on stages let totalDays = 0; for (const stage of this.config.stages) { totalDays += stage.durationDays; } targetCompletionDate.setDate(targetCompletionDate.getDate() + totalDays); const warmupStatus: IIPWarmupStatus = { ipAddress, currentStage: 1, startDate, currentStageStartDate: new Date(), targetCompletionDate, currentDailyAllocation: this.config.stages[0].maxDailyVolume, sentInCurrentStage: 0, totalSent: 0, isActive: true, dailyStats: [], metrics: { openRate: 0, bounceRate: 0, complaintRate: 0 } }; this.warmupStatuses.set(ipAddress, warmupStatus); this.saveWarmupStatuses(); logger.log('info', `Initialized warmup for IP ${ipAddress}`, { currentStage: 1, targetCompletion: targetCompletionDate.toISOString().split('T')[0] }); } /** * Schedule daily reset of send counters */ private scheduleDailyReset(): void { // Calculate time until midnight const now = new Date(); const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(0, 0, 0, 0); const timeUntilMidnight = tomorrow.getTime() - now.getTime(); // Schedule reset setTimeout(() => { this.resetDailyCounts(); // Reschedule for next day this.scheduleDailyReset(); }, timeUntilMidnight); logger.log('info', `Scheduled daily counter reset in ${Math.floor(timeUntilMidnight / 60000)} minutes`); } /** * Reset daily send counters */ private resetDailyCounts(): void { for (const ip of this.config.ipAddresses) { // Save yesterday's count to history before resetting const status = this.warmupStatuses.get(ip); if (status) { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); // Update daily stats with yesterday's data const sentCount = this.dailySendCounts.get(ip) || 0; status.dailyStats.push({ date: yesterday.toISOString().split('T')[0], sent: sentCount, opened: Math.floor(sentCount * status.metrics.openRate / 100), bounces: Math.floor(sentCount * status.metrics.bounceRate / 100), complaints: Math.floor(sentCount * status.metrics.complaintRate / 100) }); // Keep only the last 7 days of stats if (status.dailyStats.length > 7) { status.dailyStats.shift(); } } // Reset counters for today this.dailySendCounts.set(ip, 0); this.hourlySendCounts.set(ip, Array(24).fill(0)); } // Save updated statuses this.saveWarmupStatuses(); logger.log('info', 'Daily send counters reset'); } /** * Schedule daily evaluation of warmup progress */ private scheduleDailyEvaluation(): void { // Calculate time until 1 AM (do evaluation after midnight) const now = new Date(); const evaluationTime = new Date(now); evaluationTime.setDate(evaluationTime.getDate() + 1); evaluationTime.setHours(1, 0, 0, 0); const timeUntilEvaluation = evaluationTime.getTime() - now.getTime(); // Schedule evaluation setTimeout(() => { this.evaluateWarmupProgress(); // Reschedule for next day this.scheduleDailyEvaluation(); }, timeUntilEvaluation); logger.log('info', `Scheduled daily warmup evaluation in ${Math.floor(timeUntilEvaluation / 60000)} minutes`); } /** * Evaluate warmup progress and possibly advance stages */ private evaluateWarmupProgress(): void { if (!this.config.autoAdvanceStages) { logger.log('info', 'Auto-advance stages is disabled, skipping evaluation'); return; } // Convert entries to array for compatibility with older JS versions Array.from(this.warmupStatuses.entries()).forEach(([ip, status]) => { if (!status.isActive) return; // Check if current stage duration has elapsed const currentStage = this.config.stages[status.currentStage - 1]; const now = new Date(); const daysSinceStageStart = Math.floor( (now.getTime() - status.currentStageStartDate.getTime()) / (24 * 60 * 60 * 1000) ); if (daysSinceStageStart >= currentStage.durationDays) { // Check if metrics meet requirements for advancing const metricsOK = this.checkStageMetrics(status, currentStage); if (metricsOK) { // Advance to next stage if not at the final stage if (status.currentStage < this.config.stages.length) { this.advanceToNextStage(ip); } else { logger.log('info', `IP ${ip} has completed the warmup process`); } } else if (this.config.suspendOnMetricDecline) { // Suspend warmup if metrics don't meet requirements status.isActive = false; logger.log('warn', `Suspended warmup for IP ${ip} due to poor metrics`, { openRate: status.metrics.openRate, bounceRate: status.metrics.bounceRate, complaintRate: status.metrics.complaintRate }); } else { // Extend current stage if metrics don't meet requirements logger.log('info', `Extending stage ${status.currentStage} for IP ${ip} due to metrics not meeting requirements`); } } }); // Save updated statuses this.saveWarmupStatuses(); } /** * Check if the current metrics meet the requirements for the stage * @param status Warmup status to check * @param stage Stage to check against * @returns Whether metrics meet requirements */ private checkStageMetrics(status: IIPWarmupStatus, stage: IWarmupStage): boolean { // If no target metrics specified, assume met if (!stage.targetMetrics) return true; const metrics = status.metrics; let meetsRequirements = true; // Check each metric against requirements if (stage.targetMetrics.minOpenRate !== undefined && metrics.openRate < stage.targetMetrics.minOpenRate) { meetsRequirements = false; logger.log('info', `Open rate ${metrics.openRate}% below target ${stage.targetMetrics.minOpenRate}% for IP ${status.ipAddress}`); } if (stage.targetMetrics.maxBounceRate !== undefined && metrics.bounceRate > stage.targetMetrics.maxBounceRate) { meetsRequirements = false; logger.log('info', `Bounce rate ${metrics.bounceRate}% above target ${stage.targetMetrics.maxBounceRate}% for IP ${status.ipAddress}`); } if (stage.targetMetrics.maxComplaintRate !== undefined && metrics.complaintRate > stage.targetMetrics.maxComplaintRate) { meetsRequirements = false; logger.log('info', `Complaint rate ${metrics.complaintRate}% above target ${stage.targetMetrics.maxComplaintRate}% for IP ${status.ipAddress}`); } return meetsRequirements; } /** * Advance IP to the next warmup stage * @param ipAddress IP address to advance */ private advanceToNextStage(ipAddress: string): void { const status = this.warmupStatuses.get(ipAddress); if (!status) return; // Store metrics for the completed stage const completedStage = status.currentStage; // Advance to next stage status.currentStage++; status.currentStageStartDate = new Date(); status.sentInCurrentStage = 0; // Update allocation const newStage = this.config.stages[status.currentStage - 1]; status.currentDailyAllocation = newStage.maxDailyVolume; logger.log('info', `Advanced IP ${ipAddress} to warmup stage ${status.currentStage}`, { previousStage: completedStage, newDailyLimit: status.currentDailyAllocation, durationDays: newStage.durationDays }); } /** * Get warmup status for all IPs or a specific IP * @param ipAddress Optional specific IP to get status for * @returns Warmup status information */ public getWarmupStatus(ipAddress?: string): IIPWarmupStatus | Map { if (ipAddress) { return this.warmupStatuses.get(ipAddress); } return this.warmupStatuses; } /** * Add a new IP address to the warmup process * @param ipAddress IP address to add */ public addIPToWarmup(ipAddress: string): void { if (this.config.ipAddresses.includes(ipAddress)) { logger.log('info', `IP ${ipAddress} is already in warmup`); return; } // Add to configuration this.config.ipAddresses.push(ipAddress); // Initialize warmup this.initializeIPWarmup(ipAddress); // Initialize counters this.dailySendCounts.set(ipAddress, 0); this.hourlySendCounts.set(ipAddress, Array(24).fill(0)); logger.log('info', `Added IP ${ipAddress} to warmup process`); } /** * Remove an IP address from the warmup process * @param ipAddress IP address to remove */ public removeIPFromWarmup(ipAddress: string): void { const index = this.config.ipAddresses.indexOf(ipAddress); if (index === -1) { logger.log('info', `IP ${ipAddress} is not in warmup`); return; } // Remove from configuration this.config.ipAddresses.splice(index, 1); // Remove from statuses and counters this.warmupStatuses.delete(ipAddress); this.dailySendCounts.delete(ipAddress); this.hourlySendCounts.delete(ipAddress); this.saveWarmupStatuses(); logger.log('info', `Removed IP ${ipAddress} from warmup process`); } /** * Update metrics for an IP address * @param ipAddress IP address to update * @param metrics New metrics */ public updateMetrics( ipAddress: string, metrics: { openRate?: number; bounceRate?: number; complaintRate?: number } ): void { const status = this.warmupStatuses.get(ipAddress); if (!status) { logger.log('warn', `Cannot update metrics for IP ${ipAddress} - not in warmup`); return; } // Update metrics if (metrics.openRate !== undefined) { status.metrics.openRate = metrics.openRate; } if (metrics.bounceRate !== undefined) { status.metrics.bounceRate = metrics.bounceRate; } if (metrics.complaintRate !== undefined) { status.metrics.complaintRate = metrics.complaintRate; } this.saveWarmupStatuses(); logger.log('info', `Updated metrics for IP ${ipAddress}`, { openRate: status.metrics.openRate, bounceRate: status.metrics.bounceRate, complaintRate: status.metrics.complaintRate }); } /** * Record a send event for an IP address * @param ipAddress IP address used for sending */ public recordSend(ipAddress: string): void { if (!this.config.ipAddresses.includes(ipAddress)) { logger.log('warn', `Cannot record send for IP ${ipAddress} - not in warmup`); return; } // Increment daily counter const currentCount = this.dailySendCounts.get(ipAddress) || 0; this.dailySendCounts.set(ipAddress, currentCount + 1); // Increment hourly counter const hourlyCount = this.hourlySendCounts.get(ipAddress) || Array(24).fill(0); const currentHour = new Date().getHours(); hourlyCount[currentHour]++; this.hourlySendCounts.set(ipAddress, hourlyCount); // Update warmup status const status = this.warmupStatuses.get(ipAddress); if (status) { status.sentInCurrentStage++; status.totalSent++; } } /** * Check if an IP can send more emails today * @param ipAddress IP address to check * @returns Whether the IP can send more emails */ public canSendMoreToday(ipAddress: string): boolean { if (!this.config.enabled) return true; if (!this.config.ipAddresses.includes(ipAddress)) { // If not in warmup, assume it can send return true; } const status = this.warmupStatuses.get(ipAddress); if (!status || !status.isActive) { return false; } const currentCount = this.dailySendCounts.get(ipAddress) || 0; return currentCount < status.currentDailyAllocation; } /** * Check if an IP can send more emails in the current hour * @param ipAddress IP address to check * @returns Whether the IP can send more emails this hour */ public canSendMoreThisHour(ipAddress: string): boolean { if (!this.config.enabled) return true; if (!this.config.ipAddresses.includes(ipAddress)) { // If not in warmup, assume it can send return true; } const status = this.warmupStatuses.get(ipAddress); if (!status || !status.isActive) { return false; } const currentDailyLimit = status.currentDailyAllocation; const currentHour = new Date().getHours(); const hourlyAllocation = Math.ceil((currentDailyLimit * this.config.hourlyDistribution[currentHour]) / 100); const hourlyCount = this.hourlySendCounts.get(ipAddress) || Array(24).fill(0); const currentHourCount = hourlyCount[currentHour]; return currentHourCount < hourlyAllocation; } /** * Get the best IP to use for sending an email * @param emailInfo Information about the email being sent * @returns The best IP to use, or null if no suitable IP is available */ public getBestIPForSending(emailInfo: { from: string; to: string[]; domain: string; isTransactional?: boolean; }): string | null { // If warmup is disabled, return null (caller will use default IP) if (!this.config.enabled || this.config.ipAddresses.length === 0) { return null; } // Prepare information for allocation policy const availableIPs = this.config.ipAddresses .filter(ip => this.canSendMoreToday(ip) && this.canSendMoreThisHour(ip)) .map(ip => { const status = this.warmupStatuses.get(ip); return { ip, priority: status ? status.currentStage : 1, capacity: status ? (status.currentDailyAllocation - (this.dailySendCounts.get(ip) || 0)) : 0 }; }); // Use the active allocation policy to determine the best IP const policy = this.allocationPolicies.get(this.activePolicy); if (!policy) { logger.log('warn', `No allocation policy named ${this.activePolicy} found`); return null; } return policy.allocateIP(availableIPs, { ...emailInfo, isTransactional: emailInfo.isTransactional || false, isWarmup: true }); } /** * Register a new IP allocation policy * @param name Policy name * @param policy Policy implementation */ public registerAllocationPolicy(name: string, policy: IIPAllocationPolicy): void { this.allocationPolicies.set(name, policy); logger.log('info', `Registered IP allocation policy: ${name}`); } /** * Set the active IP allocation policy * @param name Policy name */ public setActiveAllocationPolicy(name: string): void { if (!this.allocationPolicies.has(name)) { logger.log('warn', `No allocation policy named ${name} found`); return; } this.activePolicy = name; logger.log('info', `Set active IP allocation policy to ${name}`); } /** * Get the total number of stages in the warmup process * @returns Number of stages */ public getStageCount(): number { return this.config.stages.length; } /** * Load warmup statuses from storage */ private loadWarmupStatuses(): void { try { const warmupDir = plugins.path.join(paths.dataDir, 'warmup'); plugins.smartfile.fs.ensureDirSync(warmupDir); const statusFile = plugins.path.join(warmupDir, 'ip_warmup_status.json'); if (plugins.fs.existsSync(statusFile)) { const data = plugins.fs.readFileSync(statusFile, 'utf8'); const statuses = JSON.parse(data); for (const status of statuses) { // Restore date objects status.startDate = new Date(status.startDate); status.currentStageStartDate = new Date(status.currentStageStartDate); status.targetCompletionDate = new Date(status.targetCompletionDate); this.warmupStatuses.set(status.ipAddress, status); } logger.log('info', `Loaded ${this.warmupStatuses.size} IP warmup statuses from storage`); } } catch (error) { logger.log('error', `Failed to load warmup statuses: ${error.message}`, { stack: error.stack }); } } /** * Save warmup statuses to storage */ private saveWarmupStatuses(): void { try { const warmupDir = plugins.path.join(paths.dataDir, 'warmup'); plugins.smartfile.fs.ensureDirSync(warmupDir); const statusFile = plugins.path.join(warmupDir, 'ip_warmup_status.json'); const statuses = Array.from(this.warmupStatuses.values()); plugins.smartfile.memory.toFsSync( JSON.stringify(statuses, null, 2), statusFile ); logger.log('debug', `Saved ${statuses.length} IP warmup statuses to storage`); } catch (error) { logger.log('error', `Failed to save warmup statuses: ${error.message}`, { stack: error.stack }); } } } /** * Policy that balances traffic across IPs based on stage and capacity */ class BalancedAllocationPolicy implements IIPAllocationPolicy { name = 'balanced'; allocateIP( availableIPs: Array<{ ip: string; priority: number; capacity: number }>, emailInfo: { from: string; to: string[]; domain: string; isTransactional: boolean; isWarmup: boolean; } ): string | null { if (availableIPs.length === 0) return null; // Sort IPs by priority (prefer higher stage IPs) and capacity const sortedIPs = [...availableIPs].sort((a, b) => { // First by priority (descending) if (b.priority !== a.priority) { return b.priority - a.priority; } // Then by remaining capacity (descending) return b.capacity - a.capacity; }); // Prioritize higher-stage IPs for transactional emails if (emailInfo.isTransactional) { return sortedIPs[0].ip; } // For marketing emails, spread across IPs with preference for higher stages // Use weighted random selection based on stage const totalWeight = sortedIPs.reduce((sum, ip) => sum + ip.priority, 0); let randomPoint = Math.random() * totalWeight; for (const ip of sortedIPs) { randomPoint -= ip.priority; if (randomPoint <= 0) { return ip.ip; } } // Fallback to the highest priority IP return sortedIPs[0].ip; } } /** * Policy that rotates through IPs in a round-robin fashion */ class RoundRobinAllocationPolicy implements IIPAllocationPolicy { name = 'roundRobin'; private lastIndex = -1; allocateIP( availableIPs: Array<{ ip: string; priority: number; capacity: number }>, emailInfo: { from: string; to: string[]; domain: string; isTransactional: boolean; isWarmup: boolean; } ): string | null { if (availableIPs.length === 0) return null; // Sort by capacity to ensure even distribution const sortedIPs = [...availableIPs].sort((a, b) => b.capacity - a.capacity); // Move to next IP this.lastIndex = (this.lastIndex + 1) % sortedIPs.length; return sortedIPs[this.lastIndex].ip; } } /** * Policy that dedicates specific IPs to specific domains */ class DedicatedDomainPolicy implements IIPAllocationPolicy { name = 'dedicated'; private domainAssignments: Map = new Map(); allocateIP( availableIPs: Array<{ ip: string; priority: number; capacity: number }>, emailInfo: { from: string; to: string[]; domain: string; isTransactional: boolean; isWarmup: boolean; } ): string | null { if (availableIPs.length === 0) return null; // Check if we have a dedicated IP for this domain if (this.domainAssignments.has(emailInfo.domain)) { const dedicatedIP = this.domainAssignments.get(emailInfo.domain); // Check if the dedicated IP is in the available list const isAvailable = availableIPs.some(ip => ip.ip === dedicatedIP); if (isAvailable) { return dedicatedIP; } } // If not, assign one and save the assignment const sortedIPs = [...availableIPs].sort((a, b) => b.capacity - a.capacity); const assignedIP = sortedIPs[0].ip; this.domainAssignments.set(emailInfo.domain, assignedIP); return assignedIP; } }