update
This commit is contained in:
parent
b0a0078ad0
commit
3f220996ee
282
readme.plan.md
282
readme.plan.md
@ -1,76 +1,258 @@
|
||||
# DCRouter Project Improvement Plan
|
||||
# SMTP Server Refactoring Plan
|
||||
|
||||
## Type Safety Enhancement Plan
|
||||
## Problem Statement
|
||||
|
||||
Our goal is to improve type safety across the DCRouter codebase to reduce runtime errors, improve developer experience, and make the code more maintainable. This document outlines the specific changes we'll implement.
|
||||
The SMTP server implementation in `classes.smtpserver.ts` has grown to be too large and complex, with over 1300 lines of code. This makes it difficult to maintain, test, and extend. We need to refactor it into multiple smaller, focused files to improve maintainability and adhere to the single responsibility principle.
|
||||
|
||||
### 1. Platform Service Interface Improvements
|
||||
## Refactoring Goals
|
||||
|
||||
- [ ] Create a comprehensive `IPlatformService` interface to replace `any` type usage
|
||||
- [ ] Define specific methods and properties that platform services should implement
|
||||
- [ ] Update all references to use the new interface
|
||||
1. Improve code organization by splitting the SMTPServer class into multiple focused modules
|
||||
2. Enhance testability by making components more isolated and easier to mock
|
||||
3. Improve maintainability by reducing file sizes and complexity
|
||||
4. Preserve existing functionality and behavior while improving the architecture
|
||||
|
||||
### 2. SMTP Session Type Safety
|
||||
## Proposed File Structure
|
||||
|
||||
- [ ] Define a proper `ISmtpSession` interface with all required properties
|
||||
- [ ] Ensure consistent usage across SMTPServer implementation
|
||||
- [ ] Add proper validation for session properties
|
||||
```
|
||||
mail/
|
||||
└── delivery/
|
||||
├── smtp/
|
||||
│ ├── index.ts - Main export file
|
||||
│ ├── interfaces.ts - All SMTP-related interfaces
|
||||
│ ├── constants.ts - Constants and enums
|
||||
│ ├── smtp-server.ts - Main server class (core functionality)
|
||||
│ ├── session-manager.ts - Session creation and management
|
||||
│ ├── command-handler.ts - SMTP command processing
|
||||
│ ├── data-handler.ts - Email data processing
|
||||
│ ├── tls-handler.ts - TLS and STARTTLS functionality
|
||||
│ ├── security-handler.ts - Security checks and validations
|
||||
│ ├── connection-manager.ts - Connection management and cleanup
|
||||
│ └── utils/
|
||||
│ ├── validation.ts - Validation utilities
|
||||
│ ├── logging.ts - Logging utilities
|
||||
│ └── helpers.ts - Other helper functions
|
||||
├── classes.smtpserver.ts - Legacy file (will be deprecated)
|
||||
└── interfaces.ts - Main delivery interfaces
|
||||
```
|
||||
|
||||
### 3. Configuration Type Enhancements
|
||||
## Module Responsibilities
|
||||
|
||||
- [ ] Replace loose config objects with strictly typed interfaces
|
||||
- [ ] Add validation functions for all configuration objects
|
||||
- [ ] Create union types for configuration options with specific values
|
||||
### 1. smtp-server.ts (150-200 lines)
|
||||
- Core server initialization and lifecycle management
|
||||
- Server startup and shutdown
|
||||
- Port binding and socket creation
|
||||
- Delegates to other modules for specific functionality
|
||||
|
||||
### 4. Function Return Types
|
||||
### 2. session-manager.ts (100-150 lines)
|
||||
- Session creation and tracking
|
||||
- Session timeout management
|
||||
- Session cleanup
|
||||
- Session state management
|
||||
|
||||
- [ ] Audit all async functions to ensure they have explicit return types
|
||||
- [ ] Replace `Promise<any>` with specific return type interfaces
|
||||
- [ ] Add proper error types for rejected promises
|
||||
### 3. command-handler.ts (200-250 lines)
|
||||
- SMTP command parsing and routing
|
||||
- Command validation
|
||||
- Command implementation (EHLO, MAIL FROM, RCPT TO, etc.)
|
||||
- Response formatting
|
||||
|
||||
### 5. String Literal Types and Enums
|
||||
### 4. data-handler.ts (150-200 lines)
|
||||
- Email data collection and processing
|
||||
- DATA command handling
|
||||
- MIME parsing
|
||||
- Email creation and forwarding
|
||||
- Email storage
|
||||
|
||||
- [ ] Replace string constants with proper TypeScript enums or string literal unions
|
||||
- [ ] Create specific types for email status values, priorities, etc.
|
||||
- [ ] Ensure consistent usage throughout the codebase
|
||||
### 5. tls-handler.ts (100-150 lines)
|
||||
- TLS connection handling
|
||||
- STARTTLS implementation
|
||||
- Certificate management
|
||||
- Secure socket creation
|
||||
|
||||
### 6. Event System Types
|
||||
### 6. security-handler.ts (100-150 lines)
|
||||
- IP reputation checking
|
||||
- Access control
|
||||
- Rate limiting
|
||||
- Security logging
|
||||
- SPAM detection
|
||||
|
||||
- [ ] Create typed event emitters for all event-based components
|
||||
- [ ] Define specific event payload interfaces for each event type
|
||||
- [ ] Ensure type safety for event handlers
|
||||
### 7. connection-manager.ts (100-150 lines)
|
||||
- Connection tracking and limits
|
||||
- Idle connection cleanup
|
||||
- Connection error handling
|
||||
- Socket event handling
|
||||
|
||||
### 7. Authentication Data Types
|
||||
### 8. interfaces.ts (100-150 lines)
|
||||
- All interface definitions
|
||||
- Type aliases
|
||||
- Type guards
|
||||
- Enum definitions
|
||||
|
||||
- [ ] Create proper interfaces for authentication data
|
||||
- [ ] Replace generic Record types with specific property interfaces
|
||||
- [ ] Add validation for auth data objects
|
||||
## Implementation Strategy
|
||||
|
||||
### 8. Email Attachment Type Safety
|
||||
### Phase 1: Initial Structure and Scaffolding (Days 1-2)
|
||||
|
||||
- [ ] Define comprehensive interfaces for email attachments
|
||||
- [ ] Ensure consistent usage between different email handling components
|
||||
- [ ] Add validation for attachment properties
|
||||
1. Create the folder structure and empty files
|
||||
- Create `mail/delivery/smtp` directory
|
||||
- Create all module files with basic exports
|
||||
- Set up barrel file (index.ts)
|
||||
|
||||
### 9. Processing Mode Type Safety
|
||||
2. Move interfaces to the new interfaces.ts file
|
||||
- Extract all interfaces from current implementation
|
||||
- Add proper documentation
|
||||
- Add any missing interface properties
|
||||
|
||||
- [ ] Create discriminated unions for different email processing modes
|
||||
- [ ] Add type guards to ensure safe handling of mode-specific properties
|
||||
- [ ] Ensure proper validation of processing mode values
|
||||
3. Extract constants and enums to constants.ts
|
||||
- Move all constants and enums to dedicated file
|
||||
- Ensure proper typing and documentation
|
||||
- Replace magic numbers with named constants
|
||||
|
||||
### 10. Third-Party Integration Types
|
||||
4. Set up the basic structure for each module
|
||||
- Define basic class skeletons
|
||||
- Set up dependency injection structure
|
||||
- Document interfaces for each module
|
||||
|
||||
- [ ] Review and update type definitions for third-party dependencies
|
||||
- [ ] Create additional type definitions where missing
|
||||
- [ ] Ensure consistent usage of external libraries
|
||||
### Phase 2: Gradual Implementation Transfer (Days 3-7)
|
||||
|
||||
## Implementation Order
|
||||
1. Start with utility modules
|
||||
- Implement validation.ts with email validation functions
|
||||
- Create logging.ts with structured logging utilities
|
||||
- Build helpers.ts with common helper functions
|
||||
|
||||
We'll implement these improvements in the following order:
|
||||
2. Implement session-manager.ts
|
||||
- Extract session creation and management logic
|
||||
- Implement timeout handling
|
||||
- Add session state management functions
|
||||
|
||||
1. First, focus on the core interfaces (Platform Service, SMTP Session)
|
||||
2. Next, improve configuration types as they affect multiple components
|
||||
3. Then, address function return types and string literals
|
||||
4. Finally, handle the remaining specialized types (auth, attachments, etc.)
|
||||
3. Implement connection-manager.ts
|
||||
- Extract connection tracking code
|
||||
- Implement connection limits
|
||||
- Add socket event handling logic
|
||||
|
||||
This approach allows us to tackle the most widely-used types first, providing the greatest immediate benefit while establishing patterns for the rest of the implementation.
|
||||
4. Implement command-handler.ts
|
||||
- Extract command parsing and processing
|
||||
- Split command handlers into separate methods
|
||||
- Implement command validation and routing
|
||||
|
||||
5. Implement data-handler.ts
|
||||
- Extract email data processing logic
|
||||
- Implement DATA command handling
|
||||
- Add email storage and forwarding
|
||||
|
||||
6. Implement tls-handler.ts
|
||||
- Extract TLS connection handling
|
||||
- Implement STARTTLS functionality
|
||||
- Add certificate management
|
||||
|
||||
7. Implement security-handler.ts
|
||||
- Extract IP reputation checking
|
||||
- Implement security logging
|
||||
- Add access control functionality
|
||||
|
||||
### Phase 3: Core Server Refactoring (Days 8-10)
|
||||
|
||||
1. Refactor the main SMTPServer class
|
||||
- Update constructor to create and initialize components
|
||||
- Implement dependency injection for all modules
|
||||
- Delegate functionality to appropriate modules
|
||||
- Reduce core class to server lifecycle management
|
||||
|
||||
2. Update event handling
|
||||
- Ensure proper event propagation between modules
|
||||
- Implement event delegation pattern
|
||||
- Add missing event handlers
|
||||
|
||||
3. Implement cross-module communication
|
||||
- Define clear interfaces for module interaction
|
||||
- Ensure proper data flow between components
|
||||
- Avoid circular dependencies
|
||||
|
||||
4. Verify functionality preservation
|
||||
- Ensure all methods have equivalent implementations
|
||||
- Add logging to trace execution flow
|
||||
- Create test cases for edge conditions
|
||||
|
||||
### Phase 4: Legacy Compatibility (Days 11-12)
|
||||
|
||||
1. Create facade in classes.smtpserver.ts
|
||||
- Keep original class signature
|
||||
- Delegate to new implementation internally
|
||||
- Ensure backward compatibility
|
||||
|
||||
2. Add deprecation notices
|
||||
- Mark legacy file with deprecation comments
|
||||
- Add migration guide in comments
|
||||
- Document breaking changes if any
|
||||
|
||||
3. Update documentation
|
||||
- Create detailed documentation for new architecture
|
||||
- Add examples of how to use the new modules
|
||||
- Document extension points
|
||||
|
||||
4. Create comprehensive tests
|
||||
- Ensure all modules have proper unit tests
|
||||
- Add integration tests between modules
|
||||
- Verify backward compatibility with existing code
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. Unit Testing
|
||||
- Create unit tests for each module in isolation
|
||||
- Mock dependencies for pure unit testing
|
||||
- Test edge cases and error handling
|
||||
|
||||
2. Integration Testing
|
||||
- Test interactions between modules
|
||||
- Verify correct event propagation
|
||||
- Test full SMTP communication flow
|
||||
|
||||
3. Regression Testing
|
||||
- Ensure all existing tests pass
|
||||
- Verify no functionality is lost
|
||||
- Compare performance metrics
|
||||
|
||||
4. Compatibility Testing
|
||||
- Test with existing code that uses the legacy class
|
||||
- Verify backward compatibility
|
||||
- Document any necessary migration steps
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- Phase 1: 1-2 days
|
||||
- Phase 2: 3-5 days
|
||||
- Phase 3: 2-3 days
|
||||
- Phase 4: 1-2 days
|
||||
|
||||
Total: 7-12 days of development time
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. All existing tests pass with the new implementation
|
||||
2. No regression in functionality or performance
|
||||
3. Each file is less than 300 lines of code
|
||||
4. Improved code organization and documentation
|
||||
5. Better separation of concerns between modules
|
||||
6. Easier maintenance and extension of SMTP functionality
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
### Risk: Breaking existing functionality
|
||||
**Mitigation**: Comprehensive test coverage and backward compatibility facade
|
||||
|
||||
### Risk: Performance degradation due to additional indirection
|
||||
**Mitigation**: Performance benchmarking before and after refactoring
|
||||
|
||||
### Risk: Increased complexity due to distributed code
|
||||
**Mitigation**: Clear documentation and proper module interfaces
|
||||
|
||||
### Risk: Time overrun due to unforeseen dependencies
|
||||
**Mitigation**: Incremental approach with working checkpoints after each phase
|
||||
|
||||
## Future Extensions
|
||||
|
||||
Once this refactoring is complete, we can more easily:
|
||||
|
||||
1. Add support for additional SMTP extensions
|
||||
2. Implement pluggable authentication mechanisms
|
||||
3. Add more sophisticated security features
|
||||
4. Improve performance with targeted optimizations
|
||||
5. Create specialized versions for different use cases
|
File diff suppressed because it is too large
Load Diff
@ -140,6 +140,11 @@ export interface ISmtpSession {
|
||||
* Timestamp of last activity for session timeout tracking
|
||||
*/
|
||||
lastActivity?: number;
|
||||
|
||||
/**
|
||||
* Timeout ID for DATA command timeout
|
||||
*/
|
||||
dataTimeoutId?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -186,11 +191,31 @@ export interface ISmtpServerOptions {
|
||||
*/
|
||||
hostname?: string;
|
||||
|
||||
/**
|
||||
* Host address to bind to (defaults to all interfaces)
|
||||
*/
|
||||
host?: string;
|
||||
|
||||
/**
|
||||
* Secure port for dedicated TLS connections
|
||||
*/
|
||||
securePort?: number;
|
||||
|
||||
/**
|
||||
* CA certificates for TLS (PEM format)
|
||||
*/
|
||||
ca?: string;
|
||||
|
||||
/**
|
||||
* Maximum size of messages in bytes
|
||||
*/
|
||||
maxSize?: number;
|
||||
|
||||
/**
|
||||
* Maximum number of concurrent connections
|
||||
*/
|
||||
maxConnections?: number;
|
||||
|
||||
/**
|
||||
* Authentication options
|
||||
*/
|
||||
@ -207,14 +232,37 @@ export interface ISmtpServerOptions {
|
||||
};
|
||||
|
||||
/**
|
||||
* Socket timeout in milliseconds (default: 5 minutes)
|
||||
* Socket timeout in milliseconds (default: 5 minutes / 300000ms)
|
||||
*/
|
||||
socketTimeout?: number;
|
||||
|
||||
/**
|
||||
* Initial connection timeout in milliseconds (default: 30 seconds)
|
||||
* Initial connection timeout in milliseconds (default: 30 seconds / 30000ms)
|
||||
*/
|
||||
connectionTimeout?: number;
|
||||
|
||||
/**
|
||||
* Interval for checking idle sessions in milliseconds (default: 5 seconds / 5000ms)
|
||||
* For testing, can be set lower (e.g. 1000ms) to detect timeouts more quickly
|
||||
*/
|
||||
cleanupInterval?: number;
|
||||
|
||||
/**
|
||||
* Maximum number of recipients allowed per message (default: 100)
|
||||
*/
|
||||
maxRecipients?: number;
|
||||
|
||||
/**
|
||||
* Maximum message size in bytes (default: 10MB / 10485760 bytes)
|
||||
* This is advertised in the EHLO SIZE extension
|
||||
*/
|
||||
size?: number;
|
||||
|
||||
/**
|
||||
* Timeout for the DATA command in milliseconds (default: 60000ms / 1 minute)
|
||||
* This controls how long to wait for the complete email data
|
||||
*/
|
||||
dataTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
632
ts/mail/delivery/smtp/command-handler.ts
Normal file
632
ts/mail/delivery/smtp/command-handler.ts
Normal file
@ -0,0 +1,632 @@
|
||||
/**
|
||||
* SMTP Command Handler
|
||||
* Responsible for parsing and handling SMTP commands
|
||||
*/
|
||||
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import { SmtpState, ISmtpSession, IEnvelopeRecipient } from '../interfaces.js';
|
||||
import { ICommandHandler, ISessionManager, IDataHandler, ITlsHandler, ISecurityHandler } from './interfaces.js';
|
||||
import { SmtpCommand, SmtpResponseCode, SMTP_DEFAULTS, SMTP_EXTENSIONS } from './constants.js';
|
||||
import { SmtpLogger } from './utils/logging.js';
|
||||
import { extractCommandName, extractCommandArgs, formatMultilineResponse } from './utils/helpers.js';
|
||||
import { validateEhlo, validateMailFrom, validateRcptTo, isValidCommandSequence } from './utils/validation.js';
|
||||
|
||||
/**
|
||||
* Handles SMTP commands and responses
|
||||
*/
|
||||
export class CommandHandler implements ICommandHandler {
|
||||
/**
|
||||
* Session manager instance
|
||||
*/
|
||||
private sessionManager: ISessionManager;
|
||||
|
||||
/**
|
||||
* Data handler instance (optional, injected when processing DATA command)
|
||||
*/
|
||||
private dataHandler?: IDataHandler;
|
||||
|
||||
/**
|
||||
* TLS handler instance (optional, injected when processing STARTTLS command)
|
||||
*/
|
||||
private tlsHandler?: ITlsHandler;
|
||||
|
||||
/**
|
||||
* Security handler instance (optional, used for IP reputation and authentication)
|
||||
*/
|
||||
private securityHandler?: ISecurityHandler;
|
||||
|
||||
/**
|
||||
* SMTP server options
|
||||
*/
|
||||
private options: {
|
||||
hostname: string;
|
||||
size?: number;
|
||||
maxRecipients: number;
|
||||
auth?: {
|
||||
required: boolean;
|
||||
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new command handler
|
||||
* @param sessionManager - Session manager instance
|
||||
* @param options - Command handler options
|
||||
* @param dataHandler - Optional data handler instance
|
||||
* @param tlsHandler - Optional TLS handler instance
|
||||
* @param securityHandler - Optional security handler instance
|
||||
*/
|
||||
constructor(
|
||||
sessionManager: ISessionManager,
|
||||
options: {
|
||||
hostname?: string;
|
||||
size?: number;
|
||||
maxRecipients?: number;
|
||||
auth?: {
|
||||
required: boolean;
|
||||
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
||||
};
|
||||
} = {},
|
||||
dataHandler?: IDataHandler,
|
||||
tlsHandler?: ITlsHandler,
|
||||
securityHandler?: ISecurityHandler
|
||||
) {
|
||||
this.sessionManager = sessionManager;
|
||||
this.dataHandler = dataHandler;
|
||||
this.tlsHandler = tlsHandler;
|
||||
this.securityHandler = securityHandler;
|
||||
|
||||
this.options = {
|
||||
hostname: options.hostname || SMTP_DEFAULTS.HOSTNAME,
|
||||
size: options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE,
|
||||
maxRecipients: options.maxRecipients || SMTP_DEFAULTS.MAX_RECIPIENTS,
|
||||
auth: options.auth
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a command from the client
|
||||
* @param socket - Client socket
|
||||
* @param commandLine - Command line from client
|
||||
*/
|
||||
public processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (!session) {
|
||||
SmtpLogger.warn(`No session found for socket from ${socket.remoteAddress}`);
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle data state differently - pass to data handler
|
||||
if (session.state === SmtpState.DATA_RECEIVING) {
|
||||
if (this.dataHandler) {
|
||||
// Let the data handler process the line
|
||||
this.dataHandler.processEmailData(socket, commandLine)
|
||||
.catch(error => {
|
||||
SmtpLogger.error(`Error processing email data: ${error.message}`, {
|
||||
sessionId: session.id,
|
||||
error
|
||||
});
|
||||
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Error processing email data: ${error.message}`);
|
||||
this.resetSession(session);
|
||||
});
|
||||
} else {
|
||||
// No data handler available
|
||||
SmtpLogger.error('Data handler not available', { sessionId: session.id });
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - data handler not available`);
|
||||
this.resetSession(session);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Log received command
|
||||
SmtpLogger.logCommand(commandLine, socket, session);
|
||||
|
||||
// Extract command and arguments
|
||||
const command = extractCommandName(commandLine);
|
||||
const args = extractCommandArgs(commandLine);
|
||||
|
||||
// Validate command sequence
|
||||
if (!this.validateCommandSequence(command, session)) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} Bad sequence of commands`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the command
|
||||
switch (command) {
|
||||
case SmtpCommand.EHLO:
|
||||
case SmtpCommand.HELO:
|
||||
this.handleEhlo(socket, args);
|
||||
break;
|
||||
|
||||
case SmtpCommand.MAIL_FROM:
|
||||
this.handleMailFrom(socket, args);
|
||||
break;
|
||||
|
||||
case SmtpCommand.RCPT_TO:
|
||||
this.handleRcptTo(socket, args);
|
||||
break;
|
||||
|
||||
case SmtpCommand.DATA:
|
||||
this.handleData(socket);
|
||||
break;
|
||||
|
||||
case SmtpCommand.RSET:
|
||||
this.handleRset(socket);
|
||||
break;
|
||||
|
||||
case SmtpCommand.NOOP:
|
||||
this.handleNoop(socket);
|
||||
break;
|
||||
|
||||
case SmtpCommand.QUIT:
|
||||
this.handleQuit(socket);
|
||||
break;
|
||||
|
||||
case SmtpCommand.STARTTLS:
|
||||
if (this.tlsHandler && this.tlsHandler.isTlsEnabled()) {
|
||||
this.tlsHandler.handleStartTls(socket);
|
||||
} else {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} STARTTLS not available`);
|
||||
}
|
||||
break;
|
||||
|
||||
case SmtpCommand.AUTH:
|
||||
this.handleAuth(socket, args);
|
||||
break;
|
||||
|
||||
case SmtpCommand.HELP:
|
||||
this.handleHelp(socket, args);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} Command not implemented`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a response to the client
|
||||
* @param socket - Client socket
|
||||
* @param response - Response to send
|
||||
*/
|
||||
public sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
|
||||
try {
|
||||
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
|
||||
SmtpLogger.logResponse(response, socket);
|
||||
} catch (error) {
|
||||
// Log error and destroy socket
|
||||
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
response,
|
||||
remoteAddress: socket.remoteAddress,
|
||||
remotePort: socket.remotePort,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle EHLO command
|
||||
* @param socket - Client socket
|
||||
* @param clientHostname - Client hostname from EHLO command
|
||||
*/
|
||||
public handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (!session) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate EHLO hostname
|
||||
const validation = validateEhlo(clientHostname);
|
||||
|
||||
if (!validation.isValid) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update session state and client hostname
|
||||
session.clientHostname = validation.hostname || clientHostname;
|
||||
this.sessionManager.updateSessionState(session, SmtpState.AFTER_EHLO);
|
||||
|
||||
// Set up EHLO response lines
|
||||
const responseLines = [
|
||||
`${this.options.hostname} greets ${session.clientHostname}`,
|
||||
SMTP_EXTENSIONS.PIPELINING,
|
||||
SMTP_EXTENSIONS.formatExtension(SMTP_EXTENSIONS.SIZE, this.options.size),
|
||||
SMTP_EXTENSIONS.EIGHTBITMIME,
|
||||
SMTP_EXTENSIONS.ENHANCEDSTATUSCODES
|
||||
];
|
||||
|
||||
// Add TLS extension if available and not already using TLS
|
||||
if (this.tlsHandler && this.tlsHandler.isTlsEnabled() && !session.useTLS) {
|
||||
responseLines.push(SMTP_EXTENSIONS.STARTTLS);
|
||||
}
|
||||
|
||||
// Add AUTH extension if configured
|
||||
if (this.options.auth && this.options.auth.methods && this.options.auth.methods.length > 0) {
|
||||
responseLines.push(`${SMTP_EXTENSIONS.AUTH} ${this.options.auth.methods.join(' ')}`);
|
||||
}
|
||||
|
||||
// Send multiline response
|
||||
this.sendResponse(socket, formatMultilineResponse(SmtpResponseCode.OK, responseLines));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MAIL FROM command
|
||||
* @param socket - Client socket
|
||||
* @param args - Command arguments
|
||||
*/
|
||||
public handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (!session) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if authentication is required but not provided
|
||||
if (this.options.auth && this.options.auth.required && !session.authenticated) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.AUTH_REQUIRED} Authentication required`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate MAIL FROM syntax
|
||||
const validation = validateMailFrom(args);
|
||||
|
||||
if (!validation.isValid) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check size parameter if provided
|
||||
if (validation.params && validation.params.SIZE) {
|
||||
const size = parseInt(validation.params.SIZE, 10);
|
||||
|
||||
if (isNaN(size)) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Invalid SIZE parameter`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (size > this.options.size!) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Message size exceeds limit`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset email data and recipients for new transaction
|
||||
session.mailFrom = validation.address || '';
|
||||
session.rcptTo = [];
|
||||
session.emailData = '';
|
||||
session.emailDataChunks = [];
|
||||
|
||||
// Update envelope information
|
||||
session.envelope = {
|
||||
mailFrom: {
|
||||
address: validation.address || '',
|
||||
args: validation.params || {}
|
||||
},
|
||||
rcptTo: []
|
||||
};
|
||||
|
||||
// Update session state
|
||||
this.sessionManager.updateSessionState(session, SmtpState.MAIL_FROM);
|
||||
|
||||
// Send success response
|
||||
this.sendResponse(socket, `${SmtpResponseCode.OK} OK`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle RCPT TO command
|
||||
* @param socket - Client socket
|
||||
* @param args - Command arguments
|
||||
*/
|
||||
public handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (!session) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate RCPT TO syntax
|
||||
const validation = validateRcptTo(args);
|
||||
|
||||
if (!validation.isValid) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've reached maximum recipients
|
||||
if (session.rcptTo.length >= this.options.maxRecipients) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.TRANSACTION_FAILED} Too many recipients`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create recipient object
|
||||
const recipient: IEnvelopeRecipient = {
|
||||
address: validation.address || '',
|
||||
args: validation.params || {}
|
||||
};
|
||||
|
||||
// Add to session data
|
||||
session.rcptTo.push(validation.address || '');
|
||||
session.envelope.rcptTo.push(recipient);
|
||||
|
||||
// Update session state
|
||||
this.sessionManager.updateSessionState(session, SmtpState.RCPT_TO);
|
||||
|
||||
// Send success response
|
||||
this.sendResponse(socket, `${SmtpResponseCode.OK} Recipient ok`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle DATA command
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
public handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (!session) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have recipients
|
||||
if (!session.rcptTo.length) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} No recipients specified`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update session state
|
||||
this.sessionManager.updateSessionState(session, SmtpState.DATA_RECEIVING);
|
||||
|
||||
// Reset email data storage
|
||||
session.emailData = '';
|
||||
session.emailDataChunks = [];
|
||||
|
||||
// Set up timeout for DATA command
|
||||
const dataTimeout = SMTP_DEFAULTS.DATA_TIMEOUT;
|
||||
if (session.dataTimeoutId) {
|
||||
clearTimeout(session.dataTimeoutId);
|
||||
}
|
||||
|
||||
session.dataTimeoutId = setTimeout(() => {
|
||||
if (session.state === SmtpState.DATA_RECEIVING) {
|
||||
SmtpLogger.warn(`DATA command timeout for session ${session.id}`, {
|
||||
sessionId: session.id,
|
||||
timeout: dataTimeout
|
||||
});
|
||||
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Data timeout`);
|
||||
this.resetSession(session);
|
||||
}
|
||||
}, dataTimeout);
|
||||
|
||||
// Send intermediate response to signal start of data
|
||||
this.sendResponse(socket, `${SmtpResponseCode.START_MAIL_INPUT} Start mail input; end with <CRLF>.<CRLF>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle RSET command
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
public handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (!session) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the transaction state
|
||||
this.resetSession(session);
|
||||
|
||||
// Send success response
|
||||
this.sendResponse(socket, `${SmtpResponseCode.OK} OK`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle NOOP command
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
public handleNoop(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (!session) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update session activity timestamp
|
||||
this.sessionManager.updateSessionActivity(session);
|
||||
|
||||
// Send success response
|
||||
this.sendResponse(socket, `${SmtpResponseCode.OK} OK`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle QUIT command
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
public handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
|
||||
// Send goodbye message
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_CLOSING} ${this.options.hostname} Service closing transmission channel`);
|
||||
|
||||
// End the connection
|
||||
socket.end();
|
||||
|
||||
// Clean up session if we have one
|
||||
if (session) {
|
||||
this.sessionManager.removeSession(socket);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AUTH command
|
||||
* @param socket - Client socket
|
||||
* @param args - Command arguments
|
||||
*/
|
||||
private handleAuth(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (!session) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have auth config
|
||||
if (!this.options.auth || !this.options.auth.methods || !this.options.auth.methods.length) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} Authentication not supported`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if TLS is required for authentication
|
||||
if (!session.useTLS) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication requires TLS`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple response for now - authentication would be implemented in the security handler
|
||||
this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication not implemented yet`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HELP command
|
||||
* @param socket - Client socket
|
||||
* @param args - Command arguments
|
||||
*/
|
||||
private handleHelp(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (!session) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update session activity timestamp
|
||||
this.sessionManager.updateSessionActivity(session);
|
||||
|
||||
// Provide help information based on arguments
|
||||
const helpCommand = args.trim().toUpperCase();
|
||||
|
||||
if (!helpCommand) {
|
||||
// General help
|
||||
const helpLines = [
|
||||
'Supported commands:',
|
||||
'EHLO/HELO domain - Identify yourself to the server',
|
||||
'MAIL FROM:<address> - Start a new mail transaction',
|
||||
'RCPT TO:<address> - Specify recipients for the message',
|
||||
'DATA - Start message data input',
|
||||
'RSET - Reset the transaction',
|
||||
'NOOP - No operation',
|
||||
'QUIT - Close the connection',
|
||||
'HELP [command] - Show help'
|
||||
];
|
||||
|
||||
// Add conditional commands
|
||||
if (this.tlsHandler && this.tlsHandler.isTlsEnabled()) {
|
||||
helpLines.push('STARTTLS - Start TLS negotiation');
|
||||
}
|
||||
|
||||
if (this.options.auth && this.options.auth.methods.length) {
|
||||
helpLines.push('AUTH mechanism - Authenticate with the server');
|
||||
}
|
||||
|
||||
this.sendResponse(socket, formatMultilineResponse(SmtpResponseCode.HELP_MESSAGE, helpLines));
|
||||
return;
|
||||
}
|
||||
|
||||
// Command-specific help
|
||||
let helpText: string;
|
||||
|
||||
switch (helpCommand) {
|
||||
case 'EHLO':
|
||||
case 'HELO':
|
||||
helpText = 'EHLO/HELO domain - Identify yourself to the server';
|
||||
break;
|
||||
|
||||
case 'MAIL':
|
||||
helpText = 'MAIL FROM:<address> [SIZE=size] - Start a new mail transaction';
|
||||
break;
|
||||
|
||||
case 'RCPT':
|
||||
helpText = 'RCPT TO:<address> - Specify a recipient for the message';
|
||||
break;
|
||||
|
||||
case 'DATA':
|
||||
helpText = 'DATA - Start message data input, end with <CRLF>.<CRLF>';
|
||||
break;
|
||||
|
||||
case 'RSET':
|
||||
helpText = 'RSET - Reset the transaction';
|
||||
break;
|
||||
|
||||
case 'NOOP':
|
||||
helpText = 'NOOP - No operation';
|
||||
break;
|
||||
|
||||
case 'QUIT':
|
||||
helpText = 'QUIT - Close the connection';
|
||||
break;
|
||||
|
||||
case 'STARTTLS':
|
||||
helpText = 'STARTTLS - Start TLS negotiation';
|
||||
break;
|
||||
|
||||
case 'AUTH':
|
||||
helpText = `AUTH mechanism - Authenticate with the server. Supported methods: ${this.options.auth?.methods.join(', ')}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
helpText = `Unknown command: ${helpCommand}`;
|
||||
break;
|
||||
}
|
||||
|
||||
this.sendResponse(socket, `${SmtpResponseCode.HELP_MESSAGE} ${helpText}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset session to after-EHLO state
|
||||
* @param session - SMTP session to reset
|
||||
*/
|
||||
private resetSession(session: ISmtpSession): void {
|
||||
// Clear any data timeout
|
||||
if (session.dataTimeoutId) {
|
||||
clearTimeout(session.dataTimeoutId);
|
||||
session.dataTimeoutId = undefined;
|
||||
}
|
||||
|
||||
// Reset data fields but keep authentication state
|
||||
session.mailFrom = '';
|
||||
session.rcptTo = [];
|
||||
session.emailData = '';
|
||||
session.emailDataChunks = [];
|
||||
session.envelope = {
|
||||
mailFrom: { address: '', args: {} },
|
||||
rcptTo: []
|
||||
};
|
||||
|
||||
// Reset state to after EHLO
|
||||
this.sessionManager.updateSessionState(session, SmtpState.AFTER_EHLO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate command sequence based on current state
|
||||
* @param command - Command to validate
|
||||
* @param session - Current session
|
||||
* @returns Whether the command is valid in the current state
|
||||
*/
|
||||
private validateCommandSequence(command: string, session: ISmtpSession): boolean {
|
||||
return isValidCommandSequence(command, session.state);
|
||||
}
|
||||
}
|
363
ts/mail/delivery/smtp/connection-manager.ts
Normal file
363
ts/mail/delivery/smtp/connection-manager.ts
Normal file
@ -0,0 +1,363 @@
|
||||
/**
|
||||
* SMTP Connection Manager
|
||||
* Responsible for managing socket connections to the SMTP server
|
||||
*/
|
||||
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import { IConnectionManager } from './interfaces.js';
|
||||
import { ISessionManager } from './interfaces.js';
|
||||
import { SmtpResponseCode, SMTP_DEFAULTS } from './constants.js';
|
||||
import { SmtpLogger } from './utils/logging.js';
|
||||
import { getSocketDetails, formatMultilineResponse } from './utils/helpers.js';
|
||||
|
||||
/**
|
||||
* Manager for SMTP connections
|
||||
* Handles connection setup, event listeners, and lifecycle management
|
||||
*/
|
||||
export class ConnectionManager implements IConnectionManager {
|
||||
/**
|
||||
* Set of active socket connections
|
||||
*/
|
||||
private activeConnections: Set<plugins.net.Socket | plugins.tls.TLSSocket> = new Set();
|
||||
|
||||
/**
|
||||
* Reference to the session manager
|
||||
*/
|
||||
private sessionManager: ISessionManager;
|
||||
|
||||
/**
|
||||
* SMTP server options
|
||||
*/
|
||||
private options: {
|
||||
hostname: string;
|
||||
maxConnections: number;
|
||||
socketTimeout: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Command handler function
|
||||
*/
|
||||
private commandHandler: (socket: plugins.net.Socket | plugins.tls.TLSSocket, line: string) => void;
|
||||
|
||||
/**
|
||||
* Creates a new connection manager
|
||||
* @param sessionManager - Session manager instance
|
||||
* @param commandHandler - Command handler function
|
||||
* @param options - Connection manager options
|
||||
*/
|
||||
constructor(
|
||||
sessionManager: ISessionManager,
|
||||
commandHandler: (socket: plugins.net.Socket | plugins.tls.TLSSocket, line: string) => void,
|
||||
options: {
|
||||
hostname?: string;
|
||||
maxConnections?: number;
|
||||
socketTimeout?: number;
|
||||
} = {}
|
||||
) {
|
||||
this.sessionManager = sessionManager;
|
||||
this.commandHandler = commandHandler;
|
||||
|
||||
this.options = {
|
||||
hostname: options.hostname || SMTP_DEFAULTS.HOSTNAME,
|
||||
maxConnections: options.maxConnections || SMTP_DEFAULTS.MAX_CONNECTIONS,
|
||||
socketTimeout: options.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new connection
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
public handleNewConnection(socket: plugins.net.Socket): void {
|
||||
// Check if maximum connections reached
|
||||
if (this.hasReachedMaxConnections()) {
|
||||
this.rejectConnection(socket, 'Too many connections');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add socket to active connections
|
||||
this.activeConnections.add(socket);
|
||||
|
||||
// Set up socket options
|
||||
socket.setKeepAlive(true);
|
||||
socket.setTimeout(this.options.socketTimeout);
|
||||
|
||||
// Set up event handlers
|
||||
this.setupSocketEventHandlers(socket);
|
||||
|
||||
// Create a session for this connection
|
||||
this.sessionManager.createSession(socket, false);
|
||||
|
||||
// Log the new connection
|
||||
const socketDetails = getSocketDetails(socket);
|
||||
SmtpLogger.logConnection(socket, 'connect');
|
||||
|
||||
// Send greeting
|
||||
this.sendGreeting(socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new secure TLS connection
|
||||
* @param socket - Client TLS socket
|
||||
*/
|
||||
public handleNewSecureConnection(socket: plugins.tls.TLSSocket): void {
|
||||
// Check if maximum connections reached
|
||||
if (this.hasReachedMaxConnections()) {
|
||||
this.rejectConnection(socket, 'Too many connections');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add socket to active connections
|
||||
this.activeConnections.add(socket);
|
||||
|
||||
// Set up socket options
|
||||
socket.setKeepAlive(true);
|
||||
socket.setTimeout(this.options.socketTimeout);
|
||||
|
||||
// Set up event handlers
|
||||
this.setupSocketEventHandlers(socket);
|
||||
|
||||
// Create a session for this connection
|
||||
this.sessionManager.createSession(socket, true);
|
||||
|
||||
// Log the new secure connection
|
||||
SmtpLogger.logConnection(socket, 'connect');
|
||||
|
||||
// Send greeting
|
||||
this.sendGreeting(socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event handlers for a socket
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
public setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
// Store existing socket event handlers before adding new ones
|
||||
const existingDataHandler = socket.listeners('data')[0];
|
||||
const existingCloseHandler = socket.listeners('close')[0];
|
||||
const existingErrorHandler = socket.listeners('error')[0];
|
||||
const existingTimeoutHandler = socket.listeners('timeout')[0];
|
||||
|
||||
// Remove existing event handlers if they exist
|
||||
if (existingDataHandler) socket.removeListener('data', existingDataHandler);
|
||||
if (existingCloseHandler) socket.removeListener('close', existingCloseHandler);
|
||||
if (existingErrorHandler) socket.removeListener('error', existingErrorHandler);
|
||||
if (existingTimeoutHandler) socket.removeListener('timeout', existingTimeoutHandler);
|
||||
|
||||
// Data event - process incoming data from the client
|
||||
let buffer = '';
|
||||
socket.on('data', (data) => {
|
||||
// Get current session and update activity timestamp
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (session) {
|
||||
this.sessionManager.updateSessionActivity(session);
|
||||
}
|
||||
|
||||
// Buffer incoming data
|
||||
buffer += data.toString();
|
||||
|
||||
// Process complete lines
|
||||
let lineEndPos;
|
||||
while ((lineEndPos = buffer.indexOf(SMTP_DEFAULTS.CRLF)) !== -1) {
|
||||
// Extract a complete line
|
||||
const line = buffer.substring(0, lineEndPos);
|
||||
buffer = buffer.substring(lineEndPos + 2); // +2 to skip CRLF
|
||||
|
||||
// Process non-empty lines
|
||||
if (line.length > 0) {
|
||||
// In DATA state, the command handler will process the data differently
|
||||
this.commandHandler(socket, line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Close event - clean up when connection is closed
|
||||
socket.on('close', (hadError) => {
|
||||
this.handleSocketClose(socket, hadError);
|
||||
});
|
||||
|
||||
// Error event - handle socket errors
|
||||
socket.on('error', (err) => {
|
||||
this.handleSocketError(socket, err);
|
||||
});
|
||||
|
||||
// Timeout event - handle socket timeouts
|
||||
socket.on('timeout', () => {
|
||||
this.handleSocketTimeout(socket);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current connection count
|
||||
* @returns Number of active connections
|
||||
*/
|
||||
public getConnectionCount(): number {
|
||||
return this.activeConnections.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server has reached the maximum number of connections
|
||||
* @returns True if max connections reached
|
||||
*/
|
||||
public hasReachedMaxConnections(): boolean {
|
||||
return this.activeConnections.size >= this.options.maxConnections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all active connections
|
||||
*/
|
||||
public closeAllConnections(): void {
|
||||
const connectionCount = this.activeConnections.size;
|
||||
if (connectionCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
SmtpLogger.info(`Closing all connections (count: ${connectionCount})`);
|
||||
|
||||
for (const socket of this.activeConnections) {
|
||||
try {
|
||||
// Send service closing notification
|
||||
this.sendServiceClosing(socket);
|
||||
|
||||
// End the socket
|
||||
socket.end();
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Error closing connection: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear active connections
|
||||
this.activeConnections.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle socket close event
|
||||
* @param socket - Client socket
|
||||
* @param hadError - Whether the socket was closed due to error
|
||||
*/
|
||||
private handleSocketClose(socket: plugins.net.Socket | plugins.tls.TLSSocket, hadError: boolean): void {
|
||||
// Remove from active connections
|
||||
this.activeConnections.delete(socket);
|
||||
|
||||
// Get the session before removing it
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
|
||||
// Remove from session manager
|
||||
this.sessionManager.removeSession(socket);
|
||||
|
||||
// Log connection close
|
||||
SmtpLogger.logConnection(socket, 'close', session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle socket error event
|
||||
* @param socket - Client socket
|
||||
* @param error - Error object
|
||||
*/
|
||||
private handleSocketError(socket: plugins.net.Socket | plugins.tls.TLSSocket, error: Error): void {
|
||||
// Get the session
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
|
||||
// Log the error
|
||||
SmtpLogger.logConnection(socket, 'error', session, error);
|
||||
|
||||
// Close the socket if not already closed
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle socket timeout event
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
private handleSocketTimeout(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
// Get the session
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
|
||||
if (session) {
|
||||
// Log the timeout
|
||||
SmtpLogger.warn(`Socket timeout from ${session.remoteAddress}`, {
|
||||
sessionId: session.id,
|
||||
remoteAddress: session.remoteAddress,
|
||||
state: session.state,
|
||||
timeout: this.options.socketTimeout
|
||||
});
|
||||
|
||||
// Send timeout notification
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_NOT_AVAILABLE} Connection timeout - closing connection`);
|
||||
} else {
|
||||
// Log timeout without session context
|
||||
const socketDetails = getSocketDetails(socket);
|
||||
SmtpLogger.warn(`Socket timeout without session from ${socketDetails.remoteAddress}:${socketDetails.remotePort}`);
|
||||
}
|
||||
|
||||
// Close the socket
|
||||
try {
|
||||
socket.end();
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Error ending timed out socket: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a connection
|
||||
* @param socket - Client socket
|
||||
* @param reason - Reason for rejection
|
||||
*/
|
||||
private rejectConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, reason: string): void {
|
||||
// Log the rejection
|
||||
const socketDetails = getSocketDetails(socket);
|
||||
SmtpLogger.warn(`Connection rejected from ${socketDetails.remoteAddress}:${socketDetails.remotePort}: ${reason}`);
|
||||
|
||||
// Send rejection message
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_NOT_AVAILABLE} ${this.options.hostname} Service temporarily unavailable - ${reason}`);
|
||||
|
||||
// Close the socket
|
||||
try {
|
||||
socket.end();
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Error ending rejected socket: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send greeting message
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
private sendGreeting(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const greeting = `${SmtpResponseCode.SERVICE_READY} ${this.options.hostname} ESMTP service ready`;
|
||||
this.sendResponse(socket, greeting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send service closing notification
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
private sendServiceClosing(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const message = `${SmtpResponseCode.SERVICE_CLOSING} ${this.options.hostname} Service closing transmission channel`;
|
||||
this.sendResponse(socket, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response to client
|
||||
* @param socket - Client socket
|
||||
* @param response - Response to send
|
||||
*/
|
||||
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
|
||||
try {
|
||||
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
|
||||
SmtpLogger.logResponse(response, socket);
|
||||
} catch (error) {
|
||||
// Log error and destroy socket
|
||||
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
response,
|
||||
remoteAddress: socket.remoteAddress,
|
||||
remotePort: socket.remotePort,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
}
|
176
ts/mail/delivery/smtp/constants.ts
Normal file
176
ts/mail/delivery/smtp/constants.ts
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* SMTP Server Constants
|
||||
* This file contains all constants and enums used by the SMTP server
|
||||
*/
|
||||
|
||||
import { SmtpState } from '../interfaces.js';
|
||||
|
||||
// Re-export SmtpState enum from the main interfaces file
|
||||
export { SmtpState };
|
||||
|
||||
/**
|
||||
* SMTP Response Codes
|
||||
* Based on RFC 5321 and common SMTP practice
|
||||
*/
|
||||
export enum SmtpResponseCode {
|
||||
// Success codes (2xx)
|
||||
SUCCESS = 250, // Requested mail action okay, completed
|
||||
SYSTEM_STATUS = 211, // System status, or system help reply
|
||||
HELP_MESSAGE = 214, // Help message
|
||||
SERVICE_READY = 220, // <domain> Service ready
|
||||
SERVICE_CLOSING = 221, // <domain> Service closing transmission channel
|
||||
AUTHENTICATION_SUCCESSFUL = 235, // Authentication successful
|
||||
OK = 250, // Requested mail action okay, completed
|
||||
FORWARD = 251, // User not local; will forward to <forward-path>
|
||||
CANNOT_VRFY = 252, // Cannot VRFY user, but will accept message and attempt delivery
|
||||
|
||||
// Intermediate codes (3xx)
|
||||
MORE_INFO_NEEDED = 334, // Server challenge for authentication
|
||||
START_MAIL_INPUT = 354, // Start mail input; end with <CRLF>.<CRLF>
|
||||
|
||||
// Temporary error codes (4xx)
|
||||
SERVICE_NOT_AVAILABLE = 421, // <domain> Service not available, closing transmission channel
|
||||
MAILBOX_TEMPORARILY_UNAVAILABLE = 450, // Requested mail action not taken: mailbox unavailable
|
||||
LOCAL_ERROR = 451, // Requested action aborted: local error in processing
|
||||
INSUFFICIENT_STORAGE = 452, // Requested action not taken: insufficient system storage
|
||||
TLS_UNAVAILABLE_TEMP = 454, // TLS not available due to temporary reason
|
||||
|
||||
// Permanent error codes (5xx)
|
||||
SYNTAX_ERROR = 500, // Syntax error, command unrecognized
|
||||
SYNTAX_ERROR_PARAMETERS = 501, // Syntax error in parameters or arguments
|
||||
COMMAND_NOT_IMPLEMENTED = 502, // Command not implemented
|
||||
BAD_SEQUENCE = 503, // Bad sequence of commands
|
||||
COMMAND_PARAMETER_NOT_IMPLEMENTED = 504, // Command parameter not implemented
|
||||
AUTH_REQUIRED = 530, // Authentication required
|
||||
AUTH_FAILED = 535, // Authentication credentials invalid
|
||||
MAILBOX_UNAVAILABLE = 550, // Requested action not taken: mailbox unavailable
|
||||
USER_NOT_LOCAL = 551, // User not local; please try <forward-path>
|
||||
EXCEEDED_STORAGE = 552, // Requested mail action aborted: exceeded storage allocation
|
||||
MAILBOX_NAME_INVALID = 553, // Requested action not taken: mailbox name not allowed
|
||||
TRANSACTION_FAILED = 554, // Transaction failed
|
||||
MAIL_RCPT_PARAMETERS_INVALID = 555, // MAIL FROM/RCPT TO parameters not recognized or not implemented
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP Command Types
|
||||
*/
|
||||
export enum SmtpCommand {
|
||||
HELO = 'HELO',
|
||||
EHLO = 'EHLO',
|
||||
MAIL_FROM = 'MAIL',
|
||||
RCPT_TO = 'RCPT',
|
||||
DATA = 'DATA',
|
||||
RSET = 'RSET',
|
||||
NOOP = 'NOOP',
|
||||
QUIT = 'QUIT',
|
||||
STARTTLS = 'STARTTLS',
|
||||
AUTH = 'AUTH',
|
||||
HELP = 'HELP',
|
||||
VRFY = 'VRFY',
|
||||
EXPN = 'EXPN',
|
||||
}
|
||||
|
||||
/**
|
||||
* Security log event types
|
||||
*/
|
||||
export enum SecurityEventType {
|
||||
CONNECTION = 'connection',
|
||||
AUTHENTICATION = 'authentication',
|
||||
COMMAND = 'command',
|
||||
DATA = 'data',
|
||||
IP_REPUTATION = 'ip_reputation',
|
||||
TLS_NEGOTIATION = 'tls_negotiation',
|
||||
DKIM = 'dkim',
|
||||
SPF = 'spf',
|
||||
DMARC = 'dmarc',
|
||||
EMAIL_VALIDATION = 'email_validation',
|
||||
SPAM = 'spam',
|
||||
ACCESS_CONTROL = 'access_control',
|
||||
}
|
||||
|
||||
/**
|
||||
* Security log levels
|
||||
*/
|
||||
export enum SecurityLogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP Server Defaults
|
||||
*/
|
||||
export const SMTP_DEFAULTS = {
|
||||
// Default timeouts in milliseconds
|
||||
CONNECTION_TIMEOUT: 30000, // 30 seconds
|
||||
SOCKET_TIMEOUT: 300000, // 5 minutes
|
||||
DATA_TIMEOUT: 60000, // 1 minute
|
||||
CLEANUP_INTERVAL: 5000, // 5 seconds
|
||||
|
||||
// Default limits
|
||||
MAX_CONNECTIONS: 100,
|
||||
MAX_RECIPIENTS: 100,
|
||||
MAX_MESSAGE_SIZE: 10485760, // 10MB
|
||||
|
||||
// Default ports
|
||||
SMTP_PORT: 25,
|
||||
SUBMISSION_PORT: 587,
|
||||
SECURE_PORT: 465,
|
||||
|
||||
// Default hostname
|
||||
HOSTNAME: 'mail.lossless.one',
|
||||
|
||||
// CRLF line ending required by SMTP protocol
|
||||
CRLF: '\r\n',
|
||||
};
|
||||
|
||||
/**
|
||||
* SMTP Command Patterns
|
||||
* Regular expressions for parsing SMTP commands
|
||||
*/
|
||||
export const SMTP_PATTERNS = {
|
||||
// Match EHLO/HELO command: "EHLO example.com"
|
||||
EHLO: /^(?:EHLO|HELO)\s+(.+)$/i,
|
||||
|
||||
// Match MAIL FROM command: "MAIL FROM:<user@example.com> [PARAM=VALUE]"
|
||||
MAIL_FROM: /^MAIL\s+FROM:<([^>]*)>((?:\s+\w+(?:=\w+)?)*)$/i,
|
||||
|
||||
// Match RCPT TO command: "RCPT TO:<user@example.com> [PARAM=VALUE]"
|
||||
RCPT_TO: /^RCPT\s+TO:<([^>]*)>((?:\s+\w+(?:=\w+)?)*)$/i,
|
||||
|
||||
// Match parameter format: "PARAM=VALUE"
|
||||
PARAM: /\s+([A-Za-z0-9][A-Za-z0-9\-]*)(?:=([^\s]+))?/g,
|
||||
|
||||
// Match email address format
|
||||
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
|
||||
// Match end of DATA marker: \r\n.\r\n
|
||||
END_DATA: /\r\n\.\r\n$/,
|
||||
};
|
||||
|
||||
/**
|
||||
* SMTP Extension List
|
||||
* These extensions are advertised in the EHLO response
|
||||
*/
|
||||
export const SMTP_EXTENSIONS = {
|
||||
// Basic extensions (RFC 1869)
|
||||
PIPELINING: 'PIPELINING',
|
||||
SIZE: 'SIZE',
|
||||
EIGHTBITMIME: '8BITMIME',
|
||||
|
||||
// Security extensions
|
||||
STARTTLS: 'STARTTLS',
|
||||
AUTH: 'AUTH',
|
||||
|
||||
// Additional extensions
|
||||
ENHANCEDSTATUSCODES: 'ENHANCEDSTATUSCODES',
|
||||
HELP: 'HELP',
|
||||
CHUNKING: 'CHUNKING',
|
||||
DSN: 'DSN',
|
||||
|
||||
// Format an extension with a parameter
|
||||
formatExtension(name: string, parameter?: string | number): string {
|
||||
return parameter !== undefined ? `${name} ${parameter}` : name;
|
||||
}
|
||||
};
|
386
ts/mail/delivery/smtp/data-handler.ts
Normal file
386
ts/mail/delivery/smtp/data-handler.ts
Normal file
@ -0,0 +1,386 @@
|
||||
/**
|
||||
* SMTP Data Handler
|
||||
* Responsible for processing email data during and after DATA command
|
||||
*/
|
||||
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { SmtpState, ISmtpSession, ISmtpTransactionResult } from '../interfaces.js';
|
||||
import { IDataHandler, ISessionManager } from './interfaces.js';
|
||||
import { SmtpResponseCode, SMTP_PATTERNS, SMTP_DEFAULTS } from './constants.js';
|
||||
import { SmtpLogger } from './utils/logging.js';
|
||||
import { Email } from '../../core/classes.email.js';
|
||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
||||
|
||||
/**
|
||||
* Handles SMTP DATA command and email data processing
|
||||
*/
|
||||
export class DataHandler implements IDataHandler {
|
||||
/**
|
||||
* Session manager instance
|
||||
*/
|
||||
private sessionManager: ISessionManager;
|
||||
|
||||
/**
|
||||
* Email server reference
|
||||
*/
|
||||
private emailServer: UnifiedEmailServer;
|
||||
|
||||
/**
|
||||
* SMTP server options
|
||||
*/
|
||||
private options: {
|
||||
size: number;
|
||||
tempDir?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new data handler
|
||||
* @param sessionManager - Session manager instance
|
||||
* @param emailServer - Email server reference
|
||||
* @param options - Data handler options
|
||||
*/
|
||||
constructor(
|
||||
sessionManager: ISessionManager,
|
||||
emailServer: UnifiedEmailServer,
|
||||
options: {
|
||||
size?: number;
|
||||
tempDir?: string;
|
||||
} = {}
|
||||
) {
|
||||
this.sessionManager = sessionManager;
|
||||
this.emailServer = emailServer;
|
||||
|
||||
this.options = {
|
||||
size: options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE,
|
||||
tempDir: options.tempDir
|
||||
};
|
||||
|
||||
// Create temp directory if specified and doesn't exist
|
||||
if (this.options.tempDir) {
|
||||
try {
|
||||
if (!fs.existsSync(this.options.tempDir)) {
|
||||
fs.mkdirSync(this.options.tempDir, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Failed to create temp directory: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
tempDir: this.options.tempDir
|
||||
});
|
||||
this.options.tempDir = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process incoming email data
|
||||
* @param socket - Client socket
|
||||
* @param data - Data chunk
|
||||
* @returns Promise that resolves when the data is processed
|
||||
*/
|
||||
public async processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void> {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (!session) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing timeout and set a new one
|
||||
if (session.dataTimeoutId) {
|
||||
clearTimeout(session.dataTimeoutId);
|
||||
}
|
||||
|
||||
session.dataTimeoutId = setTimeout(() => {
|
||||
if (session.state === SmtpState.DATA_RECEIVING) {
|
||||
SmtpLogger.warn(`DATA timeout for session ${session.id}`, { sessionId: session.id });
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Data timeout`);
|
||||
this.resetSession(session);
|
||||
}
|
||||
}, SMTP_DEFAULTS.DATA_TIMEOUT);
|
||||
|
||||
// Update activity timestamp
|
||||
this.sessionManager.updateSessionActivity(session);
|
||||
|
||||
// Store data in chunks for better memory efficiency
|
||||
if (!session.emailDataChunks) {
|
||||
session.emailDataChunks = [];
|
||||
}
|
||||
|
||||
session.emailDataChunks.push(data);
|
||||
|
||||
// Check if we've reached the max size
|
||||
let totalSize = 0;
|
||||
for (const chunk of session.emailDataChunks) {
|
||||
totalSize += chunk.length;
|
||||
}
|
||||
|
||||
if (totalSize > this.options.size) {
|
||||
SmtpLogger.warn(`Message size exceeds limit for session ${session.id}`, {
|
||||
sessionId: session.id,
|
||||
size: totalSize,
|
||||
limit: this.options.size
|
||||
});
|
||||
|
||||
this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Message too big, size limit is ${this.options.size} bytes`);
|
||||
this.resetSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for end of data marker
|
||||
const lastChunk = session.emailDataChunks[session.emailDataChunks.length - 1] || '';
|
||||
if (SMTP_PATTERNS.END_DATA.test(lastChunk)) {
|
||||
// End of data marker found
|
||||
await this.handleEndOfData(socket, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a complete email
|
||||
* @param session - SMTP session
|
||||
* @returns Promise that resolves with the result of the transaction
|
||||
*/
|
||||
public async processEmail(session: ISmtpSession): Promise<ISmtpTransactionResult> {
|
||||
// Combine all chunks and remove end of data marker
|
||||
session.emailData = (session.emailDataChunks || []).join('');
|
||||
|
||||
// Remove trailing end-of-data marker: \r\n.\r\n
|
||||
session.emailData = session.emailData.replace(/\r\n\.\r\n$/, '');
|
||||
|
||||
// Remove dot-stuffing (RFC 5321, section 4.5.2)
|
||||
session.emailData = session.emailData.replace(/\r\n\.\./g, '\r\n.');
|
||||
|
||||
try {
|
||||
// Parse email into Email object
|
||||
const email = await this.parseEmail(session);
|
||||
|
||||
// Process the email based on the processing mode
|
||||
const processingMode = session.processingMode || 'mta';
|
||||
|
||||
let result: ISmtpTransactionResult = {
|
||||
success: false,
|
||||
error: 'Email processing failed'
|
||||
};
|
||||
|
||||
switch (processingMode) {
|
||||
case 'mta':
|
||||
// Process through the MTA system
|
||||
try {
|
||||
SmtpLogger.debug(`Processing email in MTA mode for session ${session.id}`, {
|
||||
sessionId: session.id,
|
||||
messageId: email.getMessageId()
|
||||
});
|
||||
|
||||
// Queue the email for further processing by the email server
|
||||
const messageId = await this.emailServer.queueEmail(email);
|
||||
|
||||
result = {
|
||||
success: true,
|
||||
messageId,
|
||||
email
|
||||
};
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Failed to queue email: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
sessionId: session.id,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
result = {
|
||||
success: false,
|
||||
error: `Failed to queue email: ${error instanceof Error ? error.message : String(error)}`
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'forward':
|
||||
// Forward email to another server
|
||||
SmtpLogger.debug(`Processing email in FORWARD mode for session ${session.id}`, {
|
||||
sessionId: session.id,
|
||||
messageId: email.getMessageId()
|
||||
});
|
||||
|
||||
// Forward logic would be implemented here
|
||||
result = {
|
||||
success: true,
|
||||
messageId: email.getMessageId(),
|
||||
email
|
||||
};
|
||||
break;
|
||||
|
||||
case 'process':
|
||||
// Process the email immediately
|
||||
SmtpLogger.debug(`Processing email in PROCESS mode for session ${session.id}`, {
|
||||
sessionId: session.id,
|
||||
messageId: email.getMessageId()
|
||||
});
|
||||
|
||||
// Direct processing logic would be implemented here
|
||||
result = {
|
||||
success: true,
|
||||
messageId: email.getMessageId(),
|
||||
email
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
SmtpLogger.warn(`Unknown processing mode: ${processingMode}`, { sessionId: session.id });
|
||||
result = {
|
||||
success: false,
|
||||
error: `Unknown processing mode: ${processingMode}`
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Failed to parse email: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
sessionId: session.id,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to parse email: ${error instanceof Error ? error.message : String(error)}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an email to disk
|
||||
* @param session - SMTP session
|
||||
*/
|
||||
public saveEmail(session: ISmtpSession): void {
|
||||
if (!this.options.tempDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const timestamp = Date.now();
|
||||
const filename = `${session.id}-${timestamp}.eml`;
|
||||
const filePath = path.join(this.options.tempDir, filename);
|
||||
|
||||
fs.writeFileSync(filePath, session.emailData);
|
||||
|
||||
SmtpLogger.debug(`Saved email to disk: ${filePath}`, {
|
||||
sessionId: session.id,
|
||||
filePath
|
||||
});
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Failed to save email to disk: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
sessionId: session.id,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an email into an Email object
|
||||
* @param session - SMTP session
|
||||
* @returns Promise that resolves with the parsed Email object
|
||||
*/
|
||||
public async parseEmail(session: ISmtpSession): Promise<Email> {
|
||||
// Create a new Email object
|
||||
const email = new Email();
|
||||
|
||||
// Set envelope information from SMTP session
|
||||
email.setFrom(session.envelope.mailFrom.address);
|
||||
|
||||
for (const recipient of session.envelope.rcptTo) {
|
||||
email.addTo(recipient.address);
|
||||
}
|
||||
|
||||
// Parse the raw email data
|
||||
await email.parseFromRaw(session.emailData);
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle end of data marker received
|
||||
* @param socket - Client socket
|
||||
* @param session - SMTP session
|
||||
*/
|
||||
private async handleEndOfData(socket: plugins.net.Socket | plugins.tls.TLSSocket, session: ISmtpSession): Promise<void> {
|
||||
// Clear the data timeout
|
||||
if (session.dataTimeoutId) {
|
||||
clearTimeout(session.dataTimeoutId);
|
||||
session.dataTimeoutId = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update session state
|
||||
this.sessionManager.updateSessionState(session, SmtpState.FINISHED);
|
||||
|
||||
// Optionally save email to disk
|
||||
this.saveEmail(session);
|
||||
|
||||
// Process the email
|
||||
const result = await this.processEmail(session);
|
||||
|
||||
if (result.success) {
|
||||
// Send success response
|
||||
this.sendResponse(socket, `${SmtpResponseCode.OK} OK message queued as ${result.messageId}`);
|
||||
} else {
|
||||
// Send error response
|
||||
this.sendResponse(socket, `${SmtpResponseCode.TRANSACTION_FAILED} Failed to process email: ${result.error}`);
|
||||
}
|
||||
|
||||
// Reset session for new transaction
|
||||
this.resetSession(session);
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Error processing email: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
sessionId: session.id,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Error processing email: ${error instanceof Error ? error.message : String(error)}`);
|
||||
this.resetSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset session after email processing
|
||||
* @param session - SMTP session
|
||||
*/
|
||||
private resetSession(session: ISmtpSession): void {
|
||||
// Clear any data timeout
|
||||
if (session.dataTimeoutId) {
|
||||
clearTimeout(session.dataTimeoutId);
|
||||
session.dataTimeoutId = undefined;
|
||||
}
|
||||
|
||||
// Reset data fields but keep authentication state
|
||||
session.mailFrom = '';
|
||||
session.rcptTo = [];
|
||||
session.emailData = '';
|
||||
session.emailDataChunks = [];
|
||||
session.envelope = {
|
||||
mailFrom: { address: '', args: {} },
|
||||
rcptTo: []
|
||||
};
|
||||
|
||||
// Reset state to after EHLO
|
||||
this.sessionManager.updateSessionState(session, SmtpState.AFTER_EHLO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a response to the client
|
||||
* @param socket - Client socket
|
||||
* @param response - Response message
|
||||
*/
|
||||
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
|
||||
try {
|
||||
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
|
||||
SmtpLogger.logResponse(response, socket);
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
response,
|
||||
remoteAddress: socket.remoteAddress,
|
||||
remotePort: socket.remotePort,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
}
|
348
ts/mail/delivery/smtp/interfaces.ts
Normal file
348
ts/mail/delivery/smtp/interfaces.ts
Normal file
@ -0,0 +1,348 @@
|
||||
/**
|
||||
* SMTP Server Module Interfaces
|
||||
* This file contains all interfaces for the refactored SMTP server implementation
|
||||
*/
|
||||
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { Email } from '../../core/classes.email.js';
|
||||
import type { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
||||
import { SmtpState, EmailProcessingMode, IEnvelopeRecipient, ISmtpEnvelope, ISmtpSession, ISmtpAuth, ISmtpServerOptions, ISmtpTransactionResult } from '../interfaces.js';
|
||||
|
||||
// Re-export the basic interfaces from the main interfaces file
|
||||
export {
|
||||
SmtpState,
|
||||
EmailProcessingMode,
|
||||
IEnvelopeRecipient,
|
||||
ISmtpEnvelope,
|
||||
ISmtpSession,
|
||||
ISmtpAuth,
|
||||
ISmtpServerOptions,
|
||||
ISmtpTransactionResult
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for SMTP session events
|
||||
* These events are emitted by the session manager
|
||||
*/
|
||||
export interface ISessionEvents {
|
||||
created: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
|
||||
stateChanged: (session: ISmtpSession, previousState: SmtpState, newState: SmtpState) => void;
|
||||
timeout: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
|
||||
completed: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
|
||||
error: (session: ISmtpSession, error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the session manager component
|
||||
*/
|
||||
export interface ISessionManager {
|
||||
/**
|
||||
* Creates a new session for a socket connection
|
||||
*/
|
||||
createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): ISmtpSession;
|
||||
|
||||
/**
|
||||
* Updates the session state
|
||||
*/
|
||||
updateSessionState(session: ISmtpSession, newState: SmtpState): void;
|
||||
|
||||
/**
|
||||
* Updates the session's last activity timestamp
|
||||
*/
|
||||
updateSessionActivity(session: ISmtpSession): void;
|
||||
|
||||
/**
|
||||
* Removes a session
|
||||
*/
|
||||
removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
|
||||
/**
|
||||
* Gets a session for a socket
|
||||
*/
|
||||
getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined;
|
||||
|
||||
/**
|
||||
* Cleans up idle sessions
|
||||
*/
|
||||
cleanupIdleSessions(): void;
|
||||
|
||||
/**
|
||||
* Gets the current number of active sessions
|
||||
*/
|
||||
getSessionCount(): number;
|
||||
|
||||
/**
|
||||
* Clears all sessions (used when shutting down)
|
||||
*/
|
||||
clearAllSessions(): void;
|
||||
|
||||
/**
|
||||
* Register an event listener
|
||||
*/
|
||||
on<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void;
|
||||
|
||||
/**
|
||||
* Remove an event listener
|
||||
*/
|
||||
off<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the connection manager component
|
||||
*/
|
||||
export interface IConnectionManager {
|
||||
/**
|
||||
* Handle a new connection
|
||||
*/
|
||||
handleNewConnection(socket: plugins.net.Socket): void;
|
||||
|
||||
/**
|
||||
* Handle a new secure TLS connection
|
||||
*/
|
||||
handleNewSecureConnection(socket: plugins.tls.TLSSocket): void;
|
||||
|
||||
/**
|
||||
* Set up event handlers for a socket
|
||||
*/
|
||||
setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
|
||||
/**
|
||||
* Get the current connection count
|
||||
*/
|
||||
getConnectionCount(): number;
|
||||
|
||||
/**
|
||||
* Check if the server has reached the maximum number of connections
|
||||
*/
|
||||
hasReachedMaxConnections(): boolean;
|
||||
|
||||
/**
|
||||
* Close all active connections
|
||||
*/
|
||||
closeAllConnections(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the command handler component
|
||||
*/
|
||||
export interface ICommandHandler {
|
||||
/**
|
||||
* Process a command from the client
|
||||
*/
|
||||
processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void;
|
||||
|
||||
/**
|
||||
* Send a response to the client
|
||||
*/
|
||||
sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void;
|
||||
|
||||
/**
|
||||
* Handle EHLO command
|
||||
*/
|
||||
handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void;
|
||||
|
||||
/**
|
||||
* Handle MAIL FROM command
|
||||
*/
|
||||
handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void;
|
||||
|
||||
/**
|
||||
* Handle RCPT TO command
|
||||
*/
|
||||
handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void;
|
||||
|
||||
/**
|
||||
* Handle DATA command
|
||||
*/
|
||||
handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
|
||||
/**
|
||||
* Handle RSET command
|
||||
*/
|
||||
handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
|
||||
/**
|
||||
* Handle NOOP command
|
||||
*/
|
||||
handleNoop(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
|
||||
/**
|
||||
* Handle QUIT command
|
||||
*/
|
||||
handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the data handler component
|
||||
*/
|
||||
export interface IDataHandler {
|
||||
/**
|
||||
* Process incoming email data
|
||||
*/
|
||||
processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Process a complete email
|
||||
*/
|
||||
processEmail(session: ISmtpSession): Promise<ISmtpTransactionResult>;
|
||||
|
||||
/**
|
||||
* Save an email to disk
|
||||
*/
|
||||
saveEmail(session: ISmtpSession): void;
|
||||
|
||||
/**
|
||||
* Parse an email into an Email object
|
||||
*/
|
||||
parseEmail(session: ISmtpSession): Promise<Email>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the TLS handler component
|
||||
*/
|
||||
export interface ITlsHandler {
|
||||
/**
|
||||
* Handle STARTTLS command
|
||||
*/
|
||||
handleStartTls(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
||||
|
||||
/**
|
||||
* Upgrade a connection to TLS
|
||||
*/
|
||||
startTLS(socket: plugins.net.Socket): void;
|
||||
|
||||
/**
|
||||
* Create a secure server
|
||||
*/
|
||||
createSecureServer(): plugins.tls.Server | undefined;
|
||||
|
||||
/**
|
||||
* Check if TLS is enabled
|
||||
*/
|
||||
isTlsEnabled(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the security handler component
|
||||
*/
|
||||
export interface ISecurityHandler {
|
||||
/**
|
||||
* Check IP reputation for a connection
|
||||
*/
|
||||
checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Validate an email address
|
||||
*/
|
||||
isValidEmail(email: string): boolean;
|
||||
|
||||
/**
|
||||
* Validate authentication credentials
|
||||
*/
|
||||
authenticate(session: ISmtpSession, username: string, password: string, method: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
*/
|
||||
logSecurityEvent(event: string, level: string, details: Record<string, any>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the SMTP server component
|
||||
*/
|
||||
export interface ISmtpServer {
|
||||
/**
|
||||
* Start the SMTP server
|
||||
*/
|
||||
listen(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop the SMTP server
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the session manager
|
||||
*/
|
||||
getSessionManager(): ISessionManager;
|
||||
|
||||
/**
|
||||
* Get the connection manager
|
||||
*/
|
||||
getConnectionManager(): IConnectionManager;
|
||||
|
||||
/**
|
||||
* Get the command handler
|
||||
*/
|
||||
getCommandHandler(): ICommandHandler;
|
||||
|
||||
/**
|
||||
* Get the data handler
|
||||
*/
|
||||
getDataHandler(): IDataHandler;
|
||||
|
||||
/**
|
||||
* Get the TLS handler
|
||||
*/
|
||||
getTlsHandler(): ITlsHandler;
|
||||
|
||||
/**
|
||||
* Get the security handler
|
||||
*/
|
||||
getSecurityHandler(): ISecurityHandler;
|
||||
|
||||
/**
|
||||
* Get the server options
|
||||
*/
|
||||
getOptions(): ISmtpServerOptions;
|
||||
|
||||
/**
|
||||
* Get the email server reference
|
||||
*/
|
||||
getEmailServer(): UnifiedEmailServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating an SMTP server
|
||||
*/
|
||||
export interface ISmtpServerConfig {
|
||||
/**
|
||||
* Email server reference
|
||||
*/
|
||||
emailServer: UnifiedEmailServer;
|
||||
|
||||
/**
|
||||
* SMTP server options
|
||||
*/
|
||||
options: ISmtpServerOptions;
|
||||
|
||||
/**
|
||||
* Optional session manager
|
||||
*/
|
||||
sessionManager?: ISessionManager;
|
||||
|
||||
/**
|
||||
* Optional connection manager
|
||||
*/
|
||||
connectionManager?: IConnectionManager;
|
||||
|
||||
/**
|
||||
* Optional command handler
|
||||
*/
|
||||
commandHandler?: ICommandHandler;
|
||||
|
||||
/**
|
||||
* Optional data handler
|
||||
*/
|
||||
dataHandler?: IDataHandler;
|
||||
|
||||
/**
|
||||
* Optional TLS handler
|
||||
*/
|
||||
tlsHandler?: ITlsHandler;
|
||||
|
||||
/**
|
||||
* Optional security handler
|
||||
*/
|
||||
securityHandler?: ISecurityHandler;
|
||||
}
|
342
ts/mail/delivery/smtp/security-handler.ts
Normal file
342
ts/mail/delivery/smtp/security-handler.ts
Normal file
@ -0,0 +1,342 @@
|
||||
/**
|
||||
* SMTP Security Handler
|
||||
* Responsible for security aspects including IP reputation checking,
|
||||
* email validation, and authentication
|
||||
*/
|
||||
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import { ISmtpSession, ISmtpAuth } from '../interfaces.js';
|
||||
import { ISecurityHandler } from './interfaces.js';
|
||||
import { SmtpLogger } from './utils/logging.js';
|
||||
import { SecurityEventType, SecurityLogLevel } from './constants.js';
|
||||
import { isValidEmail } from './utils/validation.js';
|
||||
import { getSocketDetails, getTlsDetails } from './utils/helpers.js';
|
||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
||||
|
||||
/**
|
||||
* Interface for IP denylist entry
|
||||
*/
|
||||
interface IIpDenylistEntry {
|
||||
ip: string;
|
||||
reason: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles security aspects for SMTP server
|
||||
*/
|
||||
export class SecurityHandler implements ISecurityHandler {
|
||||
/**
|
||||
* Email server reference
|
||||
*/
|
||||
private emailServer: UnifiedEmailServer;
|
||||
|
||||
/**
|
||||
* IP reputation service
|
||||
*/
|
||||
private ipReputationService?: any;
|
||||
|
||||
/**
|
||||
* Authentication options
|
||||
*/
|
||||
private authOptions?: {
|
||||
required: boolean;
|
||||
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
||||
validateUser?: (username: string, password: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple in-memory IP denylist
|
||||
*/
|
||||
private ipDenylist: IIpDenylistEntry[] = [];
|
||||
|
||||
/**
|
||||
* Creates a new security handler
|
||||
* @param emailServer - Email server reference
|
||||
* @param ipReputationService - Optional IP reputation service
|
||||
* @param authOptions - Authentication options
|
||||
*/
|
||||
constructor(
|
||||
emailServer: UnifiedEmailServer,
|
||||
ipReputationService?: any,
|
||||
authOptions?: {
|
||||
required: boolean;
|
||||
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
||||
validateUser?: (username: string, password: string) => Promise<boolean>;
|
||||
}
|
||||
) {
|
||||
this.emailServer = emailServer;
|
||||
this.ipReputationService = ipReputationService;
|
||||
this.authOptions = authOptions;
|
||||
|
||||
// Clean expired denylist entries periodically
|
||||
setInterval(() => this.cleanExpiredDenylistEntries(), 60000); // Every minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Check IP reputation for a connection
|
||||
* @param socket - Client socket
|
||||
* @returns Promise that resolves to true if IP is allowed, false if blocked
|
||||
*/
|
||||
public async checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<boolean> {
|
||||
const socketDetails = getSocketDetails(socket);
|
||||
const ip = socketDetails.remoteAddress;
|
||||
|
||||
// Check local denylist first
|
||||
if (this.isIpDenylisted(ip)) {
|
||||
// Log the blocked connection
|
||||
this.logSecurityEvent(
|
||||
SecurityEventType.IP_REPUTATION,
|
||||
SecurityLogLevel.WARN,
|
||||
`Connection blocked from denylisted IP: ${ip}`,
|
||||
{ reason: this.getDenylistReason(ip) }
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no reputation service, allow by default
|
||||
if (!this.ipReputationService) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check with IP reputation service
|
||||
const reputationResult = await this.ipReputationService.checkIp(ip);
|
||||
|
||||
if (!reputationResult.allowed) {
|
||||
// Add to local denylist temporarily
|
||||
this.addToDenylist(ip, reputationResult.reason, 3600000); // 1 hour
|
||||
|
||||
// Log the blocked connection
|
||||
this.logSecurityEvent(
|
||||
SecurityEventType.IP_REPUTATION,
|
||||
SecurityLogLevel.WARN,
|
||||
`Connection blocked by reputation service: ${ip}`,
|
||||
{
|
||||
reason: reputationResult.reason,
|
||||
score: reputationResult.score,
|
||||
categories: reputationResult.categories
|
||||
}
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Log the allowed connection
|
||||
this.logSecurityEvent(
|
||||
SecurityEventType.IP_REPUTATION,
|
||||
SecurityLogLevel.INFO,
|
||||
`IP reputation check passed: ${ip}`,
|
||||
{
|
||||
score: reputationResult.score,
|
||||
categories: reputationResult.categories
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Log the error
|
||||
SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
ip,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
// Allow the connection on error (fail open)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an email address
|
||||
* @param email - Email address to validate
|
||||
* @returns Whether the email address is valid
|
||||
*/
|
||||
public isValidEmail(email: string): boolean {
|
||||
return isValidEmail(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate authentication credentials
|
||||
* @param session - SMTP session
|
||||
* @param username - Username
|
||||
* @param password - Password
|
||||
* @param method - Authentication method
|
||||
* @returns Promise that resolves to true if authenticated
|
||||
*/
|
||||
public async authenticate(session: ISmtpSession, username: string, password: string, method: string): Promise<boolean> {
|
||||
// Check if authentication is enabled
|
||||
if (!this.authOptions) {
|
||||
this.logSecurityEvent(
|
||||
SecurityEventType.AUTHENTICATION,
|
||||
SecurityLogLevel.WARN,
|
||||
'Authentication attempt when auth is disabled',
|
||||
{ username, method, sessionId: session.id, ip: session.remoteAddress }
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if method is supported
|
||||
if (!this.authOptions.methods.includes(method as any)) {
|
||||
this.logSecurityEvent(
|
||||
SecurityEventType.AUTHENTICATION,
|
||||
SecurityLogLevel.WARN,
|
||||
`Unsupported authentication method: ${method}`,
|
||||
{ username, method, sessionId: session.id, ip: session.remoteAddress }
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if TLS is active (should be required for auth)
|
||||
if (!session.useTLS) {
|
||||
this.logSecurityEvent(
|
||||
SecurityEventType.AUTHENTICATION,
|
||||
SecurityLogLevel.WARN,
|
||||
'Authentication attempt without TLS',
|
||||
{ username, method, sessionId: session.id, ip: session.remoteAddress }
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
let authenticated = false;
|
||||
|
||||
// Use custom validation function if provided
|
||||
if (this.authOptions.validateUser) {
|
||||
authenticated = await this.authOptions.validateUser(username, password);
|
||||
} else {
|
||||
// Default behavior - no authentication
|
||||
authenticated = false;
|
||||
}
|
||||
|
||||
// Log the authentication result
|
||||
this.logSecurityEvent(
|
||||
SecurityEventType.AUTHENTICATION,
|
||||
authenticated ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
||||
authenticated ? 'Authentication successful' : 'Authentication failed',
|
||||
{ username, method, sessionId: session.id, ip: session.remoteAddress }
|
||||
);
|
||||
|
||||
return authenticated;
|
||||
} catch (error) {
|
||||
// Log authentication error
|
||||
this.logSecurityEvent(
|
||||
SecurityEventType.AUTHENTICATION,
|
||||
SecurityLogLevel.ERROR,
|
||||
`Authentication error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ username, method, sessionId: session.id, ip: session.remoteAddress, error: error instanceof Error ? error.message : String(error) }
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
* @param event - Event type
|
||||
* @param level - Log level
|
||||
* @param details - Event details
|
||||
*/
|
||||
public logSecurityEvent(event: string, level: string, message: string, details: Record<string, any>): void {
|
||||
SmtpLogger.logSecurityEvent(
|
||||
level as SecurityLogLevel,
|
||||
event as SecurityEventType,
|
||||
message,
|
||||
details,
|
||||
details.ip,
|
||||
details.domain,
|
||||
details.success
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an IP to the denylist
|
||||
* @param ip - IP address
|
||||
* @param reason - Reason for denylisting
|
||||
* @param duration - Duration in milliseconds (optional, indefinite if not specified)
|
||||
*/
|
||||
private addToDenylist(ip: string, reason: string, duration?: number): void {
|
||||
// Remove existing entry if present
|
||||
this.ipDenylist = this.ipDenylist.filter(entry => entry.ip !== ip);
|
||||
|
||||
// Create new entry
|
||||
const entry: IIpDenylistEntry = {
|
||||
ip,
|
||||
reason,
|
||||
expiresAt: duration ? Date.now() + duration : undefined
|
||||
};
|
||||
|
||||
// Add to denylist
|
||||
this.ipDenylist.push(entry);
|
||||
|
||||
// Log the action
|
||||
this.logSecurityEvent(
|
||||
SecurityEventType.ACCESS_CONTROL,
|
||||
SecurityLogLevel.INFO,
|
||||
`Added IP to denylist: ${ip}`,
|
||||
{
|
||||
ip,
|
||||
reason,
|
||||
duration: duration ? `${duration / 1000} seconds` : 'indefinite'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is denylisted
|
||||
* @param ip - IP address
|
||||
* @returns Whether the IP is denylisted
|
||||
*/
|
||||
private isIpDenylisted(ip: string): boolean {
|
||||
const entry = this.ipDenylist.find(e => e.ip === ip);
|
||||
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if entry has expired
|
||||
if (entry.expiresAt && entry.expiresAt < Date.now()) {
|
||||
// Remove expired entry
|
||||
this.ipDenylist = this.ipDenylist.filter(e => e !== entry);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reason an IP was denylisted
|
||||
* @param ip - IP address
|
||||
* @returns Reason for denylisting or undefined if not denylisted
|
||||
*/
|
||||
private getDenylistReason(ip: string): string | undefined {
|
||||
const entry = this.ipDenylist.find(e => e.ip === ip);
|
||||
return entry?.reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean expired denylist entries
|
||||
*/
|
||||
private cleanExpiredDenylistEntries(): void {
|
||||
const now = Date.now();
|
||||
const initialCount = this.ipDenylist.length;
|
||||
|
||||
this.ipDenylist = this.ipDenylist.filter(entry => {
|
||||
return !entry.expiresAt || entry.expiresAt > now;
|
||||
});
|
||||
|
||||
const removedCount = initialCount - this.ipDenylist.length;
|
||||
|
||||
if (removedCount > 0) {
|
||||
this.logSecurityEvent(
|
||||
SecurityEventType.ACCESS_CONTROL,
|
||||
SecurityLogLevel.INFO,
|
||||
`Cleaned up ${removedCount} expired denylist entries`,
|
||||
{ remainingCount: this.ipDenylist.length }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
392
ts/mail/delivery/smtp/session-manager.ts
Normal file
392
ts/mail/delivery/smtp/session-manager.ts
Normal file
@ -0,0 +1,392 @@
|
||||
/**
|
||||
* SMTP Session Manager
|
||||
* Responsible for creating, managing, and cleaning up SMTP sessions
|
||||
*/
|
||||
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import { SmtpState, ISmtpSession, ISmtpEnvelope } from '../interfaces.js';
|
||||
import { ISessionManager, ISessionEvents } from './interfaces.js';
|
||||
import { SMTP_DEFAULTS } from './constants.js';
|
||||
import { generateSessionId, getSocketDetails } from './utils/helpers.js';
|
||||
import { SmtpLogger } from './utils/logging.js';
|
||||
|
||||
/**
|
||||
* Manager for SMTP sessions
|
||||
* Handles session creation, tracking, timeout management, and cleanup
|
||||
*/
|
||||
export class SessionManager implements ISessionManager {
|
||||
/**
|
||||
* Map of socket ID to session
|
||||
*/
|
||||
private sessions: Map<string, ISmtpSession> = new Map();
|
||||
|
||||
/**
|
||||
* Map of socket to socket ID
|
||||
*/
|
||||
private socketIds: Map<plugins.net.Socket | plugins.tls.TLSSocket, string> = new Map();
|
||||
|
||||
/**
|
||||
* SMTP server options
|
||||
*/
|
||||
private options: {
|
||||
socketTimeout: number;
|
||||
connectionTimeout: number;
|
||||
cleanupInterval: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Event listeners
|
||||
*/
|
||||
private eventListeners: {
|
||||
[K in keyof ISessionEvents]?: Set<ISessionEvents[K]>;
|
||||
} = {};
|
||||
|
||||
/**
|
||||
* Timer for cleanup interval
|
||||
*/
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new session manager
|
||||
* @param options - Session manager options
|
||||
*/
|
||||
constructor(options: {
|
||||
socketTimeout?: number;
|
||||
connectionTimeout?: number;
|
||||
cleanupInterval?: number;
|
||||
} = {}) {
|
||||
this.options = {
|
||||
socketTimeout: options.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT,
|
||||
connectionTimeout: options.connectionTimeout || SMTP_DEFAULTS.CONNECTION_TIMEOUT,
|
||||
cleanupInterval: options.cleanupInterval || SMTP_DEFAULTS.CLEANUP_INTERVAL
|
||||
};
|
||||
|
||||
// Start the cleanup timer
|
||||
this.startCleanupTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new session for a socket connection
|
||||
* @param socket - Client socket
|
||||
* @param secure - Whether the connection is secure (TLS)
|
||||
* @returns New SMTP session
|
||||
*/
|
||||
public createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): ISmtpSession {
|
||||
const sessionId = generateSessionId();
|
||||
const socketDetails = getSocketDetails(socket);
|
||||
|
||||
// Create a new session
|
||||
const session: ISmtpSession = {
|
||||
id: sessionId,
|
||||
state: SmtpState.GREETING,
|
||||
clientHostname: '',
|
||||
mailFrom: '',
|
||||
rcptTo: [],
|
||||
emailData: '',
|
||||
emailDataChunks: [],
|
||||
useTLS: secure || false,
|
||||
connectionEnded: false,
|
||||
remoteAddress: socketDetails.remoteAddress,
|
||||
secure: secure || false,
|
||||
authenticated: false,
|
||||
envelope: {
|
||||
mailFrom: { address: '', args: {} },
|
||||
rcptTo: []
|
||||
},
|
||||
lastActivity: Date.now()
|
||||
};
|
||||
|
||||
// Store session with unique ID
|
||||
const socketKey = this.getSocketKey(socket);
|
||||
this.socketIds.set(socket, socketKey);
|
||||
this.sessions.set(socketKey, session);
|
||||
|
||||
// Set socket timeout
|
||||
socket.setTimeout(this.options.socketTimeout);
|
||||
|
||||
// Emit session created event
|
||||
this.emitEvent('created', session, socket);
|
||||
|
||||
// Log session creation
|
||||
SmtpLogger.info(`Created SMTP session ${sessionId}`, {
|
||||
sessionId,
|
||||
remoteAddress: session.remoteAddress,
|
||||
remotePort: socketDetails.remotePort,
|
||||
secure: session.secure
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the session state
|
||||
* @param session - SMTP session
|
||||
* @param newState - New state
|
||||
*/
|
||||
public updateSessionState(session: ISmtpSession, newState: SmtpState): void {
|
||||
if (session.state === newState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousState = session.state;
|
||||
session.state = newState;
|
||||
|
||||
// Update activity timestamp
|
||||
this.updateSessionActivity(session);
|
||||
|
||||
// Emit state changed event
|
||||
this.emitEvent('stateChanged', session, previousState, newState);
|
||||
|
||||
// Log state change
|
||||
SmtpLogger.debug(`Session ${session.id} state changed from ${previousState} to ${newState}`, {
|
||||
sessionId: session.id,
|
||||
previousState,
|
||||
newState,
|
||||
remoteAddress: session.remoteAddress
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the session's last activity timestamp
|
||||
* @param session - SMTP session
|
||||
*/
|
||||
public updateSessionActivity(session: ISmtpSession): void {
|
||||
session.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a session
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
public removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const socketKey = this.socketIds.get(socket);
|
||||
if (!socketKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.sessions.get(socketKey);
|
||||
if (session) {
|
||||
// Mark the session as ended
|
||||
session.connectionEnded = true;
|
||||
|
||||
// Clear any data timeout if it exists
|
||||
if (session.dataTimeoutId) {
|
||||
clearTimeout(session.dataTimeoutId);
|
||||
session.dataTimeoutId = undefined;
|
||||
}
|
||||
|
||||
// Emit session completed event
|
||||
this.emitEvent('completed', session, socket);
|
||||
|
||||
// Log session removal
|
||||
SmtpLogger.info(`Removed SMTP session ${session.id}`, {
|
||||
sessionId: session.id,
|
||||
remoteAddress: session.remoteAddress,
|
||||
finalState: session.state
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from maps
|
||||
this.sessions.delete(socketKey);
|
||||
this.socketIds.delete(socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a session for a socket
|
||||
* @param socket - Client socket
|
||||
* @returns SMTP session or undefined if not found
|
||||
*/
|
||||
public getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined {
|
||||
const socketKey = this.socketIds.get(socket);
|
||||
if (!socketKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.sessions.get(socketKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up idle sessions
|
||||
*/
|
||||
public cleanupIdleSessions(): void {
|
||||
const now = Date.now();
|
||||
let timedOutCount = 0;
|
||||
|
||||
for (const [socketKey, session] of this.sessions.entries()) {
|
||||
if (session.connectionEnded) {
|
||||
// Session already marked as ended, but still in map
|
||||
this.sessions.delete(socketKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate how long the session has been idle
|
||||
const lastActivity = session.lastActivity || 0;
|
||||
const idleTime = now - lastActivity;
|
||||
|
||||
// Use appropriate timeout based on session state
|
||||
const timeout = session.state === SmtpState.DATA_RECEIVING
|
||||
? this.options.socketTimeout * 2 // Double timeout for data receiving
|
||||
: session.state === SmtpState.GREETING
|
||||
? this.options.connectionTimeout // Initial connection timeout
|
||||
: this.options.socketTimeout; // Standard timeout for other states
|
||||
|
||||
// Check if session has timed out
|
||||
if (idleTime > timeout) {
|
||||
// Find the socket for this session
|
||||
let timedOutSocket: plugins.net.Socket | plugins.tls.TLSSocket | undefined;
|
||||
|
||||
for (const [socket, key] of this.socketIds.entries()) {
|
||||
if (key === socketKey) {
|
||||
timedOutSocket = socket;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (timedOutSocket) {
|
||||
// Emit timeout event
|
||||
this.emitEvent('timeout', session, timedOutSocket);
|
||||
|
||||
// Log timeout
|
||||
SmtpLogger.warn(`Session ${session.id} timed out after ${Math.round(idleTime / 1000)}s of inactivity`, {
|
||||
sessionId: session.id,
|
||||
remoteAddress: session.remoteAddress,
|
||||
state: session.state,
|
||||
idleTime
|
||||
});
|
||||
|
||||
// End the socket connection
|
||||
try {
|
||||
timedOutSocket.end();
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Error ending timed out socket: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
sessionId: session.id,
|
||||
remoteAddress: session.remoteAddress,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from maps
|
||||
this.sessions.delete(socketKey);
|
||||
this.socketIds.delete(timedOutSocket);
|
||||
timedOutCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (timedOutCount > 0) {
|
||||
SmtpLogger.info(`Cleaned up ${timedOutCount} timed out sessions`, {
|
||||
totalSessions: this.sessions.size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current number of active sessions
|
||||
* @returns Number of active sessions
|
||||
*/
|
||||
public getSessionCount(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all sessions (used when shutting down)
|
||||
*/
|
||||
public clearAllSessions(): void {
|
||||
// Log the action
|
||||
SmtpLogger.info(`Clearing all sessions (count: ${this.sessions.size})`);
|
||||
|
||||
// Clear the sessions and socket IDs maps
|
||||
this.sessions.clear();
|
||||
this.socketIds.clear();
|
||||
|
||||
// Stop the cleanup timer
|
||||
this.stopCleanupTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event listener
|
||||
* @param event - Event name
|
||||
* @param listener - Event listener function
|
||||
*/
|
||||
public on<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void {
|
||||
if (!this.eventListeners[event]) {
|
||||
this.eventListeners[event] = new Set();
|
||||
}
|
||||
|
||||
(this.eventListeners[event] as Set<ISessionEvents[K]>).add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event listener
|
||||
* @param event - Event name
|
||||
* @param listener - Event listener function
|
||||
*/
|
||||
public off<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void {
|
||||
if (!this.eventListeners[event]) {
|
||||
return;
|
||||
}
|
||||
|
||||
(this.eventListeners[event] as Set<ISessionEvents[K]>).delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to registered listeners
|
||||
* @param event - Event name
|
||||
* @param args - Event arguments
|
||||
*/
|
||||
private emitEvent<K extends keyof ISessionEvents>(event: K, ...args: Parameters<ISessionEvents[K]>): void {
|
||||
const listeners = this.eventListeners[event] as Set<ISessionEvents[K]> | undefined;
|
||||
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(...args);
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Error in session event listener for ${String(event)}: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cleanup timer
|
||||
*/
|
||||
private startCleanupTimer(): void {
|
||||
if (this.cleanupTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.cleanupIdleSessions();
|
||||
}, this.options.cleanupInterval);
|
||||
|
||||
// Prevent the timer from keeping the process alive
|
||||
if (this.cleanupTimer.unref) {
|
||||
this.cleanupTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cleanup timer
|
||||
*/
|
||||
private stopCleanupTimer(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a unique key for a socket
|
||||
* @param socket - Client socket
|
||||
* @returns Socket key
|
||||
*/
|
||||
private getSocketKey(socket: plugins.net.Socket | plugins.tls.TLSSocket): string {
|
||||
const details = getSocketDetails(socket);
|
||||
return `${details.remoteAddress}:${details.remotePort}-${Date.now()}`;
|
||||
}
|
||||
}
|
284
ts/mail/delivery/smtp/tls-handler.ts
Normal file
284
ts/mail/delivery/smtp/tls-handler.ts
Normal file
@ -0,0 +1,284 @@
|
||||
/**
|
||||
* SMTP TLS Handler
|
||||
* Responsible for handling TLS-related SMTP functionality
|
||||
*/
|
||||
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import { ITlsHandler, ISessionManager } from './interfaces.js';
|
||||
import { SmtpResponseCode, SecurityEventType, SecurityLogLevel } from './constants.js';
|
||||
import { SmtpLogger } from './utils/logging.js';
|
||||
import { getSocketDetails, getTlsDetails } from './utils/helpers.js';
|
||||
|
||||
/**
|
||||
* Handles TLS functionality for SMTP server
|
||||
*/
|
||||
export class TlsHandler implements ITlsHandler {
|
||||
/**
|
||||
* Session manager instance
|
||||
*/
|
||||
private sessionManager: ISessionManager;
|
||||
|
||||
/**
|
||||
* TLS options
|
||||
*/
|
||||
private options: {
|
||||
key: string;
|
||||
cert: string;
|
||||
ca?: string;
|
||||
rejectUnauthorized?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new TLS handler
|
||||
* @param sessionManager - Session manager instance
|
||||
* @param options - TLS options
|
||||
*/
|
||||
constructor(
|
||||
sessionManager: ISessionManager,
|
||||
options: {
|
||||
key: string;
|
||||
cert: string;
|
||||
ca?: string;
|
||||
rejectUnauthorized?: boolean;
|
||||
}
|
||||
) {
|
||||
this.sessionManager = sessionManager;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle STARTTLS command
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
public handleStartTls(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
if (!session) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already using TLS
|
||||
if (session.useTLS) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} TLS already active`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have the necessary TLS certificates
|
||||
if (!this.isTlsEnabled()) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.TLS_UNAVAILABLE_TEMP} TLS not available`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send ready for TLS response
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_READY} Ready to start TLS`);
|
||||
|
||||
// Upgrade the connection to TLS
|
||||
try {
|
||||
this.startTLS(socket);
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`STARTTLS negotiation failed: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
sessionId: session.id,
|
||||
remoteAddress: session.remoteAddress,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
// Log security event
|
||||
SmtpLogger.logSecurityEvent(
|
||||
SecurityLogLevel.ERROR,
|
||||
SecurityEventType.TLS_NEGOTIATION,
|
||||
'STARTTLS negotiation failed',
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
session.remoteAddress
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade a connection to TLS
|
||||
* @param socket - Client socket
|
||||
*/
|
||||
public startTLS(socket: plugins.net.Socket): void {
|
||||
// Get the session for this socket
|
||||
const session = this.sessionManager.getSession(socket);
|
||||
|
||||
// Create TLS context
|
||||
const context = {
|
||||
key: this.options.key,
|
||||
cert: this.options.cert,
|
||||
ca: this.options.ca,
|
||||
isServer: true,
|
||||
rejectUnauthorized: this.options.rejectUnauthorized || false
|
||||
};
|
||||
|
||||
try {
|
||||
// Upgrade the connection
|
||||
const secureSocket = new plugins.tls.TLSSocket(socket, context);
|
||||
|
||||
// Store reference to the original socket to facilitate cleanup
|
||||
(secureSocket as any).originalSocket = socket;
|
||||
|
||||
// Log the successful upgrade
|
||||
if (session) {
|
||||
SmtpLogger.info(`Upgraded connection to TLS for session ${session.id}`, {
|
||||
sessionId: session.id,
|
||||
remoteAddress: session.remoteAddress
|
||||
});
|
||||
|
||||
// Log security event
|
||||
SmtpLogger.logSecurityEvent(
|
||||
SecurityLogLevel.INFO,
|
||||
SecurityEventType.TLS_NEGOTIATION,
|
||||
'STARTTLS negotiation successful',
|
||||
{},
|
||||
session.remoteAddress,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
// Update session properties
|
||||
session.useTLS = true;
|
||||
session.secure = true;
|
||||
|
||||
// Reset session state (per RFC 3207)
|
||||
// After STARTTLS, client must issue a new EHLO
|
||||
if (this.sessionManager.updateSessionState) {
|
||||
this.sessionManager.updateSessionState(session, SmtpState.GREETING);
|
||||
}
|
||||
} else {
|
||||
const socketDetails = getSocketDetails(socket);
|
||||
SmtpLogger.info(`Upgraded connection to TLS without session from ${socketDetails.remoteAddress}:${socketDetails.remotePort}`);
|
||||
}
|
||||
|
||||
// Securely handle TLS errors
|
||||
secureSocket.on('error', (err) => {
|
||||
SmtpLogger.error(`TLS error: ${err.message}`, {
|
||||
remoteAddress: socket.remoteAddress,
|
||||
remotePort: socket.remotePort,
|
||||
error: err
|
||||
});
|
||||
|
||||
// Log security event
|
||||
SmtpLogger.logSecurityEvent(
|
||||
SecurityLogLevel.ERROR,
|
||||
SecurityEventType.TLS_NEGOTIATION,
|
||||
'TLS error after successful negotiation',
|
||||
{ error: err.message },
|
||||
socket.remoteAddress
|
||||
);
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
// Log TLS connection details on secure
|
||||
secureSocket.on('secure', () => {
|
||||
const tlsDetails = getTlsDetails(secureSocket);
|
||||
|
||||
if (tlsDetails) {
|
||||
SmtpLogger.info('TLS connection established', {
|
||||
remoteAddress: secureSocket.remoteAddress,
|
||||
remotePort: secureSocket.remotePort,
|
||||
protocol: tlsDetails.protocol,
|
||||
cipher: tlsDetails.cipher,
|
||||
authorized: tlsDetails.authorized
|
||||
});
|
||||
|
||||
// Log security event with TLS details
|
||||
SmtpLogger.logSecurityEvent(
|
||||
SecurityLogLevel.INFO,
|
||||
SecurityEventType.TLS_NEGOTIATION,
|
||||
'TLS connection details',
|
||||
{
|
||||
protocol: tlsDetails.protocol,
|
||||
cipher: tlsDetails.cipher,
|
||||
authorized: tlsDetails.authorized
|
||||
},
|
||||
secureSocket.remoteAddress,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Failed to upgrade connection to TLS: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
remoteAddress: socket.remoteAddress,
|
||||
remotePort: socket.remotePort,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
// Log security event
|
||||
SmtpLogger.logSecurityEvent(
|
||||
SecurityLogLevel.ERROR,
|
||||
SecurityEventType.TLS_NEGOTIATION,
|
||||
'Failed to upgrade connection to TLS',
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
socket.remoteAddress,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a secure server
|
||||
* @returns TLS server instance or undefined if TLS is not enabled
|
||||
*/
|
||||
public createSecureServer(): plugins.tls.Server | undefined {
|
||||
if (!this.isTlsEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create TLS context
|
||||
const context = {
|
||||
key: this.options.key,
|
||||
cert: this.options.cert,
|
||||
ca: this.options.ca,
|
||||
rejectUnauthorized: this.options.rejectUnauthorized || false
|
||||
};
|
||||
|
||||
// Create secure server
|
||||
return new plugins.tls.Server(context);
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Failed to create secure server: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TLS is enabled
|
||||
* @returns Whether TLS is enabled
|
||||
*/
|
||||
public isTlsEnabled(): boolean {
|
||||
return !!(this.options.key && this.options.cert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a response to the client
|
||||
* @param socket - Client socket
|
||||
* @param response - Response message
|
||||
*/
|
||||
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
|
||||
try {
|
||||
socket.write(`${response}\r\n`);
|
||||
SmtpLogger.logResponse(response, socket);
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
response,
|
||||
remoteAddress: socket.remoteAddress,
|
||||
remotePort: socket.remotePort,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import SmtpState only for type reference, not available at runtime
|
||||
import { SmtpState } from '../interfaces.js';
|
201
ts/mail/delivery/smtp/utils/helpers.ts
Normal file
201
ts/mail/delivery/smtp/utils/helpers.ts
Normal file
@ -0,0 +1,201 @@
|
||||
/**
|
||||
* SMTP Helper Functions
|
||||
* Provides utility functions for SMTP server implementation
|
||||
*/
|
||||
|
||||
import * as plugins from '../../../../plugins.js';
|
||||
import { SMTP_DEFAULTS } from '../constants.js';
|
||||
import type { ISmtpSession, ISmtpServerOptions } from '../../interfaces.js';
|
||||
|
||||
/**
|
||||
* Formats a multi-line SMTP response according to RFC 5321
|
||||
* @param code - Response code
|
||||
* @param lines - Response lines
|
||||
* @returns Formatted SMTP response
|
||||
*/
|
||||
export function formatMultilineResponse(code: number, lines: string[]): string {
|
||||
if (!lines || lines.length === 0) {
|
||||
return `${code} `;
|
||||
}
|
||||
|
||||
if (lines.length === 1) {
|
||||
return `${code} ${lines[0]}`;
|
||||
}
|
||||
|
||||
let response = '';
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
response += `${code}-${lines[i]}${SMTP_DEFAULTS.CRLF}`;
|
||||
}
|
||||
response += `${code} ${lines[lines.length - 1]}`;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique session ID
|
||||
* @returns Unique session ID
|
||||
*/
|
||||
export function generateSessionId(): string {
|
||||
return `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses an integer from string with a default value
|
||||
* @param value - String value to parse
|
||||
* @param defaultValue - Default value if parsing fails
|
||||
* @returns Parsed integer or default value
|
||||
*/
|
||||
export function safeParseInt(value: string | undefined, defaultValue: number): number {
|
||||
if (!value) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsed = parseInt(value, 10);
|
||||
return isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely gets the socket details
|
||||
* @param socket - Socket to get details from
|
||||
* @returns Socket details object
|
||||
*/
|
||||
export function getSocketDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): {
|
||||
remoteAddress: string;
|
||||
remotePort: number;
|
||||
remoteFamily: string;
|
||||
localAddress: string;
|
||||
localPort: number;
|
||||
encrypted: boolean;
|
||||
} {
|
||||
return {
|
||||
remoteAddress: socket.remoteAddress || 'unknown',
|
||||
remotePort: socket.remotePort || 0,
|
||||
remoteFamily: socket.remoteFamily || 'unknown',
|
||||
localAddress: socket.localAddress || 'unknown',
|
||||
localPort: socket.localPort || 0,
|
||||
encrypted: socket instanceof plugins.tls.TLSSocket
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets TLS details if socket is TLS
|
||||
* @param socket - Socket to get TLS details from
|
||||
* @returns TLS details or undefined if not TLS
|
||||
*/
|
||||
export function getTlsDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): {
|
||||
protocol?: string;
|
||||
cipher?: string;
|
||||
authorized?: boolean;
|
||||
} | undefined {
|
||||
if (!(socket instanceof plugins.tls.TLSSocket)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
protocol: socket.getProtocol(),
|
||||
cipher: socket.getCipher()?.name,
|
||||
authorized: socket.authorized
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges default options with provided options
|
||||
* @param options - User provided options
|
||||
* @returns Merged options with defaults
|
||||
*/
|
||||
export function mergeWithDefaults(options: Partial<ISmtpServerOptions>): ISmtpServerOptions {
|
||||
return {
|
||||
port: options.port || SMTP_DEFAULTS.SMTP_PORT,
|
||||
key: options.key || '',
|
||||
cert: options.cert || '',
|
||||
hostname: options.hostname || SMTP_DEFAULTS.HOSTNAME,
|
||||
host: options.host,
|
||||
securePort: options.securePort,
|
||||
ca: options.ca,
|
||||
maxSize: options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE,
|
||||
maxConnections: options.maxConnections || SMTP_DEFAULTS.MAX_CONNECTIONS,
|
||||
socketTimeout: options.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT,
|
||||
connectionTimeout: options.connectionTimeout || SMTP_DEFAULTS.CONNECTION_TIMEOUT,
|
||||
cleanupInterval: options.cleanupInterval || SMTP_DEFAULTS.CLEANUP_INTERVAL,
|
||||
maxRecipients: options.maxRecipients || SMTP_DEFAULTS.MAX_RECIPIENTS,
|
||||
size: options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE,
|
||||
dataTimeout: options.dataTimeout || SMTP_DEFAULTS.DATA_TIMEOUT,
|
||||
auth: options.auth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a text response formatter for the SMTP server
|
||||
* @param socket - Socket to send responses to
|
||||
* @returns Function to send formatted response
|
||||
*/
|
||||
export function createResponseFormatter(socket: plugins.net.Socket | plugins.tls.TLSSocket): (response: string) => void {
|
||||
return (response: string): void => {
|
||||
try {
|
||||
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
|
||||
console.log(`→ ${response}`);
|
||||
} catch (error) {
|
||||
console.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`);
|
||||
socket.destroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts SMTP command name from a command line
|
||||
* @param commandLine - Full command line
|
||||
* @returns Command name in uppercase
|
||||
*/
|
||||
export function extractCommandName(commandLine: string): string {
|
||||
const parts = commandLine.trim().split(' ');
|
||||
return parts[0].toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts SMTP command arguments from a command line
|
||||
* @param commandLine - Full command line
|
||||
* @returns Arguments string
|
||||
*/
|
||||
export function extractCommandArgs(commandLine: string): string {
|
||||
const firstSpace = commandLine.indexOf(' ');
|
||||
if (firstSpace === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return commandLine.substring(firstSpace + 1).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes data for logging (hides sensitive info)
|
||||
* @param data - Data to sanitize
|
||||
* @returns Sanitized data
|
||||
*/
|
||||
export function sanitizeForLogging(data: any): any {
|
||||
if (!data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (typeof data !== 'object') {
|
||||
return data;
|
||||
}
|
||||
|
||||
const result: any = Array.isArray(data) ? [] : {};
|
||||
|
||||
for (const key in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||
// Sanitize sensitive fields
|
||||
if (key.toLowerCase().includes('password') ||
|
||||
key.toLowerCase().includes('token') ||
|
||||
key.toLowerCase().includes('secret') ||
|
||||
key.toLowerCase().includes('credential')) {
|
||||
result[key] = '********';
|
||||
} else if (typeof data[key] === 'object' && data[key] !== null) {
|
||||
result[key] = sanitizeForLogging(data[key]);
|
||||
} else {
|
||||
result[key] = data[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
246
ts/mail/delivery/smtp/utils/logging.ts
Normal file
246
ts/mail/delivery/smtp/utils/logging.ts
Normal file
@ -0,0 +1,246 @@
|
||||
/**
|
||||
* SMTP Logging Utilities
|
||||
* Provides structured logging for SMTP server components
|
||||
*/
|
||||
|
||||
import * as plugins from '../../../../plugins.js';
|
||||
import { logger } from '../../../../logger.js';
|
||||
import { SecurityLogLevel, SecurityEventType } from '../constants.js';
|
||||
import type { ISmtpSession } from '../../interfaces.js';
|
||||
|
||||
/**
|
||||
* SMTP connection metadata to include in logs
|
||||
*/
|
||||
export interface IConnectionMetadata {
|
||||
remoteAddress?: string;
|
||||
remotePort?: number;
|
||||
socketId?: string;
|
||||
secure?: boolean;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log levels for SMTP server
|
||||
*/
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
/**
|
||||
* Options for SMTP log
|
||||
*/
|
||||
export interface ISmtpLogOptions {
|
||||
level?: LogLevel;
|
||||
sessionId?: string;
|
||||
sessionState?: string;
|
||||
remoteAddress?: string;
|
||||
remotePort?: number;
|
||||
command?: string;
|
||||
error?: Error;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP logger - provides structured logging for SMTP server
|
||||
*/
|
||||
export class SmtpLogger {
|
||||
/**
|
||||
* Log a message with context
|
||||
* @param level - Log level
|
||||
* @param message - Log message
|
||||
* @param options - Additional log options
|
||||
*/
|
||||
public static log(level: LogLevel, message: string, options: ISmtpLogOptions = {}): void {
|
||||
// Extract error information if provided
|
||||
const errorInfo = options.error ? {
|
||||
errorMessage: options.error.message,
|
||||
errorStack: options.error.stack,
|
||||
errorName: options.error.name
|
||||
} : {};
|
||||
|
||||
// Structure log data
|
||||
const logData = {
|
||||
component: 'smtp-server',
|
||||
...options,
|
||||
...errorInfo
|
||||
};
|
||||
|
||||
// Remove error from log data to avoid duplication
|
||||
if (logData.error) {
|
||||
delete logData.error;
|
||||
}
|
||||
|
||||
// Log through the main logger
|
||||
logger.log(level, message, logData);
|
||||
|
||||
// Also console log for immediate visibility during development
|
||||
if (level === 'error' || level === 'warn') {
|
||||
console[level](`[SMTP] ${message}`, logData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug level message
|
||||
* @param message - Log message
|
||||
* @param options - Additional log options
|
||||
*/
|
||||
public static debug(message: string, options: ISmtpLogOptions = {}): void {
|
||||
this.log('debug', message, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info level message
|
||||
* @param message - Log message
|
||||
* @param options - Additional log options
|
||||
*/
|
||||
public static info(message: string, options: ISmtpLogOptions = {}): void {
|
||||
this.log('info', message, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning level message
|
||||
* @param message - Log message
|
||||
* @param options - Additional log options
|
||||
*/
|
||||
public static warn(message: string, options: ISmtpLogOptions = {}): void {
|
||||
this.log('warn', message, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error level message
|
||||
* @param message - Log message
|
||||
* @param options - Additional log options
|
||||
*/
|
||||
public static error(message: string, options: ISmtpLogOptions = {}): void {
|
||||
this.log('error', message, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log command received from client
|
||||
* @param command - The command string
|
||||
* @param socket - The client socket
|
||||
* @param session - The SMTP session
|
||||
*/
|
||||
public static logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void {
|
||||
const clientInfo = {
|
||||
remoteAddress: socket.remoteAddress,
|
||||
remotePort: socket.remotePort,
|
||||
secure: socket instanceof plugins.tls.TLSSocket,
|
||||
sessionId: session?.id,
|
||||
sessionState: session?.state
|
||||
};
|
||||
|
||||
this.info(`Command received: ${command}`, {
|
||||
...clientInfo,
|
||||
command: command.split(' ')[0]?.toUpperCase()
|
||||
});
|
||||
|
||||
// Also log to console for easy debugging
|
||||
console.log(`← ${command}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log response sent to client
|
||||
* @param response - The response string
|
||||
* @param socket - The client socket
|
||||
*/
|
||||
public static logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const clientInfo = {
|
||||
remoteAddress: socket.remoteAddress,
|
||||
remotePort: socket.remotePort,
|
||||
secure: socket instanceof plugins.tls.TLSSocket
|
||||
};
|
||||
|
||||
// Get the response code from the beginning of the response
|
||||
const responseCode = response.substring(0, 3);
|
||||
|
||||
// Log different levels based on response code
|
||||
if (responseCode.startsWith('2') || responseCode.startsWith('3')) {
|
||||
this.debug(`Response sent: ${response}`, clientInfo);
|
||||
} else if (responseCode.startsWith('4')) {
|
||||
this.warn(`Temporary error response: ${response}`, clientInfo);
|
||||
} else if (responseCode.startsWith('5')) {
|
||||
this.error(`Permanent error response: ${response}`, clientInfo);
|
||||
}
|
||||
|
||||
// Also log to console for easy debugging
|
||||
console.log(`→ ${response}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log client connection event
|
||||
* @param socket - The client socket
|
||||
* @param eventType - Type of connection event (connect, close, error)
|
||||
* @param session - The SMTP session
|
||||
* @param error - Optional error object for error events
|
||||
*/
|
||||
public static logConnection(
|
||||
socket: plugins.net.Socket | plugins.tls.TLSSocket,
|
||||
eventType: 'connect' | 'close' | 'error',
|
||||
session?: ISmtpSession,
|
||||
error?: Error
|
||||
): void {
|
||||
const clientInfo = {
|
||||
remoteAddress: socket.remoteAddress,
|
||||
remotePort: socket.remotePort,
|
||||
secure: socket instanceof plugins.tls.TLSSocket,
|
||||
sessionId: session?.id,
|
||||
sessionState: session?.state
|
||||
};
|
||||
|
||||
switch (eventType) {
|
||||
case 'connect':
|
||||
this.info(`New ${clientInfo.secure ? 'secure ' : ''}connection from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo);
|
||||
break;
|
||||
|
||||
case 'close':
|
||||
this.info(`Connection closed from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this.error(`Connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, {
|
||||
...clientInfo,
|
||||
error
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security event
|
||||
* @param level - Security log level
|
||||
* @param type - Security event type
|
||||
* @param message - Log message
|
||||
* @param details - Event details
|
||||
* @param ipAddress - Client IP address
|
||||
* @param domain - Optional domain involved
|
||||
* @param success - Whether the security check was successful
|
||||
*/
|
||||
public static logSecurityEvent(
|
||||
level: SecurityLogLevel,
|
||||
type: SecurityEventType,
|
||||
message: string,
|
||||
details: Record<string, any>,
|
||||
ipAddress?: string,
|
||||
domain?: string,
|
||||
success?: boolean
|
||||
): void {
|
||||
// Map security log level to system log level
|
||||
const logLevel: LogLevel = level === SecurityLogLevel.DEBUG ? 'debug' :
|
||||
level === SecurityLogLevel.INFO ? 'info' :
|
||||
level === SecurityLogLevel.WARN ? 'warn' : 'error';
|
||||
|
||||
// Log the security event
|
||||
this.log(logLevel, message, {
|
||||
component: 'smtp-security',
|
||||
eventType: type,
|
||||
success,
|
||||
ipAddress,
|
||||
domain,
|
||||
...details
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default instance for backward compatibility
|
||||
*/
|
||||
export const smtpLogger = SmtpLogger;
|
194
ts/mail/delivery/smtp/utils/validation.ts
Normal file
194
ts/mail/delivery/smtp/utils/validation.ts
Normal file
@ -0,0 +1,194 @@
|
||||
/**
|
||||
* SMTP Validation Utilities
|
||||
* Provides validation functions for SMTP server
|
||||
*/
|
||||
|
||||
import { SmtpState } from '../../interfaces.js';
|
||||
import { SMTP_PATTERNS } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Validates an email address
|
||||
* @param email - Email address to validate
|
||||
* @returns Whether the email address is valid
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SMTP_PATTERNS.EMAIL.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the MAIL FROM command syntax
|
||||
* @param args - Arguments string from the MAIL FROM command
|
||||
* @returns Object with validation result and extracted data
|
||||
*/
|
||||
export function validateMailFrom(args: string): {
|
||||
isValid: boolean;
|
||||
address?: string;
|
||||
params?: Record<string, string>;
|
||||
errorMessage?: string;
|
||||
} {
|
||||
if (!args) {
|
||||
return { isValid: false, errorMessage: 'Missing arguments' };
|
||||
}
|
||||
|
||||
const match = args.match(SMTP_PATTERNS.MAIL_FROM);
|
||||
if (!match) {
|
||||
return { isValid: false, errorMessage: 'Invalid syntax' };
|
||||
}
|
||||
|
||||
const [, address, paramsString] = match;
|
||||
|
||||
if (!isValidEmail(address)) {
|
||||
return { isValid: false, errorMessage: 'Invalid email address' };
|
||||
}
|
||||
|
||||
// Parse parameters if they exist
|
||||
const params: Record<string, string> = {};
|
||||
if (paramsString) {
|
||||
let paramMatch;
|
||||
const paramRegex = SMTP_PATTERNS.PARAM;
|
||||
paramRegex.lastIndex = 0; // Reset the regex
|
||||
|
||||
while ((paramMatch = paramRegex.exec(paramsString)) !== null) {
|
||||
const [, name, value = ''] = paramMatch;
|
||||
params[name.toUpperCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, address, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the RCPT TO command syntax
|
||||
* @param args - Arguments string from the RCPT TO command
|
||||
* @returns Object with validation result and extracted data
|
||||
*/
|
||||
export function validateRcptTo(args: string): {
|
||||
isValid: boolean;
|
||||
address?: string;
|
||||
params?: Record<string, string>;
|
||||
errorMessage?: string;
|
||||
} {
|
||||
if (!args) {
|
||||
return { isValid: false, errorMessage: 'Missing arguments' };
|
||||
}
|
||||
|
||||
const match = args.match(SMTP_PATTERNS.RCPT_TO);
|
||||
if (!match) {
|
||||
return { isValid: false, errorMessage: 'Invalid syntax' };
|
||||
}
|
||||
|
||||
const [, address, paramsString] = match;
|
||||
|
||||
if (!isValidEmail(address)) {
|
||||
return { isValid: false, errorMessage: 'Invalid email address' };
|
||||
}
|
||||
|
||||
// Parse parameters if they exist
|
||||
const params: Record<string, string> = {};
|
||||
if (paramsString) {
|
||||
let paramMatch;
|
||||
const paramRegex = SMTP_PATTERNS.PARAM;
|
||||
paramRegex.lastIndex = 0; // Reset the regex
|
||||
|
||||
while ((paramMatch = paramRegex.exec(paramsString)) !== null) {
|
||||
const [, name, value = ''] = paramMatch;
|
||||
params[name.toUpperCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, address, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the EHLO command syntax
|
||||
* @param args - Arguments string from the EHLO command
|
||||
* @returns Object with validation result and extracted data
|
||||
*/
|
||||
export function validateEhlo(args: string): {
|
||||
isValid: boolean;
|
||||
hostname?: string;
|
||||
errorMessage?: string;
|
||||
} {
|
||||
if (!args) {
|
||||
return { isValid: false, errorMessage: 'Missing domain name' };
|
||||
}
|
||||
|
||||
const match = args.match(SMTP_PATTERNS.EHLO);
|
||||
if (!match) {
|
||||
return { isValid: false, errorMessage: 'Invalid syntax' };
|
||||
}
|
||||
|
||||
const hostname = match[1];
|
||||
|
||||
// Check for invalid characters in hostname
|
||||
if (hostname.includes('@') || hostname.includes('<')) {
|
||||
return { isValid: false, errorMessage: 'Invalid domain name format' };
|
||||
}
|
||||
|
||||
return { isValid: true, hostname };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates command in the current SMTP state
|
||||
* @param command - SMTP command
|
||||
* @param currentState - Current SMTP state
|
||||
* @returns Whether the command is valid in the current state
|
||||
*/
|
||||
export function isValidCommandSequence(command: string, currentState: SmtpState): boolean {
|
||||
const upperCommand = command.toUpperCase();
|
||||
|
||||
// Some commands are valid in any state
|
||||
if (upperCommand === 'QUIT' || upperCommand === 'RSET' || upperCommand === 'NOOP' || upperCommand === 'HELP') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// State-specific validation
|
||||
switch (currentState) {
|
||||
case SmtpState.GREETING:
|
||||
return upperCommand === 'EHLO' || upperCommand === 'HELO';
|
||||
|
||||
case SmtpState.AFTER_EHLO:
|
||||
return upperCommand === 'MAIL' || upperCommand === 'STARTTLS' || upperCommand === 'AUTH';
|
||||
|
||||
case SmtpState.MAIL_FROM:
|
||||
case SmtpState.RCPT_TO:
|
||||
if (upperCommand === 'RCPT') {
|
||||
return true;
|
||||
}
|
||||
return currentState === SmtpState.RCPT_TO && upperCommand === 'DATA';
|
||||
|
||||
case SmtpState.DATA:
|
||||
// In DATA state, only the data content is accepted, not commands
|
||||
return false;
|
||||
|
||||
case SmtpState.DATA_RECEIVING:
|
||||
// In DATA_RECEIVING state, only the data content is accepted, not commands
|
||||
return false;
|
||||
|
||||
case SmtpState.FINISHED:
|
||||
// After data is received, only new transactions or session end
|
||||
return upperCommand === 'MAIL' || upperCommand === 'QUIT' || upperCommand === 'RSET';
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a hostname is valid according to RFC 5321
|
||||
* @param hostname - Hostname to validate
|
||||
* @returns Whether the hostname is valid
|
||||
*/
|
||||
export function isValidHostname(hostname: string): boolean {
|
||||
if (!hostname || typeof hostname !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic hostname validation
|
||||
// This is a simplified check, full RFC compliance would be more complex
|
||||
return /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/.test(hostname);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user