import * as plugins from '../plugins.js'; import * as paths from '../paths.js'; import { logger } from '../logger.js'; /** * Domain reputation metrics */ export interface IDomainReputationMetrics { /** Domain being monitored */ domain: string; /** Date the metrics were last updated */ lastUpdated: Date; /** Sending volume metrics */ volume: { /** Total emails sent in the tracking period */ sent: number; /** Delivered emails (excluding bounces) */ delivered: number; /** Hard bounces */ hardBounces: number; /** Soft bounces */ softBounces: number; /** Daily sending volume for the last 30 days */ dailySendVolume: Record; }; /** Engagement metrics */ engagement: { /** Number of opens */ opens: number; /** Number of clicks */ clicks: number; /** Calculated open rate (percentage) */ openRate: number; /** Calculated click rate (percentage) */ clickRate: number; /** Click-to-open rate (percentage) */ clickToOpenRate: number; }; /** Complaint metrics */ complaints: { /** Number of spam complaints */ total: number; /** Complaint rate (percentage) */ rate: number; /** Domains with highest complaint rates */ topDomains: Array<{ domain: string; rate: number; count: number }>; }; /** Authentication metrics */ authentication: { /** Percentage of emails with valid SPF */ spfPassRate: number; /** Percentage of emails with valid DKIM */ dkimPassRate: number; /** Percentage of emails with valid DMARC */ dmarcPassRate: number; /** Authentication failures */ failures: Array<{ type: string; domain: string; count: number }>; }; /** Blocklist status */ blocklist: { /** Current blocklist status */ listed: boolean; /** Blocklists the domain is on, if any */ activeListings: Array<{ list: string; listedSince: Date }>; /** Recent delistings */ recentDelistings: Array<{ list: string; listedFrom: Date; listedTo: Date }>; }; /** Inbox placement estimates */ inboxPlacement: { /** Overall inbox placement rate estimate */ overall: number; /** Inbox placement rates by major provider */ providers: Record; }; /** Historical reputation scores */ historical: { /** Reputation scores for the last 30 days */ reputationScores: Record; /** Trends in key metrics */ trends: { /** Open rate trend (positive or negative percentage) */ openRate: number; /** Complaint rate trend */ complaintRate: number; /** Bounce rate trend */ bounceRate: number; /** Spam listing trend */ spamListings: number; }; }; } /** * Configuration for reputation monitoring */ export interface IReputationMonitorConfig { /** Whether monitoring is enabled */ enabled?: boolean; /** Domains to monitor */ domains?: string[]; /** How frequently to update metrics (ms) */ updateFrequency?: number; /** Endpoints for external data sources */ dataSources?: { /** Spam list monitoring service */ spamLists?: string[]; /** Deliverability monitoring service endpoint */ deliverabilityMonitor?: string; }; /** Alerting thresholds */ alertThresholds?: { /** Minimum safe reputation score */ minReputationScore?: number; /** Maximum acceptable complaint rate */ maxComplaintRate?: number; /** Maximum acceptable bounce rate */ maxBounceRate?: number; /** Minimum acceptable open rate */ minOpenRate?: number; }; } /** * Reputation score components */ interface IReputationComponents { /** Engagement score (0-100) */ engagement: number; /** Complaint score (0-100) */ complaints: number; /** Authentication score (0-100) */ authentication: number; /** Volume stability score (0-100) */ volumeStability: number; /** Infrastructure score (0-100) */ infrastructure: number; /** Blocklist score (0-100) */ blocklist: number; } /** * Default configuration */ const DEFAULT_CONFIG: Required = { enabled: true, domains: [], updateFrequency: 24 * 60 * 60 * 1000, // Daily dataSources: { spamLists: [ 'zen.spamhaus.org', 'bl.spamcop.net', 'dnsbl.sorbs.net', 'b.barracudacentral.org' ], deliverabilityMonitor: null }, alertThresholds: { minReputationScore: 70, maxComplaintRate: 0.1, // 0.1% maxBounceRate: 5, // 5% minOpenRate: 15 // 15% } }; /** * Class for monitoring and tracking sender reputation for domains */ export class SenderReputationMonitor { private static instance: SenderReputationMonitor; private config: Required; private reputationData: Map = new Map(); private updateTimer: NodeJS.Timeout = null; private isInitialized: boolean = false; /** * Constructor for SenderReputationMonitor * @param config Configuration options */ constructor(config: IReputationMonitorConfig = {}) { // Merge with default config this.config = { ...DEFAULT_CONFIG, ...config, dataSources: { ...DEFAULT_CONFIG.dataSources, ...config.dataSources }, alertThresholds: { ...DEFAULT_CONFIG.alertThresholds, ...config.alertThresholds } }; // Initialize this.initialize(); } /** * Get the singleton instance * @param config Configuration options * @returns Singleton instance */ public static getInstance(config: IReputationMonitorConfig = {}): SenderReputationMonitor { if (!SenderReputationMonitor.instance) { SenderReputationMonitor.instance = new SenderReputationMonitor(config); } return SenderReputationMonitor.instance; } /** * Initialize the reputation monitor */ private initialize(): void { if (this.isInitialized) return; try { // Only load data if not running in a test environment const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; if (!isTestEnvironment) { // Load existing reputation data this.loadReputationData(); } // Initialize data for any new domains for (const domain of this.config.domains) { if (!this.reputationData.has(domain)) { this.initializeDomainData(domain); } } // Schedule updates if enabled and not in test environment if (this.config.enabled && !isTestEnvironment) { this.scheduleUpdates(); } this.isInitialized = true; logger.log('info', `Sender Reputation Monitor initialized for ${this.config.domains.length} domains`); } catch (error) { logger.log('error', `Failed to initialize Sender Reputation Monitor: ${error.message}`, { stack: error.stack }); } } /** * Initialize reputation data for a new domain * @param domain Domain to initialize */ private initializeDomainData(domain: string): void { // Create new domain reputation metrics with default values const newMetrics: IDomainReputationMetrics = { domain, lastUpdated: new Date(), volume: { sent: 0, delivered: 0, hardBounces: 0, softBounces: 0, dailySendVolume: {} }, engagement: { opens: 0, clicks: 0, openRate: 0, clickRate: 0, clickToOpenRate: 0 }, complaints: { total: 0, rate: 0, topDomains: [] }, authentication: { spfPassRate: 100, // Assume perfect initially dkimPassRate: 100, dmarcPassRate: 100, failures: [] }, blocklist: { listed: false, activeListings: [], recentDelistings: [] }, inboxPlacement: { overall: 95, // Start with optimistic estimate providers: { gmail: 95, outlook: 95, yahoo: 95, aol: 95, other: 95 } }, historical: { reputationScores: {}, trends: { openRate: 0, complaintRate: 0, bounceRate: 0, spamListings: 0 } } }; // Generate some initial historical data points const today = new Date(); for (let i = 0; i < 30; i++) { const date = new Date(today); date.setDate(date.getDate() - i); const dateKey = date.toISOString().split('T')[0]; newMetrics.historical.reputationScores[dateKey] = 95; // Default good score newMetrics.volume.dailySendVolume[dateKey] = 0; } // Save the new metrics this.reputationData.set(domain, newMetrics); logger.log('info', `Initialized reputation data for domain ${domain}`); } /** * Schedule regular reputation data updates */ private scheduleUpdates(): void { if (this.updateTimer) { clearTimeout(this.updateTimer); } this.updateTimer = setTimeout(async () => { await this.updateAllDomainMetrics(); this.scheduleUpdates(); // Reschedule for next update }, this.config.updateFrequency); logger.log('info', `Scheduled reputation updates every ${this.config.updateFrequency / (60 * 60 * 1000)} hours`); } /** * Update metrics for all monitored domains */ private async updateAllDomainMetrics(): Promise { if (!this.config.enabled) return; logger.log('info', 'Starting reputation metrics update for all domains'); for (const domain of this.config.domains) { try { await this.updateDomainMetrics(domain); logger.log('info', `Updated reputation metrics for ${domain}`); } catch (error) { logger.log('error', `Error updating metrics for ${domain}: ${error.message}`, { stack: error.stack }); } } // Save all updated data this.saveReputationData(); logger.log('info', 'Completed reputation metrics update for all domains'); } /** * Update reputation metrics for a specific domain * @param domain Domain to update */ private async updateDomainMetrics(domain: string): Promise { const metrics = this.reputationData.get(domain); if (!metrics) { logger.log('warn', `No reputation data found for domain ${domain}`); return; } try { // Update last updated timestamp metrics.lastUpdated = new Date(); // Check blocklist status await this.checkBlocklistStatus(domain, metrics); // Update historical data this.updateHistoricalData(metrics); // Calculate current reputation score const reputationScore = this.calculateReputationScore(metrics); // Save current reputation score to historical data const today = new Date().toISOString().split('T')[0]; metrics.historical.reputationScores[today] = reputationScore; // Calculate trends this.calculateTrends(metrics); // Check alert thresholds this.checkAlertThresholds(metrics); } catch (error) { logger.log('error', `Error in updateDomainMetrics for ${domain}: ${error.message}`, { stack: error.stack }); } } /** * Check domain blocklist status * @param domain Domain to check * @param metrics Metrics to update */ private async checkBlocklistStatus(domain: string, metrics: IDomainReputationMetrics): Promise { // Skip DNS lookups in test environment const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; if (isTestEnvironment || !this.config.dataSources.spamLists?.length) { return; } const previouslyListed = metrics.blocklist.listed; const previousListings = new Set(metrics.blocklist.activeListings.map(l => l.list)); // Store current listings to detect changes const currentListings: Array<{ list: string; listedSince: Date }> = []; // Check each blocklist for (const list of this.config.dataSources.spamLists) { try { const isListed = await this.checkDomainOnBlocklist(domain, list); if (isListed) { // If already known to be listed on this one, keep the original listing date const existingListing = metrics.blocklist.activeListings.find(l => l.list === list); if (existingListing) { currentListings.push(existingListing); } else { // New listing currentListings.push({ list, listedSince: new Date() }); } } } catch (error) { logger.log('warn', `Error checking ${domain} on blocklist ${list}: ${error.message}`); } } // Update active listings metrics.blocklist.activeListings = currentListings; metrics.blocklist.listed = currentListings.length > 0; // Check for delistings if (previouslyListed) { const currentListsSet = new Set(currentListings.map(l => l.list)); // Convert Set to Array for compatibility with older JS versions Array.from(previousListings).forEach(list => { if (!currentListsSet.has(list)) { // This list no longer contains the domain - it was delisted const previousListing = metrics.blocklist.activeListings.find(l => l.list === list); if (previousListing) { metrics.blocklist.recentDelistings.push({ list, listedFrom: previousListing.listedSince, listedTo: new Date() }); } } }); // Keep only recent delistings (last 90 days) const ninetyDaysAgo = new Date(); ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); metrics.blocklist.recentDelistings = metrics.blocklist.recentDelistings .filter(d => d.listedTo > ninetyDaysAgo); } } /** * Check if a domain is on a specific blocklist * @param domain Domain to check * @param list Blocklist to check * @returns Whether the domain is listed */ private async checkDomainOnBlocklist(domain: string, list: string): Promise { try { // Look up the domain in the blocklist (simplified) if (list === 'zen.spamhaus.org') { // For Spamhaus and similar lists, we check the domain MX IPs const mxRecords = await plugins.dns.promises.resolveMx(domain); if (mxRecords && mxRecords.length > 0) { // Check the primary MX record const primaryMx = mxRecords.sort((a, b) => a.priority - b.priority)[0].exchange; // Resolve IP addresses for the MX const ips = await plugins.dns.promises.resolve(primaryMx); // Check the first IP if (ips.length > 0) { const ip = ips[0]; const reversedIp = ip.split('.').reverse().join('.'); const lookupDomain = `${reversedIp}.${list}`; try { await plugins.dns.promises.resolve(lookupDomain); return true; // Listed } catch (err) { if (err.code === 'ENOTFOUND') { return false; // Not listed } throw err; // Other error } } } return false; } else { // For domain-based blocklists const lookupDomain = `${domain}.${list}`; try { await plugins.dns.promises.resolve(lookupDomain); return true; // Listed } catch (err) { if (err.code === 'ENOTFOUND') { return false; // Not listed } throw err; // Other error } } } catch (error) { logger.log('warn', `Error checking blocklist status for ${domain} on ${list}: ${error.message}`); return false; // Assume not listed on error } } /** * Update historical data in metrics * @param metrics Metrics to update */ private updateHistoricalData(metrics: IDomainReputationMetrics): void { // Keep only the last 30 days of data const dates = Object.keys(metrics.historical.reputationScores) .sort((a, b) => b.localeCompare(a)); // Sort descending if (dates.length > 30) { const daysToKeep = dates.slice(0, 30); const newScores: Record = {}; for (const day of daysToKeep) { newScores[day] = metrics.historical.reputationScores[day]; } metrics.historical.reputationScores = newScores; } // Same for daily send volume const volumeDates = Object.keys(metrics.volume.dailySendVolume) .sort((a, b) => b.localeCompare(a)); if (volumeDates.length > 30) { const daysToKeep = volumeDates.slice(0, 30); const newVolume: Record = {}; for (const day of daysToKeep) { newVolume[day] = metrics.volume.dailySendVolume[day]; } metrics.volume.dailySendVolume = newVolume; } } /** * Calculate reputation score from metrics * @param metrics Domain reputation metrics * @returns Reputation score (0-100) */ private calculateReputationScore(metrics: IDomainReputationMetrics): number { // Calculate component scores const components: IReputationComponents = { engagement: this.calculateEngagementScore(metrics), complaints: this.calculateComplaintScore(metrics), authentication: this.calculateAuthenticationScore(metrics), volumeStability: this.calculateVolumeStabilityScore(metrics), infrastructure: this.calculateInfrastructureScore(metrics), blocklist: this.calculateBlocklistScore(metrics) }; // Apply weights to components const weightedScore = components.engagement * 0.25 + components.complaints * 0.25 + components.authentication * 0.2 + components.volumeStability * 0.1 + components.infrastructure * 0.1 + components.blocklist * 0.1; // Round to 2 decimal places return Math.round(weightedScore * 100) / 100; } /** * Calculate engagement component score * @param metrics Domain metrics * @returns Engagement score (0-100) */ private calculateEngagementScore(metrics: IDomainReputationMetrics): number { const openRate = metrics.engagement.openRate; const clickRate = metrics.engagement.clickRate; // Benchmark open and click rates // <5% open rate = poor (score: 0-30) // 5-15% = average (score: 30-70) // >15% = good (score: 70-100) let openScore = 0; if (openRate < 5) { openScore = openRate * 6; // 0-30 scale } else if (openRate < 15) { openScore = 30 + (openRate - 5) * 4; // 30-70 scale } else { openScore = 70 + Math.min(30, (openRate - 15) * 2); // 70-100 scale } // Similarly for click rate let clickScore = 0; if (clickRate < 1) { clickScore = clickRate * 30; // 0-30 scale } else if (clickRate < 5) { clickScore = 30 + (clickRate - 1) * 10; // 30-70 scale } else { clickScore = 70 + Math.min(30, (clickRate - 5) * 6); // 70-100 scale } // Combine with 60% weight to open rate, 40% to click rate return (openScore * 0.6 + clickScore * 0.4); } /** * Calculate complaint component score * @param metrics Domain metrics * @returns Complaint score (0-100) */ private calculateComplaintScore(metrics: IDomainReputationMetrics): number { const complaintRate = metrics.complaints.rate; // Industry standard: complaint rate should be under 0.1% // 0% = perfect (score: 100) // 0.1% = threshold (score: 70) // 0.5% = problematic (score: 30) // 1%+ = critical (score: 0) if (complaintRate === 0) return 100; if (complaintRate >= 1) return 0; if (complaintRate < 0.1) { // 0-0.1% maps to 100-70 return 100 - (complaintRate / 0.1) * 30; } else if (complaintRate < 0.5) { // 0.1-0.5% maps to 70-30 return 70 - ((complaintRate - 0.1) / 0.4) * 40; } else { // 0.5-1% maps to 30-0 return 30 - ((complaintRate - 0.5) / 0.5) * 30; } } /** * Calculate authentication component score * @param metrics Domain metrics * @returns Authentication score (0-100) */ private calculateAuthenticationScore(metrics: IDomainReputationMetrics): number { const spfRate = metrics.authentication.spfPassRate; const dkimRate = metrics.authentication.dkimPassRate; const dmarcRate = metrics.authentication.dmarcPassRate; // Weight SPF, DKIM, and DMARC return (spfRate * 0.3 + dkimRate * 0.3 + dmarcRate * 0.4); } /** * Calculate volume stability component score * @param metrics Domain metrics * @returns Volume stability score (0-100) */ private calculateVolumeStabilityScore(metrics: IDomainReputationMetrics): number { const volumes = Object.values(metrics.volume.dailySendVolume); if (volumes.length < 2) return 100; // Not enough data // Calculate coefficient of variation (stdev / mean) const mean = volumes.reduce((sum, v) => sum + v, 0) / volumes.length; if (mean === 0) return 100; // No sending activity const variance = volumes.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / volumes.length; const stdev = Math.sqrt(variance); const cv = stdev / mean; // Convert to score: lower CV means more stability // CV < 0.1 is very stable (score: 90-100) // CV < 0.5 is normal (score: 60-90) // CV < 1.0 is somewhat unstable (score: 30-60) // CV >= 1.0 is unstable (score: 0-30) if (cv < 0.1) { return 90 + (1 - cv / 0.1) * 10; } else if (cv < 0.5) { return 60 + (1 - (cv - 0.1) / 0.4) * 30; } else if (cv < 1.0) { return 30 + (1 - (cv - 0.5) / 0.5) * 30; } else { return Math.max(0, 30 - (cv - 1.0) * 10); } } /** * Calculate infrastructure component score * @param metrics Domain metrics * @returns Infrastructure score (0-100) */ private calculateInfrastructureScore(metrics: IDomainReputationMetrics): number { // This is a placeholder; in reality, this would be based on: // - IP reputation // - Reverse DNS configuration // - IP warming status // - Historical IP behavior // For now, assume good infrastructure return 90; } /** * Calculate blocklist component score * @param metrics Domain metrics * @returns Blocklist score (0-100) */ private calculateBlocklistScore(metrics: IDomainReputationMetrics): number { // If currently listed on any blocklist, score is heavily impacted if (metrics.blocklist.listed) { // Number of active listings determines severity const listingCount = metrics.blocklist.activeListings.length; if (listingCount >= 3) return 0; // Critical: listed on 3+ lists if (listingCount === 2) return 20; // Severe: listed on 2 lists return 40; // Serious: listed on 1 list } // If recently delisted, some penalty still applies if (metrics.blocklist.recentDelistings.length > 0) { // Check how recent the delistings are const now = new Date(); const mostRecent = metrics.blocklist.recentDelistings .reduce((latest, delisting) => delisting.listedTo > latest ? delisting.listedTo : latest, new Date(0)); const daysSinceDelisting = Math.floor( (now.getTime() - mostRecent.getTime()) / (24 * 60 * 60 * 1000) ); // Score improves as time passes since delisting if (daysSinceDelisting < 7) return 60; // Delisted within last week if (daysSinceDelisting < 30) return 80; // Delisted within last month return 90; // Delisted over a month ago } // Never listed return 100; } /** * Calculate trend metrics * @param metrics Domain metrics to update */ private calculateTrends(metrics: IDomainReputationMetrics): void { // Get dates in descending order const dates = Object.keys(metrics.historical.reputationScores) .sort((a, b) => b.localeCompare(a)); if (dates.length < 7) { // Not enough data for trends metrics.historical.trends = { openRate: 0, complaintRate: 0, bounceRate: 0, spamListings: 0 }; return; } // Calculate trends over past 7 days compared to previous 7 days const current7Days = dates.slice(0, 7); const previous7Days = dates.slice(7, 14); if (previous7Days.length < 7) { // Not enough historical data return; } // Calculate averages for the periods const currentReputation = current7Days.reduce( (sum, date) => sum + metrics.historical.reputationScores[date], 0 ) / current7Days.length; const previousReputation = previous7Days.reduce( (sum, date) => sum + metrics.historical.reputationScores[date], 0 ) / previous7Days.length; // Calculate percent change const reputationChange = ((currentReputation - previousReputation) / previousReputation) * 100; // For now, use reputation change for all trends (in a real implementation // we would calculate each metric's trend separately) metrics.historical.trends = { openRate: reputationChange, complaintRate: -reputationChange, // Inverse for complaint rate (negative is good) bounceRate: -reputationChange, // Inverse for bounce rate spamListings: -reputationChange // Inverse for spam listings }; } /** * Check if metrics exceed alert thresholds * @param metrics Domain metrics to check */ private checkAlertThresholds(metrics: IDomainReputationMetrics): void { const thresholds = this.config.alertThresholds; const today = new Date().toISOString().split('T')[0]; const todayScore = metrics.historical.reputationScores[today] || 0; // Check reputation score if (todayScore < thresholds.minReputationScore) { this.sendAlert(metrics.domain, 'reputation_score', { score: todayScore, threshold: thresholds.minReputationScore }); } // Check complaint rate if (metrics.complaints.rate > thresholds.maxComplaintRate) { this.sendAlert(metrics.domain, 'complaint_rate', { rate: metrics.complaints.rate, threshold: thresholds.maxComplaintRate }); } // Check bounce rate const bounceRate = (metrics.volume.hardBounces + metrics.volume.softBounces) / Math.max(1, metrics.volume.sent) * 100; if (bounceRate > thresholds.maxBounceRate) { this.sendAlert(metrics.domain, 'bounce_rate', { rate: bounceRate, threshold: thresholds.maxBounceRate }); } // Check open rate if (metrics.engagement.openRate < thresholds.minOpenRate) { this.sendAlert(metrics.domain, 'open_rate', { rate: metrics.engagement.openRate, threshold: thresholds.minOpenRate }); } // Check blocklist status if (metrics.blocklist.listed) { this.sendAlert(metrics.domain, 'blocklist', { lists: metrics.blocklist.activeListings.map(l => l.list) }); } } /** * Send an alert for a reputation issue * @param domain Domain with the issue * @param alertType Type of alert * @param data Alert data */ private sendAlert(domain: string, alertType: string, data: any): void { logger.log('warn', `Reputation alert for ${domain}: ${alertType}`, data); // In a real implementation, this would send alerts via email, // notification systems, webhooks, etc. } /** * Record a send event for domain reputation tracking * @param domain The domain sending the email * @param event Event details */ public recordSendEvent(domain: string, event: { type: 'sent' | 'delivered' | 'bounce' | 'complaint' | 'open' | 'click'; count?: number; hardBounce?: boolean; receivingDomain?: string; }): void { // Ensure we have metrics for this domain if (!this.reputationData.has(domain)) { this.initializeDomainData(domain); } const metrics = this.reputationData.get(domain); const count = event.count || 1; const today = new Date().toISOString().split('T')[0]; // Update metrics based on event type switch (event.type) { case 'sent': metrics.volume.sent += count; // Update daily send volume metrics.volume.dailySendVolume[today] = (metrics.volume.dailySendVolume[today] || 0) + count; break; case 'delivered': metrics.volume.delivered += count; break; case 'bounce': if (event.hardBounce) { metrics.volume.hardBounces += count; } else { metrics.volume.softBounces += count; } break; case 'complaint': metrics.complaints.total += count; // Track by receiving domain if (event.receivingDomain) { const domainIndex = metrics.complaints.topDomains.findIndex( d => d.domain === event.receivingDomain ); if (domainIndex >= 0) { metrics.complaints.topDomains[domainIndex].count += count; metrics.complaints.topDomains[domainIndex].rate = metrics.complaints.topDomains[domainIndex].count / Math.max(1, metrics.volume.sent); } else { metrics.complaints.topDomains.push({ domain: event.receivingDomain, count, rate: count / Math.max(1, metrics.volume.sent) }); } // Sort by count metrics.complaints.topDomains.sort((a, b) => b.count - a.count); // Keep only top 10 if (metrics.complaints.topDomains.length > 10) { metrics.complaints.topDomains = metrics.complaints.topDomains.slice(0, 10); } } // Update overall complaint rate metrics.complaints.rate = metrics.complaints.total / Math.max(1, metrics.volume.sent); break; case 'open': metrics.engagement.opens += count; metrics.engagement.openRate = metrics.engagement.opens / Math.max(1, metrics.volume.delivered); break; case 'click': metrics.engagement.clicks += count; metrics.engagement.clickRate = metrics.engagement.clicks / Math.max(1, metrics.volume.delivered); metrics.engagement.clickToOpenRate = metrics.engagement.clicks / Math.max(1, metrics.engagement.opens); break; } // Update last updated timestamp metrics.lastUpdated = new Date(); // Save data periodically (not after every event to avoid excessive I/O) // Skip in test environment const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; if (!isTestEnvironment && Math.random() < 0.01) { // ~1% chance to save on each event this.saveReputationData(); } } /** * Get reputation data for a domain * @param domain Domain to get data for * @returns Reputation data */ public getReputationData(domain: string): IDomainReputationMetrics | null { return this.reputationData.get(domain) || null; } /** * Get summary reputation data for all domains * @returns Summary data for all domains */ public getReputationSummary(): Array<{ domain: string; score: number; status: 'excellent' | 'good' | 'fair' | 'poor' | 'critical'; listed: boolean; trend: number; }> { return Array.from(this.reputationData.entries()) .map(([domain, metrics]) => { const today = new Date().toISOString().split('T')[0]; const score = metrics.historical.reputationScores[today] || 0; // Determine status based on score let status: 'excellent' | 'good' | 'fair' | 'poor' | 'critical'; if (score >= 90) status = 'excellent'; else if (score >= 75) status = 'good'; else if (score >= 60) status = 'fair'; else if (score >= 40) status = 'poor'; else status = 'critical'; return { domain, score, status, listed: metrics.blocklist.listed, trend: metrics.historical.trends.openRate // Use open rate trend as overall trend }; }) .sort((a, b) => b.score - a.score); // Sort by score descending } /** * Add a domain to monitor * @param domain Domain to monitor */ public addDomain(domain: string): void { if (this.config.domains.includes(domain)) { logger.log('info', `Domain ${domain} is already being monitored`); return; } this.config.domains.push(domain); this.initializeDomainData(domain); this.saveReputationData(); logger.log('info', `Added ${domain} to reputation monitoring`); } /** * Remove a domain from monitoring * @param domain Domain to remove */ public removeDomain(domain: string): void { const index = this.config.domains.indexOf(domain); if (index === -1) { logger.log('info', `Domain ${domain} is not being monitored`); return; } this.config.domains.splice(index, 1); this.reputationData.delete(domain); this.saveReputationData(); logger.log('info', `Removed ${domain} from reputation monitoring`); } /** * Load reputation data from storage */ private loadReputationData(): void { // Skip loading in test environment to prevent file system race conditions const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; if (isTestEnvironment) { return; } try { const reputationDir = plugins.path.join(paths.dataDir, 'reputation'); plugins.smartfile.fs.ensureDirSync(reputationDir); const dataFile = plugins.path.join(reputationDir, 'domain_reputation.json'); if (plugins.fs.existsSync(dataFile)) { const data = plugins.fs.readFileSync(dataFile, 'utf8'); const reputationEntries = JSON.parse(data); for (const entry of reputationEntries) { // Restore Date objects entry.lastUpdated = new Date(entry.lastUpdated); for (const listing of entry.blocklist.activeListings) { listing.listedSince = new Date(listing.listedSince); } for (const delisting of entry.blocklist.recentDelistings) { delisting.listedFrom = new Date(delisting.listedFrom); delisting.listedTo = new Date(delisting.listedTo); } this.reputationData.set(entry.domain, entry); } logger.log('info', `Loaded reputation data for ${this.reputationData.size} domains`); } } catch (error) { logger.log('error', `Failed to load reputation data: ${error.message}`, { stack: error.stack }); } } /** * Save reputation data to storage */ private saveReputationData(): void { // Skip saving in test environment to prevent file system race conditions const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; if (isTestEnvironment) { return; } try { const reputationDir = plugins.path.join(paths.dataDir, 'reputation'); plugins.smartfile.fs.ensureDirSync(reputationDir); const dataFile = plugins.path.join(reputationDir, 'domain_reputation.json'); const reputationEntries = Array.from(this.reputationData.values()); plugins.smartfile.memory.toFsSync( JSON.stringify(reputationEntries, null, 2), dataFile ); logger.log('debug', `Saved reputation data for ${reputationEntries.length} domains`); } catch (error) { logger.log('error', `Failed to save reputation data: ${error.message}`, { stack: error.stack }); } } }