diff --git a/changelog.md b/changelog.md index 3658986..77b419c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mail) +remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests + +- Removed ts/mail/security/classes.dmarcverifier.ts and ts/mail/security/classes.dkimverifier.ts — DMARC and DKIM verifier implementations deleted +- Removed ts/errors/index.ts — MTA-specific error classes removed +- Added ts/mail/routing/classes.dkim.manager.ts — new DKIM key management and rotation logic +- Added ts/mail/routing/classes.email.action.executor.ts — centralized email action execution (forward/process/deliver/reject) +- Updated ts/mail/security/classes.spfverifier.ts to retain SPF parsing but removed verify/verifyAndApply logic delegating to Rust bridge +- Updated ts/mail/routing/index.ts to export new routing classes and adjusted import paths (e.g. delivery queue import updated) +- Tests trimmed: DMARC tests and rate limiter tests removed; SPF parsing test retained and simplified +- This set of changes alters public exports and removes previously available verifier APIs — major version bump recommended + ## 2026-02-11 - 4.1.1 - fix(readme) clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README diff --git a/test/test.emailauth.ts b/test/test.emailauth.ts index dc015cd..10b135c 100644 --- a/test/test.emailauth.ts +++ b/test/test.emailauth.ts @@ -1,195 +1,46 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js'; -import { DmarcVerifier, DmarcPolicy, DmarcAlignment } from '../ts/mail/security/classes.dmarcverifier.js'; -import { Email } from '../ts/mail/core/classes.email.js'; /** - * Test email authentication systems: SPF and DMARC + * Test email authentication systems: SPF parsing */ // SPF Verifier Tests tap.test('SPF Verifier - should parse SPF record', async () => { const spfVerifier = new SpfVerifier(); - + // Test valid SPF record parsing const record = 'v=spf1 a mx ip4:192.168.0.1/24 include:example.org ~all'; const parsedRecord = spfVerifier.parseSpfRecord(record); - + expect(parsedRecord).toBeTruthy(); expect(parsedRecord.version).toEqual('spf1'); expect(parsedRecord.mechanisms.length).toEqual(5); - + // Check specific mechanisms expect(parsedRecord.mechanisms[0].type).toEqual(SpfMechanismType.A); expect(parsedRecord.mechanisms[0].qualifier).toEqual(SpfQualifier.PASS); - + expect(parsedRecord.mechanisms[1].type).toEqual(SpfMechanismType.MX); expect(parsedRecord.mechanisms[1].qualifier).toEqual(SpfQualifier.PASS); - + expect(parsedRecord.mechanisms[2].type).toEqual(SpfMechanismType.IP4); expect(parsedRecord.mechanisms[2].value).toEqual('192.168.0.1/24'); - + expect(parsedRecord.mechanisms[3].type).toEqual(SpfMechanismType.INCLUDE); expect(parsedRecord.mechanisms[3].value).toEqual('example.org'); - + expect(parsedRecord.mechanisms[4].type).toEqual(SpfMechanismType.ALL); expect(parsedRecord.mechanisms[4].qualifier).toEqual(SpfQualifier.SOFTFAIL); - + // Test invalid record const invalidRecord = 'not-a-spf-record'; const invalidParsed = spfVerifier.parseSpfRecord(invalidRecord); expect(invalidParsed).toBeNull(); }); -// DMARC Verifier Tests -tap.test('DMARC Verifier - should parse DMARC record', async () => { - const dmarcVerifier = new DmarcVerifier(); - - // Test valid DMARC record parsing - const record = 'v=DMARC1; p=reject; sp=quarantine; pct=50; adkim=s; aspf=r; rua=mailto:dmarc@example.com'; - const parsedRecord = dmarcVerifier.parseDmarcRecord(record); - - expect(parsedRecord).toBeTruthy(); - expect(parsedRecord.version).toEqual('DMARC1'); - expect(parsedRecord.policy).toEqual(DmarcPolicy.REJECT); - expect(parsedRecord.subdomainPolicy).toEqual(DmarcPolicy.QUARANTINE); - expect(parsedRecord.pct).toEqual(50); - expect(parsedRecord.adkim).toEqual(DmarcAlignment.STRICT); - expect(parsedRecord.aspf).toEqual(DmarcAlignment.RELAXED); - expect(parsedRecord.reportUriAggregate).toContain('dmarc@example.com'); - - // Test invalid record - const invalidRecord = 'not-a-dmarc-record'; - const invalidParsed = dmarcVerifier.parseDmarcRecord(invalidRecord); - expect(invalidParsed).toBeNull(); -}); - -tap.test('DMARC Verifier - should verify DMARC alignment', async () => { - const dmarcVerifier = new DmarcVerifier(); - - // Test email domains with DMARC alignment - const email = new Email({ - from: 'sender@example.com', - to: 'recipient@example.net', - subject: 'Test DMARC alignment', - text: 'This is a test email' - }); - - // Test when both SPF and DKIM pass with alignment - const dmarcResult = await dmarcVerifier.verify( - email, - { domain: 'example.com', result: true }, // SPF - aligned and passed - { domain: 'example.com', result: true } // DKIM - aligned and passed - ); - - expect(dmarcResult).toBeTruthy(); - expect(dmarcResult.spfPassed).toEqual(true); - expect(dmarcResult.dkimPassed).toEqual(true); - expect(dmarcResult.spfDomainAligned).toEqual(true); - expect(dmarcResult.dkimDomainAligned).toEqual(true); - expect(dmarcResult.action).toEqual('pass'); - - // Test when neither SPF nor DKIM is aligned - const dmarcResult2 = await dmarcVerifier.verify( - email, - { domain: 'differentdomain.com', result: true }, // SPF - passed but not aligned - { domain: 'anotherdomain.com', result: true } // DKIM - passed but not aligned - ); - - // Without a DNS manager, no DMARC record will be found - - expect(dmarcResult2).toBeTruthy(); - expect(dmarcResult2.spfPassed).toEqual(true); - expect(dmarcResult2.dkimPassed).toEqual(true); - expect(dmarcResult2.spfDomainAligned).toEqual(false); - expect(dmarcResult2.dkimDomainAligned).toEqual(false); - - // Without a DMARC record, the default action is 'pass' - expect(dmarcResult2.hasDmarc).toEqual(false); - expect(dmarcResult2.policyEvaluated).toEqual(DmarcPolicy.NONE); - expect(dmarcResult2.actualPolicy).toEqual(DmarcPolicy.NONE); - expect(dmarcResult2.action).toEqual('pass'); -}); - -tap.test('DMARC Verifier - should apply policy correctly', async () => { - const dmarcVerifier = new DmarcVerifier(); - - // Create test email - const email = new Email({ - from: 'sender@example.com', - to: 'recipient@example.net', - subject: 'Test DMARC policy application', - text: 'This is a test email' - }); - - // Test pass action - const passResult: any = { - hasDmarc: true, - spfDomainAligned: true, - dkimDomainAligned: true, - spfPassed: true, - dkimPassed: true, - policyEvaluated: DmarcPolicy.NONE, - actualPolicy: DmarcPolicy.NONE, - appliedPercentage: 100, - action: 'pass', - details: 'DMARC passed' - }; - - const passApplied = dmarcVerifier.applyPolicy(email, passResult); - expect(passApplied).toEqual(true); - expect(email.mightBeSpam).toEqual(false); - expect(email.headers['X-DMARC-Result']).toEqual('DMARC passed'); - - // Test quarantine action - const quarantineResult: any = { - hasDmarc: true, - spfDomainAligned: false, - dkimDomainAligned: false, - spfPassed: false, - dkimPassed: false, - policyEvaluated: DmarcPolicy.QUARANTINE, - actualPolicy: DmarcPolicy.QUARANTINE, - appliedPercentage: 100, - action: 'quarantine', - details: 'DMARC failed, policy=quarantine' - }; - - // Reset email spam flag - email.mightBeSpam = false; - email.headers = {}; - - const quarantineApplied = dmarcVerifier.applyPolicy(email, quarantineResult); - expect(quarantineApplied).toEqual(true); - expect(email.mightBeSpam).toEqual(true); - expect(email.headers['X-Spam-Flag']).toEqual('YES'); - expect(email.headers['X-DMARC-Result']).toEqual('DMARC failed, policy=quarantine'); - - // Test reject action - const rejectResult: any = { - hasDmarc: true, - spfDomainAligned: false, - dkimDomainAligned: false, - spfPassed: false, - dkimPassed: false, - policyEvaluated: DmarcPolicy.REJECT, - actualPolicy: DmarcPolicy.REJECT, - appliedPercentage: 100, - action: 'reject', - details: 'DMARC failed, policy=reject' - }; - - // Reset email spam flag - email.mightBeSpam = false; - email.headers = {}; - - const rejectApplied = dmarcVerifier.applyPolicy(email, rejectResult); - expect(rejectApplied).toEqual(false); - expect(email.mightBeSpam).toEqual(true); -}); - tap.test('stop', async () => { await tap.stopForcefully(); }); -export default tap.start(); \ No newline at end of file +export default tap.start(); diff --git a/test/test.ratelimiter.ts b/test/test.ratelimiter.ts deleted file mode 100644 index 1725d29..0000000 --- a/test/test.ratelimiter.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { RateLimiter } from '../ts/mail/delivery/classes.ratelimiter.js'; - -tap.test('RateLimiter - should be instantiable', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 10, - periodMs: 1000, - perKey: true - }); - - expect(limiter).toBeTruthy(); -}); - -tap.test('RateLimiter - should allow requests within rate limit', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 5, - periodMs: 1000, - perKey: true - }); - - // Should allow 5 requests - for (let i = 0; i < 5; i++) { - expect(limiter.isAllowed('test')).toEqual(true); - } - - // 6th request should be denied - expect(limiter.isAllowed('test')).toEqual(false); -}); - -tap.test('RateLimiter - should enforce per-key limits', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 3, - periodMs: 1000, - perKey: true - }); - - // Should allow 3 requests for key1 - for (let i = 0; i < 3; i++) { - expect(limiter.isAllowed('key1')).toEqual(true); - } - - // 4th request for key1 should be denied - expect(limiter.isAllowed('key1')).toEqual(false); - - // But key2 should still be allowed - expect(limiter.isAllowed('key2')).toEqual(true); -}); - -tap.test('RateLimiter - should refill tokens over time', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 2, - periodMs: 100, // Short period for testing - perKey: true - }); - - // Use all tokens - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(false); - - // Wait for refill - await new Promise(resolve => setTimeout(resolve, 150)); - - // Should have tokens again - expect(limiter.isAllowed('test')).toEqual(true); -}); - -tap.test('RateLimiter - should support burst allowance', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 2, - periodMs: 100, - perKey: true, - burstTokens: 2, // Allow 2 extra tokens for bursts - initialTokens: 4 // Start with max + burst tokens - }); - - // Should allow 4 requests (2 regular + 2 burst) - for (let i = 0; i < 4; i++) { - expect(limiter.isAllowed('test')).toEqual(true); - } - - // 5th request should be denied - expect(limiter.isAllowed('test')).toEqual(false); - - // Wait for refill - await new Promise(resolve => setTimeout(resolve, 150)); - - // Should have 2 tokens again (rate-limited to normal max, not burst) - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(true); - - // 3rd request after refill should fail (only normal max is refilled, not burst) - expect(limiter.isAllowed('test')).toEqual(false); -}); - -tap.test('RateLimiter - should return correct stats', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 10, - periodMs: 1000, - perKey: true - }); - - // Make some requests - limiter.isAllowed('test'); - limiter.isAllowed('test'); - limiter.isAllowed('test'); - - // Get stats - const stats = limiter.getStats('test'); - - expect(stats.remaining).toEqual(7); - expect(stats.limit).toEqual(10); - expect(stats.allowed).toEqual(3); - expect(stats.denied).toEqual(0); -}); - -tap.test('RateLimiter - should reset limits', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 3, - periodMs: 1000, - perKey: true - }); - - // Use all tokens - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(false); - - // Reset - limiter.reset('test'); - - // Should have tokens again - expect(limiter.isAllowed('test')).toEqual(true); -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 15d05f2..76f99bc 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartmta', - version: '4.1.1', + version: '5.0.0', description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.' } diff --git a/ts/errors/index.ts b/ts/errors/index.ts deleted file mode 100644 index c3b56ef..0000000 --- a/ts/errors/index.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * MTA error classes for SMTP client operations - */ - -export class MtaConnectionError extends Error { - public code: string; - public details?: any; - constructor(message: string, detailsOrCode?: any) { - super(message); - this.name = 'MtaConnectionError'; - if (typeof detailsOrCode === 'string') { - this.code = detailsOrCode; - } else { - this.code = 'CONNECTION_ERROR'; - this.details = detailsOrCode; - } - } - static timeout(host: string, port: number, timeoutMs?: number): MtaConnectionError { - return new MtaConnectionError(`Connection to ${host}:${port} timed out${timeoutMs ? ` after ${timeoutMs}ms` : ''}`, 'TIMEOUT'); - } - static refused(host: string, port: number): MtaConnectionError { - return new MtaConnectionError(`Connection to ${host}:${port} refused`, 'REFUSED'); - } - static dnsError(host: string, err?: any): MtaConnectionError { - const errMsg = typeof err === 'string' ? err : err?.message || ''; - return new MtaConnectionError(`DNS resolution failed for ${host}${errMsg ? `: ${errMsg}` : ''}`, 'DNS_ERROR'); - } -} - -export class MtaAuthenticationError extends Error { - public code: string; - public details?: any; - constructor(message: string, detailsOrCode?: any) { - super(message); - this.name = 'MtaAuthenticationError'; - if (typeof detailsOrCode === 'string') { - this.code = detailsOrCode; - } else { - this.code = 'AUTH_ERROR'; - this.details = detailsOrCode; - } - } - static invalidCredentials(host?: string, user?: string): MtaAuthenticationError { - const detail = host && user ? `${user}@${host}` : host || user || ''; - return new MtaAuthenticationError(`Authentication failed${detail ? `: ${detail}` : ''}`, 'INVALID_CREDENTIALS'); - } -} - -export class MtaDeliveryError extends Error { - public code: string; - public responseCode?: number; - public details?: any; - constructor(message: string, detailsOrCode?: any, responseCode?: number) { - super(message); - this.name = 'MtaDeliveryError'; - if (typeof detailsOrCode === 'string') { - this.code = detailsOrCode; - this.responseCode = responseCode; - } else { - this.code = 'DELIVERY_ERROR'; - this.details = detailsOrCode; - } - } - static temporary(message: string, ...args: any[]): MtaDeliveryError { - return new MtaDeliveryError(message, 'TEMPORARY'); - } - static permanent(message: string, ...args: any[]): MtaDeliveryError { - return new MtaDeliveryError(message, 'PERMANENT'); - } -} - -export class MtaConfigurationError extends Error { - public code: string; - public details?: any; - constructor(message: string, detailsOrCode?: any) { - super(message); - this.name = 'MtaConfigurationError'; - if (typeof detailsOrCode === 'string') { - this.code = detailsOrCode; - } else { - this.code = 'CONFIG_ERROR'; - this.details = detailsOrCode; - } - } -} - -export class MtaTimeoutError extends Error { - public code: string; - public details?: any; - constructor(message: string, detailsOrCode?: any) { - super(message); - this.name = 'MtaTimeoutError'; - if (typeof detailsOrCode === 'string') { - this.code = detailsOrCode; - } else { - this.code = 'TIMEOUT'; - this.details = detailsOrCode; - } - } - static commandTimeout(command: string, hostOrTimeout?: any, timeoutMs?: number): MtaTimeoutError { - const timeout = typeof hostOrTimeout === 'number' ? hostOrTimeout : timeoutMs; - return new MtaTimeoutError(`Command '${command}' timed out${timeout ? ` after ${timeout}ms` : ''}`, 'COMMAND_TIMEOUT'); - } -} - -export class MtaProtocolError extends Error { - public code: string; - public details?: any; - constructor(message: string, detailsOrCode?: any) { - super(message); - this.name = 'MtaProtocolError'; - if (typeof detailsOrCode === 'string') { - this.code = detailsOrCode; - } else { - this.code = 'PROTOCOL_ERROR'; - this.details = detailsOrCode; - } - } -} diff --git a/ts/mail/delivery/classes.delivery.queue.ts b/ts/mail/delivery/classes.delivery.queue.ts index 609a21c..5728a8f 100644 --- a/ts/mail/delivery/classes.delivery.queue.ts +++ b/ts/mail/delivery/classes.delivery.queue.ts @@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { logger } from '../../logger.js'; -import { type EmailProcessingMode } from '../routing/classes.email.config.js'; +import { type EmailProcessingMode } from './interfaces.js'; import type { IEmailRoute } from '../routing/interfaces.js'; /** diff --git a/ts/mail/routing/classes.dkim.manager.ts b/ts/mail/routing/classes.dkim.manager.ts new file mode 100644 index 0000000..1ba7475 --- /dev/null +++ b/ts/mail/routing/classes.dkim.manager.ts @@ -0,0 +1,153 @@ +import { logger } from '../../logger.js'; +import { DKIMCreator } from '../security/classes.dkimcreator.js'; +import { DomainRegistry } from './classes.domain.registry.js'; +import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js'; +import { Email } from '../core/classes.email.js'; + +/** External DcRouter interface shape used by DkimManager */ +interface DcRouter { + storageManager: any; + dnsServer?: any; +} + +/** + * Manages DKIM key setup, rotation, and signing for all configured domains + */ +export class DkimManager { + private dkimKeys: Map = new Map(); + + constructor( + private dkimCreator: DKIMCreator, + private domainRegistry: DomainRegistry, + private dcRouter: DcRouter, + private rustBridge: RustSecurityBridge, + ) {} + + async setupDkimForDomains(): Promise { + const domainConfigs = this.domainRegistry.getAllConfigs(); + + if (domainConfigs.length === 0) { + logger.log('warn', 'No domains configured for DKIM'); + return; + } + + for (const domainConfig of domainConfigs) { + const domain = domainConfig.domain; + const selector = domainConfig.dkim?.selector || 'default'; + + try { + let keyPair: { privateKey: string; publicKey: string }; + + try { + keyPair = await this.dkimCreator.readDKIMKeys(domain); + logger.log('info', `Using existing DKIM keys for domain: ${domain}`); + } catch (error) { + keyPair = await this.dkimCreator.createDKIMKeys(); + await this.dkimCreator.createAndStoreDKIMKeys(domain); + logger.log('info', `Generated new DKIM keys for domain: ${domain}`); + } + + this.dkimKeys.set(domain, keyPair.privateKey); + logger.log('info', `DKIM keys loaded for domain: ${domain} with selector: ${selector}`); + } catch (error) { + logger.log('error', `Failed to set up DKIM for domain ${domain}: ${error.message}`); + } + } + } + + async checkAndRotateDkimKeys(): Promise { + const domainConfigs = this.domainRegistry.getAllConfigs(); + + for (const domainConfig of domainConfigs) { + const domain = domainConfig.domain; + const selector = domainConfig.dkim?.selector || 'default'; + const rotateKeys = domainConfig.dkim?.rotateKeys || false; + const rotationInterval = domainConfig.dkim?.rotationInterval || 90; + const keySize = domainConfig.dkim?.keySize || 2048; + + if (!rotateKeys) { + logger.log('debug', `DKIM key rotation disabled for ${domain}`); + continue; + } + + try { + const needsRotation = await this.dkimCreator.needsRotation(domain, selector, rotationInterval); + + if (needsRotation) { + logger.log('info', `DKIM keys need rotation for ${domain} (selector: ${selector})`); + + const newSelector = await this.dkimCreator.rotateDkimKeys(domain, selector, keySize); + + domainConfig.dkim = { + ...domainConfig.dkim, + selector: newSelector + }; + + if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) { + const keyPair = await this.dkimCreator.readDKIMKeysForSelector(domain, newSelector); + const publicKeyBase64 = keyPair.publicKey + .replace(/-----BEGIN PUBLIC KEY-----/g, '') + .replace(/-----END PUBLIC KEY-----/g, '') + .replace(/\s/g, ''); + + const ttl = domainConfig.dns?.internal?.ttl || 3600; + + this.dcRouter.dnsServer.registerHandler( + `${newSelector}._domainkey.${domain}`, + ['TXT'], + () => ({ + name: `${newSelector}._domainkey.${domain}`, + type: 'TXT', + class: 'IN', + ttl: ttl, + data: `v=DKIM1; k=rsa; p=${publicKeyBase64}` + }) + ); + + logger.log('info', `DKIM DNS handler registered for new selector: ${newSelector}._domainkey.${domain}`); + + await this.dcRouter.storageManager.set( + `/email/dkim/${domain}/public.key`, + keyPair.publicKey + ); + } + + this.dkimCreator.cleanupOldKeys(domain, 30).catch(error => { + logger.log('warn', `Failed to cleanup old DKIM keys for ${domain}: ${error.message}`); + }); + + } else { + logger.log('debug', `DKIM keys for ${domain} are up to date`); + } + } catch (error) { + logger.log('error', `Failed to check/rotate DKIM keys for ${domain}: ${error.message}`); + } + } + } + + async handleDkimSigning(email: Email, domain: string, selector: string): Promise { + try { + await this.dkimCreator.handleDKIMKeysForDomain(domain); + const { privateKey } = await this.dkimCreator.readDKIMKeys(domain); + const rawEmail = email.toRFC822String(); + + const signResult = await this.rustBridge.signDkim({ + rawMessage: rawEmail, + domain, + selector, + privateKey, + }); + + if (signResult.header) { + email.addHeader('DKIM-Signature', signResult.header); + logger.log('info', `Successfully added DKIM signature for ${domain}`); + } + } catch (error) { + logger.log('error', `Failed to sign email with DKIM: ${error.message}`); + } + } + + getDkimKey(domain: string): string | undefined { + return this.dkimKeys.get(domain); + } +} diff --git a/ts/mail/routing/classes.email.action.executor.ts b/ts/mail/routing/classes.email.action.executor.ts new file mode 100644 index 0000000..5d2190c --- /dev/null +++ b/ts/mail/routing/classes.email.action.executor.ts @@ -0,0 +1,174 @@ +import { logger } from '../../logger.js'; +import { + SecurityLogger, + SecurityLogLevel, + SecurityEventType +} from '../../security/index.js'; +import type { IEmailAction, IEmailContext } from './interfaces.js'; +import { Email } from '../core/classes.email.js'; +import { BounceManager } from '../core/classes.bouncemanager.js'; +import { UnifiedDeliveryQueue } from '../delivery/classes.delivery.queue.js'; +import type { ISmtpSendResult } from '../../security/classes.rustsecuritybridge.js'; + +/** + * Dependencies injected from UnifiedEmailServer to avoid circular imports + */ +export interface IActionExecutorDeps { + sendOutboundEmail: (host: string, port: number, email: Email, options?: { + auth?: { user: string; pass: string }; + dkimDomain?: string; + dkimSelector?: string; + }) => Promise; + bounceManager: BounceManager; + deliveryQueue: UnifiedDeliveryQueue; +} + +/** + * Executes email routing actions (forward, process, deliver, reject) + */ +export class EmailActionExecutor { + constructor(private deps: IActionExecutorDeps) {} + + async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise { + switch (action.type) { + case 'forward': + await this.handleForwardAction(action, email, context); + break; + case 'process': + await this.handleProcessAction(action, email, context); + break; + case 'deliver': + await this.handleDeliverAction(action, email, context); + break; + case 'reject': + await this.handleRejectAction(action, email, context); + break; + default: + throw new Error(`Unknown action type: ${(action as any).type}`); + } + } + + private async handleForwardAction(action: IEmailAction, email: Email, context: IEmailContext): Promise { + if (!action.forward) { + throw new Error('Forward action requires forward configuration'); + } + + const { host, port = 25, auth, addHeaders } = action.forward; + + logger.log('info', `Forwarding email to ${host}:${port}`); + + // Add forwarding headers + if (addHeaders) { + for (const [key, value] of Object.entries(addHeaders)) { + email.headers[key] = value; + } + } + + // Add standard forwarding headers + email.headers['X-Forwarded-For'] = context.session.remoteAddress || 'unknown'; + email.headers['X-Forwarded-To'] = email.to.join(', '); + email.headers['X-Forwarded-Date'] = new Date().toISOString(); + + try { + // Send email via Rust SMTP client + await this.deps.sendOutboundEmail(host, port, email, { + auth: auth as { user: string; pass: string } | undefined, + }); + + logger.log('info', `Successfully forwarded email to ${host}:${port}`); + + SecurityLogger.getInstance().logEvent({ + level: SecurityLogLevel.INFO, + type: SecurityEventType.EMAIL_FORWARDING, + message: 'Email forwarded successfully', + ipAddress: context.session.remoteAddress, + details: { + sessionId: context.session.id, + routeName: context.session.matchedRoute?.name, + targetHost: host, + targetPort: port, + recipients: email.to + }, + success: true + }); + } catch (error) { + logger.log('error', `Failed to forward email: ${error.message}`); + + SecurityLogger.getInstance().logEvent({ + level: SecurityLogLevel.ERROR, + type: SecurityEventType.EMAIL_FORWARDING, + message: 'Email forwarding failed', + ipAddress: context.session.remoteAddress, + details: { + sessionId: context.session.id, + routeName: context.session.matchedRoute?.name, + targetHost: host, + targetPort: port, + error: error.message + }, + success: false + }); + + // Handle as bounce + for (const recipient of email.getAllRecipients()) { + await this.deps.bounceManager.processSmtpFailure(recipient, error.message, { + sender: email.from, + originalEmailId: email.headers['Message-ID'] as string + }); + } + throw error; + } + } + + private async handleProcessAction(action: IEmailAction, email: Email, context: IEmailContext): Promise { + logger.log('info', `Processing email with action options`); + + // Apply scanning if requested + if (action.process?.scan) { + logger.log('info', 'Content scanning requested'); + } + + // Queue for delivery + const queue = action.process?.queue || 'normal'; + await this.deps.deliveryQueue.enqueue(email, 'process', context.session.matchedRoute!); + + logger.log('info', `Email queued for delivery in ${queue} queue`); + } + + private async handleDeliverAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise { + logger.log('info', `Delivering email locally`); + + // Queue for local delivery + await this.deps.deliveryQueue.enqueue(email, 'mta', context.session.matchedRoute!); + + logger.log('info', 'Email queued for local delivery'); + } + + private async handleRejectAction(action: IEmailAction, _email: Email, context: IEmailContext): Promise { + const code = action.reject?.code || 550; + const message = action.reject?.message || 'Message rejected'; + + logger.log('info', `Rejecting email with code ${code}: ${message}`); + + SecurityLogger.getInstance().logEvent({ + level: SecurityLogLevel.WARN, + type: SecurityEventType.EMAIL_PROCESSING, + message: 'Email rejected by routing rule', + ipAddress: context.session.remoteAddress, + details: { + sessionId: context.session.id, + routeName: context.session.matchedRoute?.name, + rejectCode: code, + rejectMessage: message, + from: _email.from, + to: _email.to + }, + success: false + }); + + // Throw error with SMTP code and message + const error = new Error(message); + (error as any).responseCode = code; + throw error; + } +} diff --git a/ts/mail/routing/classes.email.config.ts b/ts/mail/routing/classes.email.config.ts deleted file mode 100644 index c5f00fa..0000000 --- a/ts/mail/routing/classes.email.config.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { EmailProcessingMode } from '../delivery/interfaces.js'; - -// Re-export EmailProcessingMode type -export type { EmailProcessingMode }; - - -/** - * Domain rule interface for pattern-based routing - */ -export interface IDomainRule { - // Domain pattern (e.g., "*@example.com", "*@*.example.net") - pattern: string; - - // Handling mode for this pattern - mode: EmailProcessingMode; - - // Forward mode configuration - target?: { - server: string; - port?: number; - useTls?: boolean; - authentication?: { - user?: string; - pass?: string; - }; - }; - - // MTA mode configuration - mtaOptions?: IMtaOptions; - - // Process mode configuration - contentScanning?: boolean; - scanners?: IContentScanner[]; - transformations?: ITransformation[]; - - // Rate limits for this domain - rateLimits?: { - maxMessagesPerMinute?: number; - maxRecipientsPerMessage?: number; - }; -} - -/** - * MTA options interface - */ -export interface IMtaOptions { - domain?: string; - allowLocalDelivery?: boolean; - localDeliveryPath?: string; - dkimSign?: boolean; - dkimOptions?: { - domainName: string; - keySelector: string; - privateKey?: string; - }; - smtpBanner?: string; - maxConnections?: number; - connTimeout?: number; - spoolDir?: string; -} - -/** - * Content scanner interface - */ -export interface IContentScanner { - type: 'spam' | 'virus' | 'attachment'; - threshold?: number; - action: 'tag' | 'reject'; - blockedExtensions?: string[]; -} - -/** - * Transformation interface - */ -export interface ITransformation { - type: string; - header?: string; - value?: string; - domains?: string[]; - append?: boolean; - [key: string]: any; -} \ No newline at end of file diff --git a/ts/mail/routing/classes.unified.email.server.ts b/ts/mail/routing/classes.unified.email.server.ts index 2c4b0bc..88800fb 100644 --- a/ts/mail/routing/classes.unified.email.server.ts +++ b/ts/mail/routing/classes.unified.email.server.ts @@ -2,13 +2,12 @@ import * as plugins from '../../plugins.js'; import * as paths from '../../paths.js'; import { EventEmitter } from 'events'; import { logger } from '../../logger.js'; -import { - SecurityLogger, - SecurityLogLevel, - SecurityEventType +import { + SecurityLogger, + SecurityLogLevel, + SecurityEventType } from '../../security/index.js'; import { DKIMCreator } from '../security/classes.dkimcreator.js'; -import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js'; import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js'; import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js'; import { EmailRouter } from './classes.email.router.js'; @@ -23,6 +22,10 @@ import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.de import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js'; import { SmtpState } from '../delivery/interfaces.js'; import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js'; +import { EmailActionExecutor } from './classes.email.action.executor.js'; +import { DkimManager } from './classes.dkim.manager.js'; + + /** External DcRouter interface shape used by UnifiedEmailServer */ interface DcRouter { storageManager: any; @@ -51,14 +54,14 @@ export interface IUnifiedEmailServerOptions { banner?: string; debug?: boolean; useSocketHandler?: boolean; // Use socket-handler mode instead of port listening - + // Authentication options auth?: { required?: boolean; methods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[]; users?: Array<{username: string, password: string}>; }; - + // TLS options tls?: { certPath?: string; @@ -67,26 +70,26 @@ export interface IUnifiedEmailServerOptions { minVersion?: string; ciphers?: string; }; - + // Limits maxMessageSize?: number; maxClients?: number; maxConnections?: number; - + // Connection options connectionTimeout?: number; socketTimeout?: number; - + // Email routing rules routes: IEmailRoute[]; - + // Global defaults for all domains defaults?: { dnsMode?: 'forward' | 'internal-dns' | 'external-dns'; dkim?: IEmailDomainConfig['dkim']; rateLimits?: IEmailDomainConfig['rateLimits']; }; - + // Outbound settings outbound?: { maxConnections?: number; @@ -95,7 +98,7 @@ export interface IUnifiedEmailServerOptions { retryAttempts?: number; defaultFrom?: string; }; - + // Rate limiting (global limits, can be overridden per domain) rateLimits?: IHierarchicalRateLimits; } @@ -112,7 +115,7 @@ export interface ISmtpSession extends IBaseSmtpSession { username: string; [key: string]: any; }; - + /** * Matched route for this session */ @@ -156,21 +159,23 @@ export class UnifiedEmailServer extends EventEmitter { public domainRegistry: DomainRegistry; private servers: any[] = []; private stats: IServerStats; - + // Add components needed for sending and securing emails public dkimCreator: DKIMCreator; private rustBridge: RustSecurityBridge; - private ipReputationChecker: IPReputationChecker; private bounceManager: BounceManager; public deliveryQueue: UnifiedDeliveryQueue; public deliverySystem: MultiModeDeliverySystem; private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers - private dkimKeys: Map = new Map(); // domain -> private key - + + // Extracted subsystems + private actionExecutor: EmailActionExecutor; + private dkimManager: DkimManager; + constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) { super(); this.dcRouter = dcRouter; - + // Set default options this.options = { ...options, @@ -181,36 +186,29 @@ export class UnifiedEmailServer extends EventEmitter { connectionTimeout: options.connectionTimeout || 60000, // 1 minute socketTimeout: options.socketTimeout || 60000 // 1 minute }; - + // Initialize Rust security bridge (singleton) this.rustBridge = RustSecurityBridge.getInstance(); // Initialize DKIM creator with storage manager this.dkimCreator = new DKIMCreator(paths.keysDir, dcRouter.storageManager); - - // Initialize IP reputation checker with storage manager - this.ipReputationChecker = IPReputationChecker.getInstance({ - enableLocalCache: true, - enableDNSBL: true, - enableIPInfo: true - }, dcRouter.storageManager); - + // Initialize bounce manager with storage manager this.bounceManager = new BounceManager({ maxCacheSize: 10000, cacheTTL: 30 * 24 * 60 * 60 * 1000, // 30 days storageManager: dcRouter.storageManager }); - + // Initialize domain registry this.domainRegistry = new DomainRegistry(options.domains, options.defaults); - + // Initialize email router with routes and storage manager this.emailRouter = new EmailRouter(options.routes || [], { storageManager: dcRouter.storageManager, persistChanges: true }); - + // Initialize rate limiter this.rateLimiter = new UnifiedRateLimiter(options.rateLimits || { global: { @@ -222,7 +220,7 @@ export class UnifiedEmailServer extends EventEmitter { blockDuration: 300000 // 5 minutes } }); - + // Initialize delivery components const queueOptions: IQueueOptions = { storageType: 'memory', // Default to memory storage @@ -230,9 +228,9 @@ export class UnifiedEmailServer extends EventEmitter { baseRetryDelay: 300000, // 5 minutes maxRetryDelay: 3600000 // 1 hour }; - + this.deliveryQueue = new UnifiedDeliveryQueue(queueOptions); - + const deliveryOptions: IMultiModeDeliveryOptions = { globalRateLimit: 100, // Default to 100 emails per minute concurrentDeliveries: 10, @@ -244,9 +242,19 @@ export class UnifiedEmailServer extends EventEmitter { // Delivery success recorded via delivery system } }; - + this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this); - + + // Initialize action executor + this.actionExecutor = new EmailActionExecutor({ + sendOutboundEmail: this.sendOutboundEmail.bind(this), + bounceManager: this.bounceManager, + deliveryQueue: this.deliveryQueue, + }); + + // Initialize DKIM manager + this.dkimManager = new DkimManager(this.dkimCreator, this.domainRegistry, dcRouter, this.rustBridge); + // Initialize statistics this.stats = { startTime: new Date(), @@ -265,10 +273,10 @@ export class UnifiedEmailServer extends EventEmitter { min: 0 } }; - + // We'll create the SMTP servers during the start() method } - + /** * Send an outbound email via the Rust SMTP client. * Uses connection pooling in the Rust binary for efficiency. @@ -315,140 +323,19 @@ export class UnifiedEmailServer extends EventEmitter { maxPoolConnections: this.options.outbound?.maxConnections || 10, }); } - + /** * Start the unified email server */ public async start(): Promise { logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`); - + try { - // Initialize the delivery queue - await this.deliveryQueue.initialize(); - logger.log('info', 'Email delivery queue initialized'); - - // Start the delivery system - await this.deliverySystem.start(); - logger.log('info', 'Email delivery system started'); - - // Start Rust security bridge — required for all security operations - const bridgeOk = await this.rustBridge.start(); - if (!bridgeOk) { - throw new Error('Rust security bridge failed to start. The mailer-bin binary is required. Run "pnpm build" to compile it.'); - } - logger.log('info', 'Rust security bridge started — Rust is the primary security backend'); - - // Listen for bridge state changes to propagate resilience events - this.rustBridge.on('stateChange', ({ oldState, newState }: { oldState: string; newState: string }) => { - if (newState === 'failed') this.emit('bridgeFailed'); - else if (newState === 'restarting') this.emit('bridgeRestarting'); - else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered'); - }); - - // Set up DKIM for all domains - await this.setupDkimForDomains(); - logger.log('info', 'DKIM configuration completed for all domains'); - - // Create DNS manager and ensure all DNS records are created - const dnsManager = new DnsManager(this.dcRouter); - await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator); - logger.log('info', 'DNS records ensured for all configured domains'); - - // Apply per-domain rate limits - this.applyDomainRateLimits(); - logger.log('info', 'Per-domain rate limits configured'); - - // Check and rotate DKIM keys if needed - await this.checkAndRotateDkimKeys(); - logger.log('info', 'DKIM key rotation check completed'); - - // Ensure we have the necessary TLS options - const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath; - - // Prepare the certificate and key if available - let tlsCertPem: string | undefined; - let tlsKeyPem: string | undefined; - - if (hasTlsConfig) { - try { - tlsKeyPem = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8'); - tlsCertPem = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8'); - logger.log('info', 'TLS certificates loaded successfully'); - } catch (error) { - logger.log('warn', `Failed to load TLS certificates: ${error.message}`); - } - } - - // --- Start Rust SMTP server --- - // Register event handlers for email reception and auth - this.rustBridge.onEmailReceived(async (data) => { - try { - await this.handleRustEmailReceived(data); - } catch (err) { - logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`); - // Send rejection back to Rust (may fail if bridge is restarting) - try { - await this.rustBridge.sendEmailProcessingResult({ - correlationId: data.correlationId, - accepted: false, - smtpCode: 451, - smtpMessage: 'Internal processing error', - }); - } catch (sendErr) { - logger.log('warn', `Could not send rejection back to Rust: ${(sendErr as Error).message}`); - } - } - }); - - this.rustBridge.onAuthRequest(async (data) => { - try { - await this.handleRustAuthRequest(data); - } catch (err) { - logger.log('error', `Error handling auth from Rust SMTP: ${(err as Error).message}`); - try { - await this.rustBridge.sendAuthResult({ - correlationId: data.correlationId, - success: false, - message: 'Internal auth error', - }); - } catch (sendErr) { - logger.log('warn', `Could not send auth rejection back to Rust: ${(sendErr as Error).message}`); - } - } - }); - - // Determine which ports need STARTTLS and which need implicit TLS - const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465); - const securePort = (this.options.ports as number[]).find(p => p === 465); - - const started = await this.rustBridge.startSmtpServer({ - hostname: this.options.hostname, - ports: smtpPorts, - securePort: securePort, - tlsCertPem, - tlsKeyPem, - maxMessageSize: this.options.maxMessageSize || 10 * 1024 * 1024, - maxConnections: this.options.maxConnections || this.options.maxClients || 100, - maxRecipients: 100, - connectionTimeoutSecs: this.options.connectionTimeout ? Math.floor(this.options.connectionTimeout / 1000) : 30, - dataTimeoutSecs: 60, - authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length), - maxAuthFailures: 3, - socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300, - processingTimeoutSecs: 30, - rateLimits: this.options.rateLimits ? { - maxConnectionsPerIp: this.options.rateLimits.global?.maxConnectionsPerIP || 50, - maxMessagesPerSender: this.options.rateLimits.global?.maxMessagesPerMinute || 100, - maxAuthFailuresPerIp: this.options.rateLimits.global?.maxAuthFailuresPerIP || 5, - windowSecs: 60, - } : undefined, - }); - - if (!started) { - throw new Error('Failed to start Rust SMTP server'); - } - - logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`); + await this.startDeliveryPipeline(); + await this.startRustBridge(); + await this.initializeDkimAndDns(); + this.registerBridgeEventHandlers(); + await this.startSmtpServer(); logger.log('info', 'UnifiedEmailServer started successfully'); this.emit('started'); } catch (error) { @@ -456,13 +343,135 @@ export class UnifiedEmailServer extends EventEmitter { throw error; } } - + + private async startDeliveryPipeline(): Promise { + await this.deliveryQueue.initialize(); + logger.log('info', 'Email delivery queue initialized'); + + await this.deliverySystem.start(); + logger.log('info', 'Email delivery system started'); + } + + private async startRustBridge(): Promise { + const bridgeOk = await this.rustBridge.start(); + if (!bridgeOk) { + throw new Error('Rust security bridge failed to start. The mailer-bin binary is required. Run "pnpm build" to compile it.'); + } + logger.log('info', 'Rust security bridge started — Rust is the primary security backend'); + + this.rustBridge.on('stateChange', ({ oldState, newState }: { oldState: string; newState: string }) => { + if (newState === 'failed') this.emit('bridgeFailed'); + else if (newState === 'restarting') this.emit('bridgeRestarting'); + else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered'); + }); + } + + private async initializeDkimAndDns(): Promise { + await this.dkimManager.setupDkimForDomains(); + logger.log('info', 'DKIM configuration completed for all domains'); + + const dnsManager = new DnsManager(this.dcRouter); + await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator); + logger.log('info', 'DNS records ensured for all configured domains'); + + this.applyDomainRateLimits(); + logger.log('info', 'Per-domain rate limits configured'); + + await this.dkimManager.checkAndRotateDkimKeys(); + logger.log('info', 'DKIM key rotation check completed'); + } + + private registerBridgeEventHandlers(): void { + this.rustBridge.onEmailReceived(async (data) => { + try { + await this.handleRustEmailReceived(data); + } catch (err) { + logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`); + try { + await this.rustBridge.sendEmailProcessingResult({ + correlationId: data.correlationId, + accepted: false, + smtpCode: 451, + smtpMessage: 'Internal processing error', + }); + } catch (sendErr) { + logger.log('warn', `Could not send rejection back to Rust: ${(sendErr as Error).message}`); + } + } + }); + + this.rustBridge.onAuthRequest(async (data) => { + try { + await this.handleRustAuthRequest(data); + } catch (err) { + logger.log('error', `Error handling auth from Rust SMTP: ${(err as Error).message}`); + try { + await this.rustBridge.sendAuthResult({ + correlationId: data.correlationId, + success: false, + message: 'Internal auth error', + }); + } catch (sendErr) { + logger.log('warn', `Could not send auth rejection back to Rust: ${(sendErr as Error).message}`); + } + } + }); + } + + private async startSmtpServer(): Promise { + const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath; + let tlsCertPem: string | undefined; + let tlsKeyPem: string | undefined; + + if (hasTlsConfig) { + try { + tlsKeyPem = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8'); + tlsCertPem = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8'); + logger.log('info', 'TLS certificates loaded successfully'); + } catch (error) { + logger.log('warn', `Failed to load TLS certificates: ${error.message}`); + } + } + + const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465); + const securePort = (this.options.ports as number[]).find(p => p === 465); + + const started = await this.rustBridge.startSmtpServer({ + hostname: this.options.hostname, + ports: smtpPorts, + securePort: securePort, + tlsCertPem, + tlsKeyPem, + maxMessageSize: this.options.maxMessageSize || 10 * 1024 * 1024, + maxConnections: this.options.maxConnections || this.options.maxClients || 100, + maxRecipients: 100, + connectionTimeoutSecs: this.options.connectionTimeout ? Math.floor(this.options.connectionTimeout / 1000) : 30, + dataTimeoutSecs: 60, + authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length), + maxAuthFailures: 3, + socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300, + processingTimeoutSecs: 30, + rateLimits: this.options.rateLimits ? { + maxConnectionsPerIp: this.options.rateLimits.global?.maxConnectionsPerIP || 50, + maxMessagesPerSender: this.options.rateLimits.global?.maxMessagesPerMinute || 100, + maxAuthFailuresPerIp: this.options.rateLimits.global?.maxAuthFailuresPerIP || 5, + windowSecs: 60, + } : undefined, + }); + + if (!started) { + throw new Error('Failed to start Rust SMTP server'); + } + + logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`); + } + /** * Stop the unified email server */ public async stop(): Promise { logger.log('info', 'Stopping UnifiedEmailServer'); - + try { // Stop the Rust SMTP server first try { @@ -484,20 +493,20 @@ export class UnifiedEmailServer extends EventEmitter { await this.deliverySystem.stop(); logger.log('info', 'Email delivery system stopped'); } - + // Shut down the delivery queue if (this.deliveryQueue) { await this.deliveryQueue.shutdown(); logger.log('info', 'Email delivery queue shut down'); } - + // Close all Rust SMTP client connection pools try { await this.rustBridge.closeSmtpPool(); } catch { // Bridge may already be stopped } - + logger.log('info', 'UnifiedEmailServer stopped successfully'); this.emit('stopped'); } catch (error) { @@ -512,8 +521,6 @@ export class UnifiedEmailServer extends EventEmitter { /** * Handle an emailReceived event from the Rust SMTP server. - * Decodes the email data, processes it through the routing system, - * and sends back the result via the correlation-ID callback. */ private async handleRustEmailReceived(data: IEmailReceivedEvent): Promise { const { correlationId, mailFrom, rcptTo, remoteAddr, clientHostname, secure, authenticatedUser } = data; @@ -588,7 +595,6 @@ export class UnifiedEmailServer extends EventEmitter { /** * Handle an authRequest event from the Rust SMTP server. - * Validates credentials and sends back the result. */ private async handleRustAuthRequest(data: IAuthRequestEvent): Promise { const { correlationId, username, password, remoteAddr } = data; @@ -740,271 +746,57 @@ export class UnifiedEmailServer extends EventEmitter { } // First check if this is a bounce notification email - // Look for common bounce notification subject patterns const subject = email.subject || ''; const isBounceLike = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject); - + if (isBounceLike) { logger.log('info', `Email subject matches bounce notification pattern: "${subject}"`); - - // Try to process as a bounce + const isBounce = await this.processBounceNotification(email); - + if (isBounce) { logger.log('info', 'Successfully processed as bounce notification, skipping regular processing'); return email; } - + logger.log('info', 'Not a valid bounce notification, continuing with regular processing'); } // Find matching route const context: IEmailContext = { email, session }; const route = await this.emailRouter.evaluateRoutes(context); - + if (!route) { - // No matching route - reject throw new Error('No matching route for email'); } - + // Store matched route in session session.matchedRoute = route; - + // Execute action based on route - await this.executeAction(route.action, email, context); - + await this.actionExecutor.executeAction(route.action, email, context); + // Return the processed email return email; } - - /** - * Execute action based on route configuration - */ - private async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise { - switch (action.type) { - case 'forward': - await this.handleForwardAction(action, email, context); - break; - - case 'process': - await this.handleProcessAction(action, email, context); - break; - - case 'deliver': - await this.handleDeliverAction(action, email, context); - break; - - case 'reject': - await this.handleRejectAction(action, email, context); - break; - - default: - throw new Error(`Unknown action type: ${(action as any).type}`); - } - } - - /** - * Handle forward action - */ - private async handleForwardAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise { - if (!_action.forward) { - throw new Error('Forward action requires forward configuration'); - } - - const { host, port = 25, auth, addHeaders } = _action.forward; - - logger.log('info', `Forwarding email to ${host}:${port}`); - - // Add forwarding headers - if (addHeaders) { - for (const [key, value] of Object.entries(addHeaders)) { - email.headers[key] = value; - } - } - - // Add standard forwarding headers - email.headers['X-Forwarded-For'] = context.session.remoteAddress || 'unknown'; - email.headers['X-Forwarded-To'] = email.to.join(', '); - email.headers['X-Forwarded-Date'] = new Date().toISOString(); - - try { - // Send email via Rust SMTP client - await this.sendOutboundEmail(host, port, email, { - auth: auth as { user: string; pass: string } | undefined, - }); - logger.log('info', `Successfully forwarded email to ${host}:${port}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_FORWARDING, - message: 'Email forwarded successfully', - ipAddress: context.session.remoteAddress, - details: { - sessionId: context.session.id, - routeName: context.session.matchedRoute?.name, - targetHost: host, - targetPort: port, - recipients: email.to - }, - success: true - }); - } catch (error) { - logger.log('error', `Failed to forward email: ${error.message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.EMAIL_FORWARDING, - message: 'Email forwarding failed', - ipAddress: context.session.remoteAddress, - details: { - sessionId: context.session.id, - routeName: context.session.matchedRoute?.name, - targetHost: host, - targetPort: port, - error: error.message - }, - success: false - }); - - // Handle as bounce - for (const recipient of email.getAllRecipients()) { - await this.bounceManager.processSmtpFailure(recipient, error.message, { - sender: email.from, - originalEmailId: email.headers['Message-ID'] as string - }); - } - throw error; - } - } - - /** - * Handle process action - */ - private async handleProcessAction(action: IEmailAction, email: Email, context: IEmailContext): Promise { - logger.log('info', `Processing email with action options`); - - // Apply scanning if requested - if (action.process?.scan) { - // Use existing content scanner - // Note: ContentScanner integration would go here - logger.log('info', 'Content scanning requested'); - } - - // Note: DKIM signing will be applied at delivery time to ensure signature validity - - // Queue for delivery - const queue = action.process?.queue || 'normal'; - await this.deliveryQueue.enqueue(email, 'process', context.session.matchedRoute!); - - logger.log('info', `Email queued for delivery in ${queue} queue`); - } - - /** - * Handle deliver action - */ - private async handleDeliverAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise { - logger.log('info', `Delivering email locally`); - - // Queue for local delivery - await this.deliveryQueue.enqueue(email, 'mta', context.session.matchedRoute!); - - logger.log('info', 'Email queued for local delivery'); - } - - /** - * Handle reject action - */ - private async handleRejectAction(action: IEmailAction, email: Email, context: IEmailContext): Promise { - const code = action.reject?.code || 550; - const message = action.reject?.message || 'Message rejected'; - - logger.log('info', `Rejecting email with code ${code}: ${message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.WARN, - type: SecurityEventType.EMAIL_PROCESSING, - message: 'Email rejected by routing rule', - ipAddress: context.session.remoteAddress, - details: { - sessionId: context.session.id, - routeName: context.session.matchedRoute?.name, - rejectCode: code, - rejectMessage: message, - from: email.from, - to: email.to - }, - success: false - }); - - // Throw error with SMTP code and message - const error = new Error(message); - (error as any).responseCode = code; - throw error; - } - - /** - * Set up DKIM configuration for all domains - */ - private async setupDkimForDomains(): Promise { - const domainConfigs = this.domainRegistry.getAllConfigs(); - - if (domainConfigs.length === 0) { - logger.log('warn', 'No domains configured for DKIM'); - return; - } - - for (const domainConfig of domainConfigs) { - const domain = domainConfig.domain; - const selector = domainConfig.dkim?.selector || 'default'; - - try { - // Check if DKIM keys already exist for this domain - let keyPair: { privateKey: string; publicKey: string }; - - try { - // Try to read existing keys - keyPair = await this.dkimCreator.readDKIMKeys(domain); - logger.log('info', `Using existing DKIM keys for domain: ${domain}`); - } catch (error) { - // Generate new keys if they don't exist - keyPair = await this.dkimCreator.createDKIMKeys(); - // Store them for future use - await this.dkimCreator.createAndStoreDKIMKeys(domain); - logger.log('info', `Generated new DKIM keys for domain: ${domain}`); - } - - // Store the private key for signing - this.dkimKeys.set(domain, keyPair.privateKey); - - // DNS record creation is now handled by DnsManager - logger.log('info', `DKIM keys loaded for domain: ${domain} with selector: ${selector}`); - } catch (error) { - logger.log('error', `Failed to set up DKIM for domain ${domain}: ${error.message}`); - } - } - } - - /** * Apply per-domain rate limits from domain configurations */ private applyDomainRateLimits(): void { const domainConfigs = this.domainRegistry.getAllConfigs(); - + for (const domainConfig of domainConfigs) { if (domainConfig.rateLimits) { const domain = domainConfig.domain; const rateLimitConfig: any = {}; - - // Convert domain-specific rate limits to the format expected by UnifiedRateLimiter + if (domainConfig.rateLimits.outbound) { if (domainConfig.rateLimits.outbound.messagesPerMinute) { rateLimitConfig.maxMessagesPerMinute = domainConfig.rateLimits.outbound.messagesPerMinute; } - // Note: messagesPerHour and messagesPerDay would need additional implementation in rate limiter } - + if (domainConfig.rateLimits.inbound) { if (domainConfig.rateLimits.inbound.messagesPerMinute) { rateLimitConfig.maxMessagesPerMinute = domainConfig.rateLimits.inbound.messagesPerMinute; @@ -1016,8 +808,7 @@ export class UnifiedEmailServer extends EventEmitter { rateLimitConfig.maxRecipientsPerMessage = domainConfig.rateLimits.inbound.recipientsPerMessage; } } - - // Apply the rate limits if we have any + if (Object.keys(rateLimitConfig).length > 0) { this.rateLimiter.applyDomainLimits(domain, rateLimitConfig); logger.log('info', `Applied rate limits for domain ${domain}:`, rateLimitConfig); @@ -1025,89 +816,7 @@ export class UnifiedEmailServer extends EventEmitter { } } } - - /** - * Check and rotate DKIM keys if needed - */ - private async checkAndRotateDkimKeys(): Promise { - const domainConfigs = this.domainRegistry.getAllConfigs(); - - for (const domainConfig of domainConfigs) { - const domain = domainConfig.domain; - const selector = domainConfig.dkim?.selector || 'default'; - const rotateKeys = domainConfig.dkim?.rotateKeys || false; - const rotationInterval = domainConfig.dkim?.rotationInterval || 90; - const keySize = domainConfig.dkim?.keySize || 2048; - - if (!rotateKeys) { - logger.log('debug', `DKIM key rotation disabled for ${domain}`); - continue; - } - - try { - // Check if keys need rotation - const needsRotation = await this.dkimCreator.needsRotation(domain, selector, rotationInterval); - - if (needsRotation) { - logger.log('info', `DKIM keys need rotation for ${domain} (selector: ${selector})`); - - // Rotate the keys - const newSelector = await this.dkimCreator.rotateDkimKeys(domain, selector, keySize); - - // Update the domain config with new selector - domainConfig.dkim = { - ...domainConfig.dkim, - selector: newSelector - }; - - // Re-register DNS handler for new selector if internal-dns mode - if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) { - // Get new public key - const keyPair = await this.dkimCreator.readDKIMKeysForSelector(domain, newSelector); - const publicKeyBase64 = keyPair.publicKey - .replace(/-----BEGIN PUBLIC KEY-----/g, '') - .replace(/-----END PUBLIC KEY-----/g, '') - .replace(/\s/g, ''); - - const ttl = domainConfig.dns?.internal?.ttl || 3600; - - // Register new selector - this.dcRouter.dnsServer.registerHandler( - `${newSelector}._domainkey.${domain}`, - ['TXT'], - () => ({ - name: `${newSelector}._domainkey.${domain}`, - type: 'TXT', - class: 'IN', - ttl: ttl, - data: `v=DKIM1; k=rsa; p=${publicKeyBase64}` - }) - ); - - logger.log('info', `DKIM DNS handler registered for new selector: ${newSelector}._domainkey.${domain}`); - - // Store the updated public key in storage - await this.dcRouter.storageManager.set( - `/email/dkim/${domain}/public.key`, - keyPair.publicKey - ); - } - - // Clean up old keys after grace period (async, don't wait) - this.dkimCreator.cleanupOldKeys(domain, 30).catch(error => { - logger.log('warn', `Failed to cleanup old DKIM keys for ${domain}: ${error.message}`); - }); - - } else { - logger.log('debug', `DKIM keys for ${domain} are up to date`); - } - } catch (error) { - logger.log('error', `Failed to check/rotate DKIM keys for ${domain}: ${error.message}`); - } - } - } - - + /** * Generate SmartProxy routes for email ports */ @@ -1118,34 +827,32 @@ export class UnifiedEmailServer extends EventEmitter { 587: 10587, 465: 10465 }; - + const actualPortMapping = portMapping || defaultPortMapping; - - // Generate routes for each configured port + for (const externalPort of this.options.ports) { const internalPort = actualPortMapping[externalPort] || externalPort + 10000; - + let routeName = 'email-route'; let tlsMode = 'passthrough'; - - // Configure based on port + switch (externalPort) { case 25: routeName = 'smtp-route'; - tlsMode = 'passthrough'; // STARTTLS + tlsMode = 'passthrough'; break; case 587: routeName = 'submission-route'; - tlsMode = 'passthrough'; // STARTTLS + tlsMode = 'passthrough'; break; case 465: routeName = 'smtps-route'; - tlsMode = 'terminate'; // Implicit TLS + tlsMode = 'terminate'; break; default: routeName = `email-port-${externalPort}-route`; } - + routes.push({ name: routeName, match: { @@ -1163,40 +870,36 @@ export class UnifiedEmailServer extends EventEmitter { } }); } - + return routes; } - + /** * Update server configuration */ public updateOptions(options: Partial): void { - // Stop the server if changing ports - const portsChanged = options.ports && - (!this.options.ports || + const portsChanged = options.ports && + (!this.options.ports || JSON.stringify(options.ports) !== JSON.stringify(this.options.ports)); - + if (portsChanged) { this.stop().then(() => { this.options = { ...this.options, ...options }; this.start(); }); } else { - // Update options without restart this.options = { ...this.options, ...options }; - - // Update domain registry if domains changed + if (options.domains) { this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults); } - - // Update email router if routes changed + if (options.routes) { this.emailRouter.updateRoutes(options.routes); } } } - + /** * Update email routes */ @@ -1204,41 +907,28 @@ export class UnifiedEmailServer extends EventEmitter { this.options.routes = routes; this.emailRouter.updateRoutes(routes); } - + /** * Get server statistics */ public getStats(): IServerStats { return { ...this.stats }; } - + /** * Get domain registry */ public getDomainRegistry(): DomainRegistry { return this.domainRegistry; } - - /** - * Update email routes dynamically - */ - public updateRoutes(routes: IEmailRoute[]): void { - this.emailRouter.setRoutes(routes); - logger.log('info', `Updated email routes with ${routes.length} routes`); - } - + /** * Send an email through the delivery system - * @param email The email to send - * @param mode The processing mode to use - * @param rule Optional rule to apply - * @param options Optional sending options - * @returns The ID of the queued email */ public async sendEmail( - email: Email, - mode: EmailProcessingMode = 'mta', - route?: IEmailRoute, + email: Email, + mode: EmailProcessingMode = 'mta', + route?: IEmailRoute, options?: { skipSuppressionCheck?: boolean; ipAddress?: string; @@ -1246,23 +936,21 @@ export class UnifiedEmailServer extends EventEmitter { } ): Promise { logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`); - + try { - // Validate the email if (!email.from) { throw new Error('Email must have a sender address'); } - + if (!email.to || email.to.length === 0) { throw new Error('Email must have at least one recipient'); } - - // Check if any recipients are on the suppression list (unless explicitly skipped) + + // Check if any recipients are on the suppression list if (!options?.skipSuppressionCheck) { const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient)); - + if (suppressedRecipients.length > 0) { - // Filter out suppressed recipients const originalCount = email.to.length; const suppressed = suppressedRecipients.map(recipient => { const info = this.getSuppressionInfo(recipient); @@ -1272,31 +960,26 @@ export class UnifiedEmailServer extends EventEmitter { until: info?.expiresAt ? new Date(info.expiresAt).toISOString() : 'permanent' }; }); - + logger.log('warn', `Filtering out ${suppressedRecipients.length} suppressed recipient(s)`, { suppressed }); - - // If all recipients are suppressed, throw an error + if (suppressedRecipients.length === originalCount) { throw new Error('All recipients are on the suppression list'); } - - // Filter the recipients list to only include non-suppressed addresses + email.to = email.to.filter(recipient => !this.isEmailSuppressed(recipient)); } } - - // Check if the sender domain has DKIM keys and sign the email if needed + + // Sign with DKIM if configured if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) { const domain = email.from.split('@')[1]; - await this.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'mta'); + await this.dkimManager.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'mta'); } - - // Generate a unique ID for this email + const id = plugins.uuid.v4(); - - // Queue the email for delivery await this.deliveryQueue.enqueue(email, mode, route); - + logger.log('info', `Email queued with ID: ${id}`); return id; } catch (error) { @@ -1304,64 +987,25 @@ export class UnifiedEmailServer extends EventEmitter { throw error; } } - - /** - * Handle DKIM signing for an email - * @param email The email to sign - * @param domain The domain to sign with - * @param selector The DKIM selector - */ - private async handleDkimSigning(email: Email, domain: string, selector: string): Promise { - try { - // Ensure we have DKIM keys for this domain - await this.dkimCreator.handleDKIMKeysForDomain(domain); - // Get the private key - const { privateKey } = await this.dkimCreator.readDKIMKeys(domain); + // ----------------------------------------------------------------------- + // Bounce/suppression methods + // ----------------------------------------------------------------------- - // Convert Email to raw format for signing - const rawEmail = email.toRFC822String(); - - // Sign the email via Rust bridge - const signResult = await this.rustBridge.signDkim({ - rawMessage: rawEmail, - domain, - selector, - privateKey, - }); - - if (signResult.header) { - email.addHeader('DKIM-Signature', signResult.header); - logger.log('info', `Successfully added DKIM signature for ${domain}`); - } - } catch (error) { - logger.log('error', `Failed to sign email with DKIM: ${error.message}`); - // Continue without DKIM rather than failing the send - } - } - - /** - * Process a bounce notification email - * @param bounceEmail The email containing bounce notification information - * @returns Processed bounce record or null if not a bounce - */ public async processBounceNotification(bounceEmail: Email): Promise { logger.log('info', 'Processing potential bounce notification email'); - + try { - // Process as a bounce notification (no conversion needed anymore) const bounceRecord = await this.bounceManager.processBounceEmail(bounceEmail); - + if (bounceRecord) { logger.log('info', `Successfully processed bounce notification for ${bounceRecord.recipient}`, { bounceType: bounceRecord.bounceType, bounceCategory: bounceRecord.bounceCategory }); - - // Notify any registered listeners about the bounce + this.emit('bounceProcessed', bounceRecord); - - // Log security event + SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.INFO, type: SecurityEventType.EMAIL_VALIDATION, @@ -1374,7 +1018,7 @@ export class UnifiedEmailServer extends EventEmitter { }, success: true }); - + return true; } else { logger.log('info', 'Email not recognized as a bounce notification'); @@ -1382,57 +1026,35 @@ export class UnifiedEmailServer extends EventEmitter { } } catch (error) { logger.log('error', `Error processing bounce notification: ${error.message}`); - + SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.ERROR, type: SecurityEventType.EMAIL_VALIDATION, message: 'Failed to process bounce notification', - details: { - error: error.message, - subject: bounceEmail.subject - }, + details: { error: error.message, subject: bounceEmail.subject }, success: false }); - + return false; } } - - /** - * Process an SMTP failure as a bounce - * @param recipient Recipient email that failed - * @param smtpResponse SMTP error response - * @param options Additional options for bounce processing - * @returns Processed bounce record - */ + public async processSmtpFailure( recipient: string, smtpResponse: string, - options: { - sender?: string; - originalEmailId?: string; - statusCode?: string; - headers?: Record; - } = {} + options: { sender?: string; originalEmailId?: string; statusCode?: string; headers?: Record } = {} ): Promise { logger.log('info', `Processing SMTP failure for ${recipient}: ${smtpResponse}`); - + try { - // Process the SMTP failure through the bounce manager - const bounceRecord = await this.bounceManager.processSmtpFailure( - recipient, - smtpResponse, - options - ); - + const bounceRecord = await this.bounceManager.processSmtpFailure(recipient, smtpResponse, options); + logger.log('info', `Successfully processed SMTP failure for ${recipient} as ${bounceRecord.bounceCategory} bounce`, { bounceType: bounceRecord.bounceType }); - - // Notify any registered listeners about the bounce + this.emit('bounceProcessed', bounceRecord); - - // Log security event + SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.INFO, type: SecurityEventType.EMAIL_VALIDATION, @@ -1446,106 +1068,53 @@ export class UnifiedEmailServer extends EventEmitter { }, success: true }); - + return true; } catch (error) { logger.log('error', `Error processing SMTP failure: ${error.message}`); - + SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, + level: SecurityLogLevel.ERROR, type: SecurityEventType.EMAIL_VALIDATION, message: 'Failed to process SMTP failure', - details: { - recipient, - smtpResponse, - error: error.message - }, + details: { recipient, smtpResponse, error: error.message }, success: false }); - + return false; } } - - /** - * Check if an email address is suppressed (has bounced previously) - * @param email Email address to check - * @returns Whether the email is suppressed - */ + public isEmailSuppressed(email: string): boolean { return this.bounceManager.isEmailSuppressed(email); } - - /** - * Get suppression information for an email - * @param email Email address to check - * @returns Suppression information or null if not suppressed - */ - public getSuppressionInfo(email: string): { - reason: string; - timestamp: number; - expiresAt?: number; - } | null { + + public getSuppressionInfo(email: string): { reason: string; timestamp: number; expiresAt?: number } | null { return this.bounceManager.getSuppressionInfo(email); } - - /** - * Get bounce history information for an email - * @param email Email address to check - * @returns Bounce history or null if no bounces - */ - public getBounceHistory(email: string): { - lastBounce: number; - count: number; - type: BounceType; - category: BounceCategory; - } | null { + + public getBounceHistory(email: string): { lastBounce: number; count: number; type: BounceType; category: BounceCategory } | null { return this.bounceManager.getBounceInfo(email); } - - /** - * Get all suppressed email addresses - * @returns Array of suppressed email addresses - */ + public getSuppressionList(): string[] { return this.bounceManager.getSuppressionList(); } - - /** - * Get all hard bounced email addresses - * @returns Array of hard bounced email addresses - */ + public getHardBouncedAddresses(): string[] { return this.bounceManager.getHardBouncedAddresses(); } - - /** - * Add an email to the suppression list - * @param email Email address to suppress - * @param reason Reason for suppression - * @param expiresAt Optional expiration time (undefined for permanent) - */ + public addToSuppressionList(email: string, reason: string, expiresAt?: number): void { this.bounceManager.addToSuppressionList(email, reason, expiresAt); logger.log('info', `Added ${email} to suppression list: ${reason}`); } - - /** - * Remove an email from the suppression list - * @param email Email address to remove from suppression - */ + public removeFromSuppressionList(email: string): void { this.bounceManager.removeFromSuppressionList(email); logger.log('info', `Removed ${email} from suppression list`); } - - /** - * Record email bounce - * @param domain Sending domain - * @param receivingDomain Receiving domain that bounced - * @param bounceType Type of bounce (hard/soft) - * @param reason Bounce reason - */ + public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void { const bounceRecord = { id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, @@ -1563,12 +1132,11 @@ export class UnifiedEmailServer extends EventEmitter { this.bounceManager.processBounce(bounceRecord); } - + /** * Get the rate limiter instance - * @returns The unified rate limiter */ public getRateLimiter(): UnifiedRateLimiter { return this.rateLimiter; } -} \ No newline at end of file +} diff --git a/ts/mail/routing/index.ts b/ts/mail/routing/index.ts index 55c2760..9db7381 100644 --- a/ts/mail/routing/index.ts +++ b/ts/mail/routing/index.ts @@ -3,4 +3,7 @@ export * from './classes.email.router.js'; export * from './classes.unified.email.server.js'; export * from './classes.dns.manager.js'; export * from './interfaces.js'; -export * from './classes.domain.registry.js'; \ No newline at end of file +export * from './classes.domain.registry.js'; +export * from './classes.email.action.executor.js'; +export * from './classes.dkim.manager.js'; + diff --git a/ts/mail/security/classes.dkimverifier.ts b/ts/mail/security/classes.dkimverifier.ts deleted file mode 100644 index f2e9e3d..0000000 --- a/ts/mail/security/classes.dkimverifier.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { logger } from '../../logger.js'; -import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js'; -import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js'; - -/** - * Result of a DKIM verification - */ -export interface IDkimVerificationResult { - isValid: boolean; - domain?: string; - selector?: string; - status?: string; - details?: any; - errorMessage?: string; - signatureFields?: Record; -} - -/** - * DKIM verifier — delegates to the Rust security bridge. - */ -export class DKIMVerifier { - constructor() {} - - /** - * Verify DKIM signature for an email via Rust bridge - */ - public async verify( - emailData: string, - options: { - useCache?: boolean; - returnDetails?: boolean; - } = {} - ): Promise { - try { - const bridge = RustSecurityBridge.getInstance(); - const results = await bridge.verifyDkim(emailData); - const first = results[0]; - - const result: IDkimVerificationResult = { - isValid: first?.is_valid ?? false, - domain: first?.domain ?? undefined, - selector: first?.selector ?? undefined, - status: first?.status ?? 'none', - details: options.returnDetails ? results : undefined, - }; - - SecurityLogger.getInstance().logEvent({ - level: result.isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN, - type: SecurityEventType.DKIM, - message: `DKIM verification ${result.isValid ? 'passed' : 'failed'} for domain ${result.domain || 'unknown'}`, - details: { selector: result.selector, status: result.status }, - domain: result.domain || 'unknown', - success: result.isValid - }); - - logger.log(result.isValid ? 'info' : 'warn', - `DKIM verification: ${result.status} for domain ${result.domain || 'unknown'}`); - - return result; - } catch (error) { - logger.log('error', `DKIM verification failed: ${error.message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.DKIM, - message: `DKIM verification error`, - details: { error: error.message }, - success: false - }); - - return { - isValid: false, - status: 'temperror', - errorMessage: `Verification error: ${error.message}` - }; - } - } - - /** No-op — Rust bridge handles its own caching */ - public clearCache(): void {} - - /** Always 0 — cache is managed by the Rust side */ - public getCacheSize(): number { - return 0; - } -} diff --git a/ts/mail/security/classes.dmarcverifier.ts b/ts/mail/security/classes.dmarcverifier.ts deleted file mode 100644 index 7782fa1..0000000 --- a/ts/mail/security/classes.dmarcverifier.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { logger } from '../../logger.js'; -import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js'; -import type { Email } from '../core/classes.email.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 { - // DNS Manager reference for verifying records - private dnsManager?: any; - - constructor(dnsManager?: any) { - this.dnsManager = dnsManager; - } - - /** - * 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 " - 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 { - 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 = this.dnsManager ? - await this.dnsManager.verifyDmarcRecord(fromDomain) : - { found: false, valid: false, error: 'DNS Manager not available' }; - - // 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 { - // Verify DMARC - const dmarcResult = await this.verify(email, spfResult, dkimResult); - - // Apply DMARC policy - return this.applyPolicy(email, dmarcResult); - } -} \ No newline at end of file diff --git a/ts/mail/security/classes.spfverifier.ts b/ts/mail/security/classes.spfverifier.ts index 011de37..8f0b257 100644 --- a/ts/mail/security/classes.spfverifier.ts +++ b/ts/mail/security/classes.spfverifier.ts @@ -1,8 +1,4 @@ -import * as plugins from '../../plugins.js'; import { logger } from '../../logger.js'; -import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js'; -import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js'; -import type { Email } from '../core/classes.email.js'; /** * SPF result qualifiers @@ -127,107 +123,4 @@ export class SpfVerifier { return null; } } - - /** - * Verify SPF for a given email — delegates to Rust bridge - */ - public async verify( - email: Email, - ip: string, - heloDomain: string - ): Promise { - const securityLogger = SecurityLogger.getInstance(); - const mailFrom = email.from || ''; - const domain = mailFrom.split('@')[1] || ''; - - try { - const bridge = RustSecurityBridge.getInstance(); - const result = await bridge.checkSpf({ - ip, - heloDomain, - hostname: plugins.os.hostname(), - mailFrom, - }); - - const spfResult: SpfResult = { - result: result.result as SpfResult['result'], - domain: result.domain, - ip: result.ip, - explanation: result.explanation ?? undefined, - }; - - securityLogger.logEvent({ - level: spfResult.result === 'pass' ? SecurityLogLevel.INFO : - (spfResult.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO), - type: SecurityEventType.SPF, - message: `SPF ${spfResult.result} for ${spfResult.domain} from IP ${ip}`, - domain: spfResult.domain, - details: { ip, heloDomain, result: spfResult.result, explanation: spfResult.explanation }, - success: spfResult.result === 'pass' - }); - - return spfResult; - } catch (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 if email passes SPF verification and apply headers - */ - public async verifyAndApply( - email: Email, - ip: string, - heloDomain: string - ): Promise { - const result = await this.verify(email, ip, heloDomain); - - email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation || ''}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`; - - switch (result.result) { - case 'fail': - email.mightBeSpam = true; - logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`); - return false; - - case 'softfail': - email.mightBeSpam = true; - logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`); - return true; - - case 'neutral': - case 'none': - logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`); - return true; - - case 'pass': - logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`); - return true; - - case 'temperror': - case 'permerror': - logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`); - return true; - - default: - return true; - } - } } diff --git a/ts/mail/security/index.ts b/ts/mail/security/index.ts index a6dcee8..5398d61 100644 --- a/ts/mail/security/index.ts +++ b/ts/mail/security/index.ts @@ -1,5 +1,3 @@ // Email security components export * from './classes.dkimcreator.js'; -export * from './classes.dkimverifier.js'; -export * from './classes.dmarcverifier.js'; export * from './classes.spfverifier.js'; \ No newline at end of file