This commit is contained in:
2025-05-07 20:20:17 +00:00
parent f6377d1973
commit 630e911589
40 changed files with 8745 additions and 333 deletions

View File

@ -1,6 +1,7 @@
import * as plugins from '../plugins.js';
import { MtaService } from './classes.mta.js';
import { logger } from '../logger.js';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../security/index.js';
/**
* Result of a DKIM verification
@ -80,10 +81,34 @@ export class DKIMVerifier {
});
logger.log(isValid ? 'info' : 'warn', `DKIM Verification using mailauth: ${isValid ? 'pass' : 'fail'} for domain ${dkimResult.domain}`);
// Enhanced security logging
SecurityLogger.getInstance().logEvent({
level: isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
type: SecurityEventType.DKIM,
message: `DKIM verification ${isValid ? 'passed' : 'failed'} for domain ${dkimResult.domain}`,
details: {
selector: dkimResult.selector,
signatureFields: dkimResult.signature,
result: dkimResult.status.result
},
domain: dkimResult.domain,
success: isValid
});
return result;
}
} catch (mailauthError) {
logger.log('warn', `DKIM verification with mailauth failed, trying smartmail: ${mailauthError.message}`);
// Enhanced security logging
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.DKIM,
message: `DKIM verification with mailauth failed, trying smartmail fallback`,
details: { error: mailauthError.message },
success: false
});
}
// Fall back to smartmail for verification
@ -167,6 +192,20 @@ export class DKIMVerifier {
});
logger.log('info', `DKIM verification using smartmail: pass for domain ${domain}`);
// Enhanced security logging
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.DKIM,
message: `DKIM verification passed for domain ${domain} using fallback verification`,
details: {
selector,
signatureFields
},
domain,
success: true
});
return result;
} else {
// Missing domain or selector
@ -185,6 +224,17 @@ export class DKIMVerifier {
});
logger.log('warn', `DKIM verification failed: Missing domain or selector in DKIM signature`);
// Enhanced security logging
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.DKIM,
message: `DKIM verification failed: Missing domain or selector in signature`,
details: { domain, selector, signatureFields },
domain: domain || 'unknown',
success: false
});
return result;
}
} catch (error) {
@ -200,11 +250,30 @@ export class DKIMVerifier {
});
logger.log('error', `DKIM verification error: ${error.message}`);
// Enhanced security logging
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.DKIM,
message: `DKIM verification error during processing`,
details: { error: error.message },
success: false
});
return result;
}
} catch (error) {
logger.log('error', `DKIM verification failed with unexpected error: ${error.message}`);
// Enhanced security logging for unexpected errors
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.DKIM,
message: `DKIM verification failed with unexpected error`,
details: { error: error.message },
success: false
});
return {
isValid: false,
status: 'temperror',
@ -241,6 +310,17 @@ export class DKIMVerifier {
if (!txtRecords || txtRecords.length === 0) {
logger.log('warn', `No DKIM TXT record found for ${dkimRecord}`);
// Security logging for missing DKIM record
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.DKIM,
message: `No DKIM TXT record found for ${dkimRecord}`,
domain,
success: false,
details: { selector }
});
return null;
}
@ -256,9 +336,31 @@ export class DKIMVerifier {
}
logger.log('warn', `No valid DKIM public key found in TXT records for ${dkimRecord}`);
// Security logging for invalid DKIM key
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.DKIM,
message: `No valid DKIM public key found in TXT records`,
domain,
success: false,
details: { dkimRecord, selector }
});
return null;
} catch (error) {
logger.log('error', `Error fetching DKIM key: ${error.message}`);
// Security logging for DKIM key fetch error
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.DKIM,
message: `Error fetching DKIM key for domain`,
domain,
success: false,
details: { error: error.message, selector, dkimRecord: `${selector}._domainkey.${domain}` }
});
return null;
}
}

View File

@ -0,0 +1,475 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../security/index.js';
import type { MtaService } from './classes.mta.js';
import type { Email } from './classes.email.js';
import type { IDnsVerificationResult } from './classes.dnsmanager.js';
/**
* DMARC policy types
*/
export enum DmarcPolicy {
NONE = 'none',
QUARANTINE = 'quarantine',
REJECT = 'reject'
}
/**
* DMARC alignment modes
*/
export enum DmarcAlignment {
RELAXED = 'r',
STRICT = 's'
}
/**
* DMARC record fields
*/
export interface DmarcRecord {
// Required fields
version: string;
policy: DmarcPolicy;
// Optional fields
subdomainPolicy?: DmarcPolicy;
pct?: number;
adkim?: DmarcAlignment;
aspf?: DmarcAlignment;
reportInterval?: number;
failureOptions?: string;
reportUriAggregate?: string[];
reportUriForensic?: string[];
}
/**
* DMARC verification result
*/
export interface DmarcResult {
hasDmarc: boolean;
record?: DmarcRecord;
spfDomainAligned: boolean;
dkimDomainAligned: boolean;
spfPassed: boolean;
dkimPassed: boolean;
policyEvaluated: DmarcPolicy;
actualPolicy: DmarcPolicy;
appliedPercentage: number;
action: 'pass' | 'quarantine' | 'reject';
details: string;
error?: string;
}
/**
* Class for verifying and enforcing DMARC policies
*/
export class DmarcVerifier {
private mtaRef: MtaService;
constructor(mtaRefArg: MtaService) {
this.mtaRef = mtaRefArg;
}
/**
* Parse a DMARC record from a TXT record string
* @param record DMARC TXT record string
* @returns Parsed DMARC record or null if invalid
*/
public parseDmarcRecord(record: string): DmarcRecord | null {
if (!record.startsWith('v=DMARC1')) {
return null;
}
try {
// Initialize record with default values
const dmarcRecord: DmarcRecord = {
version: 'DMARC1',
policy: DmarcPolicy.NONE,
pct: 100,
adkim: DmarcAlignment.RELAXED,
aspf: DmarcAlignment.RELAXED
};
// Split the record into tag/value pairs
const parts = record.split(';').map(part => part.trim());
for (const part of parts) {
if (!part || !part.includes('=')) continue;
const [tag, value] = part.split('=').map(p => p.trim());
// Process based on tag
switch (tag.toLowerCase()) {
case 'v':
dmarcRecord.version = value;
break;
case 'p':
dmarcRecord.policy = value as DmarcPolicy;
break;
case 'sp':
dmarcRecord.subdomainPolicy = value as DmarcPolicy;
break;
case 'pct':
const pctValue = parseInt(value, 10);
if (!isNaN(pctValue) && pctValue >= 0 && pctValue <= 100) {
dmarcRecord.pct = pctValue;
}
break;
case 'adkim':
dmarcRecord.adkim = value as DmarcAlignment;
break;
case 'aspf':
dmarcRecord.aspf = value as DmarcAlignment;
break;
case 'ri':
const interval = parseInt(value, 10);
if (!isNaN(interval) && interval > 0) {
dmarcRecord.reportInterval = interval;
}
break;
case 'fo':
dmarcRecord.failureOptions = value;
break;
case 'rua':
dmarcRecord.reportUriAggregate = value.split(',').map(uri => {
if (uri.startsWith('mailto:')) {
return uri.substring(7).trim();
}
return uri.trim();
});
break;
case 'ruf':
dmarcRecord.reportUriForensic = value.split(',').map(uri => {
if (uri.startsWith('mailto:')) {
return uri.substring(7).trim();
}
return uri.trim();
});
break;
}
}
// Ensure subdomain policy is set if not explicitly provided
if (!dmarcRecord.subdomainPolicy) {
dmarcRecord.subdomainPolicy = dmarcRecord.policy;
}
return dmarcRecord;
} catch (error) {
logger.log('error', `Error parsing DMARC record: ${error.message}`, {
record,
error: error.message
});
return null;
}
}
/**
* Check if domains are aligned according to DMARC policy
* @param headerDomain Domain from header (From)
* @param authDomain Domain from authentication (SPF, DKIM)
* @param alignment Alignment mode
* @returns Whether the domains are aligned
*/
private isDomainAligned(
headerDomain: string,
authDomain: string,
alignment: DmarcAlignment
): boolean {
if (!headerDomain || !authDomain) {
return false;
}
// For strict alignment, domains must match exactly
if (alignment === DmarcAlignment.STRICT) {
return headerDomain.toLowerCase() === authDomain.toLowerCase();
}
// For relaxed alignment, the authenticated domain must be a subdomain of the header domain
// or the same as the header domain
const headerParts = headerDomain.toLowerCase().split('.');
const authParts = authDomain.toLowerCase().split('.');
// Ensures we have at least two parts (domain and TLD)
if (headerParts.length < 2 || authParts.length < 2) {
return false;
}
// Get organizational domain (last two parts)
const headerOrgDomain = headerParts.slice(-2).join('.');
const authOrgDomain = authParts.slice(-2).join('.');
return headerOrgDomain === authOrgDomain;
}
/**
* Extract domain from an email address
* @param email Email address
* @returns Domain part of the email
*/
private getDomainFromEmail(email: string): string {
if (!email) return '';
// Handle name + email format: "John Doe <john@example.com>"
const matches = email.match(/<([^>]+)>/);
const address = matches ? matches[1] : email;
const parts = address.split('@');
return parts.length > 1 ? parts[1] : '';
}
/**
* Check if DMARC verification should be applied based on percentage
* @param record DMARC record
* @returns Whether DMARC verification should be applied
*/
private shouldApplyDmarc(record: DmarcRecord): boolean {
if (record.pct === undefined || record.pct === 100) {
return true;
}
// Apply DMARC randomly based on percentage
const random = Math.floor(Math.random() * 100) + 1;
return random <= record.pct;
}
/**
* Determine the action to take based on DMARC policy
* @param policy DMARC policy
* @returns Action to take
*/
private determineAction(policy: DmarcPolicy): 'pass' | 'quarantine' | 'reject' {
switch (policy) {
case DmarcPolicy.REJECT:
return 'reject';
case DmarcPolicy.QUARANTINE:
return 'quarantine';
case DmarcPolicy.NONE:
default:
return 'pass';
}
}
/**
* Verify DMARC for an incoming email
* @param email Email to verify
* @param spfResult SPF verification result
* @param dkimResult DKIM verification result
* @returns DMARC verification result
*/
public async verify(
email: Email,
spfResult: { domain: string; result: boolean },
dkimResult: { domain: string; result: boolean }
): Promise<DmarcResult> {
const securityLogger = SecurityLogger.getInstance();
// Initialize result
const result: DmarcResult = {
hasDmarc: false,
spfDomainAligned: false,
dkimDomainAligned: false,
spfPassed: spfResult.result,
dkimPassed: dkimResult.result,
policyEvaluated: DmarcPolicy.NONE,
actualPolicy: DmarcPolicy.NONE,
appliedPercentage: 100,
action: 'pass',
details: 'DMARC not configured'
};
try {
// Extract From domain
const fromHeader = email.getFromEmail();
const fromDomain = this.getDomainFromEmail(fromHeader);
if (!fromDomain) {
result.error = 'Invalid From domain';
return result;
}
// Check alignment
result.spfDomainAligned = this.isDomainAligned(
fromDomain,
spfResult.domain,
DmarcAlignment.RELAXED
);
result.dkimDomainAligned = this.isDomainAligned(
fromDomain,
dkimResult.domain,
DmarcAlignment.RELAXED
);
// Lookup DMARC record
const dmarcVerificationResult = await this.mtaRef.dnsManager.verifyDmarcRecord(fromDomain);
// If DMARC record exists and is valid
if (dmarcVerificationResult.found && dmarcVerificationResult.valid) {
result.hasDmarc = true;
// Parse DMARC record
const parsedRecord = this.parseDmarcRecord(dmarcVerificationResult.value);
if (parsedRecord) {
result.record = parsedRecord;
result.actualPolicy = parsedRecord.policy;
result.appliedPercentage = parsedRecord.pct || 100;
// Override alignment modes if specified in record
if (parsedRecord.adkim) {
result.dkimDomainAligned = this.isDomainAligned(
fromDomain,
dkimResult.domain,
parsedRecord.adkim
);
}
if (parsedRecord.aspf) {
result.spfDomainAligned = this.isDomainAligned(
fromDomain,
spfResult.domain,
parsedRecord.aspf
);
}
// Determine DMARC compliance
const spfAligned = result.spfPassed && result.spfDomainAligned;
const dkimAligned = result.dkimPassed && result.dkimDomainAligned;
// Email passes DMARC if either SPF or DKIM passes with alignment
const dmarcPass = spfAligned || dkimAligned;
// Use record percentage to determine if policy should be applied
const applyPolicy = this.shouldApplyDmarc(parsedRecord);
if (!dmarcPass) {
// DMARC failed, apply policy
result.policyEvaluated = applyPolicy ? parsedRecord.policy : DmarcPolicy.NONE;
result.action = this.determineAction(result.policyEvaluated);
result.details = `DMARC failed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}, policy=${result.policyEvaluated}`;
} else {
result.policyEvaluated = DmarcPolicy.NONE;
result.action = 'pass';
result.details = `DMARC passed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}`;
}
} else {
result.error = 'Invalid DMARC record format';
result.details = 'DMARC record invalid';
}
} else {
// No DMARC record found or invalid
result.details = dmarcVerificationResult.error || 'No DMARC record found';
}
// Log the DMARC verification
securityLogger.logEvent({
level: result.action === 'pass' ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
type: SecurityEventType.DMARC,
message: result.details,
domain: fromDomain,
details: {
fromDomain,
spfDomain: spfResult.domain,
dkimDomain: dkimResult.domain,
spfPassed: result.spfPassed,
dkimPassed: result.dkimPassed,
spfAligned: result.spfDomainAligned,
dkimAligned: result.dkimDomainAligned,
dmarcPolicy: result.policyEvaluated,
action: result.action
},
success: result.action === 'pass'
});
return result;
} catch (error) {
logger.log('error', `Error verifying DMARC: ${error.message}`, {
error: error.message,
emailId: email.getMessageId()
});
result.error = `DMARC verification error: ${error.message}`;
// Log error
securityLogger.logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.DMARC,
message: `DMARC verification failed with error`,
details: {
error: error.message,
emailId: email.getMessageId()
},
success: false
});
return result;
}
}
/**
* Apply DMARC policy to an email
* @param email Email to apply policy to
* @param dmarcResult DMARC verification result
* @returns Whether the email should be accepted
*/
public applyPolicy(email: Email, dmarcResult: DmarcResult): boolean {
// Apply action based on DMARC verification result
switch (dmarcResult.action) {
case 'reject':
// Reject the email
email.mightBeSpam = true;
logger.log('warn', `Email rejected due to DMARC policy: ${dmarcResult.details}`, {
emailId: email.getMessageId(),
from: email.getFromEmail(),
subject: email.subject
});
return false;
case 'quarantine':
// Quarantine the email (mark as spam)
email.mightBeSpam = true;
// Add spam header
if (!email.headers['X-Spam-Flag']) {
email.headers['X-Spam-Flag'] = 'YES';
}
// Add DMARC reason header
email.headers['X-DMARC-Result'] = dmarcResult.details;
logger.log('warn', `Email quarantined due to DMARC policy: ${dmarcResult.details}`, {
emailId: email.getMessageId(),
from: email.getFromEmail(),
subject: email.subject
});
return true;
case 'pass':
default:
// Accept the email
// Add DMARC result header for information
email.headers['X-DMARC-Result'] = dmarcResult.details;
return true;
}
}
/**
* End-to-end DMARC verification and policy application
* This method should be called after SPF and DKIM verification
* @param email Email to verify
* @param spfResult SPF verification result
* @param dkimResult DKIM verification result
* @returns Whether the email should be accepted
*/
public async verifyAndApply(
email: Email,
spfResult: { domain: string; result: boolean },
dkimResult: { domain: string; result: boolean }
): Promise<boolean> {
// Verify DMARC
const dmarcResult = await this.verify(email, spfResult, dkimResult);
// Apply DMARC policy
return this.applyPolicy(email, dmarcResult);
}
}

View File

@ -38,6 +38,8 @@ export class Email {
mightBeSpam: boolean;
priority: 'high' | 'normal' | 'low';
variables: Record<string, any>;
private envelopeFrom: string;
private messageId: string;
// Static validator instance for reuse
private static emailValidator: EmailValidator;
@ -89,6 +91,12 @@ export class Email {
// Set template variables
this.variables = options.variables || {};
// Initialize envelope from (defaults to the from address)
this.envelopeFrom = this.from;
// Generate message ID if not provided
this.messageId = `<${Date.now()}.${Math.random().toString(36).substring(2, 15)}@${this.getFromDomain() || 'localhost'}>`;
}
/**
@ -468,6 +476,53 @@ export class Email {
return smartmail;
}
/**
* Get the from email address
* @returns The from email address
*/
public getFromEmail(): string {
return this.from;
}
/**
* Get the message ID
* @returns The message ID
*/
public getMessageId(): string {
return this.messageId;
}
/**
* Set a custom message ID
* @param id The message ID to set
* @returns This instance for method chaining
*/
public setMessageId(id: string): this {
this.messageId = id;
return this;
}
/**
* Get the envelope from address (return-path)
* @returns The envelope from address
*/
public getEnvelopeFrom(): string {
return this.envelopeFrom;
}
/**
* Set the envelope from address (return-path)
* @param address The envelope from address to set
* @returns This instance for method chaining
*/
public setEnvelopeFrom(address: string): this {
if (!this.isValidEmail(address)) {
throw new Error(`Invalid envelope from address: ${address}`);
}
this.envelopeFrom = address;
return this;
}
/**
* Creates an RFC822 compliant email string
* @param variables Optional template variables to apply
@ -491,6 +546,8 @@ export class Email {
result += `Subject: ${processedSubject}\r\n`;
result += `Date: ${new Date().toUTCString()}\r\n`;
result += `Message-ID: ${this.messageId}\r\n`;
result += `Return-Path: <${this.envelopeFrom}>\r\n`;
// Add custom headers
for (const [key, value] of Object.entries(this.headers)) {

View File

@ -160,6 +160,9 @@ export class EmailSendJob {
this.deliveryInfo.deliveryTime = new Date();
this.log(`Email delivered successfully to ${currentMx}`);
// Record delivery for sender reputation monitoring
this.recordDeliveryEvent('delivered');
// Save successful email record
await this.saveSuccess();
return DeliveryStatus.DELIVERED;
@ -262,7 +265,35 @@ export class EmailSendJob {
this.log(`Connecting to ${mxServer}:25`);
setCommandTimeout();
this.socket = plugins.net.connect(25, mxServer);
// Check if IP warmup is enabled and get an IP to use
let localAddress: string | undefined = undefined;
if (this.mtaRef.config.outbound?.warmup?.enabled) {
const warmupManager = this.mtaRef.getIPWarmupManager();
if (warmupManager) {
const fromDomain = this.email.getFromDomain();
const bestIP = warmupManager.getBestIPForSending({
from: this.email.from,
to: this.email.getAllRecipients(),
domain: fromDomain,
isTransactional: this.email.priority === 'high'
});
if (bestIP) {
this.log(`Using warmed-up IP ${bestIP} for sending`);
localAddress = bestIP;
// Record the send for warm-up tracking
warmupManager.recordSend(bestIP);
}
}
}
// Connect with specified local address if available
this.socket = plugins.net.connect({
port: 25,
host: mxServer,
localAddress
});
this.socket.on('error', (err) => {
this.log(`Socket error: ${err.message}`);
@ -461,6 +492,54 @@ export class EmailSendJob {
return message;
}
/**
* Record an event for sender reputation monitoring
* @param eventType Type of event
* @param isHardBounce Whether the event is a hard bounce (for bounce events)
*/
private recordDeliveryEvent(
eventType: 'sent' | 'delivered' | 'bounce' | 'complaint',
isHardBounce: boolean = false
): void {
try {
// Check if reputation monitoring is enabled
if (!this.mtaRef.config.outbound?.reputation?.enabled) {
return;
}
const reputationMonitor = this.mtaRef.getReputationMonitor();
if (!reputationMonitor) {
return;
}
// Get domain from sender
const domain = this.email.getFromDomain();
if (!domain) {
return;
}
// Determine receiving domain for complaint tracking
let receivingDomain = null;
if (eventType === 'complaint' && this.email.to.length > 0) {
const recipient = this.email.to[0];
const parts = recipient.split('@');
if (parts.length === 2) {
receivingDomain = parts[1];
}
}
// Record the event
reputationMonitor.recordSendEvent(domain, {
type: eventType,
count: 1,
hardBounce: isHardBounce,
receivingDomain
});
} catch (error) {
this.log(`Error recording delivery event: ${error.message}`);
}
}
/**
* Send a command to the SMTP server and wait for the expected response
*/

View File

@ -5,9 +5,15 @@ import { Email } from './classes.email.js';
import { EmailSendJob, DeliveryStatus } from './classes.emailsendjob.js';
import { DKIMCreator } from './classes.dkimcreator.js';
import { DKIMVerifier } from './classes.dkimverifier.js';
import { SpfVerifier } from './classes.spfverifier.js';
import { DmarcVerifier } from './classes.dmarcverifier.js';
import { SMTPServer, type ISmtpServerOptions } from './classes.smtpserver.js';
import { DNSManager } from './classes.dnsmanager.js';
import { ApiManager } from './classes.apimanager.js';
import { RateLimiter, type IRateLimitConfig } from './classes.ratelimiter.js';
import { ContentScanner } from '../security/classes.contentscanner.js';
import { IPWarmupManager } from '../deliverability/classes.ipwarmupmanager.js';
import { SenderReputationMonitor } from '../deliverability/classes.senderreputationmonitor.js';
import type { SzPlatformService } from '../platformservice.js';
/**
@ -57,6 +63,33 @@ export interface IMtaConfig {
/** Whether to apply per domain (vs globally) */
perDomain?: boolean;
};
/** IP warmup configuration */
warmup?: {
/** Whether IP warmup is enabled */
enabled?: boolean;
/** IP addresses to warm up */
ipAddresses?: string[];
/** Target domains to warm up */
targetDomains?: string[];
/** Allocation policy to use */
allocationPolicy?: string;
/** Fallback percentage for ESP routing during warmup */
fallbackPercentage?: number;
};
/** Reputation monitoring configuration */
reputation?: {
/** Whether reputation monitoring is enabled */
enabled?: boolean;
/** How frequently to update metrics (ms) */
updateFrequency?: number;
/** Alert thresholds */
alertThresholds?: {
/** Minimum acceptable reputation score */
minReputationScore?: number;
/** Maximum acceptable complaint rate */
maxComplaintRate?: number;
};
};
};
/** Security settings */
security?: {
@ -66,10 +99,26 @@ export interface IMtaConfig {
verifyDkim?: boolean;
/** Whether to verify SPF on inbound */
verifySpf?: boolean;
/** Whether to verify DMARC on inbound */
verifyDmarc?: boolean;
/** Whether to enforce DMARC policy */
enforceDmarc?: boolean;
/** Whether to use TLS for outbound when available */
useTls?: boolean;
/** Whether to require valid certificates */
requireValidCerts?: boolean;
/** Log level for email security events */
securityLogLevel?: 'info' | 'warn' | 'error';
/** Whether to check IP reputation for inbound emails */
checkIPReputation?: boolean;
/** Whether to scan content for malicious payloads */
scanContent?: boolean;
/** Action to take when malicious content is detected */
maliciousContentAction?: 'tag' | 'quarantine' | 'reject';
/** Minimum threat score to trigger action */
threatScoreThreshold?: number;
/** Whether to reject connections from high-risk IPs */
rejectHighRiskIPs?: boolean;
};
/** Domains configuration */
domains?: {
@ -121,6 +170,18 @@ interface MtaStats {
expiresAt: Date;
daysUntilExpiry: number;
};
warmupInfo?: {
enabled: boolean;
activeIPs: number;
inWarmupPhase: number;
completedWarmup: number;
};
reputationInfo?: {
enabled: boolean;
monitoredDomains: number;
averageScore: number;
domainsWithIssues: number;
};
}
/**
@ -130,6 +191,11 @@ export class MtaService {
/** Reference to the platform service */
public platformServiceRef: SzPlatformService;
// Get access to the email service and bounce manager
private get emailService() {
return this.platformServiceRef.emailService;
}
/** SMTP server instance */
public server: SMTPServer;
@ -139,6 +205,12 @@ export class MtaService {
/** DKIM verifier for validating incoming emails */
public dkimVerifier: DKIMVerifier;
/** SPF verifier for validating incoming emails */
public spfVerifier: SpfVerifier;
/** DMARC verifier for email authentication policy enforcement */
public dmarcVerifier: DmarcVerifier;
/** DNS manager for handling DNS records */
public dnsManager: DNSManager;
@ -151,17 +223,20 @@ export class MtaService {
/** Email queue processing state */
private queueProcessing = false;
/** Rate limiters for outbound emails */
private rateLimiters: Map<string, {
tokens: number;
lastRefill: number;
}> = new Map();
/** Rate limiter for outbound emails */
private rateLimiter: RateLimiter;
/** IP warmup manager for controlled scaling of new IPs */
private ipWarmupManager: IPWarmupManager;
/** Sender reputation monitor for tracking domain reputation */
private reputationMonitor: SenderReputationMonitor;
/** Certificate cache */
private certificate: Certificate = null;
/** MTA configuration */
private config: IMtaConfig;
public config: IMtaConfig;
/** Stats for monitoring */
private stats: MtaStats;
@ -191,9 +266,46 @@ export class MtaService {
this.dkimVerifier = new DKIMVerifier(this);
this.dnsManager = new DNSManager(this);
this.apiManager = new ApiManager();
// Initialize authentication verifiers
this.spfVerifier = new SpfVerifier(this);
this.dmarcVerifier = new DmarcVerifier(this);
// Initialize SMTP rule engine
this.smtpRuleEngine = new plugins.smartrule.SmartRule<Email>();
// Initialize rate limiter with config
const rateLimitConfig = this.config.outbound?.rateLimit;
this.rateLimiter = new RateLimiter({
maxPerPeriod: rateLimitConfig?.maxPerPeriod || 100,
periodMs: rateLimitConfig?.periodMs || 60000,
perKey: rateLimitConfig?.perDomain || true,
burstTokens: 5 // Allow small bursts
});
// Initialize IP warmup manager
const warmupConfig = this.config.outbound?.warmup;
this.ipWarmupManager = IPWarmupManager.getInstance({
enabled: warmupConfig?.enabled || false,
ipAddresses: warmupConfig?.ipAddresses || [],
targetDomains: warmupConfig?.targetDomains || [],
fallbackPercentage: warmupConfig?.fallbackPercentage || 50
});
// Set active allocation policy if specified
if (warmupConfig?.allocationPolicy) {
this.ipWarmupManager.setActiveAllocationPolicy(warmupConfig.allocationPolicy);
}
// Initialize sender reputation monitor
const reputationConfig = this.config.outbound?.reputation;
this.reputationMonitor = SenderReputationMonitor.getInstance({
enabled: reputationConfig?.enabled || false,
domains: this.config.domains?.local || [],
updateFrequency: reputationConfig?.updateFrequency || 24 * 60 * 60 * 1000,
alertThresholds: reputationConfig?.alertThresholds || {}
});
// Initialize stats
this.stats = {
startTime: new Date(),
@ -234,14 +346,37 @@ export class MtaService {
maxPerPeriod: 100,
periodMs: 60000, // 1 minute
perDomain: true
},
warmup: {
enabled: false,
ipAddresses: [],
targetDomains: [],
allocationPolicy: 'balanced',
fallbackPercentage: 50
},
reputation: {
enabled: false,
updateFrequency: 24 * 60 * 60 * 1000, // Daily
alertThresholds: {
minReputationScore: 70,
maxComplaintRate: 0.1 // 0.1%
}
}
},
security: {
useDkim: true,
verifyDkim: true,
verifySpf: true,
verifyDmarc: true,
enforceDmarc: true,
useTls: true,
requireValidCerts: false
requireValidCerts: false,
securityLogLevel: 'warn',
checkIPReputation: true,
scanContent: true,
maliciousContentAction: 'tag',
threatScoreThreshold: 50,
rejectHighRiskIPs: false
},
domains: {
local: ['lossless.one'],
@ -393,6 +528,14 @@ export class MtaService {
// Update stats
this.stats.queueSize = this.emailQueue.size;
// Record 'sent' event for sender reputation monitoring
if (this.config.outbound?.reputation?.enabled) {
const fromDomain = email.getFromDomain();
if (fromDomain) {
this.reputationMonitor.recordSendEvent(fromDomain, { type: 'sent' });
}
}
console.log(`Email added to queue: ${id}`);
return id;
@ -413,18 +556,62 @@ export class MtaService {
throw new Error('MTA service is not running');
}
// Apply SMTP rule engine decisions
try {
await this.smtpRuleEngine.makeDecision(email);
} catch (err) {
console.error('Error executing SMTP rules:', err);
}
try {
console.log(`Processing incoming email from ${email.from} to ${email.to}`);
// Update stats
this.stats.emailsReceived++;
// Apply SMTP rule engine decisions
try {
await this.smtpRuleEngine.makeDecision(email);
} catch (err) {
console.error('Error executing SMTP rules:', err);
}
// Scan for malicious content if enabled
if (this.config.security?.scanContent !== false) {
const contentScanner = ContentScanner.getInstance();
const scanResult = await contentScanner.scanEmail(email);
// Log the scan result
console.log(`Content scan result for email ${email.getMessageId()}: score=${scanResult.threatScore}, isClean=${scanResult.isClean}`);
// Take action based on the scan result and configuration
if (!scanResult.isClean) {
const threatScoreThreshold = this.config.security?.threatScoreThreshold || 50;
// Check if the threat score exceeds the threshold
if (scanResult.threatScore >= threatScoreThreshold) {
const action = this.config.security?.maliciousContentAction || 'tag';
switch (action) {
case 'reject':
// Reject the email
console.log(`Rejecting email from ${email.from} due to malicious content: ${scanResult.threatType} (score: ${scanResult.threatScore})`);
return false;
case 'quarantine':
// Save to quarantine folder instead of regular processing
await this.saveToQuarantine(email, scanResult);
return true;
case 'tag':
default:
// Tag the email by modifying subject and adding headers
email.subject = `[SUSPICIOUS] ${email.subject}`;
email.addHeader('X-Content-Scanned', 'True');
email.addHeader('X-Threat-Type', scanResult.threatType || 'unknown');
email.addHeader('X-Threat-Score', scanResult.threatScore.toString());
email.addHeader('X-Threat-Details', scanResult.threatDetails || 'Suspicious content detected');
email.mightBeSpam = true;
console.log(`Tagged email from ${email.from} with suspicious content: ${scanResult.threatType} (score: ${scanResult.threatScore})`);
break;
}
}
}
}
// Check if the recipient domain is local
const recipientDomain = email.to[0].split('@')[1];
const isLocalDomain = this.isLocalDomain(recipientDomain);
@ -444,6 +631,55 @@ export class MtaService {
return false;
}
}
/**
* Save a suspicious email to quarantine
* @param email The email to quarantine
* @param scanResult The scan result
*/
private async saveToQuarantine(email: Email, scanResult: any): Promise<void> {
try {
// Create quarantine directory if it doesn't exist
const quarantinePath = plugins.path.join(paths.dataDir, 'emails', 'quarantine');
plugins.smartfile.fs.ensureDirSync(quarantinePath);
// Generate a filename with timestamp and details
const timestamp = Date.now();
const safeFrom = email.from.replace(/[^a-zA-Z0-9]/g, '_');
const filename = `${timestamp}_${safeFrom}_${scanResult.threatScore}.eml`;
// Save the email
const emailContent = email.toRFC822String();
const filePath = plugins.path.join(quarantinePath, filename);
plugins.smartfile.memory.toFsSync(emailContent, filePath);
// Save scan metadata alongside the email
const metadataPath = plugins.path.join(quarantinePath, `${filename}.meta.json`);
const metadata = {
timestamp,
from: email.from,
to: email.to,
subject: email.subject,
messageId: email.getMessageId(),
scanResult: {
threatType: scanResult.threatType,
threatDetails: scanResult.threatDetails,
threatScore: scanResult.threatScore,
scannedElements: scanResult.scannedElements
}
};
plugins.smartfile.memory.toFsSync(
JSON.stringify(metadata, null, 2),
metadataPath
);
console.log(`Email quarantined: ${filePath}`);
} catch (error) {
console.error('Error saving email to quarantine:', error);
}
}
/**
* Check if a domain is local
@ -456,6 +692,14 @@ export class MtaService {
* Save an email to a local mailbox
*/
private async saveToLocalMailbox(email: Email): Promise<void> {
// Check if this is a bounce notification
const isBounceNotification = this.isBounceNotification(email);
if (isBounceNotification) {
await this.processBounceNotification(email);
return;
}
// Simplified implementation - in a real system, this would store to a user's mailbox
const mailboxPath = plugins.path.join(paths.receivedEmailsDir, 'local');
plugins.smartfile.fs.ensureDirSync(mailboxPath);
@ -470,6 +714,77 @@ export class MtaService {
console.log(`Email saved to local mailbox: ${filename}`);
}
/**
* Check if an email is a bounce notification
*/
private isBounceNotification(email: Email): boolean {
// Check subject for bounce-related keywords
const subject = email.subject?.toLowerCase() || '';
if (
subject.includes('mail delivery') ||
subject.includes('delivery failed') ||
subject.includes('undeliverable') ||
subject.includes('delivery status') ||
subject.includes('failure notice') ||
subject.includes('returned mail') ||
subject.includes('delivery problem')
) {
return true;
}
// Check sender address for common bounced email addresses
const from = email.from.toLowerCase();
if (
from.includes('mailer-daemon') ||
from.includes('postmaster') ||
from.includes('mail-delivery') ||
from.includes('bounces')
) {
return true;
}
return false;
}
/**
* Process a bounce notification
*/
private async processBounceNotification(email: Email): Promise<void> {
try {
console.log(`Processing bounce notification from ${email.from}`);
// Convert to Smartmail for bounce processing
const smartmail = await email.toSmartmail();
// If we have a bounce manager available, process it
if (this.emailService?.bounceManager) {
const bounceResult = await this.emailService.bounceManager.processBounceEmail(smartmail);
if (bounceResult) {
console.log(`Processed bounce for recipient: ${bounceResult.recipient}, type: ${bounceResult.bounceType}`);
} else {
console.log('Could not extract bounce information from email');
}
} else {
console.log('Bounce manager not available, saving bounce notification for later processing');
// Save to bounces directory for later processing
const bouncesPath = plugins.path.join(paths.dataDir, 'emails', 'bounces');
plugins.smartfile.fs.ensureDirSync(bouncesPath);
const emailContent = email.toRFC822String();
const filename = `${Date.now()}_bounce.eml`;
plugins.smartfile.memory.toFsSync(
emailContent,
plugins.path.join(bouncesPath, filename)
);
}
} catch (error) {
console.error('Error processing bounce notification:', error);
}
}
/**
* Start processing the email queue
@ -572,6 +887,17 @@ export class MtaService {
this.stats.emailsFailed++;
console.log(`Email ${entry.id} failed permanently: ${entry.error.message}`);
// Record bounce event for reputation monitoring
if (this.config.outbound?.reputation?.enabled) {
const domain = entry.email.getFromDomain();
if (domain) {
this.reputationMonitor.recordSendEvent(domain, {
type: 'bounce',
hardBounce: true
});
}
}
// Remove from queue
this.emailQueue.delete(entry.id);
} else if (status === DeliveryStatus.DEFERRED) {
@ -587,6 +913,17 @@ export class MtaService {
// Remove from queue
this.emailQueue.delete(entry.id);
} else {
// Record soft bounce for reputation monitoring
if (this.config.outbound?.reputation?.enabled) {
const domain = entry.email.getFromDomain();
if (domain) {
this.reputationMonitor.recordSendEvent(domain, {
type: 'bounce',
hardBounce: false
});
}
}
// Schedule retry
const delay = this.calculateRetryDelay(entry.attempts);
entry.nextAttempt = new Date(Date.now() + delay);
@ -602,9 +939,33 @@ export class MtaService {
if (entry.attempts >= this.config.outbound.retries.max) {
entry.status = DeliveryStatus.FAILED;
this.stats.emailsFailed++;
// Record bounce event for reputation monitoring after max retries
if (this.config.outbound?.reputation?.enabled) {
const domain = entry.email.getFromDomain();
if (domain) {
this.reputationMonitor.recordSendEvent(domain, {
type: 'bounce',
hardBounce: true
});
}
}
this.emailQueue.delete(entry.id);
} else {
entry.status = DeliveryStatus.DEFERRED;
// Record soft bounce for reputation monitoring
if (this.config.outbound?.reputation?.enabled) {
const domain = entry.email.getFromDomain();
if (domain) {
this.reputationMonitor.recordSendEvent(domain, {
type: 'bounce',
hardBounce: false
});
}
}
const delay = this.calculateRetryDelay(entry.attempts);
entry.nextAttempt = new Date(Date.now() + delay);
}
@ -635,42 +996,11 @@ export class MtaService {
* Check if an email can be sent under rate limits
*/
private checkRateLimit(email: Email): boolean {
const config = this.config.outbound.rateLimit;
if (!config || !config.maxPerPeriod) {
return true; // No rate limit configured
}
// Get the appropriate domain key
const domainKey = email.getFromDomain();
// Determine which limiter to use
const key = config.perDomain ? email.getFromDomain() : 'global';
// Initialize limiter if needed
if (!this.rateLimiters.has(key)) {
this.rateLimiters.set(key, {
tokens: config.maxPerPeriod,
lastRefill: Date.now()
});
}
const limiter = this.rateLimiters.get(key);
// Refill tokens based on time elapsed
const now = Date.now();
const elapsedMs = now - limiter.lastRefill;
const tokensToAdd = Math.floor(elapsedMs / config.periodMs) * config.maxPerPeriod;
if (tokensToAdd > 0) {
limiter.tokens = Math.min(config.maxPerPeriod, limiter.tokens + tokensToAdd);
limiter.lastRefill = now - (elapsedMs % config.periodMs);
}
// Check if we have tokens available
if (limiter.tokens > 0) {
limiter.tokens--;
return true;
} else {
console.log(`Rate limit exceeded for ${key}`);
return false;
}
// Check if sending is allowed under rate limits
return this.rateLimiter.consume(domainKey);
}
/**
@ -974,10 +1304,24 @@ export class MtaService {
}
}
/**
* Get the IP warmup manager
*/
public getIPWarmupManager(): IPWarmupManager {
return this.ipWarmupManager;
}
/**
* Get the sender reputation monitor
*/
public getReputationMonitor(): SenderReputationMonitor {
return this.reputationMonitor;
}
/**
* Get MTA service statistics
*/
public getStats(): MtaStats {
public getStats(): MtaStats & { rateLimiting?: any } {
// Update queue size
this.stats.queueSize = this.emailQueue.size;
@ -995,6 +1339,80 @@ export class MtaService {
};
}
return { ...this.stats };
// Add rate limiting stats
const statsWithRateLimiting = {
...this.stats,
rateLimiting: {
global: this.rateLimiter.getStats('global')
}
};
// Add warmup information if enabled
if (this.config.outbound?.warmup?.enabled) {
const warmupStatuses = this.ipWarmupManager.getWarmupStatus() as Map<string, any>;
let activeIPs = 0;
let inWarmupPhase = 0;
let completedWarmup = 0;
warmupStatuses.forEach(status => {
activeIPs++;
if (status.isActive) {
if (status.currentStage < this.ipWarmupManager.getStageCount()) {
inWarmupPhase++;
} else {
completedWarmup++;
}
}
});
statsWithRateLimiting.warmupInfo = {
enabled: true,
activeIPs,
inWarmupPhase,
completedWarmup
};
} else {
statsWithRateLimiting.warmupInfo = {
enabled: false,
activeIPs: 0,
inWarmupPhase: 0,
completedWarmup: 0
};
}
// Add reputation metrics if enabled
if (this.config.outbound?.reputation?.enabled) {
const reputationSummary = this.reputationMonitor.getReputationSummary();
// Calculate average reputation score
const avgScore = reputationSummary.length > 0
? reputationSummary.reduce((sum, domain) => sum + domain.score, 0) / reputationSummary.length
: 0;
// Count domains with issues
const domainsWithIssues = reputationSummary.filter(
domain => domain.status === 'poor' || domain.status === 'critical' || domain.listed
).length;
statsWithRateLimiting.reputationInfo = {
enabled: true,
monitoredDomains: reputationSummary.length,
averageScore: avgScore,
domainsWithIssues
};
} else {
statsWithRateLimiting.reputationInfo = {
enabled: false,
monitoredDomains: 0,
averageScore: 0,
domainsWithIssues: 0
};
}
// Clean up old rate limiter buckets to prevent memory leaks
this.rateLimiter.cleanup();
return statsWithRateLimiting;
}
}

View File

@ -0,0 +1,281 @@
import { logger } from '../logger.js';
/**
* Configuration options for rate limiter
*/
export interface IRateLimitConfig {
/** Maximum tokens per period */
maxPerPeriod: number;
/** Time period in milliseconds */
periodMs: number;
/** Whether to apply per domain/key (vs globally) */
perKey: boolean;
/** Initial token count (defaults to max) */
initialTokens?: number;
/** Grace tokens to allow occasional bursts */
burstTokens?: number;
/** Apply global limit in addition to per-key limits */
useGlobalLimit?: boolean;
}
/**
* Token bucket for an individual key
*/
interface TokenBucket {
/** Current number of tokens */
tokens: number;
/** Last time tokens were refilled */
lastRefill: number;
/** Total allowed requests */
allowed: number;
/** Total denied requests */
denied: number;
}
/**
* Rate limiter using token bucket algorithm
* Provides more sophisticated rate limiting with burst handling
*/
export class RateLimiter {
/** Rate limit configuration */
private config: IRateLimitConfig;
/** Token buckets per key */
private buckets: Map<string, TokenBucket> = new Map();
/** Global bucket for non-keyed rate limiting */
private globalBucket: TokenBucket;
/**
* Create a new rate limiter
* @param config Rate limiter configuration
*/
constructor(config: IRateLimitConfig) {
// Set defaults
this.config = {
maxPerPeriod: config.maxPerPeriod,
periodMs: config.periodMs,
perKey: config.perKey ?? true,
initialTokens: config.initialTokens ?? config.maxPerPeriod,
burstTokens: config.burstTokens ?? 0,
useGlobalLimit: config.useGlobalLimit ?? false
};
// Initialize global bucket
this.globalBucket = {
tokens: this.config.initialTokens,
lastRefill: Date.now(),
allowed: 0,
denied: 0
};
// Log initialization
logger.log('info', `Rate limiter initialized: ${this.config.maxPerPeriod} per ${this.config.periodMs}ms${this.config.perKey ? ' per key' : ''}`);
}
/**
* Check if a request is allowed under rate limits
* @param key Key to check rate limit for (e.g. domain, user, IP)
* @param cost Token cost (defaults to 1)
* @returns Whether the request is allowed
*/
public isAllowed(key: string = 'global', cost: number = 1): boolean {
// If using global bucket directly, just check that
if (key === 'global' || !this.config.perKey) {
return this.checkBucket(this.globalBucket, cost);
}
// Get the key-specific bucket
const bucket = this.getBucket(key);
// If we also need to check global limit
if (this.config.useGlobalLimit) {
// Both key bucket and global bucket must have tokens
return this.checkBucket(bucket, cost) && this.checkBucket(this.globalBucket, cost);
} else {
// Only need to check the key-specific bucket
return this.checkBucket(bucket, cost);
}
}
/**
* Check if a bucket has enough tokens and consume them
* @param bucket The token bucket to check
* @param cost Token cost
* @returns Whether tokens were consumed
*/
private checkBucket(bucket: TokenBucket, cost: number): boolean {
// Refill tokens based on elapsed time
this.refillBucket(bucket);
// Check if we have enough tokens
if (bucket.tokens >= cost) {
// Use tokens
bucket.tokens -= cost;
bucket.allowed++;
return true;
} else {
// Rate limit exceeded
bucket.denied++;
return false;
}
}
/**
* Consume tokens for a request (if available)
* @param key Key to consume tokens for
* @param cost Token cost (defaults to 1)
* @returns Whether tokens were consumed
*/
public consume(key: string = 'global', cost: number = 1): boolean {
const isAllowed = this.isAllowed(key, cost);
return isAllowed;
}
/**
* Get the remaining tokens for a key
* @param key Key to check
* @returns Number of remaining tokens
*/
public getRemainingTokens(key: string = 'global'): number {
const bucket = this.getBucket(key);
this.refillBucket(bucket);
return bucket.tokens;
}
/**
* Get stats for a specific key
* @param key Key to get stats for
* @returns Rate limit statistics
*/
public getStats(key: string = 'global'): {
remaining: number;
limit: number;
resetIn: number;
allowed: number;
denied: number;
} {
const bucket = this.getBucket(key);
this.refillBucket(bucket);
// Calculate time until next token
const resetIn = bucket.tokens < this.config.maxPerPeriod ?
Math.ceil(this.config.periodMs / this.config.maxPerPeriod) :
0;
return {
remaining: bucket.tokens,
limit: this.config.maxPerPeriod,
resetIn,
allowed: bucket.allowed,
denied: bucket.denied
};
}
/**
* Get or create a token bucket for a key
* @param key The rate limit key
* @returns Token bucket
*/
private getBucket(key: string): TokenBucket {
if (!this.config.perKey || key === 'global') {
return this.globalBucket;
}
if (!this.buckets.has(key)) {
// Create new bucket
this.buckets.set(key, {
tokens: this.config.initialTokens,
lastRefill: Date.now(),
allowed: 0,
denied: 0
});
}
return this.buckets.get(key);
}
/**
* Refill tokens in a bucket based on elapsed time
* @param bucket Token bucket to refill
*/
private refillBucket(bucket: TokenBucket): void {
const now = Date.now();
const elapsedMs = now - bucket.lastRefill;
// Calculate how many tokens to add
const rate = this.config.maxPerPeriod / this.config.periodMs;
const tokensToAdd = elapsedMs * rate;
if (tokensToAdd >= 0.1) { // Allow for partial token refills
// Add tokens, but don't exceed the normal maximum (without burst)
// This ensures burst tokens are only used for bursts and don't refill
const normalMax = this.config.maxPerPeriod;
bucket.tokens = Math.min(
// Don't exceed max + burst
this.config.maxPerPeriod + (this.config.burstTokens || 0),
// Don't exceed normal max when refilling
Math.min(normalMax, bucket.tokens + tokensToAdd)
);
// Update last refill time
bucket.lastRefill = now;
}
}
/**
* Reset rate limits for a specific key
* @param key Key to reset
*/
public reset(key: string = 'global'): void {
if (key === 'global' || !this.config.perKey) {
this.globalBucket.tokens = this.config.initialTokens;
this.globalBucket.lastRefill = Date.now();
} else if (this.buckets.has(key)) {
const bucket = this.buckets.get(key);
bucket.tokens = this.config.initialTokens;
bucket.lastRefill = Date.now();
}
}
/**
* Reset all rate limiters
*/
public resetAll(): void {
this.globalBucket.tokens = this.config.initialTokens;
this.globalBucket.lastRefill = Date.now();
for (const bucket of this.buckets.values()) {
bucket.tokens = this.config.initialTokens;
bucket.lastRefill = Date.now();
}
}
/**
* Cleanup old buckets to prevent memory leaks
* @param maxAge Maximum age in milliseconds
*/
public cleanup(maxAge: number = 24 * 60 * 60 * 1000): void {
const now = Date.now();
let removed = 0;
for (const [key, bucket] of this.buckets.entries()) {
if (now - bucket.lastRefill > maxAge) {
this.buckets.delete(key);
removed++;
}
}
if (removed > 0) {
logger.log('debug', `Cleaned up ${removed} stale rate limit buckets`);
}
}
}

View File

@ -3,6 +3,13 @@ import * as paths from '../paths.js';
import { Email } from './classes.email.js';
import type { MtaService } from './classes.mta.js';
import { logger } from '../logger.js';
import {
SecurityLogger,
SecurityLogLevel,
SecurityEventType,
IPReputationChecker,
ReputationThreshold
} from '../security/index.js';
export interface ISmtpServerOptions {
port: number;
@ -53,8 +60,10 @@ export class SMTPServer {
});
}
private handleNewConnection(socket: plugins.net.Socket): void {
console.log(`New connection from ${socket.remoteAddress}:${socket.remotePort}`);
private async handleNewConnection(socket: plugins.net.Socket): Promise<void> {
const clientIp = socket.remoteAddress;
const clientPort = socket.remotePort;
console.log(`New connection from ${clientIp}:${clientPort}`);
// Initialize a new session
this.sessions.set(socket, {
@ -66,6 +75,68 @@ export class SMTPServer {
useTLS: false,
connectionEnded: false
});
// Check IP reputation
try {
if (this.mtaRef.config.security?.checkIPReputation !== false && clientIp) {
const reputationChecker = IPReputationChecker.getInstance();
const reputation = await reputationChecker.checkReputation(clientIp);
// Log the reputation check
SecurityLogger.getInstance().logEvent({
level: reputation.score < ReputationThreshold.HIGH_RISK
? SecurityLogLevel.WARN
: SecurityLogLevel.INFO,
type: SecurityEventType.IP_REPUTATION,
message: `IP reputation checked for new SMTP connection: score=${reputation.score}`,
ipAddress: clientIp,
details: {
clientPort,
score: reputation.score,
isSpam: reputation.isSpam,
isProxy: reputation.isProxy,
isTor: reputation.isTor,
isVPN: reputation.isVPN,
country: reputation.country,
blacklists: reputation.blacklists,
socketId: socket.remotePort.toString() + socket.remoteFamily
}
});
// Handle high-risk IPs - add delay or reject based on score
if (reputation.score < ReputationThreshold.HIGH_RISK) {
// For high-risk connections, add an artificial delay to slow down potential spam
const delayMs = Math.min(5000, Math.max(1000, (ReputationThreshold.HIGH_RISK - reputation.score) * 100));
await new Promise(resolve => setTimeout(resolve, delayMs));
if (reputation.score < 5) {
// Very high risk - can optionally reject the connection
if (this.mtaRef.config.security?.rejectHighRiskIPs) {
this.sendResponse(socket, `554 Transaction failed - IP is on spam blocklist`);
socket.destroy();
return;
}
}
}
}
} catch (error) {
logger.log('error', `Error checking IP reputation: ${error.message}`, {
ip: clientIp,
error: error.message
});
}
// Log the connection as a security event
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.CONNECTION,
message: `New SMTP connection established`,
ipAddress: clientIp,
details: {
clientPort,
socketId: socket.remotePort.toString() + socket.remoteFamily
}
});
// Send greeting
this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`);
@ -75,21 +146,69 @@ export class SMTPServer {
});
socket.on('end', () => {
console.log(`Connection ended from ${socket.remoteAddress}:${socket.remotePort}`);
const clientIp = socket.remoteAddress;
const clientPort = socket.remotePort;
console.log(`Connection ended from ${clientIp}:${clientPort}`);
const session = this.sessions.get(socket);
if (session) {
session.connectionEnded = true;
// Log connection end as security event
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.CONNECTION,
message: `SMTP connection ended normally`,
ipAddress: clientIp,
details: {
clientPort,
state: SmtpState[session.state],
from: session.mailFrom || 'not set'
}
});
}
});
socket.on('error', (err) => {
const clientIp = socket.remoteAddress;
const clientPort = socket.remotePort;
console.error(`Socket error: ${err.message}`);
// Log connection error as security event
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.CONNECTION,
message: `SMTP connection error`,
ipAddress: clientIp,
details: {
clientPort,
error: err.message,
errorCode: (err as any).code,
from: this.sessions.get(socket)?.mailFrom || 'not set'
}
});
this.sessions.delete(socket);
socket.destroy();
});
socket.on('close', () => {
console.log(`Connection closed from ${socket.remoteAddress}:${socket.remotePort}`);
const clientIp = socket.remoteAddress;
const clientPort = socket.remotePort;
console.log(`Connection closed from ${clientIp}:${clientPort}`);
// Log connection closure as security event
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.CONNECTION,
message: `SMTP connection closed`,
ipAddress: clientIp,
details: {
clientPort,
sessionEnded: this.sessions.get(socket)?.connectionEnded || false
}
});
this.sessions.delete(socket);
});
}
@ -358,33 +477,165 @@ export class SMTPServer {
// Prepare headers for DKIM verification results
const customHeaders: Record<string, string> = {};
// Verifying the email with enhanced DKIM verification
try {
const verificationResult = await this.mtaRef.dkimVerifier.verify(session.emailData, {
useCache: true,
returnDetails: false
});
// Authentication results
let dkimResult = { domain: '', result: false };
let spfResult = { domain: '', result: false };
// Check security configuration
const securityConfig = this.mtaRef.config.security || {};
// 1. Verify DKIM signature if enabled
if (securityConfig.verifyDkim !== false) {
try {
const verificationResult = await this.mtaRef.dkimVerifier.verify(session.emailData, {
useCache: true,
returnDetails: false
});
mightBeSpam = !verificationResult.isValid;
if (!verificationResult.isValid) {
logger.log('warn', `DKIM verification failed for incoming email: ${verificationResult.errorMessage || 'Unknown error'}`);
} else {
logger.log('info', `DKIM verification passed for incoming email from domain ${verificationResult.domain}`);
dkimResult.result = verificationResult.isValid;
dkimResult.domain = verificationResult.domain || '';
if (!verificationResult.isValid) {
logger.log('warn', `DKIM verification failed for incoming email: ${verificationResult.errorMessage || 'Unknown error'}`);
// Enhanced security logging
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.DKIM,
message: `DKIM verification failed for incoming email`,
domain: verificationResult.domain || session.mailFrom.split('@')[1],
details: {
error: verificationResult.errorMessage || 'Unknown error',
status: verificationResult.status,
selector: verificationResult.selector,
senderIP: socket.remoteAddress
},
ipAddress: socket.remoteAddress,
success: false
});
} else {
logger.log('info', `DKIM verification passed for incoming email from domain ${verificationResult.domain}`);
// Enhanced security logging
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.DKIM,
message: `DKIM verification passed for incoming email`,
domain: verificationResult.domain,
details: {
selector: verificationResult.selector,
status: verificationResult.status,
senderIP: socket.remoteAddress
},
ipAddress: socket.remoteAddress,
success: true
});
}
// Store verification results in headers
if (verificationResult.domain) {
customHeaders['X-DKIM-Domain'] = verificationResult.domain;
}
customHeaders['X-DKIM-Status'] = verificationResult.status || 'unknown';
customHeaders['X-DKIM-Result'] = verificationResult.isValid ? 'pass' : 'fail';
} catch (error) {
logger.log('error', `Failed to verify DKIM signature: ${error.message}`);
customHeaders['X-DKIM-Status'] = 'error';
customHeaders['X-DKIM-Result'] = 'error';
}
}
// 2. Verify SPF if enabled
if (securityConfig.verifySpf !== false) {
try {
// Get the client IP and hostname
const clientIp = socket.remoteAddress || '127.0.0.1';
const clientHostname = session.clientHostname || 'localhost';
// Store verification results in headers
if (verificationResult.domain) {
customHeaders['X-DKIM-Domain'] = verificationResult.domain;
// Parse the email to get envelope from
const parsedEmail = await plugins.mailparser.simpleParser(session.emailData);
// Create a temporary Email object for SPF verification
const tempEmail = new Email({
from: parsedEmail.from?.value[0].address || session.mailFrom,
to: session.rcptTo[0],
subject: "Temporary Email for SPF Verification",
text: "This is a temporary email for SPF verification"
});
// Set envelope from for SPF verification
tempEmail.setEnvelopeFrom(session.mailFrom);
// Verify SPF
const spfVerified = await this.mtaRef.spfVerifier.verifyAndApply(
tempEmail,
clientIp,
clientHostname
);
// Update SPF result
spfResult.result = spfVerified;
spfResult.domain = session.mailFrom.split('@')[1] || '';
// Copy SPF headers from the temp email
if (tempEmail.headers['Received-SPF']) {
customHeaders['Received-SPF'] = tempEmail.headers['Received-SPF'];
}
// Set spam flag if SPF fails badly
if (tempEmail.mightBeSpam) {
mightBeSpam = true;
}
} catch (error) {
logger.log('error', `Failed to verify SPF: ${error.message}`);
customHeaders['Received-SPF'] = `error (${error.message})`;
}
}
// 3. Verify DMARC if enabled
if (securityConfig.verifyDmarc !== false) {
try {
// Parse the email again
const parsedEmail = await plugins.mailparser.simpleParser(session.emailData);
customHeaders['X-DKIM-Status'] = verificationResult.status || 'unknown';
customHeaders['X-DKIM-Result'] = verificationResult.isValid ? 'pass' : 'fail';
} catch (error) {
logger.log('error', `Failed to verify DKIM signature: ${error.message}`);
mightBeSpam = true;
customHeaders['X-DKIM-Status'] = 'error';
customHeaders['X-DKIM-Result'] = 'error';
// Create a temporary Email object for DMARC verification
const tempEmail = new Email({
from: parsedEmail.from?.value[0].address || session.mailFrom,
to: session.rcptTo[0],
subject: "Temporary Email for DMARC Verification",
text: "This is a temporary email for DMARC verification"
});
// Verify DMARC
const dmarcResult = await this.mtaRef.dmarcVerifier.verify(
tempEmail,
spfResult,
dkimResult
);
// Apply DMARC policy
const dmarcPassed = this.mtaRef.dmarcVerifier.applyPolicy(tempEmail, dmarcResult);
// Add DMARC result to headers
if (tempEmail.headers['X-DMARC-Result']) {
customHeaders['X-DMARC-Result'] = tempEmail.headers['X-DMARC-Result'];
}
// Add Authentication-Results header combining all authentication results
customHeaders['Authentication-Results'] = `${this.mtaRef.config.smtp.hostname}; ` +
`spf=${spfResult.result ? 'pass' : 'fail'} smtp.mailfrom=${session.mailFrom}; ` +
`dkim=${dkimResult.result ? 'pass' : 'fail'} header.d=${dkimResult.domain || 'unknown'}; ` +
`dmarc=${dmarcPassed ? 'pass' : 'fail'} header.from=${tempEmail.getFromDomain()}`;
// Set spam flag if DMARC fails
if (tempEmail.mightBeSpam) {
mightBeSpam = true;
}
} catch (error) {
logger.log('error', `Failed to verify DMARC: ${error.message}`);
customHeaders['X-DMARC-Result'] = `error (${error.message})`;
}
}
try {
@ -411,15 +662,62 @@ export class SMTPServer {
attachments: email.attachments.length,
mightBeSpam: email.mightBeSpam
});
// Enhanced security logging for received email
SecurityLogger.getInstance().logEvent({
level: mightBeSpam ? SecurityLogLevel.WARN : SecurityLogLevel.INFO,
type: mightBeSpam ? SecurityEventType.SPAM : SecurityEventType.EMAIL_VALIDATION,
message: `Email received and ${mightBeSpam ? 'flagged as potential spam' : 'validated successfully'}`,
domain: email.from.split('@')[1],
ipAddress: socket.remoteAddress,
details: {
from: email.from,
subject: email.subject,
recipientCount: email.getAllRecipients().length,
attachmentCount: email.attachments.length,
hasAttachments: email.hasAttachments(),
dkimStatus: customHeaders['X-DKIM-Result'] || 'unknown'
},
success: !mightBeSpam
});
// Process or forward the email via MTA service
try {
await this.mtaRef.processIncomingEmail(email);
} catch (err) {
console.error('Error in MTA processing of incoming email:', err);
// Log processing errors
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_VALIDATION,
message: `Error processing incoming email`,
domain: email.from.split('@')[1],
ipAddress: socket.remoteAddress,
details: {
error: err.message,
from: email.from,
stack: err.stack
},
success: false
});
}
} catch (error) {
console.error('Error parsing email:', error);
// Log parsing errors
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_VALIDATION,
message: `Error parsing incoming email`,
ipAddress: socket.remoteAddress,
details: {
error: error.message,
sender: session.mailFrom,
stack: error.stack
},
success: false
});
}
}

View File

@ -0,0 +1,599 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../security/index.js';
import type { MtaService } from './classes.mta.js';
import type { Email } from './classes.email.js';
import type { IDnsVerificationResult } from './classes.dnsmanager.js';
/**
* SPF result qualifiers
*/
export enum SpfQualifier {
PASS = '+',
NEUTRAL = '?',
SOFTFAIL = '~',
FAIL = '-'
}
/**
* SPF mechanism types
*/
export enum SpfMechanismType {
ALL = 'all',
INCLUDE = 'include',
A = 'a',
MX = 'mx',
IP4 = 'ip4',
IP6 = 'ip6',
EXISTS = 'exists',
REDIRECT = 'redirect',
EXP = 'exp'
}
/**
* SPF mechanism definition
*/
export interface SpfMechanism {
qualifier: SpfQualifier;
type: SpfMechanismType;
value?: string;
}
/**
* SPF record parsed data
*/
export interface SpfRecord {
version: string;
mechanisms: SpfMechanism[];
modifiers: Record<string, string>;
}
/**
* SPF verification result
*/
export interface SpfResult {
result: 'pass' | 'neutral' | 'softfail' | 'fail' | 'temperror' | 'permerror' | 'none';
explanation?: string;
domain: string;
ip: string;
record?: string;
error?: string;
}
/**
* Maximum lookup limit for SPF records (prevent infinite loops)
*/
const MAX_SPF_LOOKUPS = 10;
/**
* Class for verifying SPF records
*/
export class SpfVerifier {
private mtaRef: MtaService;
private lookupCount: number = 0;
constructor(mtaRefArg: MtaService) {
this.mtaRef = mtaRefArg;
}
/**
* Parse SPF record from TXT record
* @param record SPF TXT record
* @returns Parsed SPF record or null if invalid
*/
public parseSpfRecord(record: string): SpfRecord | null {
if (!record.startsWith('v=spf1')) {
return null;
}
try {
const spfRecord: SpfRecord = {
version: 'spf1',
mechanisms: [],
modifiers: {}
};
// Split into terms
const terms = record.split(' ').filter(term => term.length > 0);
// Skip version term
for (let i = 1; i < terms.length; i++) {
const term = terms[i];
// Check if it's a modifier (name=value)
if (term.includes('=')) {
const [name, value] = term.split('=');
spfRecord.modifiers[name] = value;
continue;
}
// Parse as mechanism
let qualifier = SpfQualifier.PASS; // Default is +
let mechanismText = term;
// Check for qualifier
if (term.startsWith('+') || term.startsWith('-') ||
term.startsWith('~') || term.startsWith('?')) {
qualifier = term[0] as SpfQualifier;
mechanismText = term.substring(1);
}
// Parse mechanism type and value
const colonIndex = mechanismText.indexOf(':');
let type: SpfMechanismType;
let value: string | undefined;
if (colonIndex !== -1) {
type = mechanismText.substring(0, colonIndex) as SpfMechanismType;
value = mechanismText.substring(colonIndex + 1);
} else {
type = mechanismText as SpfMechanismType;
}
spfRecord.mechanisms.push({ qualifier, type, value });
}
return spfRecord;
} catch (error) {
logger.log('error', `Error parsing SPF record: ${error.message}`, {
record,
error: error.message
});
return null;
}
}
/**
* Check if IP is in CIDR range
* @param ip IP address to check
* @param cidr CIDR range
* @returns Whether the IP is in the CIDR range
*/
private isIpInCidr(ip: string, cidr: string): boolean {
try {
const ipAddress = plugins.ip.Address4.parse(ip);
return ipAddress.isInSubnet(new plugins.ip.Address4(cidr));
} catch (error) {
// Try IPv6
try {
const ipAddress = plugins.ip.Address6.parse(ip);
return ipAddress.isInSubnet(new plugins.ip.Address6(cidr));
} catch (e) {
return false;
}
}
}
/**
* Check if a domain has the specified IP in its A or AAAA records
* @param domain Domain to check
* @param ip IP address to check
* @returns Whether the domain resolves to the IP
*/
private async isDomainResolvingToIp(domain: string, ip: string): Promise<boolean> {
try {
// First try IPv4
const ipv4Addresses = await plugins.dns.promises.resolve4(domain);
if (ipv4Addresses.includes(ip)) {
return true;
}
// Then try IPv6
const ipv6Addresses = await plugins.dns.promises.resolve6(domain);
if (ipv6Addresses.includes(ip)) {
return true;
}
return false;
} catch (error) {
return false;
}
}
/**
* Verify SPF for a given email with IP and helo domain
* @param email Email to verify
* @param ip Sender IP address
* @param heloDomain HELO/EHLO domain used by sender
* @returns SPF verification result
*/
public async verify(
email: Email,
ip: string,
heloDomain: string
): Promise<SpfResult> {
const securityLogger = SecurityLogger.getInstance();
// Reset lookup count
this.lookupCount = 0;
// Get domain from envelope from (return-path)
const domain = email.getEnvelopeFrom().split('@')[1] || '';
if (!domain) {
return {
result: 'permerror',
explanation: 'No envelope from domain',
domain: '',
ip
};
}
try {
// Look up SPF record
const spfVerificationResult = await this.mtaRef.dnsManager.verifySpfRecord(domain);
if (!spfVerificationResult.found) {
return {
result: 'none',
explanation: 'No SPF record found',
domain,
ip
};
}
if (!spfVerificationResult.valid) {
return {
result: 'permerror',
explanation: 'Invalid SPF record',
domain,
ip,
record: spfVerificationResult.value
};
}
// Parse SPF record
const spfRecord = this.parseSpfRecord(spfVerificationResult.value);
if (!spfRecord) {
return {
result: 'permerror',
explanation: 'Failed to parse SPF record',
domain,
ip,
record: spfVerificationResult.value
};
}
// Check SPF record
const result = await this.checkSpfRecord(spfRecord, domain, ip);
// Log the result
const spfLogLevel = result.result === 'pass' ?
SecurityLogLevel.INFO :
(result.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO);
securityLogger.logEvent({
level: spfLogLevel,
type: SecurityEventType.SPF,
message: `SPF ${result.result} for ${domain} from IP ${ip}`,
domain,
details: {
ip,
heloDomain,
result: result.result,
explanation: result.explanation,
record: spfVerificationResult.value
},
success: result.result === 'pass'
});
return {
...result,
domain,
ip,
record: spfVerificationResult.value
};
} catch (error) {
// Log error
logger.log('error', `SPF verification error: ${error.message}`, {
domain,
ip,
error: error.message
});
securityLogger.logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.SPF,
message: `SPF verification error for ${domain}`,
domain,
details: {
ip,
error: error.message
},
success: false
});
return {
result: 'temperror',
explanation: `Error verifying SPF: ${error.message}`,
domain,
ip,
error: error.message
};
}
}
/**
* Check SPF record against IP address
* @param spfRecord Parsed SPF record
* @param domain Domain being checked
* @param ip IP address to check
* @returns SPF result
*/
private async checkSpfRecord(
spfRecord: SpfRecord,
domain: string,
ip: string
): Promise<SpfResult> {
// Check for 'redirect' modifier
if (spfRecord.modifiers.redirect) {
this.lookupCount++;
if (this.lookupCount > MAX_SPF_LOOKUPS) {
return {
result: 'permerror',
explanation: 'Too many DNS lookups',
domain,
ip
};
}
// Handle redirect
const redirectDomain = spfRecord.modifiers.redirect;
const redirectResult = await this.mtaRef.dnsManager.verifySpfRecord(redirectDomain);
if (!redirectResult.found || !redirectResult.valid) {
return {
result: 'permerror',
explanation: `Invalid redirect to ${redirectDomain}`,
domain,
ip
};
}
const redirectRecord = this.parseSpfRecord(redirectResult.value);
if (!redirectRecord) {
return {
result: 'permerror',
explanation: `Failed to parse redirect record from ${redirectDomain}`,
domain,
ip
};
}
return this.checkSpfRecord(redirectRecord, redirectDomain, ip);
}
// Check each mechanism in order
for (const mechanism of spfRecord.mechanisms) {
let matched = false;
switch (mechanism.type) {
case SpfMechanismType.ALL:
matched = true;
break;
case SpfMechanismType.IP4:
if (mechanism.value) {
matched = this.isIpInCidr(ip, mechanism.value);
}
break;
case SpfMechanismType.IP6:
if (mechanism.value) {
matched = this.isIpInCidr(ip, mechanism.value);
}
break;
case SpfMechanismType.A:
this.lookupCount++;
if (this.lookupCount > MAX_SPF_LOOKUPS) {
return {
result: 'permerror',
explanation: 'Too many DNS lookups',
domain,
ip
};
}
// Check if domain has A/AAAA record matching IP
const checkDomain = mechanism.value || domain;
matched = await this.isDomainResolvingToIp(checkDomain, ip);
break;
case SpfMechanismType.MX:
this.lookupCount++;
if (this.lookupCount > MAX_SPF_LOOKUPS) {
return {
result: 'permerror',
explanation: 'Too many DNS lookups',
domain,
ip
};
}
// Check MX records
const mxDomain = mechanism.value || domain;
try {
const mxRecords = await plugins.dns.promises.resolveMx(mxDomain);
for (const mx of mxRecords) {
// Check if this MX record's IP matches
const mxMatches = await this.isDomainResolvingToIp(mx.exchange, ip);
if (mxMatches) {
matched = true;
break;
}
}
} catch (error) {
// No MX records or error
matched = false;
}
break;
case SpfMechanismType.INCLUDE:
if (!mechanism.value) {
continue;
}
this.lookupCount++;
if (this.lookupCount > MAX_SPF_LOOKUPS) {
return {
result: 'permerror',
explanation: 'Too many DNS lookups',
domain,
ip
};
}
// Check included domain's SPF record
const includeDomain = mechanism.value;
const includeResult = await this.mtaRef.dnsManager.verifySpfRecord(includeDomain);
if (!includeResult.found || !includeResult.valid) {
continue; // Skip this mechanism
}
const includeRecord = this.parseSpfRecord(includeResult.value);
if (!includeRecord) {
continue; // Skip this mechanism
}
// Recursively check the included SPF record
const includeCheck = await this.checkSpfRecord(includeRecord, includeDomain, ip);
// Include mechanism matches if the result is "pass"
matched = includeCheck.result === 'pass';
break;
case SpfMechanismType.EXISTS:
if (!mechanism.value) {
continue;
}
this.lookupCount++;
if (this.lookupCount > MAX_SPF_LOOKUPS) {
return {
result: 'permerror',
explanation: 'Too many DNS lookups',
domain,
ip
};
}
// Check if domain exists (has any A record)
try {
await plugins.dns.promises.resolve(mechanism.value, 'A');
matched = true;
} catch (error) {
matched = false;
}
break;
}
// If this mechanism matched, return its result
if (matched) {
switch (mechanism.qualifier) {
case SpfQualifier.PASS:
return {
result: 'pass',
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
domain,
ip
};
case SpfQualifier.FAIL:
return {
result: 'fail',
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
domain,
ip
};
case SpfQualifier.SOFTFAIL:
return {
result: 'softfail',
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
domain,
ip
};
case SpfQualifier.NEUTRAL:
return {
result: 'neutral',
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
domain,
ip
};
}
}
}
// If no mechanism matched, default to neutral
return {
result: 'neutral',
explanation: 'No matching mechanism found',
domain,
ip
};
}
/**
* Check if email passes SPF verification
* @param email Email to verify
* @param ip Sender IP address
* @param heloDomain HELO/EHLO domain used by sender
* @returns Whether email passes SPF
*/
public async verifyAndApply(
email: Email,
ip: string,
heloDomain: string
): Promise<boolean> {
const result = await this.verify(email, ip, heloDomain);
// Add headers
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
// Apply policy based on result
switch (result.result) {
case 'fail':
// Fail - mark as spam
email.mightBeSpam = true;
logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`);
return false;
case 'softfail':
// Soft fail - accept but mark as suspicious
email.mightBeSpam = true;
logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
case 'neutral':
case 'none':
// Neutral or none - accept but note in headers
logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
case 'pass':
// Pass - accept
logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
case 'temperror':
case 'permerror':
// Temporary or permanent error - log but accept
logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
default:
return true;
}
}
}

View File

@ -1,7 +1,10 @@
export * from './classes.dkimcreator.js';
export * from './classes.emailsignjob.js';
export * from './classes.dkimverifier.js';
export * from './classes.dmarcverifier.js';
export * from './classes.spfverifier.js';
export * from './classes.mta.js';
export * from './classes.smtpserver.js';
export * from './classes.emailsendjob.js';
export * from './classes.email.js';
export * from './classes.ratelimiter.js';