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:
153
ts/mail/routing/classes.dkim.manager.ts
Normal file
153
ts/mail/routing/classes.dkim.manager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
174
ts/mail/routing/classes.email.action.executor.ts
Normal file
174
ts/mail/routing/classes.email.action.executor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
export * from './classes.domain.registry.js';
|
||||
export * from './classes.email.action.executor.js';
|
||||
export * from './classes.dkim.manager.js';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user