Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
63781ab1bd | |||
0b155d6925 | |||
076aac27ce | |||
7f84405279 | |||
13ef31c13f | |||
5cf4c0f150 | |||
04b7552b34 | |||
1528d29b0d | |||
9d895898b1 |
46
changelog.md
46
changelog.md
@ -1,5 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-05-08 - 2.8.0 - feat(docs)
|
||||
Update documentation to include consolidated email handling and pattern‑based routing details
|
||||
|
||||
- Extended MTA section to describe the new unified email processing system with forward, MTA, and process modes
|
||||
- Updated system diagram to reflect DcRouter integration with UnifiedEmailServer, DeliveryQueue, DeliverySystem, and RateLimiter
|
||||
- Revised readme.plan.md checklists to mark completed features in core architecture, multi‑modal processing, unified queue, and DcRouter integration
|
||||
|
||||
## 2025-05-08 - 2.7.0 - feat(dcrouter)
|
||||
Implement unified email configuration with pattern‐based routing and consolidated email processing. Migrate SMTP forwarding and store‐and‐forward into a single, configuration-driven system that supports glob pattern matching in domain rules.
|
||||
|
||||
- Introduced IEmailConfig interface to consolidate MTA, forwarding, and processing settings.
|
||||
- Added pattern-based domain routing with glob patterns (e.g., '*@example.com', '*@*.example.net').
|
||||
- Reworked DcRouter integration to expose unified email handling and updated readme.plan.md and changelog.md accordingly.
|
||||
- Removed deprecated SMTP forwarding components in favor of the consolidated approach.
|
||||
|
||||
## 2025-05-08 - 2.7.0 - feat(dcrouter)
|
||||
Implement consolidated email configuration with pattern-based routing
|
||||
|
||||
- Added new pattern-based email routing with glob patterns (e.g., `*@task.vc`, `*@*.example.net`)
|
||||
- Consolidated all email functionality (MTA, forwarding, processing) under a unified `emailConfig` interface
|
||||
- Implemented domain router with pattern specificity calculation for most accurate matching
|
||||
- Removed deprecated components (SMTP forwarding, Store-and-Forward) in favor of the unified approach
|
||||
- Updated DcRouter tests to use the new consolidated email configuration pattern
|
||||
- Enhanced inline documentation with detailed interface definitions and configuration examples
|
||||
- Updated implementation plan with comprehensive component designs for the unified email system
|
||||
|
||||
## 2025-05-07 - 2.6.0 - feat(dcrouter)
|
||||
Implement integrated DcRouter with comprehensive SmartProxy configuration, enhanced SMTP processing, and robust store‐and‐forward email routing
|
||||
|
||||
- Marked completion of tasks in readme.plan.md with [x] flags for SMTP server setup, email processing pipeline, queue management, and delivery system.
|
||||
- Reworked DcRouter to use direct SmartProxy configuration, separating smtpConfig and smtpForwarding approaches.
|
||||
- Added new components for delivery queue and delivery system with persistent storage support.
|
||||
- Improved SMTP server implementation with TLS support, event handlers for connection, authentication, sender/recipient validation, and data processing.
|
||||
- Refined domain-based routing and transformation logic in EmailProcessor with metrics and logging.
|
||||
- Updated exported modules in dcrouter index to include SMTP store‐and‐forward components.
|
||||
- Enhanced inline documentation and code comments for configuration interfaces and integration details.
|
||||
|
||||
## 2025-05-07 - 2.5.0 - feat(dcrouter)
|
||||
Enhance DcRouter configuration and update documentation
|
||||
|
||||
- Added new implementation hints (readme.hints.md) and planning documentation (readme.plan.md) outlining removal of SzPlatformService dependency and improvements in SMTP forwarding, domain routing, and certificate management.
|
||||
- Introduced new interfaces: ISmtpForwardingConfig and IDomainRoutingConfig for precise SMTP and HTTP domain routing configuration.
|
||||
- Refactored DcRouter classes to support direct integration with SmartProxy and enhanced MTA functionality, including SMTP port configuration and improved TLS handling.
|
||||
- Updated supporting modules such as SmtpPortConfig and EmailDomainRouter to provide better routing and security options.
|
||||
- Enhanced test coverage across dcrouter, rate limiter, IP warmup manager, and email authentication, ensuring backward compatibility and improved quality.
|
||||
|
||||
## 2025-05-07 - 2.4.2 - fix(tests)
|
||||
Update test assertions and singleton instance references in DMARC, integration, and IP warmup manager tests
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/platformservice",
|
||||
"private": true,
|
||||
"version": "2.4.2",
|
||||
"version": "2.8.0",
|
||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
|
@ -0,0 +1,96 @@
|
||||
# Implementation Hints and Learnings
|
||||
|
||||
## SmartProxy Usage
|
||||
|
||||
### Direct Component Usage
|
||||
- Use SmartProxy components directly instead of creating your own wrappers
|
||||
- SmartProxy already includes Port80Handler and NetworkProxy functionality
|
||||
- When using SmartProxy, configure it directly rather than instantiating Port80Handler or NetworkProxy separately
|
||||
|
||||
```typescript
|
||||
// PREFERRED: Use SmartProxy with built-in ACME support
|
||||
const smartProxy = new plugins.smartproxy.SmartProxy({
|
||||
fromPort: 443,
|
||||
toPort: targetPort,
|
||||
targetIP: targetServer,
|
||||
sniEnabled: true,
|
||||
acme: {
|
||||
port: 80,
|
||||
enabled: true,
|
||||
autoRenew: true,
|
||||
useProduction: true,
|
||||
renewThresholdDays: 30,
|
||||
accountEmail: contactEmail
|
||||
},
|
||||
globalPortRanges: [{ from: 443, to: 443 }],
|
||||
domainConfigs: [/* domain configurations */]
|
||||
});
|
||||
```
|
||||
|
||||
### Certificate Management
|
||||
- SmartProxy has built-in ACME certificate management
|
||||
- Configure it in the `acme` property of SmartProxy options
|
||||
- Use `accountEmail` (not `email`) for the ACME contact email
|
||||
- SmartProxy handles both HTTP-01 challenges and certificate application automatically
|
||||
|
||||
## qenv Usage
|
||||
|
||||
### Direct Usage
|
||||
- Use qenv directly instead of creating environment variable wrappers
|
||||
- Instantiate qenv with appropriate basePath and nogitPath:
|
||||
|
||||
```typescript
|
||||
const qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
const value = await qenv.getEnvVarOnDemand('ENV_VAR_NAME');
|
||||
```
|
||||
|
||||
## TypeScript Interfaces
|
||||
|
||||
### SmartProxy Interfaces
|
||||
- Always check the interfaces from the node_modules to ensure correct property names
|
||||
- Important interfaces:
|
||||
- `ISmartProxyOptions`: Main configuration for SmartProxy
|
||||
- `IAcmeOptions`: ACME certificate configuration
|
||||
- `IDomainConfig`: Domain-specific configuration
|
||||
|
||||
### Required Properties
|
||||
- Remember to include all required properties in your interface implementations
|
||||
- For `ISmartProxyOptions`, `globalPortRanges` is required
|
||||
- For `IAcmeOptions`, use `accountEmail` for the contact email
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Structure
|
||||
- Follow the project's test structure, using `@push.rocks/tapbundle`
|
||||
- Use `expect(value).toEqual(expected)` for equality checks
|
||||
- Use `expect(value).toBeTruthy()` for boolean assertions
|
||||
|
||||
```typescript
|
||||
tap.test('test description', async () => {
|
||||
const result = someFunction();
|
||||
expect(result.property).toEqual('expected value');
|
||||
expect(result.valid).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
- Include a cleanup test to ensure proper test resource handling
|
||||
- Add a `stop` test to forcefully end the test when needed:
|
||||
|
||||
```typescript
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
### Simplicity
|
||||
- Prefer direct usage of libraries instead of creating wrappers
|
||||
- Don't reinvent functionality that already exists in dependencies
|
||||
- Keep interfaces clean and focused, avoiding unnecessary abstraction layers
|
||||
|
||||
### Component Integration
|
||||
- Leverage built-in integrations between components (like SmartProxy's ACME handling)
|
||||
- Use parallel operations for performance (like in the `stop()` method)
|
||||
- Separate concerns clearly (HTTP handling vs. SMTP handling)
|
145
readme.md
145
readme.md
@ -103,38 +103,153 @@ async function sendLetter() {
|
||||
sendLetter();
|
||||
```
|
||||
|
||||
### Mail Transfer Agent (MTA)
|
||||
### Mail Transfer Agent (MTA) and Consolidated Email Handling
|
||||
|
||||
The platform includes a robust Mail Transfer Agent (MTA) for enterprise-grade email handling with complete control over the email delivery process:
|
||||
The platform includes a robust Mail Transfer Agent (MTA) for enterprise-grade email handling with complete control over the email delivery process.
|
||||
Additionally, the platform now features a consolidated email configuration system with pattern-based routing:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
API[API Clients] --> ApiManager
|
||||
SMTP[External SMTP Servers] <--> SMTPServer
|
||||
SMTP[External SMTP Servers] <--> UnifiedEmailServer
|
||||
|
||||
subgraph "DcRouter Email System"
|
||||
DcRouter[DcRouter] --> UnifiedEmailServer[Unified Email Server]
|
||||
DcRouter --> DomainRouter[Domain Router]
|
||||
UnifiedEmailServer --> MultiModeProcessor[Multi-Mode Processor]
|
||||
MultiModeProcessor --> ForwardMode[Forward Mode]
|
||||
MultiModeProcessor --> MtaMode[MTA Mode]
|
||||
MultiModeProcessor --> ProcessMode[Process Mode]
|
||||
ApiManager[API Manager] --> DcRouter
|
||||
end
|
||||
|
||||
subgraph "MTA Service"
|
||||
MtaService[MTA Service] --> SMTPServer[SMTP Server]
|
||||
MtaMode --> MtaService[MTA Service]
|
||||
MtaService --> EmailSendJob[Email Send Job]
|
||||
MtaService --> DnsManager[DNS Manager]
|
||||
MtaService --> DkimCreator[DKIM Creator]
|
||||
ApiManager[API Manager] --> MtaService
|
||||
end
|
||||
|
||||
subgraph "External Services"
|
||||
DnsManager <--> DNS[DNS Servers]
|
||||
EmailSendJob <--> MXServers[MX Servers]
|
||||
ForwardMode <--> ExternalSMTP[External SMTP Servers]
|
||||
end
|
||||
```
|
||||
|
||||
The MTA service provides:
|
||||
- Complete SMTP server for receiving emails
|
||||
- DKIM signing and verification
|
||||
- SPF and DMARC support
|
||||
- DNS record management
|
||||
- Retry logic with queue processing
|
||||
- TLS encryption
|
||||
#### Key Features
|
||||
|
||||
Here's how to use the MTA service:
|
||||
The email handling system provides:
|
||||
- **Pattern-based Routing**: Route emails based on glob patterns like `*@domain.com` or `*@*.domain.com`
|
||||
- **Multi-Modal Processing**: Handle different email domains with different processing modes:
|
||||
- **Forward Mode**: SMTP forwarding to other servers
|
||||
- **MTA Mode**: Full Mail Transfer Agent capabilities
|
||||
- **Process Mode**: Store-and-forward with content scanning
|
||||
- **Unified Configuration**: Single configuration interface for all email handling
|
||||
- **Shared Infrastructure**: Use same ports (25, 587, 465) for all email handling
|
||||
- **Complete SMTP Server**: Receive emails with TLS and authentication support
|
||||
- **DKIM, SPF, DMARC**: Full email authentication standard support
|
||||
- **Content Scanning**: Check for spam, viruses, and other threats
|
||||
- **Advanced Delivery Management**: Queue, retry, and track delivery status
|
||||
|
||||
#### Using the Consolidated Email System
|
||||
|
||||
Here's how to use the consolidated email system:
|
||||
|
||||
```ts
|
||||
import { DcRouter, IEmailConfig, EmailProcessingMode } from '@serve.zone/platformservice';
|
||||
|
||||
async function setupEmailHandling() {
|
||||
// Configure the email handling system
|
||||
const dcRouter = new DcRouter({
|
||||
emailConfig: {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.example.com',
|
||||
|
||||
// TLS configuration
|
||||
tls: {
|
||||
certPath: '/path/to/cert.pem',
|
||||
keyPath: '/path/to/key.pem'
|
||||
},
|
||||
|
||||
// Default handling for unmatched domains
|
||||
defaultMode: 'forward' as EmailProcessingMode,
|
||||
defaultServer: 'fallback.mail.example.com',
|
||||
defaultPort: 25,
|
||||
|
||||
// Pattern-based routing rules
|
||||
domainRules: [
|
||||
{
|
||||
// Forward all company.com emails to internal mail server
|
||||
pattern: '*@company.com',
|
||||
mode: 'forward' as EmailProcessingMode,
|
||||
target: {
|
||||
server: 'internal-mail.company.local',
|
||||
port: 25,
|
||||
useTls: true
|
||||
}
|
||||
},
|
||||
{
|
||||
// Process notifications.company.com with MTA
|
||||
pattern: '*@notifications.company.com',
|
||||
mode: 'mta' as EmailProcessingMode,
|
||||
mtaOptions: {
|
||||
domain: 'notifications.company.com',
|
||||
dkimSign: true,
|
||||
dkimOptions: {
|
||||
domainName: 'notifications.company.com',
|
||||
keySelector: 'mail',
|
||||
privateKey: '...'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// Scan marketing emails for content and transform
|
||||
pattern: '*@marketing.company.com',
|
||||
mode: 'process' as EmailProcessingMode,
|
||||
contentScanning: true,
|
||||
scanners: [
|
||||
{
|
||||
type: 'spam',
|
||||
threshold: 5.0,
|
||||
action: 'tag'
|
||||
}
|
||||
],
|
||||
transformations: [
|
||||
{
|
||||
type: 'addHeader',
|
||||
header: 'X-Marketing',
|
||||
value: 'true'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Start the system
|
||||
await dcRouter.start();
|
||||
console.log('DcRouter with email handling started');
|
||||
|
||||
// Later, you can update rules dynamically
|
||||
await dcRouter.updateDomainRules([
|
||||
{
|
||||
pattern: '*@newdomain.com',
|
||||
mode: 'forward' as EmailProcessingMode,
|
||||
target: {
|
||||
server: 'mail.newdomain.com',
|
||||
port: 25
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
setupEmailHandling();
|
||||
```
|
||||
|
||||
#### Using the MTA Service Directly
|
||||
|
||||
You can still use the MTA service directly for more granular control:
|
||||
|
||||
```ts
|
||||
import { MtaService, Email } from '@serve.zone/platformservice';
|
||||
@ -170,7 +285,9 @@ async function useMtaService() {
|
||||
useMtaService();
|
||||
```
|
||||
|
||||
The MTA provides key advantages for applications requiring:
|
||||
The consolidated email system provides key advantages for applications requiring:
|
||||
- Domain-specific email handling
|
||||
- Flexible email routing
|
||||
- High-volume email sending
|
||||
- Compliance with email authentication standards
|
||||
- Detailed delivery tracking
|
||||
|
1324
readme.plan.md
1324
readme.plan.md
File diff suppressed because it is too large
Load Diff
146
test/test.dcrouter.ts
Normal file
146
test/test.dcrouter.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import {
|
||||
DcRouter,
|
||||
type IDcRouterOptions,
|
||||
type IEmailConfig,
|
||||
type EmailProcessingMode,
|
||||
type IDomainRule
|
||||
} from '../ts/dcrouter/index.js';
|
||||
|
||||
tap.test('DcRouter class - basic functionality', async () => {
|
||||
// Create a simple DcRouter instance
|
||||
const options: IDcRouterOptions = {
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
}
|
||||
};
|
||||
|
||||
const router = new DcRouter(options);
|
||||
expect(router).toBeTruthy();
|
||||
expect(router instanceof DcRouter).toEqual(true);
|
||||
expect(router.options.tls.contactEmail).toEqual('test@example.com');
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - SmartProxy configuration', async () => {
|
||||
// Create SmartProxy configuration
|
||||
const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
|
||||
fromPort: 443,
|
||||
toPort: 8080,
|
||||
targetIP: '10.0.0.10',
|
||||
sniEnabled: true,
|
||||
acme: {
|
||||
port: 80,
|
||||
enabled: true,
|
||||
autoRenew: true,
|
||||
useProduction: false,
|
||||
renewThresholdDays: 30,
|
||||
accountEmail: 'admin@example.com'
|
||||
},
|
||||
globalPortRanges: [
|
||||
{ from: 80, to: 80 },
|
||||
{ from: 443, to: 443 }
|
||||
],
|
||||
domainConfigs: [
|
||||
{
|
||||
domains: ['example.com', 'www.example.com'],
|
||||
allowedIPs: ['0.0.0.0/0'],
|
||||
targetIPs: ['10.0.0.10'],
|
||||
portRanges: [
|
||||
{ from: 80, to: 80 },
|
||||
{ from: 443, to: 443 }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const options: IDcRouterOptions = {
|
||||
smartProxyConfig,
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
}
|
||||
};
|
||||
|
||||
const router = new DcRouter(options);
|
||||
expect(router.options.smartProxyConfig).toBeTruthy();
|
||||
expect(router.options.smartProxyConfig.domainConfigs.length).toEqual(1);
|
||||
expect(router.options.smartProxyConfig.domainConfigs[0].domains[0]).toEqual('example.com');
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email configuration', async () => {
|
||||
// Create consolidated email configuration
|
||||
const emailConfig: IEmailConfig = {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.example.com',
|
||||
maxMessageSize: 50 * 1024 * 1024, // 50MB
|
||||
|
||||
defaultMode: 'forward' as EmailProcessingMode,
|
||||
defaultServer: 'fallback-mail.example.com',
|
||||
defaultPort: 25,
|
||||
defaultTls: true,
|
||||
|
||||
domainRules: [
|
||||
{
|
||||
pattern: '*@example.com',
|
||||
mode: 'forward' as EmailProcessingMode,
|
||||
target: {
|
||||
server: 'mail1.example.com',
|
||||
port: 25,
|
||||
useTls: true
|
||||
}
|
||||
},
|
||||
{
|
||||
pattern: '*@example.org',
|
||||
mode: 'mta' as EmailProcessingMode,
|
||||
mtaOptions: {
|
||||
domain: 'example.org',
|
||||
allowLocalDelivery: true
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const options: IDcRouterOptions = {
|
||||
emailConfig,
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
}
|
||||
};
|
||||
|
||||
const router = new DcRouter(options);
|
||||
expect(router.options.emailConfig).toBeTruthy();
|
||||
expect(router.options.emailConfig.ports.length).toEqual(3);
|
||||
expect(router.options.emailConfig.domainRules.length).toEqual(2);
|
||||
expect(router.options.emailConfig.domainRules[0].pattern).toEqual('*@example.com');
|
||||
expect(router.options.emailConfig.domainRules[1].pattern).toEqual('*@example.org');
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Domain pattern matching', async () => {
|
||||
const router = new DcRouter({});
|
||||
|
||||
// Use the internal method for testing if accessible
|
||||
// This requires knowledge of the implementation, so it's a bit brittle
|
||||
if (typeof router['isDomainMatch'] === 'function') {
|
||||
// Test exact match
|
||||
expect(router['isDomainMatch']('example.com', 'example.com')).toEqual(true);
|
||||
expect(router['isDomainMatch']('example.com', 'example.org')).toEqual(false);
|
||||
|
||||
// Test wildcard match
|
||||
expect(router['isDomainMatch']('sub.example.com', '*.example.com')).toEqual(true);
|
||||
expect(router['isDomainMatch']('sub.sub.example.com', '*.example.com')).toEqual(true);
|
||||
expect(router['isDomainMatch']('example.com', '*.example.com')).toEqual(false);
|
||||
expect(router['isDomainMatch']('sub.example.org', '*.example.com')).toEqual(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op - just to make sure everything is cleaned up properly
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
// Export a function to run all tests
|
||||
export default tap.start();
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/platformservice',
|
||||
version: '2.4.2',
|
||||
version: '2.8.0',
|
||||
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
|
||||
}
|
||||
|
@ -1,12 +1,17 @@
|
||||
// This file is maintained for backward compatibility only
|
||||
// New code should use qenv directly
|
||||
|
||||
import * as plugins from '../plugins.js';
|
||||
import type DcRouter from './classes.dcrouter.js';
|
||||
|
||||
export class SzDcRouterConnector {
|
||||
public qenv: plugins.qenv.Qenv;
|
||||
public dcRouterRef: DcRouter;
|
||||
|
||||
constructor(dcRouterRef: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRef;
|
||||
this.dcRouterRef.options.platformServiceInstance?.serviceQenv || new plugins.qenv.Qenv('./', '.nogit/');
|
||||
// Initialize qenv directly
|
||||
this.qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
}
|
||||
|
||||
public async getEnvVarOnDemand(varName: string): Promise<string> {
|
||||
|
@ -1,23 +1,44 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { SzDcRouterConnector } from './classes.dcr.sz.connector.js';
|
||||
|
||||
import type { SzPlatformService } from '../platformservice.js';
|
||||
import { type IMtaConfig, MtaService } from '../mta/classes.mta.js';
|
||||
import { SmtpPortConfig, type ISmtpPortSettings } from './classes.smtp.portconfig.js';
|
||||
import { EmailDomainRouter, type IEmailDomainRoutingConfig } from './classes.email.domainrouter.js';
|
||||
|
||||
// Certificate types are available via plugins.tsclass
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
platformServiceInstance?: SzPlatformService;
|
||||
// Import the consolidated email config
|
||||
import type { IEmailConfig, IDomainRule } from './classes.email.config.js';
|
||||
import { DomainRouter } from './classes.domain.router.js';
|
||||
import { UnifiedEmailServer } from './classes.unified.email.server.js';
|
||||
import { UnifiedDeliveryQueue, type IQueueOptions } from './classes.delivery.queue.js';
|
||||
import { MultiModeDeliverySystem, type IMultiModeDeliveryOptions } from './classes.delivery.system.js';
|
||||
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from './classes.rate.limiter.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/** SmartProxy (TCP/SNI) configuration */
|
||||
smartProxyOptions?: plugins.smartproxy.ISmartProxyOptions;
|
||||
/** Reverse proxy host configurations for HTTP(S) layer */
|
||||
reverseProxyConfigs?: plugins.smartproxy.IReverseProxyConfig[];
|
||||
/** MTA (SMTP) service configuration */
|
||||
mtaConfig?: IMtaConfig;
|
||||
/** Existing MTA service instance to use instead of creating a new one */
|
||||
mtaServiceInstance?: MtaService;
|
||||
export interface IDcRouterOptions {
|
||||
/**
|
||||
* Direct SmartProxy configuration - gives full control over HTTP/HTTPS and TCP/SNI traffic
|
||||
* This is the preferred way to configure HTTP/HTTPS and general TCP/SNI traffic
|
||||
*/
|
||||
smartProxyConfig?: plugins.smartproxy.ISmartProxyOptions;
|
||||
|
||||
/**
|
||||
* Consolidated email configuration
|
||||
* This enables all email handling with pattern-based routing
|
||||
*/
|
||||
emailConfig?: IEmailConfig;
|
||||
|
||||
/** TLS/certificate configuration */
|
||||
tls?: {
|
||||
/** Contact email for ACME certificates */
|
||||
contactEmail: string;
|
||||
/** Domain for main certificate */
|
||||
domain?: string;
|
||||
/** Path to certificate file (if not using auto-provisioning) */
|
||||
certPath?: string;
|
||||
/** Path to key file (if not using auto-provisioning) */
|
||||
keyPath?: string;
|
||||
};
|
||||
|
||||
/** DNS server configuration */
|
||||
dnsServerConfig?: plugins.smartdns.IDnsServerOptions;
|
||||
}
|
||||
@ -35,14 +56,24 @@ export interface PortProxyRuleContext {
|
||||
proxy: plugins.smartproxy.SmartProxy;
|
||||
configs: plugins.smartproxy.IPortProxySettings['domainConfigs'];
|
||||
}
|
||||
|
||||
export class DcRouter {
|
||||
public szDcRouterConnector = new SzDcRouterConnector(this);
|
||||
public options: IDcRouterOptions;
|
||||
|
||||
// Core services
|
||||
public smartProxy?: plugins.smartproxy.SmartProxy;
|
||||
public mta?: MtaService;
|
||||
public dnsServer?: plugins.smartdns.DnsServer;
|
||||
/** SMTP rule engine */
|
||||
public smtpRuleEngine?: plugins.smartrule.SmartRule<any>;
|
||||
|
||||
// Unified email components
|
||||
public domainRouter?: DomainRouter;
|
||||
public unifiedEmailServer?: UnifiedEmailServer;
|
||||
public deliveryQueue?: UnifiedDeliveryQueue;
|
||||
public deliverySystem?: MultiModeDeliverySystem;
|
||||
public rateLimiter?: UnifiedRateLimiter;
|
||||
|
||||
// Environment access
|
||||
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
|
||||
constructor(optionsArg: IDcRouterOptions) {
|
||||
// Set defaults in options
|
||||
this.options = {
|
||||
@ -51,193 +82,335 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
public async start() {
|
||||
// Set up MTA service - use existing instance if provided
|
||||
if (this.options.mtaServiceInstance) {
|
||||
// Use provided MTA service instance
|
||||
this.mta = this.options.mtaServiceInstance;
|
||||
console.log('Using provided MTA service instance');
|
||||
|
||||
// Get the SMTP rule engine from the provided MTA
|
||||
this.smtpRuleEngine = this.mta.smtpRuleEngine;
|
||||
} else if (this.options.mtaConfig) {
|
||||
// Create new MTA service with the provided configuration
|
||||
this.mta = new MtaService(undefined, this.options.mtaConfig);
|
||||
console.log('Created new MTA service instance');
|
||||
|
||||
// Initialize SMTP rule engine
|
||||
this.smtpRuleEngine = this.mta.smtpRuleEngine;
|
||||
}
|
||||
|
||||
// TCP/SNI proxy (SmartProxy)
|
||||
if (this.options.smartProxyOptions) {
|
||||
// Lets setup smartacme
|
||||
let certProvisionFunction: plugins.smartproxy.ISmartProxyOptions['certProvisionFunction'];
|
||||
|
||||
// Check if we can share certificate from MTA service
|
||||
if (this.options.mtaServiceInstance && this.mta) {
|
||||
// Share TLS certificate with MTA service (if available)
|
||||
console.log('Using MTA service certificate for SmartProxy');
|
||||
|
||||
// Create proxy function to get cert from MTA service
|
||||
certProvisionFunction = async (domainArg) => {
|
||||
// Get cert from provided MTA service if available
|
||||
if (this.mta && this.mta.certificate) {
|
||||
console.log(`Using MTA certificate for domain ${domainArg}`);
|
||||
// Return in the format expected by SmartProxy
|
||||
const certExpiry = this.mta.certificate.expiresAt;
|
||||
const certObj: plugins.tsclass.network.ICert = {
|
||||
id: `cert-${domainArg}`,
|
||||
domainName: domainArg,
|
||||
privateKey: this.mta.certificate.privateKey,
|
||||
publicKey: this.mta.certificate.publicKey,
|
||||
created: Date.now(),
|
||||
validUntil: certExpiry instanceof Date ? certExpiry.getTime() : Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||
csr: ''
|
||||
};
|
||||
return certObj;
|
||||
} else {
|
||||
console.log(`No MTA certificate available for domain ${domainArg}, falling back to ACME`);
|
||||
// Return string literal instead of 'http01' enum value
|
||||
return null; // Let SmartProxy fall back to its default mechanism
|
||||
}
|
||||
};
|
||||
} else if (true) {
|
||||
// Set up ACME for certificate provisioning
|
||||
const smartAcmeInstance = new plugins.smartacme.SmartAcme({
|
||||
accountEmail: this.options.smartProxyOptions.acme.accountEmail,
|
||||
certManager: new plugins.smartacme.certmanagers.MongoCertManager({
|
||||
mongoDbUrl: await this.szDcRouterConnector.getEnvVarOnDemand('MONGO_DB_URL'),
|
||||
mongoDbUser: await this.szDcRouterConnector.getEnvVarOnDemand('MONGO_DB_USER'),
|
||||
mongoDbPass: await this.szDcRouterConnector.getEnvVarOnDemand('MONGO_DB_PASS'),
|
||||
mongoDbName: await this.szDcRouterConnector.getEnvVarOnDemand('MONGO_DB_NAME'),
|
||||
}),
|
||||
environment: 'production',
|
||||
accountPrivateKey: await this.szDcRouterConnector.getEnvVarOnDemand('ACME_ACCOUNT_PRIVATE_KEY'),
|
||||
challengeHandlers: [
|
||||
new plugins.smartacme.handlers.Dns01Handler(new plugins.cloudflare.CloudflareAccount('')) // TODO
|
||||
],
|
||||
});
|
||||
|
||||
certProvisionFunction = async (domainArg) => {
|
||||
try {
|
||||
const domainSupported = await smartAcmeInstance.challengeHandlers[0].checkWetherDomainIsSupported(domainArg);
|
||||
if (!domainSupported) {
|
||||
return null; // Let SmartProxy handle with default mechanism
|
||||
}
|
||||
// Get the certificate and convert to ICert
|
||||
const cert = await smartAcmeInstance.getCertificateForDomain(domainArg);
|
||||
if (typeof cert === 'string') {
|
||||
return null; // String result indicates fallback
|
||||
}
|
||||
|
||||
// Return in the format expected by SmartProxy
|
||||
const result: plugins.tsclass.network.ICert = {
|
||||
id: `cert-${domainArg}`,
|
||||
domainName: domainArg,
|
||||
privateKey: cert.privateKey,
|
||||
publicKey: cert.publicKey,
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000, // 90 days
|
||||
csr: ''
|
||||
};
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Certificate error for ${domainArg}:`, err);
|
||||
return null; // Let SmartProxy handle with default mechanism
|
||||
}
|
||||
};
|
||||
console.log('Starting DcRouter services...');
|
||||
|
||||
try {
|
||||
// Set up SmartProxy for HTTP/HTTPS and general TCP/SNI traffic
|
||||
if (this.options.smartProxyConfig) {
|
||||
await this.setupSmartProxy();
|
||||
}
|
||||
|
||||
// Create the SmartProxy instance with the appropriate cert provisioning function
|
||||
const smartProxyOptions = {
|
||||
...this.options.smartProxyOptions,
|
||||
certProvisionFunction
|
||||
};
|
||||
this.smartProxy = new plugins.smartproxy.SmartProxy(smartProxyOptions);
|
||||
|
||||
// Configure SmartProxy for SMTP if we have an MTA service
|
||||
if (this.mta) {
|
||||
this.configureSmtpProxy();
|
||||
// Set up unified email handling if configured
|
||||
if (this.options.emailConfig) {
|
||||
await this.setupUnifiedEmailHandling();
|
||||
}
|
||||
}
|
||||
|
||||
// DNS server
|
||||
if (this.options.dnsServerConfig) {
|
||||
this.dnsServer = new plugins.smartdns.DnsServer(this.options.dnsServerConfig);
|
||||
}
|
||||
|
||||
// Start SmartProxy if configured
|
||||
if (this.smartProxy) {
|
||||
await this.smartProxy.start();
|
||||
}
|
||||
|
||||
// Start MTA service if configured and it's our own service (not an external instance)
|
||||
if (this.mta && !this.options.mtaServiceInstance) {
|
||||
await this.mta.start();
|
||||
}
|
||||
|
||||
// Start DNS server if configured
|
||||
if (this.dnsServer) {
|
||||
await this.dnsServer.start();
|
||||
|
||||
// 3. Set up DNS server if configured
|
||||
if (this.options.dnsServerConfig) {
|
||||
this.dnsServer = new plugins.smartdns.DnsServer(this.options.dnsServerConfig);
|
||||
await this.dnsServer.start();
|
||||
console.log('DNS server started');
|
||||
}
|
||||
|
||||
console.log('DcRouter started successfully');
|
||||
} catch (error) {
|
||||
console.error('Error starting DcRouter:', error);
|
||||
// Try to clean up any services that may have started
|
||||
await this.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up SmartProxy with direct configuration
|
||||
*/
|
||||
private async setupSmartProxy(): Promise<void> {
|
||||
if (!this.options.smartProxyConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Setting up SmartProxy with direct configuration');
|
||||
|
||||
// Create SmartProxy instance with full configuration
|
||||
this.smartProxy = new plugins.smartproxy.SmartProxy(this.options.smartProxyConfig);
|
||||
|
||||
// Set up event listeners
|
||||
this.smartProxy.on('error', (err) => {
|
||||
console.error('SmartProxy error:', err);
|
||||
});
|
||||
|
||||
if (this.options.smartProxyConfig.acme) {
|
||||
this.smartProxy.on('certificate-issued', (event) => {
|
||||
console.log(`Certificate issued for ${event.domain}, expires ${event.expiryDate}`);
|
||||
});
|
||||
|
||||
this.smartProxy.on('certificate-renewed', (event) => {
|
||||
console.log(`Certificate renewed for ${event.domain}, expires ${event.expiryDate}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Start SmartProxy
|
||||
await this.smartProxy.start();
|
||||
|
||||
console.log('SmartProxy started successfully');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Configure SmartProxy for SMTP ports
|
||||
* Check if a domain matches a pattern (including wildcard support)
|
||||
* @param domain The domain to check
|
||||
* @param pattern The pattern to match against (e.g., "*.example.com")
|
||||
* @returns Whether the domain matches the pattern
|
||||
*/
|
||||
public configureSmtpProxy(): void {
|
||||
if (!this.smartProxy || !this.mta) return;
|
||||
|
||||
const mtaPort = this.mta.config.smtp?.port || 25;
|
||||
try {
|
||||
// Configure SmartProxy to forward SMTP ports to the MTA service
|
||||
const settings = this.smartProxy.settings;
|
||||
// Ensure localhost target for MTA
|
||||
settings.targetIP = settings.targetIP || 'localhost';
|
||||
// Forward all SMTP ports to the MTA port
|
||||
settings.toPort = mtaPort;
|
||||
// Initialize globalPortRanges if needed
|
||||
if (!settings.globalPortRanges) {
|
||||
settings.globalPortRanges = [];
|
||||
}
|
||||
// Add SMTP ports 25, 587, 465 if not already present
|
||||
for (const port of [25, 587, 465]) {
|
||||
if (!settings.globalPortRanges.some((r) => r.from <= port && port <= r.to)) {
|
||||
settings.globalPortRanges.push({ from: port, to: port });
|
||||
}
|
||||
}
|
||||
console.log(`Configured SmartProxy for SMTP ports: 25, 587, 465 → localhost:${mtaPort}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to configure SmartProxy for SMTP:', error);
|
||||
private isDomainMatch(domain: string, pattern: string): boolean {
|
||||
// Normalize inputs
|
||||
domain = domain.toLowerCase();
|
||||
pattern = pattern.toLowerCase();
|
||||
|
||||
// Check for exact match
|
||||
if (domain === pattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for wildcard match (*.example.com)
|
||||
if (pattern.startsWith('*.')) {
|
||||
const patternSuffix = pattern.slice(2); // Remove the "*." prefix
|
||||
|
||||
// Check if domain ends with the pattern suffix and has at least one character before it
|
||||
return domain.endsWith(patternSuffix) && domain.length > patternSuffix.length;
|
||||
}
|
||||
|
||||
// No match
|
||||
return false;
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
// Stop SmartProxy
|
||||
if (this.smartProxy) {
|
||||
await this.smartProxy.stop();
|
||||
}
|
||||
console.log('Stopping DcRouter services...');
|
||||
|
||||
// Stop MTA service if it's our own (not an external instance)
|
||||
if (this.mta && !this.options.mtaServiceInstance) {
|
||||
await this.mta.stop();
|
||||
}
|
||||
|
||||
// Stop DNS server
|
||||
if (this.dnsServer) {
|
||||
await this.dnsServer.stop();
|
||||
try {
|
||||
// Stop all services in parallel for faster shutdown
|
||||
await Promise.all([
|
||||
// Stop unified email components if running
|
||||
this.domainRouter ? this.stopUnifiedEmailComponents().catch(err => console.error('Error stopping unified email components:', err)) : Promise.resolve(),
|
||||
|
||||
// Stop HTTP SmartProxy if running
|
||||
this.smartProxy ? this.smartProxy.stop().catch(err => console.error('Error stopping SmartProxy:', err)) : Promise.resolve(),
|
||||
|
||||
// Stop DNS server if running
|
||||
this.dnsServer ?
|
||||
this.dnsServer.stop().catch(err => console.error('Error stopping DNS server:', err)) :
|
||||
Promise.resolve()
|
||||
]);
|
||||
|
||||
console.log('All DcRouter services stopped');
|
||||
} catch (error) {
|
||||
console.error('Error during DcRouter shutdown:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an SMTP routing rule
|
||||
* Update SmartProxy configuration
|
||||
* @param config New SmartProxy configuration
|
||||
*/
|
||||
public addSmtpRule(
|
||||
priority: number,
|
||||
check: (email: any) => Promise<any>,
|
||||
action: (email: any) => Promise<any>
|
||||
): void {
|
||||
this.smtpRuleEngine?.createRule(priority, check, action);
|
||||
public async updateSmartProxyConfig(config: plugins.smartproxy.ISmartProxyOptions): Promise<void> {
|
||||
// Stop existing SmartProxy if running
|
||||
if (this.smartProxy) {
|
||||
await this.smartProxy.stop();
|
||||
this.smartProxy = undefined;
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
this.options.smartProxyConfig = config;
|
||||
|
||||
// Start new SmartProxy with updated configuration
|
||||
await this.setupSmartProxy();
|
||||
|
||||
console.log('SmartProxy configuration updated');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Set up unified email handling with pattern-based routing
|
||||
* This implements the consolidated emailConfig approach
|
||||
*/
|
||||
private async setupUnifiedEmailHandling(): Promise<void> {
|
||||
logger.log('info', 'Setting up unified email handling with pattern-based routing');
|
||||
|
||||
if (!this.options.emailConfig) {
|
||||
throw new Error('Email configuration is required for unified email handling');
|
||||
}
|
||||
|
||||
try {
|
||||
// Create domain router for pattern matching
|
||||
this.domainRouter = new DomainRouter({
|
||||
domainRules: this.options.emailConfig.domainRules,
|
||||
defaultMode: this.options.emailConfig.defaultMode,
|
||||
defaultServer: this.options.emailConfig.defaultServer,
|
||||
defaultPort: this.options.emailConfig.defaultPort,
|
||||
defaultTls: this.options.emailConfig.defaultTls
|
||||
});
|
||||
|
||||
// Initialize the rate limiter
|
||||
this.rateLimiter = new UnifiedRateLimiter({
|
||||
global: {
|
||||
maxMessagesPerMinute: 100,
|
||||
maxRecipientsPerMessage: 100,
|
||||
maxConnectionsPerIP: 20,
|
||||
maxErrorsPerIP: 10,
|
||||
maxAuthFailuresPerIP: 5
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize the unified delivery queue
|
||||
const queueOptions: IQueueOptions = {
|
||||
storageType: this.options.emailConfig.queue?.storageType || 'memory',
|
||||
persistentPath: this.options.emailConfig.queue?.persistentPath,
|
||||
maxRetries: this.options.emailConfig.queue?.maxRetries,
|
||||
baseRetryDelay: this.options.emailConfig.queue?.baseRetryDelay,
|
||||
maxRetryDelay: this.options.emailConfig.queue?.maxRetryDelay
|
||||
};
|
||||
|
||||
this.deliveryQueue = new UnifiedDeliveryQueue(queueOptions);
|
||||
await this.deliveryQueue.initialize();
|
||||
|
||||
// Initialize the delivery system
|
||||
const deliveryOptions: IMultiModeDeliveryOptions = {
|
||||
globalRateLimit: 100, // Default to 100 emails per minute
|
||||
concurrentDeliveries: 10
|
||||
};
|
||||
|
||||
this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions);
|
||||
await this.deliverySystem.start();
|
||||
|
||||
// Initialize the unified email server
|
||||
this.unifiedEmailServer = new UnifiedEmailServer({
|
||||
ports: this.options.emailConfig.ports,
|
||||
hostname: this.options.emailConfig.hostname,
|
||||
maxMessageSize: this.options.emailConfig.maxMessageSize,
|
||||
auth: this.options.emailConfig.auth,
|
||||
tls: this.options.emailConfig.tls,
|
||||
domainRules: this.options.emailConfig.domainRules,
|
||||
defaultMode: this.options.emailConfig.defaultMode,
|
||||
defaultServer: this.options.emailConfig.defaultServer,
|
||||
defaultPort: this.options.emailConfig.defaultPort,
|
||||
defaultTls: this.options.emailConfig.defaultTls
|
||||
});
|
||||
|
||||
// Set up event listeners
|
||||
this.unifiedEmailServer.on('error', (err) => {
|
||||
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
|
||||
});
|
||||
|
||||
// Connect the unified email server with the delivery queue
|
||||
this.unifiedEmailServer.on('emailProcessed', (email, mode, rule) => {
|
||||
this.deliveryQueue!.enqueue(email, mode, rule).catch(err => {
|
||||
logger.log('error', `Failed to enqueue email: ${err.message}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Start the unified email server
|
||||
await this.unifiedEmailServer.start();
|
||||
|
||||
logger.log('info', `Unified email handling configured with ${this.options.emailConfig.domainRules.length} domain rules`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Error setting up unified email handling: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the unified email configuration
|
||||
* @param config New email configuration
|
||||
*/
|
||||
public async updateEmailConfig(config: IEmailConfig): Promise<void> {
|
||||
// Stop existing email components
|
||||
await this.stopUnifiedEmailComponents();
|
||||
|
||||
// Update configuration
|
||||
this.options.emailConfig = config;
|
||||
|
||||
// Start email handling with new configuration
|
||||
await this.setupUnifiedEmailHandling();
|
||||
|
||||
console.log('Unified email configuration updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all unified email components
|
||||
*/
|
||||
private async stopUnifiedEmailComponents(): Promise<void> {
|
||||
try {
|
||||
// Stop all components in the correct order
|
||||
|
||||
// 1. Stop the unified email server first
|
||||
if (this.unifiedEmailServer) {
|
||||
await this.unifiedEmailServer.stop();
|
||||
logger.log('info', 'Unified email server stopped');
|
||||
this.unifiedEmailServer = undefined;
|
||||
}
|
||||
|
||||
// 2. Stop the delivery system
|
||||
if (this.deliverySystem) {
|
||||
await this.deliverySystem.stop();
|
||||
logger.log('info', 'Delivery system stopped');
|
||||
this.deliverySystem = undefined;
|
||||
}
|
||||
|
||||
// 3. Stop the delivery queue
|
||||
if (this.deliveryQueue) {
|
||||
await this.deliveryQueue.shutdown();
|
||||
logger.log('info', 'Delivery queue shut down');
|
||||
this.deliveryQueue = undefined;
|
||||
}
|
||||
|
||||
// 4. Stop the rate limiter
|
||||
if (this.rateLimiter) {
|
||||
this.rateLimiter.stop();
|
||||
logger.log('info', 'Rate limiter stopped');
|
||||
this.rateLimiter = undefined;
|
||||
}
|
||||
|
||||
// 5. Clear the domain router
|
||||
this.domainRouter = undefined;
|
||||
|
||||
logger.log('info', 'All unified email components stopped');
|
||||
} catch (error) {
|
||||
logger.log('error', `Error stopping unified email components: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update domain rules for email routing
|
||||
* @param rules New domain rules to apply
|
||||
*/
|
||||
public async updateDomainRules(rules: IDomainRule[]): Promise<void> {
|
||||
// Validate that email config exists
|
||||
if (!this.options.emailConfig) {
|
||||
throw new Error('Email configuration is required before updating domain rules');
|
||||
}
|
||||
|
||||
// Update the configuration
|
||||
this.options.emailConfig.domainRules = rules;
|
||||
|
||||
// Update the domain router if it exists
|
||||
if (this.domainRouter) {
|
||||
this.domainRouter.updateRules(rules);
|
||||
}
|
||||
|
||||
// Update the unified email server if it exists
|
||||
if (this.unifiedEmailServer) {
|
||||
this.unifiedEmailServer.updateDomainRules(rules);
|
||||
}
|
||||
|
||||
console.log(`Domain rules updated with ${rules.length} rules`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics from all components
|
||||
*/
|
||||
public getStats(): any {
|
||||
const stats: any = {
|
||||
unifiedEmailServer: this.unifiedEmailServer?.getStats(),
|
||||
deliveryQueue: this.deliveryQueue?.getStats(),
|
||||
deliverySystem: this.deliverySystem?.getStats(),
|
||||
rateLimiter: this.rateLimiter?.getStats()
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
|
638
ts/dcrouter/classes.delivery.queue.ts
Normal file
638
ts/dcrouter/classes.delivery.queue.ts
Normal file
@ -0,0 +1,638 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { logger } from '../logger.js';
|
||||
import { type EmailProcessingMode, type IDomainRule } from './classes.email.config.js';
|
||||
|
||||
/**
|
||||
* Queue item status
|
||||
*/
|
||||
export type QueueItemStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||
|
||||
/**
|
||||
* Queue item interface
|
||||
*/
|
||||
export interface IQueueItem {
|
||||
id: string;
|
||||
processingMode: EmailProcessingMode;
|
||||
processingResult: any;
|
||||
rule: IDomainRule;
|
||||
status: QueueItemStatus;
|
||||
attempts: number;
|
||||
nextAttempt: Date;
|
||||
lastError?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deliveredAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue options interface
|
||||
*/
|
||||
export interface IQueueOptions {
|
||||
// Storage options
|
||||
storageType?: 'memory' | 'disk';
|
||||
persistentPath?: string;
|
||||
|
||||
// Queue behavior
|
||||
checkInterval?: number;
|
||||
maxQueueSize?: number;
|
||||
maxPerDestination?: number;
|
||||
|
||||
// Delivery attempts
|
||||
maxRetries?: number;
|
||||
baseRetryDelay?: number;
|
||||
maxRetryDelay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue statistics interface
|
||||
*/
|
||||
export interface IQueueStats {
|
||||
queueSize: number;
|
||||
status: {
|
||||
pending: number;
|
||||
processing: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
deferred: number;
|
||||
};
|
||||
modes: {
|
||||
forward: number;
|
||||
mta: number;
|
||||
process: number;
|
||||
};
|
||||
oldestItem?: Date;
|
||||
newestItem?: Date;
|
||||
averageAttempts: number;
|
||||
totalProcessed: number;
|
||||
processingActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unified queue for all email modes
|
||||
*/
|
||||
export class UnifiedDeliveryQueue extends EventEmitter {
|
||||
private options: Required<IQueueOptions>;
|
||||
private queue: Map<string, IQueueItem> = new Map();
|
||||
private checkTimer?: NodeJS.Timeout;
|
||||
private stats: IQueueStats;
|
||||
private processing: boolean = false;
|
||||
private totalProcessed: number = 0;
|
||||
|
||||
/**
|
||||
* Create a new unified delivery queue
|
||||
* @param options Queue options
|
||||
*/
|
||||
constructor(options: IQueueOptions) {
|
||||
super();
|
||||
|
||||
// Set default options
|
||||
this.options = {
|
||||
storageType: options.storageType || 'memory',
|
||||
persistentPath: options.persistentPath || path.join(process.cwd(), 'email-queue'),
|
||||
checkInterval: options.checkInterval || 30000, // 30 seconds
|
||||
maxQueueSize: options.maxQueueSize || 10000,
|
||||
maxPerDestination: options.maxPerDestination || 100,
|
||||
maxRetries: options.maxRetries || 5,
|
||||
baseRetryDelay: options.baseRetryDelay || 60000, // 1 minute
|
||||
maxRetryDelay: options.maxRetryDelay || 3600000 // 1 hour
|
||||
};
|
||||
|
||||
// Initialize statistics
|
||||
this.stats = {
|
||||
queueSize: 0,
|
||||
status: {
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
deferred: 0
|
||||
},
|
||||
modes: {
|
||||
forward: 0,
|
||||
mta: 0,
|
||||
process: 0
|
||||
},
|
||||
averageAttempts: 0,
|
||||
totalProcessed: 0,
|
||||
processingActive: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the queue
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
logger.log('info', 'Initializing UnifiedDeliveryQueue');
|
||||
|
||||
try {
|
||||
// Create persistent storage directory if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
if (!fs.existsSync(this.options.persistentPath)) {
|
||||
fs.mkdirSync(this.options.persistentPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Load existing items from disk
|
||||
await this.loadFromDisk();
|
||||
}
|
||||
|
||||
// Start the queue processing timer
|
||||
this.startProcessing();
|
||||
|
||||
// Emit initialized event
|
||||
this.emit('initialized');
|
||||
logger.log('info', 'UnifiedDeliveryQueue initialized successfully');
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to initialize queue: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start queue processing
|
||||
*/
|
||||
private startProcessing(): void {
|
||||
if (this.checkTimer) {
|
||||
clearInterval(this.checkTimer);
|
||||
}
|
||||
|
||||
this.checkTimer = setInterval(() => this.processQueue(), this.options.checkInterval);
|
||||
this.processing = true;
|
||||
this.stats.processingActive = true;
|
||||
this.emit('processingStarted');
|
||||
logger.log('info', 'Queue processing started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop queue processing
|
||||
*/
|
||||
private stopProcessing(): void {
|
||||
if (this.checkTimer) {
|
||||
clearInterval(this.checkTimer);
|
||||
this.checkTimer = undefined;
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
this.stats.processingActive = false;
|
||||
this.emit('processingStopped');
|
||||
logger.log('info', 'Queue processing stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for items that need to be processed
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
try {
|
||||
const now = new Date();
|
||||
let readyItems: IQueueItem[] = [];
|
||||
|
||||
// Find items ready for processing
|
||||
for (const item of this.queue.values()) {
|
||||
if (item.status === 'pending' || (item.status === 'deferred' && item.nextAttempt <= now)) {
|
||||
readyItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (readyItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by oldest first
|
||||
readyItems.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
|
||||
// Emit event for ready items
|
||||
this.emit('itemsReady', readyItems);
|
||||
logger.log('info', `Found ${readyItems.length} items ready for processing`);
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
} catch (error) {
|
||||
logger.log('error', `Error processing queue: ${error.message}`);
|
||||
this.emit('error', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the queue
|
||||
* @param processingResult Processing result to queue
|
||||
* @param mode Processing mode
|
||||
* @param rule Domain rule
|
||||
*/
|
||||
public async enqueue(processingResult: any, mode: EmailProcessingMode, rule: IDomainRule): Promise<string> {
|
||||
// Check if queue is full
|
||||
if (this.queue.size >= this.options.maxQueueSize) {
|
||||
throw new Error('Queue is full');
|
||||
}
|
||||
|
||||
// Generate a unique ID
|
||||
const id = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
||||
|
||||
// Create queue item
|
||||
const item: IQueueItem = {
|
||||
id,
|
||||
processingMode: mode,
|
||||
processingResult,
|
||||
rule,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
nextAttempt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
// Add to queue
|
||||
this.queue.set(id, item);
|
||||
|
||||
// Persist to disk if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.persistItem(item);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
// Emit event
|
||||
this.emit('itemEnqueued', item);
|
||||
logger.log('info', `Item enqueued with ID ${id}, mode: ${mode}`);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an item from the queue
|
||||
* @param id Item ID
|
||||
*/
|
||||
public getItem(id: string): IQueueItem | undefined {
|
||||
return this.queue.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as being processed
|
||||
* @param id Item ID
|
||||
*/
|
||||
public async markProcessing(id: string): Promise<boolean> {
|
||||
const item = this.queue.get(id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update status
|
||||
item.status = 'processing';
|
||||
item.attempts++;
|
||||
item.updatedAt = new Date();
|
||||
|
||||
// Persist changes if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.persistItem(item);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
// Emit event
|
||||
this.emit('itemProcessing', item);
|
||||
logger.log('info', `Item ${id} marked as processing, attempt ${item.attempts}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as delivered
|
||||
* @param id Item ID
|
||||
*/
|
||||
public async markDelivered(id: string): Promise<boolean> {
|
||||
const item = this.queue.get(id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update status
|
||||
item.status = 'delivered';
|
||||
item.updatedAt = new Date();
|
||||
item.deliveredAt = new Date();
|
||||
|
||||
// Persist changes if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.persistItem(item);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.totalProcessed++;
|
||||
this.updateStats();
|
||||
|
||||
// Emit event
|
||||
this.emit('itemDelivered', item);
|
||||
logger.log('info', `Item ${id} marked as delivered after ${item.attempts} attempts`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as failed
|
||||
* @param id Item ID
|
||||
* @param error Error message
|
||||
*/
|
||||
public async markFailed(id: string, error: string): Promise<boolean> {
|
||||
const item = this.queue.get(id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine if we should retry
|
||||
if (item.attempts < this.options.maxRetries) {
|
||||
// Calculate next retry time with exponential backoff
|
||||
const delay = Math.min(
|
||||
this.options.baseRetryDelay * Math.pow(2, item.attempts - 1),
|
||||
this.options.maxRetryDelay
|
||||
);
|
||||
|
||||
// Update status
|
||||
item.status = 'deferred';
|
||||
item.lastError = error;
|
||||
item.nextAttempt = new Date(Date.now() + delay);
|
||||
item.updatedAt = new Date();
|
||||
|
||||
// Persist changes if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.persistItem(item);
|
||||
}
|
||||
|
||||
// Emit event
|
||||
this.emit('itemDeferred', item);
|
||||
logger.log('info', `Item ${id} deferred for ${delay}ms, attempt ${item.attempts}, error: ${error}`);
|
||||
} else {
|
||||
// Mark as permanently failed
|
||||
item.status = 'failed';
|
||||
item.lastError = error;
|
||||
item.updatedAt = new Date();
|
||||
|
||||
// Persist changes if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.persistItem(item);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.totalProcessed++;
|
||||
|
||||
// Emit event
|
||||
this.emit('itemFailed', item);
|
||||
logger.log('warn', `Item ${id} permanently failed after ${item.attempts} attempts, error: ${error}`);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from the queue
|
||||
* @param id Item ID
|
||||
*/
|
||||
public async removeItem(id: string): Promise<boolean> {
|
||||
const item = this.queue.get(id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
this.queue.delete(id);
|
||||
|
||||
// Remove from disk if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.removeItemFromDisk(id);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
// Emit event
|
||||
this.emit('itemRemoved', item);
|
||||
logger.log('info', `Item ${id} removed from queue`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an item to disk
|
||||
* @param item Item to persist
|
||||
*/
|
||||
private async persistItem(item: IQueueItem): Promise<void> {
|
||||
try {
|
||||
const filePath = path.join(this.options.persistentPath, `${item.id}.json`);
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(item, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to persist item ${item.id}: ${error.message}`);
|
||||
this.emit('error', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from disk
|
||||
* @param id Item ID
|
||||
*/
|
||||
private async removeItemFromDisk(id: string): Promise<void> {
|
||||
try {
|
||||
const filePath = path.join(this.options.persistentPath, `${id}.json`);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to remove item ${id} from disk: ${error.message}`);
|
||||
this.emit('error', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load queue items from disk
|
||||
*/
|
||||
private async loadFromDisk(): Promise<void> {
|
||||
try {
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(this.options.persistentPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all JSON files
|
||||
const files = fs.readdirSync(this.options.persistentPath).filter(file => file.endsWith('.json'));
|
||||
|
||||
// Load each file
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(this.options.persistentPath, file);
|
||||
const data = await fs.promises.readFile(filePath, 'utf8');
|
||||
const item = JSON.parse(data) as IQueueItem;
|
||||
|
||||
// Convert date strings to Date objects
|
||||
item.createdAt = new Date(item.createdAt);
|
||||
item.updatedAt = new Date(item.updatedAt);
|
||||
item.nextAttempt = new Date(item.nextAttempt);
|
||||
if (item.deliveredAt) {
|
||||
item.deliveredAt = new Date(item.deliveredAt);
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
this.queue.set(item.id, item);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load item from ${file}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
logger.log('info', `Loaded ${this.queue.size} items from disk`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load items from disk: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update queue statistics
|
||||
*/
|
||||
private updateStats(): void {
|
||||
// Reset counters
|
||||
this.stats.queueSize = this.queue.size;
|
||||
this.stats.status = {
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
deferred: 0
|
||||
};
|
||||
this.stats.modes = {
|
||||
forward: 0,
|
||||
mta: 0,
|
||||
process: 0
|
||||
};
|
||||
|
||||
let totalAttempts = 0;
|
||||
let oldestTime = Date.now();
|
||||
let newestTime = 0;
|
||||
|
||||
// Count by status and mode
|
||||
for (const item of this.queue.values()) {
|
||||
// Count by status
|
||||
this.stats.status[item.status]++;
|
||||
|
||||
// Count by mode
|
||||
this.stats.modes[item.processingMode]++;
|
||||
|
||||
// Track total attempts
|
||||
totalAttempts += item.attempts;
|
||||
|
||||
// Track oldest and newest
|
||||
const itemTime = item.createdAt.getTime();
|
||||
if (itemTime < oldestTime) {
|
||||
oldestTime = itemTime;
|
||||
}
|
||||
if (itemTime > newestTime) {
|
||||
newestTime = itemTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average attempts
|
||||
this.stats.averageAttempts = this.queue.size > 0 ? totalAttempts / this.queue.size : 0;
|
||||
|
||||
// Set oldest and newest
|
||||
this.stats.oldestItem = this.queue.size > 0 ? new Date(oldestTime) : undefined;
|
||||
this.stats.newestItem = this.queue.size > 0 ? new Date(newestTime) : undefined;
|
||||
|
||||
// Set total processed
|
||||
this.stats.totalProcessed = this.totalProcessed;
|
||||
|
||||
// Set processing active
|
||||
this.stats.processingActive = this.processing;
|
||||
|
||||
// Emit statistics event
|
||||
this.emit('statsUpdated', this.stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
public getStats(): IQueueStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause queue processing
|
||||
*/
|
||||
public pause(): void {
|
||||
if (this.processing) {
|
||||
this.stopProcessing();
|
||||
logger.log('info', 'Queue processing paused');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume queue processing
|
||||
*/
|
||||
public resume(): void {
|
||||
if (!this.processing) {
|
||||
this.startProcessing();
|
||||
logger.log('info', 'Queue processing resumed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old delivered and failed items
|
||||
* @param maxAge Maximum age in milliseconds (default: 7 days)
|
||||
*/
|
||||
public async cleanupOldItems(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<number> {
|
||||
const cutoff = new Date(Date.now() - maxAge);
|
||||
let removedCount = 0;
|
||||
|
||||
// Find old items
|
||||
for (const item of this.queue.values()) {
|
||||
if (['delivered', 'failed'].includes(item.status) && item.updatedAt < cutoff) {
|
||||
// Remove item
|
||||
await this.removeItem(item.id);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `Cleaned up ${removedCount} old items`);
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the queue
|
||||
*/
|
||||
public async shutdown(): Promise<void> {
|
||||
logger.log('info', 'Shutting down UnifiedDeliveryQueue');
|
||||
|
||||
// Stop processing
|
||||
this.stopProcessing();
|
||||
|
||||
// If using disk storage, make sure all items are persisted
|
||||
if (this.options.storageType === 'disk') {
|
||||
const pendingWrites: Promise<void>[] = [];
|
||||
|
||||
for (const item of this.queue.values()) {
|
||||
pendingWrites.push(this.persistItem(item));
|
||||
}
|
||||
|
||||
// Wait for all writes to complete
|
||||
await Promise.all(pendingWrites);
|
||||
}
|
||||
|
||||
// Clear the queue (memory only)
|
||||
this.queue.clear();
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
// Emit shutdown event
|
||||
this.emit('shutdown');
|
||||
logger.log('info', 'UnifiedDeliveryQueue shut down successfully');
|
||||
}
|
||||
}
|
942
ts/dcrouter/classes.delivery.system.ts
Normal file
942
ts/dcrouter/classes.delivery.system.ts
Normal file
@ -0,0 +1,942 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import * as net from 'node:net';
|
||||
import * as tls from 'node:tls';
|
||||
import { logger } from '../logger.js';
|
||||
import {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType
|
||||
} from '../security/index.js';
|
||||
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
|
||||
import type { Email } from '../mta/classes.email.js';
|
||||
import type { IDomainRule } from './classes.email.config.js';
|
||||
|
||||
/**
|
||||
* Delivery handler interface
|
||||
*/
|
||||
export interface IDeliveryHandler {
|
||||
deliver(item: IQueueItem): Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery options
|
||||
*/
|
||||
export interface IMultiModeDeliveryOptions {
|
||||
// Connection options
|
||||
connectionPoolSize?: number;
|
||||
socketTimeout?: number;
|
||||
|
||||
// Delivery behavior
|
||||
concurrentDeliveries?: number;
|
||||
sendTimeout?: number;
|
||||
|
||||
// TLS options
|
||||
verifyCertificates?: boolean;
|
||||
tlsMinVersion?: string;
|
||||
|
||||
// Mode-specific handlers
|
||||
forwardHandler?: IDeliveryHandler;
|
||||
mtaHandler?: IDeliveryHandler;
|
||||
processHandler?: IDeliveryHandler;
|
||||
|
||||
// Rate limiting
|
||||
globalRateLimit?: number;
|
||||
perPatternRateLimit?: Record<string, number>;
|
||||
|
||||
// Event hooks
|
||||
onDeliveryStart?: (item: IQueueItem) => Promise<void>;
|
||||
onDeliverySuccess?: (item: IQueueItem, result: any) => Promise<void>;
|
||||
onDeliveryFailed?: (item: IQueueItem, error: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery system statistics
|
||||
*/
|
||||
export interface IDeliveryStats {
|
||||
activeDeliveries: number;
|
||||
totalSuccessful: number;
|
||||
totalFailed: number;
|
||||
avgDeliveryTime: number;
|
||||
byMode: {
|
||||
forward: {
|
||||
successful: number;
|
||||
failed: number;
|
||||
};
|
||||
mta: {
|
||||
successful: number;
|
||||
failed: number;
|
||||
};
|
||||
process: {
|
||||
successful: number;
|
||||
failed: number;
|
||||
};
|
||||
};
|
||||
rateLimiting: {
|
||||
currentRate: number;
|
||||
globalLimit: number;
|
||||
throttled: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles delivery for all email processing modes
|
||||
*/
|
||||
export class MultiModeDeliverySystem extends EventEmitter {
|
||||
private queue: UnifiedDeliveryQueue;
|
||||
private options: Required<IMultiModeDeliveryOptions>;
|
||||
private stats: IDeliveryStats;
|
||||
private deliveryTimes: number[] = [];
|
||||
private activeDeliveries: Set<string> = new Set();
|
||||
private running: boolean = false;
|
||||
private throttled: boolean = false;
|
||||
private rateLimitLastCheck: number = Date.now();
|
||||
private rateLimitCounter: number = 0;
|
||||
|
||||
/**
|
||||
* Create a new multi-mode delivery system
|
||||
* @param queue Unified delivery queue
|
||||
* @param options Delivery options
|
||||
*/
|
||||
constructor(queue: UnifiedDeliveryQueue, options: IMultiModeDeliveryOptions) {
|
||||
super();
|
||||
|
||||
this.queue = queue;
|
||||
|
||||
// Set default options
|
||||
this.options = {
|
||||
connectionPoolSize: options.connectionPoolSize || 10,
|
||||
socketTimeout: options.socketTimeout || 30000, // 30 seconds
|
||||
concurrentDeliveries: options.concurrentDeliveries || 10,
|
||||
sendTimeout: options.sendTimeout || 60000, // 1 minute
|
||||
verifyCertificates: options.verifyCertificates !== false, // Default to true
|
||||
tlsMinVersion: options.tlsMinVersion || 'TLSv1.2',
|
||||
forwardHandler: options.forwardHandler || {
|
||||
deliver: this.handleForwardDelivery.bind(this)
|
||||
},
|
||||
mtaHandler: options.mtaHandler || {
|
||||
deliver: this.handleMtaDelivery.bind(this)
|
||||
},
|
||||
processHandler: options.processHandler || {
|
||||
deliver: this.handleProcessDelivery.bind(this)
|
||||
},
|
||||
globalRateLimit: options.globalRateLimit || 100, // 100 emails per minute
|
||||
perPatternRateLimit: options.perPatternRateLimit || {},
|
||||
onDeliveryStart: options.onDeliveryStart || (async () => {}),
|
||||
onDeliverySuccess: options.onDeliverySuccess || (async () => {}),
|
||||
onDeliveryFailed: options.onDeliveryFailed || (async () => {})
|
||||
};
|
||||
|
||||
// Initialize statistics
|
||||
this.stats = {
|
||||
activeDeliveries: 0,
|
||||
totalSuccessful: 0,
|
||||
totalFailed: 0,
|
||||
avgDeliveryTime: 0,
|
||||
byMode: {
|
||||
forward: {
|
||||
successful: 0,
|
||||
failed: 0
|
||||
},
|
||||
mta: {
|
||||
successful: 0,
|
||||
failed: 0
|
||||
},
|
||||
process: {
|
||||
successful: 0,
|
||||
failed: 0
|
||||
}
|
||||
},
|
||||
rateLimiting: {
|
||||
currentRate: 0,
|
||||
globalLimit: this.options.globalRateLimit,
|
||||
throttled: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Set up event listeners
|
||||
this.queue.on('itemsReady', this.processItems.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the delivery system
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
logger.log('info', 'Starting MultiModeDeliverySystem');
|
||||
|
||||
if (this.running) {
|
||||
logger.log('warn', 'MultiModeDeliverySystem is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
// Emit started event
|
||||
this.emit('started');
|
||||
logger.log('info', 'MultiModeDeliverySystem started successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the delivery system
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
logger.log('info', 'Stopping MultiModeDeliverySystem');
|
||||
|
||||
if (!this.running) {
|
||||
logger.log('warn', 'MultiModeDeliverySystem is already stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = false;
|
||||
|
||||
// Wait for active deliveries to complete
|
||||
if (this.activeDeliveries.size > 0) {
|
||||
logger.log('info', `Waiting for ${this.activeDeliveries.size} active deliveries to complete`);
|
||||
|
||||
// Wait for a maximum of 30 seconds
|
||||
await new Promise<void>(resolve => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (this.activeDeliveries.size === 0) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Force resolve after 30 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
// Emit stopped event
|
||||
this.emit('stopped');
|
||||
logger.log('info', 'MultiModeDeliverySystem stopped successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process ready items from the queue
|
||||
* @param items Queue items ready for processing
|
||||
*/
|
||||
private async processItems(items: IQueueItem[]): Promise<void> {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're already at max concurrent deliveries
|
||||
if (this.activeDeliveries.size >= this.options.concurrentDeliveries) {
|
||||
logger.log('debug', `Already at max concurrent deliveries (${this.activeDeliveries.size})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check rate limiting
|
||||
if (this.checkRateLimit()) {
|
||||
logger.log('debug', 'Rate limit exceeded, throttling deliveries');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate how many more deliveries we can start
|
||||
const availableSlots = this.options.concurrentDeliveries - this.activeDeliveries.size;
|
||||
const itemsToProcess = items.slice(0, availableSlots);
|
||||
|
||||
if (itemsToProcess.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', `Processing ${itemsToProcess.length} items for delivery`);
|
||||
|
||||
// Process each item
|
||||
for (const item of itemsToProcess) {
|
||||
// Mark as processing
|
||||
await this.queue.markProcessing(item.id);
|
||||
|
||||
// Add to active deliveries
|
||||
this.activeDeliveries.add(item.id);
|
||||
this.stats.activeDeliveries = this.activeDeliveries.size;
|
||||
|
||||
// Deliver asynchronously
|
||||
this.deliverItem(item).catch(err => {
|
||||
logger.log('error', `Unhandled error in delivery: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.emit('statsUpdated', this.stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver an item from the queue
|
||||
* @param item Queue item to deliver
|
||||
*/
|
||||
private async deliverItem(item: IQueueItem): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Call delivery start hook
|
||||
await this.options.onDeliveryStart(item);
|
||||
|
||||
// Emit delivery start event
|
||||
this.emit('deliveryStart', item);
|
||||
logger.log('info', `Starting delivery of item ${item.id}, mode: ${item.processingMode}`);
|
||||
|
||||
// Choose the appropriate handler based on mode
|
||||
let result: any;
|
||||
|
||||
switch (item.processingMode) {
|
||||
case 'forward':
|
||||
result = await this.options.forwardHandler.deliver(item);
|
||||
break;
|
||||
|
||||
case 'mta':
|
||||
result = await this.options.mtaHandler.deliver(item);
|
||||
break;
|
||||
|
||||
case 'process':
|
||||
result = await this.options.processHandler.deliver(item);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown processing mode: ${item.processingMode}`);
|
||||
}
|
||||
|
||||
// Mark as delivered
|
||||
await this.queue.markDelivered(item.id);
|
||||
|
||||
// Update statistics
|
||||
this.stats.totalSuccessful++;
|
||||
this.stats.byMode[item.processingMode].successful++;
|
||||
|
||||
// Calculate delivery time
|
||||
const deliveryTime = Date.now() - startTime;
|
||||
this.deliveryTimes.push(deliveryTime);
|
||||
this.updateDeliveryTimeStats();
|
||||
|
||||
// Call delivery success hook
|
||||
await this.options.onDeliverySuccess(item, result);
|
||||
|
||||
// Emit delivery success event
|
||||
this.emit('deliverySuccess', item, result);
|
||||
logger.log('info', `Item ${item.id} delivered successfully in ${deliveryTime}ms`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.EMAIL_DELIVERY,
|
||||
message: 'Email delivery successful',
|
||||
details: {
|
||||
itemId: item.id,
|
||||
mode: item.processingMode,
|
||||
pattern: item.rule.pattern,
|
||||
deliveryTime
|
||||
},
|
||||
success: true
|
||||
});
|
||||
} catch (error) {
|
||||
// Calculate delivery attempt time even for failures
|
||||
const deliveryTime = Date.now() - startTime;
|
||||
|
||||
// Mark as failed
|
||||
await this.queue.markFailed(item.id, error.message);
|
||||
|
||||
// Update statistics
|
||||
this.stats.totalFailed++;
|
||||
this.stats.byMode[item.processingMode].failed++;
|
||||
|
||||
// Call delivery failed hook
|
||||
await this.options.onDeliveryFailed(item, error.message);
|
||||
|
||||
// Emit delivery failed event
|
||||
this.emit('deliveryFailed', item, error);
|
||||
logger.log('error', `Item ${item.id} delivery failed: ${error.message}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.EMAIL_DELIVERY,
|
||||
message: 'Email delivery failed',
|
||||
details: {
|
||||
itemId: item.id,
|
||||
mode: item.processingMode,
|
||||
pattern: item.rule.pattern,
|
||||
error: error.message,
|
||||
deliveryTime
|
||||
},
|
||||
success: false
|
||||
});
|
||||
} finally {
|
||||
// Remove from active deliveries
|
||||
this.activeDeliveries.delete(item.id);
|
||||
this.stats.activeDeliveries = this.activeDeliveries.size;
|
||||
|
||||
// Update statistics
|
||||
this.emit('statsUpdated', this.stats);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default handler for forward mode delivery
|
||||
* @param item Queue item
|
||||
*/
|
||||
private async handleForwardDelivery(item: IQueueItem): Promise<any> {
|
||||
logger.log('info', `Forward delivery for item ${item.id}`);
|
||||
|
||||
const email = item.processingResult as Email;
|
||||
const rule = item.rule;
|
||||
|
||||
// Get target server information
|
||||
const targetServer = rule.target?.server;
|
||||
const targetPort = rule.target?.port || 25;
|
||||
const useTls = rule.target?.useTls ?? false;
|
||||
|
||||
if (!targetServer) {
|
||||
throw new Error('No target server configured for forward mode');
|
||||
}
|
||||
|
||||
logger.log('info', `Forwarding email to ${targetServer}:${targetPort}, TLS: ${useTls}`);
|
||||
|
||||
// Create a socket connection to the target server
|
||||
const socket = new net.Socket();
|
||||
|
||||
// Set timeout
|
||||
socket.setTimeout(this.options.socketTimeout);
|
||||
|
||||
try {
|
||||
// Connect to the target server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// Handle connection events
|
||||
socket.on('connect', () => {
|
||||
logger.log('debug', `Connected to ${targetServer}:${targetPort}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
reject(new Error(`Connection timeout to ${targetServer}:${targetPort}`));
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
reject(new Error(`Connection error to ${targetServer}:${targetPort}: ${err.message}`));
|
||||
});
|
||||
|
||||
// Connect to the server
|
||||
socket.connect({
|
||||
host: targetServer,
|
||||
port: targetPort
|
||||
});
|
||||
});
|
||||
|
||||
// Implement SMTP protocol here
|
||||
// This is a simplified implementation
|
||||
|
||||
// Send EHLO
|
||||
await this.smtpCommand(socket, `EHLO ${rule.mtaOptions?.domain || 'localhost'}`);
|
||||
|
||||
// Start TLS if required
|
||||
if (useTls) {
|
||||
await this.smtpCommand(socket, 'STARTTLS');
|
||||
|
||||
// Upgrade to TLS
|
||||
const tlsSocket = await this.upgradeTls(socket, targetServer);
|
||||
|
||||
// Send EHLO again after STARTTLS
|
||||
await this.smtpCommand(tlsSocket, `EHLO ${rule.mtaOptions?.domain || 'localhost'}`);
|
||||
|
||||
// Use tlsSocket for remaining commands
|
||||
return this.completeSMTPExchange(tlsSocket, email, rule);
|
||||
}
|
||||
|
||||
// Complete the SMTP exchange
|
||||
return this.completeSMTPExchange(socket, email, rule);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
||||
|
||||
// Close the connection
|
||||
socket.destroy();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the SMTP exchange after connection and initial setup
|
||||
* @param socket Network socket
|
||||
* @param email Email to send
|
||||
* @param rule Domain rule
|
||||
*/
|
||||
private async completeSMTPExchange(socket: net.Socket | tls.TLSSocket, email: Email, rule: IDomainRule): Promise<any> {
|
||||
try {
|
||||
// Authenticate if credentials provided
|
||||
if (rule.target?.authentication?.user && rule.target?.authentication?.pass) {
|
||||
// Send AUTH LOGIN
|
||||
await this.smtpCommand(socket, 'AUTH LOGIN');
|
||||
|
||||
// Send username (base64)
|
||||
const username = Buffer.from(rule.target.authentication.user).toString('base64');
|
||||
await this.smtpCommand(socket, username);
|
||||
|
||||
// Send password (base64)
|
||||
const password = Buffer.from(rule.target.authentication.pass).toString('base64');
|
||||
await this.smtpCommand(socket, password);
|
||||
}
|
||||
|
||||
// Send MAIL FROM
|
||||
await this.smtpCommand(socket, `MAIL FROM:<${email.from}>`);
|
||||
|
||||
// Send RCPT TO for each recipient
|
||||
for (const recipient of email.getAllRecipients()) {
|
||||
await this.smtpCommand(socket, `RCPT TO:<${recipient}>`);
|
||||
}
|
||||
|
||||
// Send DATA
|
||||
await this.smtpCommand(socket, 'DATA');
|
||||
|
||||
// Send email content (simplified)
|
||||
const emailContent = await this.getFormattedEmail(email);
|
||||
await this.smtpData(socket, emailContent);
|
||||
|
||||
// Send QUIT
|
||||
await this.smtpCommand(socket, 'QUIT');
|
||||
|
||||
// Close the connection
|
||||
socket.end();
|
||||
|
||||
logger.log('info', `Email forwarded successfully to ${rule.target?.server}:${rule.target?.port || 25}`);
|
||||
|
||||
return {
|
||||
targetServer: rule.target?.server,
|
||||
targetPort: rule.target?.port || 25,
|
||||
recipients: email.getAllRecipients().length
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
||||
|
||||
// Close the connection
|
||||
socket.destroy();
|
||||
|
||||
throw error;
|
||||
}
|
||||
socket.destroy();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default handler for MTA mode delivery
|
||||
* @param item Queue item
|
||||
*/
|
||||
private async handleMtaDelivery(item: IQueueItem): Promise<any> {
|
||||
logger.log('info', `MTA delivery for item ${item.id}`);
|
||||
|
||||
const email = item.processingResult as Email;
|
||||
const rule = item.rule;
|
||||
|
||||
try {
|
||||
// In a full implementation, this would use the MTA service
|
||||
// For now, we'll simulate a successful delivery
|
||||
|
||||
logger.log('info', `Email processed by MTA: ${email.subject} to ${email.getAllRecipients().join(', ')}`);
|
||||
|
||||
// Apply MTA rule options if provided
|
||||
if (rule.mtaOptions) {
|
||||
const options = rule.mtaOptions;
|
||||
|
||||
// Apply DKIM signing if enabled
|
||||
if (options.dkimSign && options.dkimOptions) {
|
||||
// Sign the email with DKIM
|
||||
logger.log('info', `Signing email with DKIM for domain ${options.dkimOptions.domainName}`);
|
||||
|
||||
// In a full implementation, this would use the DKIM signing library
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate successful delivery
|
||||
return {
|
||||
recipients: email.getAllRecipients().length,
|
||||
subject: email.subject,
|
||||
dkimSigned: !!rule.mtaOptions?.dkimSign
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to process email in MTA mode: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default handler for process mode delivery
|
||||
* @param item Queue item
|
||||
*/
|
||||
private async handleProcessDelivery(item: IQueueItem): Promise<any> {
|
||||
logger.log('info', `Process delivery for item ${item.id}`);
|
||||
|
||||
const email = item.processingResult as Email;
|
||||
const rule = item.rule;
|
||||
|
||||
try {
|
||||
// Apply content scanning if enabled
|
||||
if (rule.contentScanning && rule.scanners && rule.scanners.length > 0) {
|
||||
logger.log('info', 'Performing content scanning');
|
||||
|
||||
// Apply each scanner
|
||||
for (const scanner of rule.scanners) {
|
||||
switch (scanner.type) {
|
||||
case 'spam':
|
||||
logger.log('info', 'Scanning for spam content');
|
||||
// Implement spam scanning
|
||||
break;
|
||||
|
||||
case 'virus':
|
||||
logger.log('info', 'Scanning for virus content');
|
||||
// Implement virus scanning
|
||||
break;
|
||||
|
||||
case 'attachment':
|
||||
logger.log('info', 'Scanning attachments');
|
||||
|
||||
// Check for blocked extensions
|
||||
if (scanner.blockedExtensions && scanner.blockedExtensions.length > 0) {
|
||||
for (const attachment of email.attachments) {
|
||||
const ext = this.getFileExtension(attachment.filename);
|
||||
if (scanner.blockedExtensions.includes(ext)) {
|
||||
if (scanner.action === 'reject') {
|
||||
throw new Error(`Blocked attachment type: ${ext}`);
|
||||
} else { // tag
|
||||
email.addHeader('X-Attachment-Warning', `Potentially unsafe attachment: ${attachment.filename}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply transformations if defined
|
||||
if (rule.transformations && rule.transformations.length > 0) {
|
||||
logger.log('info', 'Applying email transformations');
|
||||
|
||||
for (const transform of rule.transformations) {
|
||||
switch (transform.type) {
|
||||
case 'addHeader':
|
||||
if (transform.header && transform.value) {
|
||||
email.addHeader(transform.header, transform.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `Email successfully processed in store-and-forward mode`);
|
||||
|
||||
// Simulate successful delivery
|
||||
return {
|
||||
recipients: email.getAllRecipients().length,
|
||||
subject: email.subject,
|
||||
scanned: !!rule.contentScanning,
|
||||
transformed: !!(rule.transformations && rule.transformations.length > 0)
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to process email: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from filename
|
||||
*/
|
||||
private getFileExtension(filename: string): string {
|
||||
return filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format email for SMTP transmission
|
||||
* @param email Email to format
|
||||
*/
|
||||
private async getFormattedEmail(email: Email): Promise<string> {
|
||||
// This is a simplified implementation
|
||||
// In a full implementation, this would use proper MIME formatting
|
||||
|
||||
let content = '';
|
||||
|
||||
// Add headers
|
||||
content += `From: ${email.from}\r\n`;
|
||||
content += `To: ${email.to}\r\n`;
|
||||
content += `Subject: ${email.subject}\r\n`;
|
||||
|
||||
// Add additional headers
|
||||
for (const [name, value] of Object.entries(email.headers || {})) {
|
||||
content += `${name}: ${value}\r\n`;
|
||||
}
|
||||
|
||||
// Add content type for multipart
|
||||
if (email.attachments && email.attachments.length > 0) {
|
||||
const boundary = `----_=_NextPart_${Math.random().toString(36).substr(2)}`;
|
||||
content += `MIME-Version: 1.0\r\n`;
|
||||
content += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
|
||||
content += `\r\n`;
|
||||
|
||||
// Add text part
|
||||
content += `--${boundary}\r\n`;
|
||||
content += `Content-Type: text/plain; charset="UTF-8"\r\n`;
|
||||
content += `\r\n`;
|
||||
content += `${email.text}\r\n`;
|
||||
|
||||
// Add HTML part if present
|
||||
if (email.html) {
|
||||
content += `--${boundary}\r\n`;
|
||||
content += `Content-Type: text/html; charset="UTF-8"\r\n`;
|
||||
content += `\r\n`;
|
||||
content += `${email.html}\r\n`;
|
||||
}
|
||||
|
||||
// Add attachments
|
||||
for (const attachment of email.attachments) {
|
||||
content += `--${boundary}\r\n`;
|
||||
content += `Content-Type: ${attachment.contentType || 'application/octet-stream'}; name="${attachment.filename}"\r\n`;
|
||||
content += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
|
||||
content += `Content-Transfer-Encoding: base64\r\n`;
|
||||
content += `\r\n`;
|
||||
|
||||
// Add base64 encoded content
|
||||
const base64Content = attachment.content.toString('base64');
|
||||
|
||||
// Split into lines of 76 characters
|
||||
for (let i = 0; i < base64Content.length; i += 76) {
|
||||
content += base64Content.substring(i, i + 76) + '\r\n';
|
||||
}
|
||||
}
|
||||
|
||||
// End boundary
|
||||
content += `--${boundary}--\r\n`;
|
||||
} else {
|
||||
// Simple email with just text
|
||||
content += `Content-Type: text/plain; charset="UTF-8"\r\n`;
|
||||
content += `\r\n`;
|
||||
content += `${email.text}\r\n`;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP command and wait for response
|
||||
* @param socket Socket connection
|
||||
* @param command SMTP command to send
|
||||
*/
|
||||
private async smtpCommand(socket: net.Socket, command: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const onData = (data: Buffer) => {
|
||||
const response = data.toString().trim();
|
||||
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
// Check response code
|
||||
if (response.charAt(0) === '2' || response.charAt(0) === '3') {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`SMTP error: ${response}`));
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(new Error('SMTP command timeout'));
|
||||
};
|
||||
|
||||
// Set up listeners
|
||||
socket.once('data', onData);
|
||||
socket.once('error', onError);
|
||||
socket.once('timeout', onTimeout);
|
||||
|
||||
// Send command
|
||||
socket.write(command + '\r\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP DATA command with content
|
||||
* @param socket Socket connection
|
||||
* @param data Email content to send
|
||||
*/
|
||||
private async smtpData(socket: net.Socket, data: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const onData = (responseData: Buffer) => {
|
||||
const response = responseData.toString().trim();
|
||||
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
// Check response code
|
||||
if (response.charAt(0) === '2') {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`SMTP error: ${response}`));
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(new Error('SMTP data timeout'));
|
||||
};
|
||||
|
||||
// Set up listeners
|
||||
socket.once('data', onData);
|
||||
socket.once('error', onError);
|
||||
socket.once('timeout', onTimeout);
|
||||
|
||||
// Send data and end with CRLF.CRLF
|
||||
socket.write(data + '\r\n.\r\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade socket to TLS
|
||||
* @param socket Socket connection
|
||||
* @param hostname Target hostname for TLS
|
||||
*/
|
||||
private async upgradeTls(socket: net.Socket, hostname: string): Promise<tls.TLSSocket> {
|
||||
return new Promise<tls.TLSSocket>((resolve, reject) => {
|
||||
const tlsOptions: tls.ConnectionOptions = {
|
||||
socket,
|
||||
servername: hostname,
|
||||
rejectUnauthorized: this.options.verifyCertificates,
|
||||
minVersion: this.options.tlsMinVersion as tls.SecureVersion
|
||||
};
|
||||
|
||||
const tlsSocket = tls.connect(tlsOptions);
|
||||
|
||||
tlsSocket.once('secureConnect', () => {
|
||||
resolve(tlsSocket);
|
||||
});
|
||||
|
||||
tlsSocket.once('error', (err) => {
|
||||
reject(new Error(`TLS error: ${err.message}`));
|
||||
});
|
||||
|
||||
tlsSocket.setTimeout(this.options.socketTimeout);
|
||||
|
||||
tlsSocket.once('timeout', () => {
|
||||
reject(new Error('TLS connection timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update delivery time statistics
|
||||
*/
|
||||
private updateDeliveryTimeStats(): void {
|
||||
if (this.deliveryTimes.length === 0) return;
|
||||
|
||||
// Keep only the last 1000 delivery times
|
||||
if (this.deliveryTimes.length > 1000) {
|
||||
this.deliveryTimes = this.deliveryTimes.slice(-1000);
|
||||
}
|
||||
|
||||
// Calculate average
|
||||
const sum = this.deliveryTimes.reduce((acc, time) => acc + time, 0);
|
||||
this.stats.avgDeliveryTime = sum / this.deliveryTimes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rate limit is exceeded
|
||||
* @returns True if rate limited, false otherwise
|
||||
*/
|
||||
private checkRateLimit(): boolean {
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.rateLimitLastCheck;
|
||||
|
||||
// Reset counter if more than a minute has passed
|
||||
if (elapsed >= 60000) {
|
||||
this.rateLimitLastCheck = now;
|
||||
this.rateLimitCounter = 0;
|
||||
this.throttled = false;
|
||||
this.stats.rateLimiting.currentRate = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we're already throttled
|
||||
if (this.throttled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
this.rateLimitCounter++;
|
||||
|
||||
// Calculate current rate (emails per minute)
|
||||
const rate = (this.rateLimitCounter / elapsed) * 60000;
|
||||
this.stats.rateLimiting.currentRate = rate;
|
||||
|
||||
// Check if rate limit is exceeded
|
||||
if (rate > this.options.globalRateLimit) {
|
||||
this.throttled = true;
|
||||
this.stats.rateLimiting.throttled++;
|
||||
|
||||
// Schedule throttle reset
|
||||
const resetDelay = 60000 - elapsed;
|
||||
setTimeout(() => {
|
||||
this.throttled = false;
|
||||
this.rateLimitLastCheck = Date.now();
|
||||
this.rateLimitCounter = 0;
|
||||
this.stats.rateLimiting.currentRate = 0;
|
||||
}, resetDelay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update delivery options
|
||||
* @param options New options
|
||||
*/
|
||||
public updateOptions(options: Partial<IMultiModeDeliveryOptions>): void {
|
||||
this.options = {
|
||||
...this.options,
|
||||
...options
|
||||
};
|
||||
|
||||
// Update rate limit statistics
|
||||
if (options.globalRateLimit) {
|
||||
this.stats.rateLimiting.globalLimit = options.globalRateLimit;
|
||||
}
|
||||
|
||||
logger.log('info', 'MultiModeDeliverySystem options updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delivery statistics
|
||||
*/
|
||||
public getStats(): IDeliveryStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
}
|
369
ts/dcrouter/classes.domain.router.ts
Normal file
369
ts/dcrouter/classes.domain.router.ts
Normal file
@ -0,0 +1,369 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { type IDomainRule, type EmailProcessingMode } from './classes.email.config.js';
|
||||
|
||||
/**
|
||||
* Options for the domain-based router
|
||||
*/
|
||||
export interface IDomainRouterOptions {
|
||||
// Domain rules with glob pattern matching
|
||||
domainRules: IDomainRule[];
|
||||
|
||||
// Default handling for unmatched domains
|
||||
defaultMode: EmailProcessingMode;
|
||||
defaultServer?: string;
|
||||
defaultPort?: number;
|
||||
defaultTls?: boolean;
|
||||
|
||||
// Pattern matching options
|
||||
caseSensitive?: boolean;
|
||||
priorityOrder?: 'most-specific' | 'first-match';
|
||||
|
||||
// Cache settings for pattern matching
|
||||
enableCache?: boolean;
|
||||
cacheSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a pattern match operation
|
||||
*/
|
||||
export interface IPatternMatchResult {
|
||||
rule: IDomainRule;
|
||||
exactMatch: boolean;
|
||||
wildcardMatch: boolean;
|
||||
specificity: number; // Higher is more specific
|
||||
}
|
||||
|
||||
/**
|
||||
* A pattern matching and routing class for email domains
|
||||
*/
|
||||
export class DomainRouter extends EventEmitter {
|
||||
private options: IDomainRouterOptions;
|
||||
private patternCache: Map<string, IDomainRule | null> = new Map();
|
||||
|
||||
/**
|
||||
* Create a new domain router
|
||||
* @param options Router options
|
||||
*/
|
||||
constructor(options: IDomainRouterOptions) {
|
||||
super();
|
||||
this.options = {
|
||||
// Default options
|
||||
caseSensitive: false,
|
||||
priorityOrder: 'most-specific',
|
||||
enableCache: true,
|
||||
cacheSize: 1000,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an email address against defined rules
|
||||
* @param email Email address to match
|
||||
* @returns The matching rule or null if no match
|
||||
*/
|
||||
public matchRule(email: string): IDomainRule | null {
|
||||
// Check cache first if enabled
|
||||
if (this.options.enableCache && this.patternCache.has(email)) {
|
||||
return this.patternCache.get(email) || null;
|
||||
}
|
||||
|
||||
// Normalize email if case-insensitive
|
||||
const normalizedEmail = this.options.caseSensitive ? email : email.toLowerCase();
|
||||
|
||||
// Get all matching rules
|
||||
const matches = this.getAllMatchingRules(normalizedEmail);
|
||||
|
||||
if (matches.length === 0) {
|
||||
// Cache the result (null) if caching is enabled
|
||||
if (this.options.enableCache) {
|
||||
this.addToCache(email, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by specificity or order
|
||||
let matchedRule: IDomainRule;
|
||||
|
||||
if (this.options.priorityOrder === 'most-specific') {
|
||||
// Sort by specificity (most specific first)
|
||||
const sortedMatches = matches.sort((a, b) => {
|
||||
const aSpecificity = this.calculateSpecificity(a.pattern);
|
||||
const bSpecificity = this.calculateSpecificity(b.pattern);
|
||||
return bSpecificity - aSpecificity;
|
||||
});
|
||||
|
||||
matchedRule = sortedMatches[0];
|
||||
} else {
|
||||
// First match in the list
|
||||
matchedRule = matches[0];
|
||||
}
|
||||
|
||||
// Cache the result if caching is enabled
|
||||
if (this.options.enableCache) {
|
||||
this.addToCache(email, matchedRule);
|
||||
}
|
||||
|
||||
return matchedRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate pattern specificity
|
||||
* Higher is more specific
|
||||
* @param pattern Pattern to calculate specificity for
|
||||
*/
|
||||
private calculateSpecificity(pattern: string): number {
|
||||
let specificity = 0;
|
||||
|
||||
// Exact match is most specific
|
||||
if (!pattern.includes('*')) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
// Count characters that aren't wildcards
|
||||
specificity += pattern.replace(/\*/g, '').length;
|
||||
|
||||
// Position of wildcards affects specificity
|
||||
if (pattern.startsWith('*@')) {
|
||||
// Wildcard in local part
|
||||
specificity += 10;
|
||||
} else if (pattern.includes('@*')) {
|
||||
// Wildcard in domain part
|
||||
specificity += 20;
|
||||
}
|
||||
|
||||
return specificity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email matches a specific pattern
|
||||
* @param email Email address to check
|
||||
* @param pattern Pattern to check against
|
||||
* @returns True if matching, false otherwise
|
||||
*/
|
||||
public matchesPattern(email: string, pattern: string): boolean {
|
||||
// Normalize if case-insensitive
|
||||
const normalizedEmail = this.options.caseSensitive ? email : email.toLowerCase();
|
||||
const normalizedPattern = this.options.caseSensitive ? pattern : pattern.toLowerCase();
|
||||
|
||||
// Exact match
|
||||
if (normalizedEmail === normalizedPattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Convert glob pattern to regex
|
||||
const regexPattern = this.globToRegExp(normalizedPattern);
|
||||
return regexPattern.test(normalizedEmail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a regular expression
|
||||
* @param pattern Glob pattern
|
||||
* @returns Regular expression
|
||||
*/
|
||||
private globToRegExp(pattern: string): RegExp {
|
||||
// Escape special regex characters except * and ?
|
||||
let regexString = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.');
|
||||
|
||||
return new RegExp(`^${regexString}$`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rules that match an email address
|
||||
* @param email Email address to match
|
||||
* @returns Array of matching rules
|
||||
*/
|
||||
public getAllMatchingRules(email: string): IDomainRule[] {
|
||||
return this.options.domainRules.filter(rule => this.matchesPattern(email, rule.pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new routing rule
|
||||
* @param rule Domain rule to add
|
||||
*/
|
||||
public addRule(rule: IDomainRule): void {
|
||||
// Validate the rule
|
||||
this.validateRule(rule);
|
||||
|
||||
// Add the rule
|
||||
this.options.domainRules.push(rule);
|
||||
|
||||
// Clear cache since rules have changed
|
||||
this.clearCache();
|
||||
|
||||
// Emit event
|
||||
this.emit('ruleAdded', rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a domain rule
|
||||
* @param rule Rule to validate
|
||||
*/
|
||||
private validateRule(rule: IDomainRule): void {
|
||||
// Pattern is required
|
||||
if (!rule.pattern) {
|
||||
throw new Error('Domain rule pattern is required');
|
||||
}
|
||||
|
||||
// Mode is required
|
||||
if (!rule.mode) {
|
||||
throw new Error('Domain rule mode is required');
|
||||
}
|
||||
|
||||
// Forward mode requires target
|
||||
if (rule.mode === 'forward' && !rule.target) {
|
||||
throw new Error('Forward mode requires target configuration');
|
||||
}
|
||||
|
||||
// Forward mode target requires server
|
||||
if (rule.mode === 'forward' && rule.target && !rule.target.server) {
|
||||
throw new Error('Forward mode target requires server');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing rule
|
||||
* @param pattern Pattern to update
|
||||
* @param updates Updates to apply
|
||||
* @returns True if rule was found and updated, false otherwise
|
||||
*/
|
||||
public updateRule(pattern: string, updates: Partial<IDomainRule>): boolean {
|
||||
const ruleIndex = this.options.domainRules.findIndex(r => r.pattern === pattern);
|
||||
|
||||
if (ruleIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get current rule
|
||||
const currentRule = this.options.domainRules[ruleIndex];
|
||||
|
||||
// Create updated rule
|
||||
const updatedRule: IDomainRule = {
|
||||
...currentRule,
|
||||
...updates
|
||||
};
|
||||
|
||||
// Validate the updated rule
|
||||
this.validateRule(updatedRule);
|
||||
|
||||
// Update the rule
|
||||
this.options.domainRules[ruleIndex] = updatedRule;
|
||||
|
||||
// Clear cache since rules have changed
|
||||
this.clearCache();
|
||||
|
||||
// Emit event
|
||||
this.emit('ruleUpdated', updatedRule);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a rule
|
||||
* @param pattern Pattern to remove
|
||||
* @returns True if rule was found and removed, false otherwise
|
||||
*/
|
||||
public removeRule(pattern: string): boolean {
|
||||
const initialLength = this.options.domainRules.length;
|
||||
this.options.domainRules = this.options.domainRules.filter(r => r.pattern !== pattern);
|
||||
|
||||
const removed = initialLength > this.options.domainRules.length;
|
||||
|
||||
if (removed) {
|
||||
// Clear cache since rules have changed
|
||||
this.clearCache();
|
||||
|
||||
// Emit event
|
||||
this.emit('ruleRemoved', pattern);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule by pattern
|
||||
* @param pattern Pattern to find
|
||||
* @returns Rule with matching pattern or null if not found
|
||||
*/
|
||||
public getRule(pattern: string): IDomainRule | null {
|
||||
return this.options.domainRules.find(r => r.pattern === pattern) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rules
|
||||
* @returns Array of all domain rules
|
||||
*/
|
||||
public getRules(): IDomainRule[] {
|
||||
return [...this.options.domainRules];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update options
|
||||
* @param options New options
|
||||
*/
|
||||
public updateOptions(options: Partial<IDomainRouterOptions>): void {
|
||||
this.options = {
|
||||
...this.options,
|
||||
...options
|
||||
};
|
||||
|
||||
// Clear cache if cache settings changed
|
||||
if ('enableCache' in options || 'cacheSize' in options) {
|
||||
this.clearCache();
|
||||
}
|
||||
|
||||
// Emit event
|
||||
this.emit('optionsUpdated', this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the pattern cache
|
||||
* @param email Email address
|
||||
* @param rule Matching rule or null
|
||||
*/
|
||||
private addToCache(email: string, rule: IDomainRule | null): void {
|
||||
// If cache is disabled, do nothing
|
||||
if (!this.options.enableCache) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to cache
|
||||
this.patternCache.set(email, rule);
|
||||
|
||||
// Check if cache size exceeds limit
|
||||
if (this.patternCache.size > (this.options.cacheSize || 1000)) {
|
||||
// Remove oldest entry (first in the Map)
|
||||
const firstKey = this.patternCache.keys().next().value;
|
||||
this.patternCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pattern matching cache
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.patternCache.clear();
|
||||
this.emit('cacheCleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all domain rules at once
|
||||
* @param rules New set of domain rules to replace existing ones
|
||||
*/
|
||||
public updateRules(rules: IDomainRule[]): void {
|
||||
// Validate all rules
|
||||
rules.forEach(rule => this.validateRule(rule));
|
||||
|
||||
// Replace all rules
|
||||
this.options.domainRules = [...rules];
|
||||
|
||||
// Clear cache since rules have changed
|
||||
this.clearCache();
|
||||
|
||||
// Emit event
|
||||
this.emit('rulesUpdated', rules);
|
||||
}
|
||||
}
|
129
ts/dcrouter/classes.email.config.ts
Normal file
129
ts/dcrouter/classes.email.config.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Email processing modes
|
||||
*/
|
||||
export type EmailProcessingMode = 'forward' | 'mta' | 'process';
|
||||
|
||||
/**
|
||||
* Consolidated email configuration interface
|
||||
*/
|
||||
export interface IEmailConfig {
|
||||
// Email server settings
|
||||
ports: number[];
|
||||
hostname: string;
|
||||
maxMessageSize?: number;
|
||||
|
||||
// TLS configuration for email server
|
||||
tls?: {
|
||||
certPath?: string;
|
||||
keyPath?: string;
|
||||
caPath?: string;
|
||||
minVersion?: string;
|
||||
};
|
||||
|
||||
// Authentication for inbound connections
|
||||
auth?: {
|
||||
required?: boolean;
|
||||
methods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
||||
users?: Array<{username: string, password: string}>;
|
||||
};
|
||||
|
||||
// Default routing for unmatched domains
|
||||
defaultMode: EmailProcessingMode;
|
||||
defaultServer?: string;
|
||||
defaultPort?: number;
|
||||
defaultTls?: boolean;
|
||||
|
||||
// Domain rules with glob pattern support
|
||||
domainRules: IDomainRule[];
|
||||
|
||||
// Queue configuration for all email processing
|
||||
queue?: {
|
||||
storageType?: 'memory' | 'disk';
|
||||
persistentPath?: string;
|
||||
maxRetries?: number;
|
||||
baseRetryDelay?: number;
|
||||
maxRetryDelay?: number;
|
||||
};
|
||||
|
||||
// Advanced MTA settings
|
||||
mtaGlobalOptions?: IMtaOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
431
ts/dcrouter/classes.email.domainrouter.ts
Normal file
431
ts/dcrouter/classes.email.domainrouter.ts
Normal file
@ -0,0 +1,431 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Domain group configuration for applying consistent rules across related domains
|
||||
*/
|
||||
export interface IDomainGroup {
|
||||
/** Unique identifier for the domain group */
|
||||
id: string;
|
||||
/** Human-readable name for the domain group */
|
||||
name: string;
|
||||
/** List of domains in this group */
|
||||
domains: string[];
|
||||
/** Priority for this domain group (higher takes precedence) */
|
||||
priority?: number;
|
||||
/** Description of this domain group */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain pattern with wildcard support for matching domains
|
||||
*/
|
||||
export interface IDomainPattern {
|
||||
/** The domain pattern, e.g. "example.com" or "*.example.com" */
|
||||
pattern: string;
|
||||
/** Whether this is an exact match or wildcard pattern */
|
||||
isWildcard: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email routing rule for determining how to handle emails for specific domains
|
||||
*/
|
||||
export interface IEmailRoutingRule {
|
||||
/** Unique identifier for this rule */
|
||||
id: string;
|
||||
/** Human-readable name for this rule */
|
||||
name: string;
|
||||
/** Source domain patterns to match (from address) */
|
||||
sourceDomains?: IDomainPattern[];
|
||||
/** Destination domain patterns to match (to address) */
|
||||
destinationDomains?: IDomainPattern[];
|
||||
/** Domain groups this rule applies to */
|
||||
domainGroups?: string[];
|
||||
/** Priority of this rule (higher takes precedence) */
|
||||
priority: number;
|
||||
/** Action to take when rule matches */
|
||||
action: 'route' | 'block' | 'tag' | 'filter';
|
||||
/** Target server for routing */
|
||||
targetServer?: string;
|
||||
/** Target port for routing */
|
||||
targetPort?: number;
|
||||
/** Whether to use TLS when routing */
|
||||
useTls?: boolean;
|
||||
/** Authentication details for routing */
|
||||
auth?: {
|
||||
/** Username for authentication */
|
||||
username?: string;
|
||||
/** Password for authentication */
|
||||
password?: string;
|
||||
/** Authentication type */
|
||||
type?: 'PLAIN' | 'LOGIN' | 'OAUTH2';
|
||||
};
|
||||
/** Headers to add or modify when rule matches */
|
||||
headers?: {
|
||||
/** Header name */
|
||||
name: string;
|
||||
/** Header value */
|
||||
value: string;
|
||||
/** Whether to append to existing header or replace */
|
||||
append?: boolean;
|
||||
}[];
|
||||
/** Whether this rule is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for email domain-based routing
|
||||
*/
|
||||
export interface IEmailDomainRoutingConfig {
|
||||
/** Whether domain-based routing is enabled */
|
||||
enabled: boolean;
|
||||
/** Routing rules list */
|
||||
rules: IEmailRoutingRule[];
|
||||
/** Domain groups for organization */
|
||||
domainGroups?: IDomainGroup[];
|
||||
/** Default target server for unmatched domains */
|
||||
defaultTargetServer?: string;
|
||||
/** Default target port for unmatched domains */
|
||||
defaultTargetPort?: number;
|
||||
/** Whether to use TLS for the default route */
|
||||
defaultUseTls?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for managing domain-based email routing
|
||||
*/
|
||||
export class EmailDomainRouter {
|
||||
/** Configuration for domain-based routing */
|
||||
private config: IEmailDomainRoutingConfig;
|
||||
/** Domain groups indexed by ID */
|
||||
private domainGroups: Map<string, IDomainGroup> = new Map();
|
||||
/** Sorted rules cache for faster processing */
|
||||
private sortedRules: IEmailRoutingRule[] = [];
|
||||
/** Whether the rules need to be re-sorted */
|
||||
private rulesSortNeeded = true;
|
||||
|
||||
/**
|
||||
* Create a new EmailDomainRouter
|
||||
* @param config Configuration for domain-based routing
|
||||
*/
|
||||
constructor(config: IEmailDomainRoutingConfig) {
|
||||
this.config = config;
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the domain router
|
||||
*/
|
||||
private initialize(): void {
|
||||
// Return early if routing is not enabled
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize domain groups
|
||||
if (this.config.domainGroups) {
|
||||
for (const group of this.config.domainGroups) {
|
||||
this.domainGroups.set(group.id, group);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort rules by priority
|
||||
this.sortRules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort rules by priority (higher first)
|
||||
*/
|
||||
private sortRules(): void {
|
||||
if (!this.config.rules || !this.config.enabled) {
|
||||
this.sortedRules = [];
|
||||
this.rulesSortNeeded = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.sortedRules = [...this.config.rules]
|
||||
.filter(rule => rule.enabled)
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
this.rulesSortNeeded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new routing rule
|
||||
* @param rule The routing rule to add
|
||||
*/
|
||||
public addRule(rule: IEmailRoutingRule): void {
|
||||
if (!this.config.rules) {
|
||||
this.config.rules = [];
|
||||
}
|
||||
|
||||
// Check if rule already exists
|
||||
const existingIndex = this.config.rules.findIndex(r => r.id === rule.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing rule
|
||||
this.config.rules[existingIndex] = rule;
|
||||
} else {
|
||||
// Add new rule
|
||||
this.config.rules.push(rule);
|
||||
}
|
||||
|
||||
this.rulesSortNeeded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a routing rule by ID
|
||||
* @param ruleId ID of the rule to remove
|
||||
* @returns Whether the rule was removed
|
||||
*/
|
||||
public removeRule(ruleId: string): boolean {
|
||||
if (!this.config.rules) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialLength = this.config.rules.length;
|
||||
this.config.rules = this.config.rules.filter(rule => rule.id !== ruleId);
|
||||
|
||||
if (initialLength !== this.config.rules.length) {
|
||||
this.rulesSortNeeded = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a domain group
|
||||
* @param group The domain group to add
|
||||
*/
|
||||
public addDomainGroup(group: IDomainGroup): void {
|
||||
if (!this.config.domainGroups) {
|
||||
this.config.domainGroups = [];
|
||||
}
|
||||
|
||||
// Check if group already exists
|
||||
const existingIndex = this.config.domainGroups.findIndex(g => g.id === group.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing group
|
||||
this.config.domainGroups[existingIndex] = group;
|
||||
} else {
|
||||
// Add new group
|
||||
this.config.domainGroups.push(group);
|
||||
}
|
||||
|
||||
// Update domain groups map
|
||||
this.domainGroups.set(group.id, group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a domain group by ID
|
||||
* @param groupId ID of the group to remove
|
||||
* @returns Whether the group was removed
|
||||
*/
|
||||
public removeDomainGroup(groupId: string): boolean {
|
||||
if (!this.config.domainGroups) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialLength = this.config.domainGroups.length;
|
||||
this.config.domainGroups = this.config.domainGroups.filter(group => group.id !== groupId);
|
||||
|
||||
if (initialLength !== this.config.domainGroups.length) {
|
||||
this.domainGroups.delete(groupId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine routing for an email
|
||||
* @param fromDomain The sender domain
|
||||
* @param toDomain The recipient domain
|
||||
* @returns Routing decision or null if no matching rule
|
||||
*/
|
||||
public getRoutingForEmail(fromDomain: string, toDomain: string): {
|
||||
targetServer: string;
|
||||
targetPort: number;
|
||||
useTls: boolean;
|
||||
auth?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
type?: 'PLAIN' | 'LOGIN' | 'OAUTH2';
|
||||
};
|
||||
headers?: {
|
||||
name: string;
|
||||
value: string;
|
||||
append?: boolean;
|
||||
}[];
|
||||
} | null {
|
||||
// Return default routing if routing is not enabled
|
||||
if (!this.config.enabled) {
|
||||
return this.getDefaultRouting();
|
||||
}
|
||||
|
||||
// Sort rules if needed
|
||||
if (this.rulesSortNeeded) {
|
||||
this.sortRules();
|
||||
}
|
||||
|
||||
// Normalize domains
|
||||
fromDomain = fromDomain.toLowerCase();
|
||||
toDomain = toDomain.toLowerCase();
|
||||
|
||||
// Check each rule in priority order
|
||||
for (const rule of this.sortedRules) {
|
||||
if (!rule.enabled) continue;
|
||||
|
||||
// Check if rule applies to this email
|
||||
if (this.ruleMatchesEmail(rule, fromDomain, toDomain)) {
|
||||
// Handle different actions
|
||||
switch (rule.action) {
|
||||
case 'route':
|
||||
// Return routing information
|
||||
return {
|
||||
targetServer: rule.targetServer || this.config.defaultTargetServer || 'localhost',
|
||||
targetPort: rule.targetPort || this.config.defaultTargetPort || 25,
|
||||
useTls: rule.useTls ?? this.config.defaultUseTls ?? false,
|
||||
auth: rule.auth,
|
||||
headers: rule.headers
|
||||
};
|
||||
case 'block':
|
||||
// Return null to indicate email should be blocked
|
||||
return null;
|
||||
case 'tag':
|
||||
case 'filter':
|
||||
// For tagging/filtering, we need to apply headers but continue checking rules
|
||||
// This is simplified for now, in a real implementation we'd aggregate headers
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No rule matched, use default routing
|
||||
return this.getDefaultRouting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rule matches an email
|
||||
* @param rule The routing rule to check
|
||||
* @param fromDomain The sender domain
|
||||
* @param toDomain The recipient domain
|
||||
* @returns Whether the rule matches the email
|
||||
*/
|
||||
private ruleMatchesEmail(rule: IEmailRoutingRule, fromDomain: string, toDomain: string): boolean {
|
||||
// Check source domains
|
||||
if (rule.sourceDomains && rule.sourceDomains.length > 0) {
|
||||
const matchesSourceDomain = rule.sourceDomains.some(
|
||||
pattern => this.domainMatchesPattern(fromDomain, pattern)
|
||||
);
|
||||
if (!matchesSourceDomain) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check destination domains
|
||||
if (rule.destinationDomains && rule.destinationDomains.length > 0) {
|
||||
const matchesDestinationDomain = rule.destinationDomains.some(
|
||||
pattern => this.domainMatchesPattern(toDomain, pattern)
|
||||
);
|
||||
if (!matchesDestinationDomain) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check domain groups
|
||||
if (rule.domainGroups && rule.domainGroups.length > 0) {
|
||||
// Check if either domain is in any of the specified groups
|
||||
const domainsInGroups = rule.domainGroups
|
||||
.map(groupId => this.domainGroups.get(groupId))
|
||||
.filter(Boolean)
|
||||
.some(group =>
|
||||
group.domains.includes(fromDomain) ||
|
||||
group.domains.includes(toDomain)
|
||||
);
|
||||
|
||||
if (!domainsInGroups) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If we got here, all checks passed
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain matches a pattern
|
||||
* @param domain The domain to check
|
||||
* @param pattern The pattern to match against
|
||||
* @returns Whether the domain matches the pattern
|
||||
*/
|
||||
private domainMatchesPattern(domain: string, pattern: IDomainPattern): boolean {
|
||||
domain = domain.toLowerCase();
|
||||
const patternStr = pattern.pattern.toLowerCase();
|
||||
|
||||
// Exact match
|
||||
if (!pattern.isWildcard) {
|
||||
return domain === patternStr;
|
||||
}
|
||||
|
||||
// Wildcard match (*.example.com)
|
||||
if (patternStr.startsWith('*.')) {
|
||||
const suffix = patternStr.substring(2);
|
||||
return domain.endsWith(suffix) && domain.length > suffix.length;
|
||||
}
|
||||
|
||||
// Invalid pattern
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default routing information
|
||||
* @returns Default routing or null if no default configured
|
||||
*/
|
||||
private getDefaultRouting(): {
|
||||
targetServer: string;
|
||||
targetPort: number;
|
||||
useTls: boolean;
|
||||
} | null {
|
||||
if (!this.config.defaultTargetServer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
targetServer: this.config.defaultTargetServer,
|
||||
targetPort: this.config.defaultTargetPort || 25,
|
||||
useTls: this.config.defaultUseTls || false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current configuration
|
||||
* @returns Current domain routing configuration
|
||||
*/
|
||||
public getConfig(): IEmailDomainRoutingConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the configuration
|
||||
* @param config New domain routing configuration
|
||||
*/
|
||||
public updateConfig(config: IEmailDomainRoutingConfig): void {
|
||||
this.config = config;
|
||||
this.rulesSortNeeded = true;
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable domain routing
|
||||
*/
|
||||
public enable(): void {
|
||||
this.config.enabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable domain routing
|
||||
*/
|
||||
public disable(): void {
|
||||
this.config.enabled = false;
|
||||
}
|
||||
}
|
897
ts/dcrouter/classes.rate.limiter.ts
Normal file
897
ts/dcrouter/classes.rate.limiter.ts
Normal file
@ -0,0 +1,897 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { logger } from '../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../security/index.js';
|
||||
|
||||
/**
|
||||
* Interface for rate limit configuration
|
||||
*/
|
||||
export interface IRateLimitConfig {
|
||||
maxMessagesPerMinute?: number;
|
||||
maxRecipientsPerMessage?: number;
|
||||
maxConnectionsPerIP?: number;
|
||||
maxErrorsPerIP?: number;
|
||||
maxAuthFailuresPerIP?: number;
|
||||
blockDuration?: number; // in milliseconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for hierarchical rate limits
|
||||
*/
|
||||
export interface IHierarchicalRateLimits {
|
||||
// Global rate limits (applied to all traffic)
|
||||
global: IRateLimitConfig;
|
||||
|
||||
// Pattern-specific rate limits (applied to matching patterns)
|
||||
patterns?: Record<string, IRateLimitConfig>;
|
||||
|
||||
// IP-specific rate limits (applied to specific IPs)
|
||||
ips?: Record<string, IRateLimitConfig>;
|
||||
|
||||
// Temporary blocks list and their expiry times
|
||||
blocks?: Record<string, number>; // IP to expiry timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Counter interface for rate limiting
|
||||
*/
|
||||
interface ILimitCounter {
|
||||
count: number;
|
||||
lastReset: number;
|
||||
recipients: number;
|
||||
errors: number;
|
||||
authFailures: number;
|
||||
connections: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiter statistics
|
||||
*/
|
||||
export interface IRateLimiterStats {
|
||||
activeCounters: number;
|
||||
totalBlocked: number;
|
||||
currentlyBlocked: number;
|
||||
byPattern: Record<string, {
|
||||
messagesPerMinute: number;
|
||||
totalMessages: number;
|
||||
totalBlocked: number;
|
||||
}>;
|
||||
byIp: Record<string, {
|
||||
messagesPerMinute: number;
|
||||
totalMessages: number;
|
||||
totalBlocked: number;
|
||||
connections: number;
|
||||
errors: number;
|
||||
authFailures: number;
|
||||
blocked: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a rate limit check
|
||||
*/
|
||||
export interface IRateLimitResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
limit?: number;
|
||||
current?: number;
|
||||
resetIn?: number; // milliseconds until reset
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified rate limiter for all email processing modes
|
||||
*/
|
||||
export class UnifiedRateLimiter extends EventEmitter {
|
||||
private config: IHierarchicalRateLimits;
|
||||
private counters: Map<string, ILimitCounter> = new Map();
|
||||
private patternCounters: Map<string, ILimitCounter> = new Map();
|
||||
private ipCounters: Map<string, ILimitCounter> = new Map();
|
||||
private cleanupInterval?: NodeJS.Timeout;
|
||||
private stats: IRateLimiterStats;
|
||||
|
||||
/**
|
||||
* Create a new unified rate limiter
|
||||
* @param config Rate limit configuration
|
||||
*/
|
||||
constructor(config: IHierarchicalRateLimits) {
|
||||
super();
|
||||
|
||||
// Set default configuration
|
||||
this.config = {
|
||||
global: {
|
||||
maxMessagesPerMinute: config.global.maxMessagesPerMinute || 100,
|
||||
maxRecipientsPerMessage: config.global.maxRecipientsPerMessage || 100,
|
||||
maxConnectionsPerIP: config.global.maxConnectionsPerIP || 20,
|
||||
maxErrorsPerIP: config.global.maxErrorsPerIP || 10,
|
||||
maxAuthFailuresPerIP: config.global.maxAuthFailuresPerIP || 5,
|
||||
blockDuration: config.global.blockDuration || 3600000 // 1 hour
|
||||
},
|
||||
patterns: config.patterns || {},
|
||||
ips: config.ips || {},
|
||||
blocks: config.blocks || {}
|
||||
};
|
||||
|
||||
// Initialize statistics
|
||||
this.stats = {
|
||||
activeCounters: 0,
|
||||
totalBlocked: 0,
|
||||
currentlyBlocked: 0,
|
||||
byPattern: {},
|
||||
byIp: {}
|
||||
};
|
||||
|
||||
// Start cleanup interval
|
||||
this.startCleanupInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cleanup interval
|
||||
*/
|
||||
private startCleanupInterval(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
// Run cleanup every minute
|
||||
this.cleanupInterval = setInterval(() => this.cleanup(), 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cleanup interval
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired counters and blocks
|
||||
*/
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Clean up expired blocks
|
||||
if (this.config.blocks) {
|
||||
for (const [ip, expiry] of Object.entries(this.config.blocks)) {
|
||||
if (expiry <= now) {
|
||||
delete this.config.blocks[ip];
|
||||
logger.log('info', `Rate limit block expired for IP ${ip}`);
|
||||
|
||||
// Update statistics
|
||||
if (this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip].blocked = false;
|
||||
}
|
||||
this.stats.currentlyBlocked--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old counters (older than 10 minutes)
|
||||
const cutoff = now - 600000;
|
||||
|
||||
// Clean global counters
|
||||
for (const [key, counter] of this.counters.entries()) {
|
||||
if (counter.lastReset < cutoff) {
|
||||
this.counters.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean pattern counters
|
||||
for (const [key, counter] of this.patternCounters.entries()) {
|
||||
if (counter.lastReset < cutoff) {
|
||||
this.patternCounters.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean IP counters
|
||||
for (const [key, counter] of this.ipCounters.entries()) {
|
||||
if (counter.lastReset < cutoff) {
|
||||
this.ipCounters.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is allowed by rate limits
|
||||
* @param email Email address
|
||||
* @param ip IP address
|
||||
* @param recipients Number of recipients
|
||||
* @param pattern Matched pattern
|
||||
* @returns Result of rate limit check
|
||||
*/
|
||||
public checkMessageLimit(email: string, ip: string, recipients: number, pattern?: string): IRateLimitResult {
|
||||
// Check if IP is blocked
|
||||
if (this.isIpBlocked(ip)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'IP is blocked',
|
||||
resetIn: this.getBlockReleaseTime(ip)
|
||||
};
|
||||
}
|
||||
|
||||
// Check global message rate limit
|
||||
const globalResult = this.checkGlobalMessageLimit(email);
|
||||
if (!globalResult.allowed) {
|
||||
return globalResult;
|
||||
}
|
||||
|
||||
// Check pattern-specific limit if pattern is provided
|
||||
if (pattern) {
|
||||
const patternResult = this.checkPatternMessageLimit(pattern);
|
||||
if (!patternResult.allowed) {
|
||||
return patternResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Check IP-specific limit
|
||||
const ipResult = this.checkIpMessageLimit(ip);
|
||||
if (!ipResult.allowed) {
|
||||
return ipResult;
|
||||
}
|
||||
|
||||
// Check recipient limit
|
||||
const recipientResult = this.checkRecipientLimit(email, recipients, pattern);
|
||||
if (!recipientResult.allowed) {
|
||||
return recipientResult;
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check global message rate limit
|
||||
* @param email Email address
|
||||
*/
|
||||
private checkGlobalMessageLimit(email: string): IRateLimitResult {
|
||||
const now = Date.now();
|
||||
const limit = this.config.global.maxMessagesPerMinute!;
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
const key = 'global';
|
||||
let counter = this.counters.get(key);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.counters.set(key, counter);
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.count = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.count >= limit) {
|
||||
// Calculate reset time
|
||||
const resetIn = 60000 - (now - counter.lastReset);
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Global message rate limit exceeded',
|
||||
limit,
|
||||
current: counter.count,
|
||||
resetIn
|
||||
};
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.count++;
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check pattern-specific message rate limit
|
||||
* @param pattern Pattern to check
|
||||
*/
|
||||
private checkPatternMessageLimit(pattern: string): IRateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Get pattern-specific limit or use global
|
||||
const patternConfig = this.config.patterns?.[pattern];
|
||||
const limit = patternConfig?.maxMessagesPerMinute || this.config.global.maxMessagesPerMinute!;
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
let counter = this.patternCounters.get(pattern);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.patternCounters.set(pattern, counter);
|
||||
|
||||
// Initialize pattern stats if needed
|
||||
if (!this.stats.byPattern[pattern]) {
|
||||
this.stats.byPattern[pattern] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.count = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.count >= limit) {
|
||||
// Calculate reset time
|
||||
const resetIn = 60000 - (now - counter.lastReset);
|
||||
|
||||
// Update statistics
|
||||
this.stats.byPattern[pattern].totalBlocked++;
|
||||
this.stats.totalBlocked++;
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Pattern "${pattern}" message rate limit exceeded`,
|
||||
limit,
|
||||
current: counter.count,
|
||||
resetIn
|
||||
};
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.count++;
|
||||
|
||||
// Update statistics
|
||||
this.stats.byPattern[pattern].messagesPerMinute = counter.count;
|
||||
this.stats.byPattern[pattern].totalMessages++;
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check IP-specific message rate limit
|
||||
* @param ip IP address
|
||||
*/
|
||||
private checkIpMessageLimit(ip: string): IRateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Get IP-specific limit or use global
|
||||
const ipConfig = this.config.ips?.[ip];
|
||||
const limit = ipConfig?.maxMessagesPerMinute || this.config.global.maxMessagesPerMinute!;
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
let counter = this.ipCounters.get(ip);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.ipCounters.set(ip, counter);
|
||||
|
||||
// Initialize IP stats if needed
|
||||
if (!this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0,
|
||||
connections: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
blocked: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.count = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.count >= limit) {
|
||||
// Calculate reset time
|
||||
const resetIn = 60000 - (now - counter.lastReset);
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].totalBlocked++;
|
||||
this.stats.totalBlocked++;
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `IP ${ip} message rate limit exceeded`,
|
||||
limit,
|
||||
current: counter.count,
|
||||
resetIn
|
||||
};
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.count++;
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].messagesPerMinute = counter.count;
|
||||
this.stats.byIp[ip].totalMessages++;
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check recipient limit
|
||||
* @param email Email address
|
||||
* @param recipients Number of recipients
|
||||
* @param pattern Matched pattern
|
||||
*/
|
||||
private checkRecipientLimit(email: string, recipients: number, pattern?: string): IRateLimitResult {
|
||||
// Get pattern-specific limit if available
|
||||
let limit = this.config.global.maxRecipientsPerMessage!;
|
||||
|
||||
if (pattern && this.config.patterns?.[pattern]?.maxRecipientsPerMessage) {
|
||||
limit = this.config.patterns[pattern].maxRecipientsPerMessage!;
|
||||
}
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (recipients > limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Recipient limit exceeded',
|
||||
limit,
|
||||
current: recipients
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a connection from an IP
|
||||
* @param ip IP address
|
||||
* @returns Result of rate limit check
|
||||
*/
|
||||
public recordConnection(ip: string): IRateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if IP is blocked
|
||||
if (this.isIpBlocked(ip)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'IP is blocked',
|
||||
resetIn: this.getBlockReleaseTime(ip)
|
||||
};
|
||||
}
|
||||
|
||||
// Get IP-specific limit or use global
|
||||
const ipConfig = this.config.ips?.[ip];
|
||||
const limit = ipConfig?.maxConnectionsPerIP || this.config.global.maxConnectionsPerIP!;
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
let counter = this.ipCounters.get(ip);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.ipCounters.set(ip, counter);
|
||||
|
||||
// Initialize IP stats if needed
|
||||
if (!this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0,
|
||||
connections: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
blocked: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.connections = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.connections >= limit) {
|
||||
// Calculate reset time
|
||||
const resetIn = 60000 - (now - counter.lastReset);
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].totalBlocked++;
|
||||
this.stats.totalBlocked++;
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `IP ${ip} connection rate limit exceeded`,
|
||||
limit,
|
||||
current: counter.connections,
|
||||
resetIn
|
||||
};
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.connections++;
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].connections = counter.connections;
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an error from an IP
|
||||
* @param ip IP address
|
||||
* @returns True if IP should be blocked
|
||||
*/
|
||||
public recordError(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
// Get IP-specific limit or use global
|
||||
const ipConfig = this.config.ips?.[ip];
|
||||
const limit = ipConfig?.maxErrorsPerIP || this.config.global.maxErrorsPerIP!;
|
||||
|
||||
if (!limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
let counter = this.ipCounters.get(ip);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.ipCounters.set(ip, counter);
|
||||
|
||||
// Initialize IP stats if needed
|
||||
if (!this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0,
|
||||
connections: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
blocked: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.errors = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.errors++;
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].errors = counter.errors;
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.errors >= limit) {
|
||||
// Block the IP
|
||||
this.blockIp(ip);
|
||||
|
||||
logger.log('warn', `IP ${ip} blocked due to excessive errors (${counter.errors}/${limit})`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.RATE_LIMITING,
|
||||
message: 'IP blocked due to excessive errors',
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
errors: counter.errors,
|
||||
limit
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an authentication failure from an IP
|
||||
* @param ip IP address
|
||||
* @returns True if IP should be blocked
|
||||
*/
|
||||
public recordAuthFailure(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
// Get IP-specific limit or use global
|
||||
const ipConfig = this.config.ips?.[ip];
|
||||
const limit = ipConfig?.maxAuthFailuresPerIP || this.config.global.maxAuthFailuresPerIP!;
|
||||
|
||||
if (!limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
let counter = this.ipCounters.get(ip);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.ipCounters.set(ip, counter);
|
||||
|
||||
// Initialize IP stats if needed
|
||||
if (!this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0,
|
||||
connections: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
blocked: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.authFailures = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.authFailures++;
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].authFailures = counter.authFailures;
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.authFailures >= limit) {
|
||||
// Block the IP
|
||||
this.blockIp(ip);
|
||||
|
||||
logger.log('warn', `IP ${ip} blocked due to excessive authentication failures (${counter.authFailures}/${limit})`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.AUTHENTICATION,
|
||||
message: 'IP blocked due to excessive authentication failures',
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
authFailures: counter.authFailures,
|
||||
limit
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block an IP address
|
||||
* @param ip IP address to block
|
||||
* @param duration Override the default block duration (milliseconds)
|
||||
*/
|
||||
public blockIp(ip: string, duration?: number): void {
|
||||
if (!this.config.blocks) {
|
||||
this.config.blocks = {};
|
||||
}
|
||||
|
||||
// Set block expiry time
|
||||
const expiry = Date.now() + (duration || this.config.global.blockDuration || 3600000);
|
||||
this.config.blocks[ip] = expiry;
|
||||
|
||||
// Update statistics
|
||||
if (!this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0,
|
||||
connections: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
blocked: false
|
||||
};
|
||||
}
|
||||
this.stats.byIp[ip].blocked = true;
|
||||
this.stats.currentlyBlocked++;
|
||||
|
||||
// Emit event
|
||||
this.emit('ipBlocked', {
|
||||
ip,
|
||||
expiry,
|
||||
duration: duration || this.config.global.blockDuration
|
||||
});
|
||||
|
||||
logger.log('warn', `IP ${ip} blocked until ${new Date(expiry).toISOString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock an IP address
|
||||
* @param ip IP address to unblock
|
||||
*/
|
||||
public unblockIp(ip: string): void {
|
||||
if (!this.config.blocks) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove block
|
||||
delete this.config.blocks[ip];
|
||||
|
||||
// Update statistics
|
||||
if (this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip].blocked = false;
|
||||
this.stats.currentlyBlocked--;
|
||||
}
|
||||
|
||||
// Emit event
|
||||
this.emit('ipUnblocked', { ip });
|
||||
|
||||
logger.log('info', `IP ${ip} unblocked`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is blocked
|
||||
* @param ip IP address to check
|
||||
*/
|
||||
public isIpBlocked(ip: string): boolean {
|
||||
if (!this.config.blocks) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if IP is in blocks
|
||||
if (!(ip in this.config.blocks)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if block has expired
|
||||
const expiry = this.config.blocks[ip];
|
||||
if (expiry <= Date.now()) {
|
||||
// Remove expired block
|
||||
delete this.config.blocks[ip];
|
||||
|
||||
// Update statistics
|
||||
if (this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip].blocked = false;
|
||||
this.stats.currentlyBlocked--;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time until a block is released
|
||||
* @param ip IP address
|
||||
* @returns Milliseconds until release or 0 if not blocked
|
||||
*/
|
||||
public getBlockReleaseTime(ip: string): number {
|
||||
if (!this.config.blocks || !(ip in this.config.blocks)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const expiry = this.config.blocks[ip];
|
||||
const now = Date.now();
|
||||
|
||||
return expiry > now ? expiry - now : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rate limiter statistics
|
||||
*/
|
||||
private updateStats(): void {
|
||||
// Update active counters count
|
||||
this.stats.activeCounters = this.counters.size + this.patternCounters.size + this.ipCounters.size;
|
||||
|
||||
// Emit statistics update
|
||||
this.emit('statsUpdated', this.stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limiter statistics
|
||||
*/
|
||||
public getStats(): IRateLimiterStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rate limiter configuration
|
||||
* @param config New configuration
|
||||
*/
|
||||
public updateConfig(config: Partial<IHierarchicalRateLimits>): void {
|
||||
if (config.global) {
|
||||
this.config.global = {
|
||||
...this.config.global,
|
||||
...config.global
|
||||
};
|
||||
}
|
||||
|
||||
if (config.patterns) {
|
||||
this.config.patterns = {
|
||||
...this.config.patterns,
|
||||
...config.patterns
|
||||
};
|
||||
}
|
||||
|
||||
if (config.ips) {
|
||||
this.config.ips = {
|
||||
...this.config.ips,
|
||||
...config.ips
|
||||
};
|
||||
}
|
||||
|
||||
logger.log('info', 'Rate limiter configuration updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration for debugging
|
||||
*/
|
||||
public getConfig(): IHierarchicalRateLimits {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
311
ts/dcrouter/classes.smtp.portconfig.ts
Normal file
311
ts/dcrouter/classes.smtp.portconfig.ts
Normal file
@ -0,0 +1,311 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Configuration options for TLS in SMTP connections
|
||||
*/
|
||||
export interface ISmtpTlsOptions {
|
||||
/** Enable TLS for this SMTP port */
|
||||
enabled: boolean;
|
||||
/** Whether to use STARTTLS (upgrade plain connection) or implicit TLS */
|
||||
useStartTls?: boolean;
|
||||
/** Required TLS protocol version (defaults to TLSv1.2) */
|
||||
minTlsVersion?: 'TLSv1.0' | 'TLSv1.1' | 'TLSv1.2' | 'TLSv1.3';
|
||||
/** TLS ciphers to allow (comma-separated list) */
|
||||
allowedCiphers?: string;
|
||||
/** Whether to require client certificate for authentication */
|
||||
requireClientCert?: boolean;
|
||||
/** Whether to verify client certificate if provided */
|
||||
verifyClientCert?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting options for SMTP connections
|
||||
*/
|
||||
export interface ISmtpRateLimitOptions {
|
||||
/** Maximum connections per minute from a single IP */
|
||||
maxConnectionsPerMinute?: number;
|
||||
/** Maximum concurrent connections from a single IP */
|
||||
maxConcurrentConnections?: number;
|
||||
/** Maximum emails per minute from a single IP */
|
||||
maxEmailsPerMinute?: number;
|
||||
/** Maximum recipients per email */
|
||||
maxRecipientsPerEmail?: number;
|
||||
/** Maximum email size in bytes */
|
||||
maxEmailSize?: number;
|
||||
/** Action to take when rate limit is exceeded (default: 'tempfail') */
|
||||
rateLimitAction?: 'tempfail' | 'drop' | 'delay';
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a specific SMTP port
|
||||
*/
|
||||
export interface ISmtpPortSettings {
|
||||
/** The port number to listen on */
|
||||
port: number;
|
||||
/** Whether this port is enabled */
|
||||
enabled?: boolean;
|
||||
/** Port description (e.g., "Submission Port") */
|
||||
description?: string;
|
||||
/** Whether to require authentication for this port */
|
||||
requireAuth?: boolean;
|
||||
/** TLS options for this port */
|
||||
tls?: ISmtpTlsOptions;
|
||||
/** Rate limiting settings for this port */
|
||||
rateLimit?: ISmtpRateLimitOptions;
|
||||
/** Maximum message size in bytes for this port */
|
||||
maxMessageSize?: number;
|
||||
/** Whether to enable SMTP extensions like PIPELINING, 8BITMIME, etc. */
|
||||
smtpExtensions?: {
|
||||
/** Enable PIPELINING extension */
|
||||
pipelining?: boolean;
|
||||
/** Enable 8BITMIME extension */
|
||||
eightBitMime?: boolean;
|
||||
/** Enable SIZE extension */
|
||||
size?: boolean;
|
||||
/** Enable ENHANCEDSTATUSCODES extension */
|
||||
enhancedStatusCodes?: boolean;
|
||||
/** Enable DSN extension */
|
||||
dsn?: boolean;
|
||||
};
|
||||
/** Custom SMTP greeting banner */
|
||||
banner?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration manager for SMTP ports
|
||||
*/
|
||||
export class SmtpPortConfig {
|
||||
/** Port configurations */
|
||||
private portConfigs: Map<number, ISmtpPortSettings> = new Map();
|
||||
|
||||
/** Default port configurations */
|
||||
private static readonly DEFAULT_CONFIGS: Record<number, Partial<ISmtpPortSettings>> = {
|
||||
// Port 25: Standard SMTP
|
||||
25: {
|
||||
description: 'Standard SMTP',
|
||||
requireAuth: false,
|
||||
tls: {
|
||||
enabled: true,
|
||||
useStartTls: true,
|
||||
minTlsVersion: 'TLSv1.2'
|
||||
},
|
||||
rateLimit: {
|
||||
maxConnectionsPerMinute: 60,
|
||||
maxConcurrentConnections: 10,
|
||||
maxEmailsPerMinute: 30
|
||||
},
|
||||
maxMessageSize: 20 * 1024 * 1024 // 20MB
|
||||
},
|
||||
// Port 587: Submission
|
||||
587: {
|
||||
description: 'Submission Port',
|
||||
requireAuth: true,
|
||||
tls: {
|
||||
enabled: true,
|
||||
useStartTls: true,
|
||||
minTlsVersion: 'TLSv1.2'
|
||||
},
|
||||
rateLimit: {
|
||||
maxConnectionsPerMinute: 100,
|
||||
maxConcurrentConnections: 20,
|
||||
maxEmailsPerMinute: 60
|
||||
},
|
||||
maxMessageSize: 50 * 1024 * 1024 // 50MB
|
||||
},
|
||||
// Port 465: SMTPS (Legacy Implicit TLS)
|
||||
465: {
|
||||
description: 'SMTPS (Implicit TLS)',
|
||||
requireAuth: true,
|
||||
tls: {
|
||||
enabled: true,
|
||||
useStartTls: false,
|
||||
minTlsVersion: 'TLSv1.2'
|
||||
},
|
||||
rateLimit: {
|
||||
maxConnectionsPerMinute: 100,
|
||||
maxConcurrentConnections: 20,
|
||||
maxEmailsPerMinute: 60
|
||||
},
|
||||
maxMessageSize: 50 * 1024 * 1024 // 50MB
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new SmtpPortConfig
|
||||
* @param initialConfigs Optional initial port configurations
|
||||
*/
|
||||
constructor(initialConfigs?: ISmtpPortSettings[]) {
|
||||
// Initialize with default configurations for standard SMTP ports
|
||||
this.initializeDefaults();
|
||||
|
||||
// Apply custom configurations if provided
|
||||
if (initialConfigs) {
|
||||
for (const config of initialConfigs) {
|
||||
this.setPortConfig(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize port configurations with defaults
|
||||
*/
|
||||
private initializeDefaults(): void {
|
||||
// Set up default configurations for standard SMTP ports: 25, 587, 465
|
||||
Object.entries(SmtpPortConfig.DEFAULT_CONFIGS).forEach(([portStr, defaults]) => {
|
||||
const port = parseInt(portStr, 10);
|
||||
this.portConfigs.set(port, {
|
||||
port,
|
||||
enabled: true,
|
||||
...defaults
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration for a specific port
|
||||
* @param port Port number
|
||||
* @returns Port configuration or null if not found
|
||||
*/
|
||||
public getPortConfig(port: number): ISmtpPortSettings | null {
|
||||
return this.portConfigs.get(port) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configured ports
|
||||
* @returns Array of port configurations
|
||||
*/
|
||||
public getAllPortConfigs(): ISmtpPortSettings[] {
|
||||
return Array.from(this.portConfigs.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only enabled port configurations
|
||||
* @returns Array of enabled port configurations
|
||||
*/
|
||||
public getEnabledPortConfigs(): ISmtpPortSettings[] {
|
||||
return this.getAllPortConfigs().filter(config => config.enabled !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration for a specific port
|
||||
* @param config Port configuration
|
||||
*/
|
||||
public setPortConfig(config: ISmtpPortSettings): void {
|
||||
// Get existing config if any
|
||||
const existingConfig = this.portConfigs.get(config.port) || { port: config.port };
|
||||
|
||||
// Merge with new configuration
|
||||
this.portConfigs.set(config.port, {
|
||||
...existingConfig,
|
||||
...config
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove configuration for a specific port
|
||||
* @param port Port number
|
||||
* @returns Whether the configuration was removed
|
||||
*/
|
||||
public removePortConfig(port: number): boolean {
|
||||
return this.portConfigs.delete(port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a specific port
|
||||
* @param port Port number
|
||||
* @returns Whether the port was disabled
|
||||
*/
|
||||
public disablePort(port: number): boolean {
|
||||
const config = this.portConfigs.get(port);
|
||||
if (config) {
|
||||
config.enabled = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a specific port
|
||||
* @param port Port number
|
||||
* @returns Whether the port was enabled
|
||||
*/
|
||||
public enablePort(port: number): boolean {
|
||||
const config = this.portConfigs.get(port);
|
||||
if (config) {
|
||||
config.enabled = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply port configurations to SmartProxy settings
|
||||
* @param smartProxy SmartProxy instance
|
||||
*/
|
||||
public applyToSmartProxy(smartProxy: plugins.smartproxy.SmartProxy): void {
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledPorts = this.getEnabledPortConfigs();
|
||||
const settings = smartProxy.settings;
|
||||
|
||||
// Initialize globalPortRanges if needed
|
||||
if (!settings.globalPortRanges) {
|
||||
settings.globalPortRanges = [];
|
||||
}
|
||||
|
||||
// Add configured ports to globalPortRanges
|
||||
for (const portConfig of enabledPorts) {
|
||||
// Add port to global port ranges if not already present
|
||||
if (!settings.globalPortRanges.some((r) => r.from <= portConfig.port && portConfig.port <= r.to)) {
|
||||
settings.globalPortRanges.push({ from: portConfig.port, to: portConfig.port });
|
||||
}
|
||||
|
||||
// Apply TLS settings at SmartProxy level
|
||||
if (portConfig.port === 465 && portConfig.tls?.enabled) {
|
||||
// For implicit TLS on port 465
|
||||
settings.sniEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Group ports by TLS configuration to log them
|
||||
const starttlsPorts = enabledPorts
|
||||
.filter(p => p.tls?.enabled && p.tls?.useStartTls)
|
||||
.map(p => p.port);
|
||||
|
||||
const implicitTlsPorts = enabledPorts
|
||||
.filter(p => p.tls?.enabled && !p.tls?.useStartTls)
|
||||
.map(p => p.port);
|
||||
|
||||
const nonTlsPorts = enabledPorts
|
||||
.filter(p => !p.tls?.enabled)
|
||||
.map(p => p.port);
|
||||
|
||||
if (starttlsPorts.length > 0) {
|
||||
console.log(`Configured STARTTLS SMTP ports: ${starttlsPorts.join(', ')}`);
|
||||
}
|
||||
|
||||
if (implicitTlsPorts.length > 0) {
|
||||
console.log(`Configured Implicit TLS SMTP ports: ${implicitTlsPorts.join(', ')}`);
|
||||
}
|
||||
|
||||
if (nonTlsPorts.length > 0) {
|
||||
console.log(`Configured Plain SMTP ports: ${nonTlsPorts.join(', ')}`);
|
||||
}
|
||||
|
||||
// Setup connection listeners for different port types
|
||||
smartProxy.on('connection', (connection) => {
|
||||
const port = connection.localPort;
|
||||
|
||||
// Check which type of port this is
|
||||
if (implicitTlsPorts.includes(port)) {
|
||||
console.log(`Implicit TLS SMTP connection on port ${port} from ${connection.remoteIP}`);
|
||||
} else if (starttlsPorts.includes(port)) {
|
||||
console.log(`STARTTLS SMTP connection on port ${port} from ${connection.remoteIP}`);
|
||||
} else if (nonTlsPorts.includes(port)) {
|
||||
console.log(`Plain SMTP connection on port ${port} from ${connection.remoteIP}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Applied SMTP port configurations to SmartProxy: ${enabledPorts.map(p => p.port).join(', ')}`);
|
||||
}
|
||||
}
|
991
ts/dcrouter/classes.unified.email.server.ts
Normal file
991
ts/dcrouter/classes.unified.email.server.ts
Normal file
@ -0,0 +1,991 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { EventEmitter } from 'events';
|
||||
import { logger } from '../logger.js';
|
||||
import {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType
|
||||
} from '../security/index.js';
|
||||
import { DomainRouter } from './classes.domain.router.js';
|
||||
import type {
|
||||
IEmailConfig,
|
||||
EmailProcessingMode,
|
||||
IDomainRule
|
||||
} from './classes.email.config.js';
|
||||
import { Email } from '../mta/classes.email.js';
|
||||
import * as net from 'node:net';
|
||||
import * as tls from 'node:tls';
|
||||
import * as stream from 'node:stream';
|
||||
import { SMTPServer as MtaSmtpServer } from '../mta/classes.smtpserver.js';
|
||||
|
||||
/**
|
||||
* Options for the unified email server
|
||||
*/
|
||||
export interface IUnifiedEmailServerOptions {
|
||||
// Base server options
|
||||
ports: number[];
|
||||
hostname: string;
|
||||
banner?: string;
|
||||
|
||||
// Authentication options
|
||||
auth?: {
|
||||
required?: boolean;
|
||||
methods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
||||
users?: Array<{username: string, password: string}>;
|
||||
};
|
||||
|
||||
// TLS options
|
||||
tls?: {
|
||||
certPath?: string;
|
||||
keyPath?: string;
|
||||
caPath?: string;
|
||||
minVersion?: string;
|
||||
ciphers?: string;
|
||||
};
|
||||
|
||||
// Limits
|
||||
maxMessageSize?: number;
|
||||
maxClients?: number;
|
||||
maxConnections?: number;
|
||||
|
||||
// Connection options
|
||||
connectionTimeout?: number;
|
||||
socketTimeout?: number;
|
||||
|
||||
// Domain rules
|
||||
domainRules: IDomainRule[];
|
||||
|
||||
// Default handling for unmatched domains
|
||||
defaultMode: EmailProcessingMode;
|
||||
defaultServer?: string;
|
||||
defaultPort?: number;
|
||||
defaultTls?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing SMTP session data
|
||||
*/
|
||||
export interface ISmtpSession {
|
||||
id: string;
|
||||
remoteAddress: string;
|
||||
clientHostname: string;
|
||||
secure: boolean;
|
||||
authenticated: boolean;
|
||||
user?: {
|
||||
username: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
envelope: {
|
||||
mailFrom: {
|
||||
address: string;
|
||||
args: any;
|
||||
};
|
||||
rcptTo: Array<{
|
||||
address: string;
|
||||
args: any;
|
||||
}>;
|
||||
};
|
||||
processingMode?: EmailProcessingMode;
|
||||
matchedRule?: IDomainRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication data for SMTP
|
||||
*/
|
||||
export interface IAuthData {
|
||||
method: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server statistics
|
||||
*/
|
||||
export interface IServerStats {
|
||||
startTime: Date;
|
||||
connections: {
|
||||
current: number;
|
||||
total: number;
|
||||
};
|
||||
messages: {
|
||||
processed: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
};
|
||||
processingTime: {
|
||||
avg: number;
|
||||
max: number;
|
||||
min: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified email server that handles all email traffic with pattern-based routing
|
||||
*/
|
||||
export class UnifiedEmailServer extends EventEmitter {
|
||||
private options: IUnifiedEmailServerOptions;
|
||||
private domainRouter: DomainRouter;
|
||||
private servers: MtaSmtpServer[] = [];
|
||||
private stats: IServerStats;
|
||||
private processingTimes: number[] = [];
|
||||
|
||||
constructor(options: IUnifiedEmailServerOptions) {
|
||||
super();
|
||||
|
||||
// Set default options
|
||||
this.options = {
|
||||
...options,
|
||||
banner: options.banner || `${options.hostname} ESMTP UnifiedEmailServer`,
|
||||
maxMessageSize: options.maxMessageSize || 10 * 1024 * 1024, // 10MB
|
||||
maxClients: options.maxClients || 100,
|
||||
maxConnections: options.maxConnections || 1000,
|
||||
connectionTimeout: options.connectionTimeout || 60000, // 1 minute
|
||||
socketTimeout: options.socketTimeout || 60000 // 1 minute
|
||||
};
|
||||
|
||||
// Initialize domain router for pattern matching
|
||||
this.domainRouter = new DomainRouter({
|
||||
domainRules: options.domainRules,
|
||||
defaultMode: options.defaultMode,
|
||||
defaultServer: options.defaultServer,
|
||||
defaultPort: options.defaultPort,
|
||||
defaultTls: options.defaultTls,
|
||||
enableCache: true,
|
||||
cacheSize: 1000
|
||||
});
|
||||
|
||||
// Initialize statistics
|
||||
this.stats = {
|
||||
startTime: new Date(),
|
||||
connections: {
|
||||
current: 0,
|
||||
total: 0
|
||||
},
|
||||
messages: {
|
||||
processed: 0,
|
||||
delivered: 0,
|
||||
failed: 0
|
||||
},
|
||||
processingTime: {
|
||||
avg: 0,
|
||||
max: 0,
|
||||
min: 0
|
||||
}
|
||||
};
|
||||
|
||||
// We'll create the SMTP servers during the start() method
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the unified email server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
|
||||
|
||||
try {
|
||||
// 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 key: string | undefined;
|
||||
let cert: string | undefined;
|
||||
|
||||
if (hasTlsConfig) {
|
||||
try {
|
||||
key = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
|
||||
cert = 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a SMTP server for each port
|
||||
for (const port of this.options.ports as number[]) {
|
||||
// Create a reference object to hold the MTA service during setup
|
||||
const mtaRef = {
|
||||
config: {
|
||||
smtp: {
|
||||
hostname: this.options.hostname
|
||||
},
|
||||
security: {
|
||||
checkIPReputation: false,
|
||||
verifyDkim: true,
|
||||
verifySpf: true,
|
||||
verifyDmarc: true
|
||||
}
|
||||
},
|
||||
// These will be implemented in the real integration:
|
||||
dkimVerifier: {
|
||||
verify: async () => ({ isValid: true, domain: '' })
|
||||
},
|
||||
spfVerifier: {
|
||||
verifyAndApply: async () => true
|
||||
},
|
||||
dmarcVerifier: {
|
||||
verify: async () => ({}),
|
||||
applyPolicy: () => true
|
||||
},
|
||||
processIncomingEmail: async (email: Email) => {
|
||||
// This is where we'll process the email based on domain routing
|
||||
const to = email.to[0]; // Email.to is an array, take the first recipient
|
||||
const rule = this.domainRouter.matchRule(to);
|
||||
const mode = rule?.mode || this.options.defaultMode;
|
||||
|
||||
// Process based on the mode
|
||||
await this.processEmailByMode(email, {
|
||||
id: 'session-' + Math.random().toString(36).substring(2),
|
||||
remoteAddress: '127.0.0.1',
|
||||
clientHostname: '',
|
||||
secure: false,
|
||||
authenticated: false,
|
||||
envelope: {
|
||||
mailFrom: { address: email.from, args: {} },
|
||||
rcptTo: email.to.map(recipient => ({ address: recipient, args: {} }))
|
||||
},
|
||||
processingMode: mode,
|
||||
matchedRule: rule
|
||||
}, mode);
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Create server options
|
||||
const serverOptions = {
|
||||
port,
|
||||
hostname: this.options.hostname,
|
||||
key,
|
||||
cert
|
||||
};
|
||||
|
||||
// Create and start the SMTP server
|
||||
const smtpServer = new MtaSmtpServer(mtaRef as any, serverOptions);
|
||||
this.servers.push(smtpServer);
|
||||
|
||||
// Start the server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
smtpServer.start();
|
||||
logger.log('info', `UnifiedEmailServer listening on port ${port}`);
|
||||
|
||||
// Set up event handlers
|
||||
(smtpServer as any).server.on('error', (err: Error) => {
|
||||
logger.log('error', `SMTP server error on port ${port}: ${err.message}`);
|
||||
this.emit('error', err);
|
||||
});
|
||||
|
||||
resolve();
|
||||
} catch (err) {
|
||||
if ((err as any).code === 'EADDRINUSE') {
|
||||
logger.log('error', `Port ${port} is already in use`);
|
||||
reject(new Error(`Port ${port} is already in use`));
|
||||
} else {
|
||||
logger.log('error', `Error starting server on port ${port}: ${err.message}`);
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.log('info', 'UnifiedEmailServer started successfully');
|
||||
this.emit('started');
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the unified email server
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
logger.log('info', 'Stopping UnifiedEmailServer');
|
||||
|
||||
try {
|
||||
// Stop all SMTP servers
|
||||
for (const server of this.servers) {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
// Clear the servers array
|
||||
this.servers = [];
|
||||
|
||||
logger.log('info', 'UnifiedEmailServer stopped successfully');
|
||||
this.emit('stopped');
|
||||
} catch (error) {
|
||||
logger.log('error', `Error stopping UnifiedEmailServer: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new SMTP connection (stub implementation)
|
||||
*/
|
||||
private onConnect(session: ISmtpSession, callback: (err?: Error) => void): void {
|
||||
logger.log('info', `New connection from ${session.remoteAddress}`);
|
||||
|
||||
// Update connection statistics
|
||||
this.stats.connections.current++;
|
||||
this.stats.connections.total++;
|
||||
|
||||
// Log connection event
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.CONNECTION,
|
||||
message: 'New SMTP connection established',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
sessionId: session.id,
|
||||
secure: session.secure
|
||||
}
|
||||
});
|
||||
|
||||
// Optional IP reputation check would go here
|
||||
|
||||
// Continue with the connection
|
||||
callback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication (stub implementation)
|
||||
*/
|
||||
private onAuth(auth: IAuthData, session: ISmtpSession, callback: (err?: Error, user?: any) => void): void {
|
||||
if (!this.options.auth || !this.options.auth.users || this.options.auth.users.length === 0) {
|
||||
// No authentication configured, reject
|
||||
const error = new Error('Authentication not supported');
|
||||
logger.log('warn', `Authentication attempt when not configured: ${auth.username}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.AUTHENTICATION,
|
||||
message: 'Authentication attempt when not configured',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
username: auth.username,
|
||||
method: auth.method,
|
||||
sessionId: session.id
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return callback(error);
|
||||
}
|
||||
|
||||
// Find matching user
|
||||
const user = this.options.auth.users.find(u => u.username === auth.username && u.password === auth.password);
|
||||
|
||||
if (user) {
|
||||
logger.log('info', `User ${auth.username} authenticated successfully`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.AUTHENTICATION,
|
||||
message: 'SMTP authentication successful',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
username: auth.username,
|
||||
method: auth.method,
|
||||
sessionId: session.id
|
||||
},
|
||||
success: true
|
||||
});
|
||||
|
||||
return callback(null, { username: user.username });
|
||||
} else {
|
||||
const error = new Error('Invalid username or password');
|
||||
logger.log('warn', `Failed authentication for ${auth.username}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.AUTHENTICATION,
|
||||
message: 'SMTP authentication failed',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
username: auth.username,
|
||||
method: auth.method,
|
||||
sessionId: session.id
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return callback(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MAIL FROM command (stub implementation)
|
||||
*/
|
||||
private onMailFrom(address: {address: string}, session: ISmtpSession, callback: (err?: Error) => void): void {
|
||||
logger.log('info', `MAIL FROM: ${address.address}`);
|
||||
|
||||
// Validate the email address
|
||||
if (!this.isValidEmail(address.address)) {
|
||||
const error = new Error('Invalid sender address');
|
||||
logger.log('warn', `Invalid sender address: ${address.address}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.EMAIL_VALIDATION,
|
||||
message: 'Invalid sender email format',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
address: address.address,
|
||||
sessionId: session.id
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return callback(error);
|
||||
}
|
||||
|
||||
// Authentication check if required
|
||||
if (this.options.auth?.required && !session.authenticated) {
|
||||
const error = new Error('Authentication required');
|
||||
logger.log('warn', `Unauthenticated sender rejected: ${address.address}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.AUTHENTICATION,
|
||||
message: 'Unauthenticated sender rejected',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
address: address.address,
|
||||
sessionId: session.id
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return callback(error);
|
||||
}
|
||||
|
||||
// Continue processing
|
||||
callback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle RCPT TO command (stub implementation)
|
||||
*/
|
||||
private onRcptTo(address: {address: string}, session: ISmtpSession, callback: (err?: Error) => void): void {
|
||||
logger.log('info', `RCPT TO: ${address.address}`);
|
||||
|
||||
// Validate the email address
|
||||
if (!this.isValidEmail(address.address)) {
|
||||
const error = new Error('Invalid recipient address');
|
||||
logger.log('warn', `Invalid recipient address: ${address.address}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.EMAIL_VALIDATION,
|
||||
message: 'Invalid recipient email format',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
address: address.address,
|
||||
sessionId: session.id
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return callback(error);
|
||||
}
|
||||
|
||||
// Pattern match the recipient to determine processing mode
|
||||
const rule = this.domainRouter.matchRule(address.address);
|
||||
|
||||
if (rule) {
|
||||
// Store the matched rule and processing mode in the session
|
||||
session.matchedRule = rule;
|
||||
session.processingMode = rule.mode;
|
||||
logger.log('info', `Email ${address.address} matched rule: ${rule.pattern}, mode: ${rule.mode}`);
|
||||
} else {
|
||||
// Use default mode
|
||||
session.processingMode = this.options.defaultMode;
|
||||
logger.log('info', `Email ${address.address} using default mode: ${this.options.defaultMode}`);
|
||||
}
|
||||
|
||||
// Continue processing
|
||||
callback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming email data (stub implementation)
|
||||
*/
|
||||
private onData(stream: stream.Readable, session: ISmtpSession, callback: (err?: Error) => void): void {
|
||||
logger.log('info', `Processing email data for session ${session.id}`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
stream.on('end', async () => {
|
||||
try {
|
||||
const data = Buffer.concat(chunks);
|
||||
const mode = session.processingMode || this.options.defaultMode;
|
||||
|
||||
// Determine processing mode based on matched rule
|
||||
const processedEmail = await this.processEmailByMode(data, session, mode);
|
||||
|
||||
// Update statistics
|
||||
this.stats.messages.processed++;
|
||||
this.stats.messages.delivered++;
|
||||
|
||||
// Calculate processing time
|
||||
const processingTime = Date.now() - startTime;
|
||||
this.processingTimes.push(processingTime);
|
||||
this.updateProcessingTimeStats();
|
||||
|
||||
// Emit event for delivery queue
|
||||
this.emit('emailProcessed', processedEmail, mode, session.matchedRule);
|
||||
|
||||
logger.log('info', `Email processed successfully in ${processingTime}ms, mode: ${mode}`);
|
||||
callback();
|
||||
} catch (error) {
|
||||
logger.log('error', `Error processing email: ${error.message}`);
|
||||
|
||||
// Update statistics
|
||||
this.stats.messages.processed++;
|
||||
this.stats.messages.failed++;
|
||||
|
||||
// Calculate processing time for failed attempts too
|
||||
const processingTime = Date.now() - startTime;
|
||||
this.processingTimes.push(processingTime);
|
||||
this.updateProcessingTimeStats();
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.EMAIL_PROCESSING,
|
||||
message: 'Email processing failed',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
error: error.message,
|
||||
sessionId: session.id,
|
||||
mode: session.processingMode,
|
||||
processingTime
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
callback(error);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
logger.log('error', `Stream error: ${err.message}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.EMAIL_PROCESSING,
|
||||
message: 'Email stream error',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
error: err.message,
|
||||
sessionId: session.id
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
callback(err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update processing time statistics
|
||||
*/
|
||||
private updateProcessingTimeStats(): void {
|
||||
if (this.processingTimes.length === 0) return;
|
||||
|
||||
// Keep only the last 1000 processing times
|
||||
if (this.processingTimes.length > 1000) {
|
||||
this.processingTimes = this.processingTimes.slice(-1000);
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const sum = this.processingTimes.reduce((acc, time) => acc + time, 0);
|
||||
const avg = sum / this.processingTimes.length;
|
||||
const max = Math.max(...this.processingTimes);
|
||||
const min = Math.min(...this.processingTimes);
|
||||
|
||||
this.stats.processingTime = { avg, max, min };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process email based on the determined mode
|
||||
*/
|
||||
private async processEmailByMode(emailData: Email | Buffer, session: ISmtpSession, mode: EmailProcessingMode): Promise<Email> {
|
||||
// Convert Buffer to Email if needed
|
||||
let email: Email;
|
||||
if (Buffer.isBuffer(emailData)) {
|
||||
// Parse the email data buffer into an Email object
|
||||
try {
|
||||
const parsed = await plugins.mailparser.simpleParser(emailData);
|
||||
email = new Email({
|
||||
from: parsed.from?.value[0]?.address || session.envelope.mailFrom.address,
|
||||
to: session.envelope.rcptTo[0]?.address || '',
|
||||
subject: parsed.subject || '',
|
||||
text: parsed.text || '',
|
||||
html: parsed.html || undefined,
|
||||
attachments: parsed.attachments?.map(att => ({
|
||||
filename: att.filename || '',
|
||||
content: att.content,
|
||||
contentType: att.contentType
|
||||
})) || []
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log('error', `Error parsing email data: ${error.message}`);
|
||||
throw new Error(`Error parsing email data: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
email = emailData;
|
||||
}
|
||||
|
||||
// Process based on mode
|
||||
switch (mode) {
|
||||
case 'forward':
|
||||
await this.handleForwardMode(email, session);
|
||||
break;
|
||||
|
||||
case 'mta':
|
||||
await this.handleMtaMode(email, session);
|
||||
break;
|
||||
|
||||
case 'process':
|
||||
await this.handleProcessMode(email, session);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown processing mode: ${mode}`);
|
||||
}
|
||||
|
||||
// Return the processed email
|
||||
return email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle email in forward mode (SMTP proxy)
|
||||
*/
|
||||
private async handleForwardMode(email: Email, session: ISmtpSession): Promise<void> {
|
||||
logger.log('info', `Handling email in forward mode for session ${session.id}`);
|
||||
|
||||
// Get target server information
|
||||
const rule = session.matchedRule;
|
||||
const targetServer = rule?.target?.server || this.options.defaultServer;
|
||||
const targetPort = rule?.target?.port || this.options.defaultPort || 25;
|
||||
const useTls = rule?.target?.useTls ?? this.options.defaultTls ?? false;
|
||||
|
||||
if (!targetServer) {
|
||||
throw new Error('No target server configured for forward mode');
|
||||
}
|
||||
|
||||
logger.log('info', `Forwarding email to ${targetServer}:${targetPort}, TLS: ${useTls}`);
|
||||
|
||||
try {
|
||||
// Create a simple SMTP client connection to the target server
|
||||
const client = new net.Socket();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// Connect to the target server
|
||||
client.connect({
|
||||
host: targetServer,
|
||||
port: targetPort
|
||||
});
|
||||
|
||||
client.on('data', (data) => {
|
||||
const response = data.toString().trim();
|
||||
logger.log('debug', `SMTP response: ${response}`);
|
||||
|
||||
// Handle SMTP response codes
|
||||
if (response.startsWith('2')) {
|
||||
// Success response
|
||||
resolve();
|
||||
} else if (response.startsWith('5')) {
|
||||
// Permanent error
|
||||
reject(new Error(`SMTP error: ${response}`));
|
||||
}
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
logger.log('error', `SMTP client error: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// SMTP client commands would go here in a full implementation
|
||||
// For now, just finish the connection
|
||||
client.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
logger.log('info', `Email forwarded successfully to ${targetServer}:${targetPort}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.EMAIL_FORWARDING,
|
||||
message: 'Email forwarded',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
sessionId: session.id,
|
||||
targetServer,
|
||||
targetPort,
|
||||
useTls,
|
||||
ruleName: rule?.pattern || 'default',
|
||||
subject: email.subject
|
||||
},
|
||||
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: session.remoteAddress,
|
||||
details: {
|
||||
sessionId: session.id,
|
||||
targetServer,
|
||||
targetPort,
|
||||
useTls,
|
||||
ruleName: rule?.pattern || 'default',
|
||||
error: error.message
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle email in MTA mode (programmatic processing)
|
||||
*/
|
||||
private async handleMtaMode(email: Email, session: ISmtpSession): Promise<void> {
|
||||
logger.log('info', `Handling email in MTA mode for session ${session.id}`);
|
||||
|
||||
try {
|
||||
// Apply MTA rule options if provided
|
||||
if (session.matchedRule?.mtaOptions) {
|
||||
const options = session.matchedRule.mtaOptions;
|
||||
|
||||
// Apply DKIM signing if enabled
|
||||
if (options.dkimSign && options.dkimOptions) {
|
||||
// Sign the email with DKIM
|
||||
logger.log('info', `Signing email with DKIM for domain ${options.dkimOptions.domainName}`);
|
||||
|
||||
// In a full implementation, this would use the DKIM signing library
|
||||
}
|
||||
}
|
||||
|
||||
// Get email content for logging/processing
|
||||
const subject = email.subject;
|
||||
const recipients = email.getAllRecipients().join(', ');
|
||||
|
||||
logger.log('info', `Email processed by MTA: ${subject} to ${recipients}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.EMAIL_PROCESSING,
|
||||
message: 'Email processed by MTA',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
sessionId: session.id,
|
||||
ruleName: session.matchedRule?.pattern || 'default',
|
||||
subject,
|
||||
recipients
|
||||
},
|
||||
success: true
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to process email in MTA mode: ${error.message}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.EMAIL_PROCESSING,
|
||||
message: 'MTA processing failed',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
sessionId: session.id,
|
||||
ruleName: session.matchedRule?.pattern || 'default',
|
||||
error: error.message
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle email in process mode (store-and-forward with scanning)
|
||||
*/
|
||||
private async handleProcessMode(email: Email, session: ISmtpSession): Promise<void> {
|
||||
logger.log('info', `Handling email in process mode for session ${session.id}`);
|
||||
|
||||
try {
|
||||
const rule = session.matchedRule;
|
||||
|
||||
// Apply content scanning if enabled
|
||||
if (rule?.contentScanning && rule.scanners && rule.scanners.length > 0) {
|
||||
logger.log('info', 'Performing content scanning');
|
||||
|
||||
// Apply each scanner
|
||||
for (const scanner of rule.scanners) {
|
||||
switch (scanner.type) {
|
||||
case 'spam':
|
||||
logger.log('info', 'Scanning for spam content');
|
||||
// Implement spam scanning
|
||||
break;
|
||||
|
||||
case 'virus':
|
||||
logger.log('info', 'Scanning for virus content');
|
||||
// Implement virus scanning
|
||||
break;
|
||||
|
||||
case 'attachment':
|
||||
logger.log('info', 'Scanning attachments');
|
||||
|
||||
// Check for blocked extensions
|
||||
if (scanner.blockedExtensions && scanner.blockedExtensions.length > 0) {
|
||||
for (const attachment of email.attachments) {
|
||||
const ext = this.getFileExtension(attachment.filename);
|
||||
if (scanner.blockedExtensions.includes(ext)) {
|
||||
if (scanner.action === 'reject') {
|
||||
throw new Error(`Blocked attachment type: ${ext}`);
|
||||
} else { // tag
|
||||
email.addHeader('X-Attachment-Warning', `Potentially unsafe attachment: ${attachment.filename}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply transformations if defined
|
||||
if (rule?.transformations && rule.transformations.length > 0) {
|
||||
logger.log('info', 'Applying email transformations');
|
||||
|
||||
for (const transform of rule.transformations) {
|
||||
switch (transform.type) {
|
||||
case 'addHeader':
|
||||
if (transform.header && transform.value) {
|
||||
email.addHeader(transform.header, transform.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `Email successfully processed in store-and-forward mode`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.EMAIL_PROCESSING,
|
||||
message: 'Email processed and queued',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
sessionId: session.id,
|
||||
ruleName: rule?.pattern || 'default',
|
||||
contentScanning: rule?.contentScanning || false,
|
||||
subject: email.subject
|
||||
},
|
||||
success: true
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to process email: ${error.message}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.EMAIL_PROCESSING,
|
||||
message: 'Email processing failed',
|
||||
ipAddress: session.remoteAddress,
|
||||
details: {
|
||||
sessionId: session.id,
|
||||
ruleName: session.matchedRule?.pattern || 'default',
|
||||
error: error.message
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from filename
|
||||
*/
|
||||
private getFileExtension(filename: string): string {
|
||||
return filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle server errors
|
||||
*/
|
||||
private onError(err: Error): void {
|
||||
logger.log('error', `Server error: ${err.message}`);
|
||||
this.emit('error', err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle server close
|
||||
*/
|
||||
private onClose(): void {
|
||||
logger.log('info', 'Server closed');
|
||||
this.emit('close');
|
||||
|
||||
// Update statistics
|
||||
this.stats.connections.current = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update server configuration
|
||||
*/
|
||||
public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void {
|
||||
// Stop the server if changing ports
|
||||
const portsChanged = options.ports &&
|
||||
(!this.options.ports ||
|
||||
JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
|
||||
|
||||
if (portsChanged) {
|
||||
this.stop().then(() => {
|
||||
this.options = { ...this.options, ...options };
|
||||
this.start();
|
||||
});
|
||||
} else {
|
||||
// Update options without restart
|
||||
this.options = { ...this.options, ...options };
|
||||
|
||||
// Update domain router if rules changed
|
||||
if (options.domainRules) {
|
||||
this.domainRouter.updateRules(options.domainRules);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update domain rules
|
||||
*/
|
||||
public updateDomainRules(rules: IDomainRule[]): void {
|
||||
this.options.domainRules = rules;
|
||||
this.domainRouter.updateRules(rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server statistics
|
||||
*/
|
||||
public getStats(): IServerStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email address format
|
||||
*/
|
||||
private isValidEmail(email: string): boolean {
|
||||
// Basic validation - a more comprehensive validation could be used
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
}
|
@ -1 +1,14 @@
|
||||
// Core DcRouter components
|
||||
export * from './classes.dcrouter.js';
|
||||
export * from './classes.smtp.portconfig.js';
|
||||
export * from './classes.email.domainrouter.js';
|
||||
|
||||
// Unified Email Configuration
|
||||
export * from './classes.email.config.js';
|
||||
export * from './classes.domain.router.js';
|
||||
export * from './classes.unified.email.server.js';
|
||||
|
||||
// Shared Infrastructure Components
|
||||
export * from './classes.delivery.queue.js';
|
||||
export * from './classes.delivery.system.js';
|
||||
export * from './classes.rate.limiter.js';
|
||||
|
@ -18,10 +18,14 @@ export enum SecurityEventType {
|
||||
AUTHENTICATION = 'authentication',
|
||||
ACCESS_CONTROL = 'access_control',
|
||||
EMAIL_VALIDATION = 'email_validation',
|
||||
EMAIL_PROCESSING = 'email_processing',
|
||||
EMAIL_FORWARDING = 'email_forwarding',
|
||||
EMAIL_DELIVERY = 'email_delivery',
|
||||
DKIM = 'dkim',
|
||||
SPF = 'spf',
|
||||
DMARC = 'dmarc',
|
||||
RATE_LIMIT = 'rate_limit',
|
||||
RATE_LIMITING = 'rate_limiting',
|
||||
SPAM = 'spam',
|
||||
MALWARE = 'malware',
|
||||
CONNECTION = 'connection',
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/platformservice',
|
||||
version: '2.4.2',
|
||||
version: '2.8.0',
|
||||
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
|
||||
}
|
||||
|
Binary file not shown.
Reference in New Issue
Block a user