896 lines
28 KiB
TypeScript
896 lines
28 KiB
TypeScript
|
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<IIPWarmupConfig> = {
|
||
|
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<IIPWarmupConfig>;
|
||
|
private warmupStatuses: Map<string, IIPWarmupStatus> = new Map();
|
||
|
private dailySendCounts: Map<string, number> = new Map();
|
||
|
private hourlySendCounts: Map<string, number[]> = new Map();
|
||
|
private isInitialized: boolean = false;
|
||
|
private allocationPolicies: Map<string, IIPAllocationPolicy> = 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<string, IIPWarmupStatus> {
|
||
|
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<string, string> = 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;
|
||
|
}
|
||
|
}
|