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

This commit is contained in:
2026-02-11 07:55:28 +00:00
parent cf8fcb6efa
commit 526dcb4dac
15 changed files with 619 additions and 1870 deletions

View File

@@ -1,5 +1,17 @@
# Changelog # 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) ## 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 clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README

View File

@@ -1,10 +1,8 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js'; 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 // SPF Verifier Tests
@@ -41,153 +39,6 @@ tap.test('SPF Verifier - should parse SPF record', async () => {
expect(invalidParsed).toBeNull(); 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 () => { tap.test('stop', async () => {
await tap.stopForcefully(); await tap.stopForcefully();
}); });

View File

@@ -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();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartmta', 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.' description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
} }

View File

@@ -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;
}
}
}

View File

@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { logger } from '../../logger.js'; 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'; import type { IEmailRoute } from '../routing/interfaces.js';
/** /**

View File

@@ -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<string, string> = new Map();
constructor(
private dkimCreator: DKIMCreator,
private domainRegistry: DomainRegistry,
private dcRouter: DcRouter,
private rustBridge: RustSecurityBridge,
) {}
async setupDkimForDomains(): Promise<void> {
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<void> {
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<void> {
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);
}
}

View File

@@ -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<ISmtpSendResult>;
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;
}
}

View File

@@ -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;
}

View File

@@ -8,7 +8,6 @@ import {
SecurityEventType SecurityEventType
} from '../../security/index.js'; } from '../../security/index.js';
import { DKIMCreator } from '../security/classes.dkimcreator.js'; import { DKIMCreator } from '../security/classes.dkimcreator.js';
import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js'; import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js'; import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js';
import { EmailRouter } from './classes.email.router.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 { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
import { SmtpState } from '../delivery/interfaces.js'; import { SmtpState } from '../delivery/interfaces.js';
import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } 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 */ /** External DcRouter interface shape used by UnifiedEmailServer */
interface DcRouter { interface DcRouter {
storageManager: any; storageManager: any;
@@ -160,12 +163,14 @@ export class UnifiedEmailServer extends EventEmitter {
// Add components needed for sending and securing emails // Add components needed for sending and securing emails
public dkimCreator: DKIMCreator; public dkimCreator: DKIMCreator;
private rustBridge: RustSecurityBridge; private rustBridge: RustSecurityBridge;
private ipReputationChecker: IPReputationChecker;
private bounceManager: BounceManager; private bounceManager: BounceManager;
public deliveryQueue: UnifiedDeliveryQueue; public deliveryQueue: UnifiedDeliveryQueue;
public deliverySystem: MultiModeDeliverySystem; public deliverySystem: MultiModeDeliverySystem;
private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers
private dkimKeys: Map<string, string> = new Map(); // domain -> private key
// Extracted subsystems
private actionExecutor: EmailActionExecutor;
private dkimManager: DkimManager;
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) { constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
super(); super();
@@ -188,13 +193,6 @@ export class UnifiedEmailServer extends EventEmitter {
// Initialize DKIM creator with storage manager // Initialize DKIM creator with storage manager
this.dkimCreator = new DKIMCreator(paths.keysDir, dcRouter.storageManager); 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 // Initialize bounce manager with storage manager
this.bounceManager = new BounceManager({ this.bounceManager = new BounceManager({
maxCacheSize: 10000, maxCacheSize: 10000,
@@ -247,6 +245,16 @@ export class UnifiedEmailServer extends EventEmitter {
this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this); 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 // Initialize statistics
this.stats = { this.stats = {
startTime: new Date(), startTime: new Date(),
@@ -323,70 +331,62 @@ export class UnifiedEmailServer extends EventEmitter {
logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`); logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
try { try {
// Initialize the delivery queue 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) {
logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
throw error;
}
}
private async startDeliveryPipeline(): Promise<void> {
await this.deliveryQueue.initialize(); await this.deliveryQueue.initialize();
logger.log('info', 'Email delivery queue initialized'); logger.log('info', 'Email delivery queue initialized');
// Start the delivery system
await this.deliverySystem.start(); await this.deliverySystem.start();
logger.log('info', 'Email delivery system started'); logger.log('info', 'Email delivery system started');
}
// Start Rust security bridge — required for all security operations private async startRustBridge(): Promise<void> {
const bridgeOk = await this.rustBridge.start(); const bridgeOk = await this.rustBridge.start();
if (!bridgeOk) { if (!bridgeOk) {
throw new Error('Rust security bridge failed to start. The mailer-bin binary is required. Run "pnpm build" to compile it.'); 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'); 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 }) => { this.rustBridge.on('stateChange', ({ oldState, newState }: { oldState: string; newState: string }) => {
if (newState === 'failed') this.emit('bridgeFailed'); if (newState === 'failed') this.emit('bridgeFailed');
else if (newState === 'restarting') this.emit('bridgeRestarting'); else if (newState === 'restarting') this.emit('bridgeRestarting');
else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered'); else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered');
}); });
}
// Set up DKIM for all domains private async initializeDkimAndDns(): Promise<void> {
await this.setupDkimForDomains(); await this.dkimManager.setupDkimForDomains();
logger.log('info', 'DKIM configuration completed for all domains'); 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); const dnsManager = new DnsManager(this.dcRouter);
await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator); await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator);
logger.log('info', 'DNS records ensured for all configured domains'); logger.log('info', 'DNS records ensured for all configured domains');
// Apply per-domain rate limits
this.applyDomainRateLimits(); this.applyDomainRateLimits();
logger.log('info', 'Per-domain rate limits configured'); logger.log('info', 'Per-domain rate limits configured');
// Check and rotate DKIM keys if needed await this.dkimManager.checkAndRotateDkimKeys();
await this.checkAndRotateDkimKeys();
logger.log('info', 'DKIM key rotation check completed'); 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 --- private registerBridgeEventHandlers(): void {
// Register event handlers for email reception and auth
this.rustBridge.onEmailReceived(async (data) => { this.rustBridge.onEmailReceived(async (data) => {
try { try {
await this.handleRustEmailReceived(data); await this.handleRustEmailReceived(data);
} catch (err) { } catch (err) {
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`); 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 { try {
await this.rustBridge.sendEmailProcessingResult({ await this.rustBridge.sendEmailProcessingResult({
correlationId: data.correlationId, correlationId: data.correlationId,
@@ -416,8 +416,23 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
}); });
}
private async startSmtpServer(): Promise<void> {
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}`);
}
}
// Determine which ports need STARTTLS and which need implicit TLS
const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465); const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465);
const securePort = (this.options.ports as number[]).find(p => p === 465); const securePort = (this.options.ports as number[]).find(p => p === 465);
@@ -449,12 +464,6 @@ export class UnifiedEmailServer extends EventEmitter {
} }
logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`); logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
logger.log('info', 'UnifiedEmailServer started successfully');
this.emit('started');
} catch (error) {
logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
throw error;
}
} }
/** /**
@@ -512,8 +521,6 @@ export class UnifiedEmailServer extends EventEmitter {
/** /**
* Handle an emailReceived event from the Rust SMTP server. * 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<void> { private async handleRustEmailReceived(data: IEmailReceivedEvent): Promise<void> {
const { correlationId, mailFrom, rcptTo, remoteAddr, clientHostname, secure, authenticatedUser } = data; 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. * Handle an authRequest event from the Rust SMTP server.
* Validates credentials and sends back the result.
*/ */
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> { private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
const { correlationId, username, password, remoteAddr } = data; const { correlationId, username, password, remoteAddr } = data;
@@ -740,14 +746,12 @@ export class UnifiedEmailServer extends EventEmitter {
} }
// First check if this is a bounce notification email // First check if this is a bounce notification email
// Look for common bounce notification subject patterns
const subject = email.subject || ''; const subject = email.subject || '';
const isBounceLike = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject); const isBounceLike = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject);
if (isBounceLike) { if (isBounceLike) {
logger.log('info', `Email subject matches bounce notification pattern: "${subject}"`); logger.log('info', `Email subject matches bounce notification pattern: "${subject}"`);
// Try to process as a bounce
const isBounce = await this.processBounceNotification(email); const isBounce = await this.processBounceNotification(email);
if (isBounce) { if (isBounce) {
@@ -763,7 +767,6 @@ export class UnifiedEmailServer extends EventEmitter {
const route = await this.emailRouter.evaluateRoutes(context); const route = await this.emailRouter.evaluateRoutes(context);
if (!route) { if (!route) {
// No matching route - reject
throw new Error('No matching route for email'); throw new Error('No matching route for email');
} }
@@ -771,221 +774,12 @@ export class UnifiedEmailServer extends EventEmitter {
session.matchedRoute = route; session.matchedRoute = route;
// Execute action based on 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 the processed email
return email; return email;
} }
/**
* Execute action based on route configuration
*/
private async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 * Apply per-domain rate limits from domain configurations
*/ */
@@ -997,12 +791,10 @@ export class UnifiedEmailServer extends EventEmitter {
const domain = domainConfig.domain; const domain = domainConfig.domain;
const rateLimitConfig: any = {}; const rateLimitConfig: any = {};
// Convert domain-specific rate limits to the format expected by UnifiedRateLimiter
if (domainConfig.rateLimits.outbound) { if (domainConfig.rateLimits.outbound) {
if (domainConfig.rateLimits.outbound.messagesPerMinute) { if (domainConfig.rateLimits.outbound.messagesPerMinute) {
rateLimitConfig.maxMessagesPerMinute = 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) {
@@ -1017,7 +809,6 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
// Apply the rate limits if we have any
if (Object.keys(rateLimitConfig).length > 0) { if (Object.keys(rateLimitConfig).length > 0) {
this.rateLimiter.applyDomainLimits(domain, rateLimitConfig); this.rateLimiter.applyDomainLimits(domain, rateLimitConfig);
logger.log('info', `Applied rate limits for domain ${domain}:`, rateLimitConfig); logger.log('info', `Applied rate limits for domain ${domain}:`, rateLimitConfig);
@@ -1026,88 +817,6 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
/**
* Check and rotate DKIM keys if needed
*/
private async checkAndRotateDkimKeys(): Promise<void> {
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 * Generate SmartProxy routes for email ports
*/ */
@@ -1121,26 +830,24 @@ export class UnifiedEmailServer extends EventEmitter {
const actualPortMapping = portMapping || defaultPortMapping; const actualPortMapping = portMapping || defaultPortMapping;
// Generate routes for each configured port
for (const externalPort of this.options.ports) { for (const externalPort of this.options.ports) {
const internalPort = actualPortMapping[externalPort] || externalPort + 10000; const internalPort = actualPortMapping[externalPort] || externalPort + 10000;
let routeName = 'email-route'; let routeName = 'email-route';
let tlsMode = 'passthrough'; let tlsMode = 'passthrough';
// Configure based on port
switch (externalPort) { switch (externalPort) {
case 25: case 25:
routeName = 'smtp-route'; routeName = 'smtp-route';
tlsMode = 'passthrough'; // STARTTLS tlsMode = 'passthrough';
break; break;
case 587: case 587:
routeName = 'submission-route'; routeName = 'submission-route';
tlsMode = 'passthrough'; // STARTTLS tlsMode = 'passthrough';
break; break;
case 465: case 465:
routeName = 'smtps-route'; routeName = 'smtps-route';
tlsMode = 'terminate'; // Implicit TLS tlsMode = 'terminate';
break; break;
default: default:
routeName = `email-port-${externalPort}-route`; routeName = `email-port-${externalPort}-route`;
@@ -1171,7 +878,6 @@ export class UnifiedEmailServer extends EventEmitter {
* Update server configuration * Update server configuration
*/ */
public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void { public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void {
// Stop the server if changing ports
const portsChanged = options.ports && const portsChanged = options.ports &&
(!this.options.ports || (!this.options.ports ||
JSON.stringify(options.ports) !== JSON.stringify(this.options.ports)); JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
@@ -1182,15 +888,12 @@ export class UnifiedEmailServer extends EventEmitter {
this.start(); this.start();
}); });
} else { } else {
// Update options without restart
this.options = { ...this.options, ...options }; this.options = { ...this.options, ...options };
// Update domain registry if domains changed
if (options.domains) { if (options.domains) {
this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults); this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults);
} }
// Update email router if routes changed
if (options.routes) { if (options.routes) {
this.emailRouter.updateRoutes(options.routes); this.emailRouter.updateRoutes(options.routes);
} }
@@ -1219,21 +922,8 @@ export class UnifiedEmailServer extends EventEmitter {
return this.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 * 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( public async sendEmail(
email: Email, email: Email,
@@ -1248,7 +938,6 @@ export class UnifiedEmailServer extends EventEmitter {
logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`); logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`);
try { try {
// Validate the email
if (!email.from) { if (!email.from) {
throw new Error('Email must have a sender address'); throw new Error('Email must have a sender address');
} }
@@ -1257,12 +946,11 @@ export class UnifiedEmailServer extends EventEmitter {
throw new Error('Email must have at least one recipient'); 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) { if (!options?.skipSuppressionCheck) {
const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient)); const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient));
if (suppressedRecipients.length > 0) { if (suppressedRecipients.length > 0) {
// Filter out suppressed recipients
const originalCount = email.to.length; const originalCount = email.to.length;
const suppressed = suppressedRecipients.map(recipient => { const suppressed = suppressedRecipients.map(recipient => {
const info = this.getSuppressionInfo(recipient); const info = this.getSuppressionInfo(recipient);
@@ -1275,26 +963,21 @@ export class UnifiedEmailServer extends EventEmitter {
logger.log('warn', `Filtering out ${suppressedRecipients.length} suppressed recipient(s)`, { suppressed }); logger.log('warn', `Filtering out ${suppressedRecipients.length} suppressed recipient(s)`, { suppressed });
// If all recipients are suppressed, throw an error
if (suppressedRecipients.length === originalCount) { if (suppressedRecipients.length === originalCount) {
throw new Error('All recipients are on the suppression list'); 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)); 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) { if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) {
const domain = email.from.split('@')[1]; 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(); const id = plugins.uuid.v4();
// Queue the email for delivery
await this.deliveryQueue.enqueue(email, mode, route); await this.deliveryQueue.enqueue(email, mode, route);
logger.log('info', `Email queued with ID: ${id}`); logger.log('info', `Email queued with ID: ${id}`);
@@ -1305,51 +988,14 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
/** // -----------------------------------------------------------------------
* Handle DKIM signing for an email // Bounce/suppression methods
* @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<void> {
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);
// 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<boolean> { public async processBounceNotification(bounceEmail: Email): Promise<boolean> {
logger.log('info', 'Processing potential bounce notification email'); logger.log('info', 'Processing potential bounce notification email');
try { try {
// Process as a bounce notification (no conversion needed anymore)
const bounceRecord = await this.bounceManager.processBounceEmail(bounceEmail); const bounceRecord = await this.bounceManager.processBounceEmail(bounceEmail);
if (bounceRecord) { if (bounceRecord) {
@@ -1358,10 +1004,8 @@ export class UnifiedEmailServer extends EventEmitter {
bounceCategory: bounceRecord.bounceCategory bounceCategory: bounceRecord.bounceCategory
}); });
// Notify any registered listeners about the bounce
this.emit('bounceProcessed', bounceRecord); this.emit('bounceProcessed', bounceRecord);
// Log security event
SecurityLogger.getInstance().logEvent({ SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO, level: SecurityLogLevel.INFO,
type: SecurityEventType.EMAIL_VALIDATION, type: SecurityEventType.EMAIL_VALIDATION,
@@ -1387,10 +1031,7 @@ export class UnifiedEmailServer extends EventEmitter {
level: SecurityLogLevel.ERROR, level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_VALIDATION, type: SecurityEventType.EMAIL_VALIDATION,
message: 'Failed to process bounce notification', message: 'Failed to process bounce notification',
details: { details: { error: error.message, subject: bounceEmail.subject },
error: error.message,
subject: bounceEmail.subject
},
success: false success: false
}); });
@@ -1398,41 +1039,22 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
/**
* 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( public async processSmtpFailure(
recipient: string, recipient: string,
smtpResponse: string, smtpResponse: string,
options: { options: { sender?: string; originalEmailId?: string; statusCode?: string; headers?: Record<string, string> } = {}
sender?: string;
originalEmailId?: string;
statusCode?: string;
headers?: Record<string, string>;
} = {}
): Promise<boolean> { ): Promise<boolean> {
logger.log('info', `Processing SMTP failure for ${recipient}: ${smtpResponse}`); logger.log('info', `Processing SMTP failure for ${recipient}: ${smtpResponse}`);
try { 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`, { logger.log('info', `Successfully processed SMTP failure for ${recipient} as ${bounceRecord.bounceCategory} bounce`, {
bounceType: bounceRecord.bounceType bounceType: bounceRecord.bounceType
}); });
// Notify any registered listeners about the bounce
this.emit('bounceProcessed', bounceRecord); this.emit('bounceProcessed', bounceRecord);
// Log security event
SecurityLogger.getInstance().logEvent({ SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO, level: SecurityLogLevel.INFO,
type: SecurityEventType.EMAIL_VALIDATION, type: SecurityEventType.EMAIL_VALIDATION,
@@ -1455,11 +1077,7 @@ export class UnifiedEmailServer extends EventEmitter {
level: SecurityLogLevel.ERROR, level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_VALIDATION, type: SecurityEventType.EMAIL_VALIDATION,
message: 'Failed to process SMTP failure', message: 'Failed to process SMTP failure',
details: { details: { recipient, smtpResponse, error: error.message },
recipient,
smtpResponse,
error: error.message
},
success: false success: false
}); });
@@ -1467,85 +1085,36 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
/**
* 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 { public isEmailSuppressed(email: string): boolean {
return this.bounceManager.isEmailSuppressed(email); return this.bounceManager.isEmailSuppressed(email);
} }
/** public getSuppressionInfo(email: string): { reason: string; timestamp: number; expiresAt?: number } | null {
* 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 {
return this.bounceManager.getSuppressionInfo(email); return this.bounceManager.getSuppressionInfo(email);
} }
/** public getBounceHistory(email: string): { lastBounce: number; count: number; type: BounceType; category: BounceCategory } | null {
* 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 {
return this.bounceManager.getBounceInfo(email); return this.bounceManager.getBounceInfo(email);
} }
/**
* Get all suppressed email addresses
* @returns Array of suppressed email addresses
*/
public getSuppressionList(): string[] { public getSuppressionList(): string[] {
return this.bounceManager.getSuppressionList(); return this.bounceManager.getSuppressionList();
} }
/**
* Get all hard bounced email addresses
* @returns Array of hard bounced email addresses
*/
public getHardBouncedAddresses(): string[] { public getHardBouncedAddresses(): string[] {
return this.bounceManager.getHardBouncedAddresses(); 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 { public addToSuppressionList(email: string, reason: string, expiresAt?: number): void {
this.bounceManager.addToSuppressionList(email, reason, expiresAt); this.bounceManager.addToSuppressionList(email, reason, expiresAt);
logger.log('info', `Added ${email} to suppression list: ${reason}`); 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 { public removeFromSuppressionList(email: string): void {
this.bounceManager.removeFromSuppressionList(email); this.bounceManager.removeFromSuppressionList(email);
logger.log('info', `Removed ${email} from suppression list`); 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 { public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void {
const bounceRecord = { const bounceRecord = {
id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
@@ -1566,7 +1135,6 @@ export class UnifiedEmailServer extends EventEmitter {
/** /**
* Get the rate limiter instance * Get the rate limiter instance
* @returns The unified rate limiter
*/ */
public getRateLimiter(): UnifiedRateLimiter { public getRateLimiter(): UnifiedRateLimiter {
return this.rateLimiter; return this.rateLimiter;

View File

@@ -4,3 +4,6 @@ export * from './classes.unified.email.server.js';
export * from './classes.dns.manager.js'; export * from './classes.dns.manager.js';
export * from './interfaces.js'; export * from './interfaces.js';
export * from './classes.domain.registry.js'; export * from './classes.domain.registry.js';
export * from './classes.email.action.executor.js';
export * from './classes.dkim.manager.js';

View File

@@ -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<string, string>;
}
/**
* 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<IDkimVerificationResult> {
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;
}
}

View File

@@ -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 <john@example.com>"
const matches = email.match(/<([^>]+)>/);
const address = matches ? matches[1] : email;
const parts = address.split('@');
return parts.length > 1 ? parts[1] : '';
}
/**
* Check if DMARC verification should be applied based on percentage
* @param record DMARC record
* @returns Whether DMARC verification should be applied
*/
private shouldApplyDmarc(record: DmarcRecord): boolean {
if (record.pct === undefined || record.pct === 100) {
return true;
}
// Apply DMARC randomly based on percentage
const random = Math.floor(Math.random() * 100) + 1;
return random <= record.pct;
}
/**
* Determine the action to take based on DMARC policy
* @param policy DMARC policy
* @returns Action to take
*/
private determineAction(policy: DmarcPolicy): 'pass' | 'quarantine' | 'reject' {
switch (policy) {
case DmarcPolicy.REJECT:
return 'reject';
case DmarcPolicy.QUARANTINE:
return 'quarantine';
case DmarcPolicy.NONE:
default:
return 'pass';
}
}
/**
* Verify DMARC for an incoming email
* @param email Email to verify
* @param spfResult SPF verification result
* @param dkimResult DKIM verification result
* @returns DMARC verification result
*/
public async verify(
email: Email,
spfResult: { domain: string; result: boolean },
dkimResult: { domain: string; result: boolean }
): Promise<DmarcResult> {
const securityLogger = SecurityLogger.getInstance();
// Initialize result
const result: DmarcResult = {
hasDmarc: false,
spfDomainAligned: false,
dkimDomainAligned: false,
spfPassed: spfResult.result,
dkimPassed: dkimResult.result,
policyEvaluated: DmarcPolicy.NONE,
actualPolicy: DmarcPolicy.NONE,
appliedPercentage: 100,
action: 'pass',
details: 'DMARC not configured'
};
try {
// Extract From domain
const fromHeader = email.getFromEmail();
const fromDomain = this.getDomainFromEmail(fromHeader);
if (!fromDomain) {
result.error = 'Invalid From domain';
return result;
}
// Check alignment
result.spfDomainAligned = this.isDomainAligned(
fromDomain,
spfResult.domain,
DmarcAlignment.RELAXED
);
result.dkimDomainAligned = this.isDomainAligned(
fromDomain,
dkimResult.domain,
DmarcAlignment.RELAXED
);
// Lookup DMARC record
const dmarcVerificationResult = 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<boolean> {
// Verify DMARC
const dmarcResult = await this.verify(email, spfResult, dkimResult);
// Apply DMARC policy
return this.applyPolicy(email, dmarcResult);
}
}

View File

@@ -1,8 +1,4 @@
import * as plugins from '../../plugins.js';
import { logger } from '../../logger.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 * SPF result qualifiers
@@ -127,107 +123,4 @@ export class SpfVerifier {
return null; return null;
} }
} }
/**
* Verify SPF for a given email — delegates to Rust bridge
*/
public async verify(
email: Email,
ip: string,
heloDomain: string
): Promise<SpfResult> {
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<boolean> {
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;
}
}
} }

View File

@@ -1,5 +1,3 @@
// Email security components // Email security components
export * from './classes.dkimcreator.js'; export * from './classes.dkimcreator.js';
export * from './classes.dkimverifier.js';
export * from './classes.dmarcverifier.js';
export * from './classes.spfverifier.js'; export * from './classes.spfverifier.js';