feat: Implement Deno-native STARTTLS handler and connection wrapper
- Refactored STARTTLS implementation to use Deno's native TLS via Deno.startTls(). - Introduced ConnectionWrapper to provide a Node.js net.Socket-compatible interface for Deno.Conn and Deno.TlsConn. - Updated TlsHandler to utilize the new STARTTLS implementation. - Added comprehensive SMTP authentication tests for PLAIN and LOGIN mechanisms. - Implemented rate limiting tests for SMTP server connections and commands. - Enhanced error handling and logging throughout the STARTTLS and connection upgrade processes.
This commit is contained in:
@@ -18,6 +18,7 @@ import { mergeWithDefaults } from './utils/helpers.ts';
|
||||
import { SmtpLogger } from './utils/logging.ts';
|
||||
import { adaptiveLogger } from './utils/adaptive-logging.ts';
|
||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.ts';
|
||||
import { ConnectionWrapper } from './utils/connection-wrapper.ts';
|
||||
|
||||
/**
|
||||
* SMTP Server implementation
|
||||
@@ -65,15 +66,20 @@ export class SmtpServer implements ISmtpServer {
|
||||
private options: ISmtpServerOptions;
|
||||
|
||||
/**
|
||||
* Net server instance
|
||||
* Deno listener instance (replaces Node.js net.Server)
|
||||
*/
|
||||
private server: plugins.net.Server | null = null;
|
||||
|
||||
private listener: Deno.Listener | null = null;
|
||||
|
||||
/**
|
||||
* Secure server instance
|
||||
* Accept loop promise for clean shutdown
|
||||
*/
|
||||
private acceptLoop: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Secure server instance (TLS/SSL)
|
||||
*/
|
||||
private secureServer: plugins.tls.Server | null = null;
|
||||
|
||||
|
||||
/**
|
||||
* Whether the server is running
|
||||
*/
|
||||
@@ -146,53 +152,19 @@ export class SmtpServer implements ISmtpServer {
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the server
|
||||
this.server = plugins.net.createServer((socket) => {
|
||||
// Check IP reputation before handling connection
|
||||
this.securityHandler.checkIpReputation(socket)
|
||||
.then(allowed => {
|
||||
if (allowed) {
|
||||
this.connectionManager.handleNewConnection(socket);
|
||||
} else {
|
||||
// Close connection if IP is not allowed
|
||||
socket.destroy();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
remoteAddress: socket.remoteAddress,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
// Allow connection on error (fail open)
|
||||
this.connectionManager.handleNewConnection(socket);
|
||||
});
|
||||
// Create Deno listener (native networking, replaces Node.js net.createServer)
|
||||
this.listener = Deno.listen({
|
||||
hostname: this.options.host || '0.0.0.0',
|
||||
port: this.options.port,
|
||||
transport: 'tcp',
|
||||
});
|
||||
|
||||
// Set up error handling with recovery
|
||||
this.server.on('error', (err) => {
|
||||
SmtpLogger.error(`SMTP server error: ${err.message}`, { error: err });
|
||||
|
||||
// Try to recover from specific errors
|
||||
if (this.shouldAttemptRecovery(err)) {
|
||||
this.attemptServerRecovery('standard', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Start listening
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!this.server) {
|
||||
reject(new Error('Server not initialized'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.server.listen(this.options.port, this.options.host, () => {
|
||||
SmtpLogger.info(`SMTP server listening on ${this.options.host || '0.0.0.0'}:${this.options.port}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.server.on('error', reject);
|
||||
|
||||
SmtpLogger.info(`SMTP server listening on ${this.options.host || '0.0.0.0'}:${this.options.port}`, {
|
||||
component: 'smtp-server',
|
||||
});
|
||||
|
||||
// Start accepting connections in the background
|
||||
this.acceptLoop = this.acceptConnections();
|
||||
|
||||
// Start secure server if configured
|
||||
if (this.options.securePort && this.tlsHandler.isTlsEnabled()) {
|
||||
@@ -305,6 +277,67 @@ export class SmtpServer implements ISmtpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept connections in a loop (Deno-native networking)
|
||||
*/
|
||||
private async acceptConnections(): Promise<void> {
|
||||
if (!this.listener) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const conn of this.listener) {
|
||||
if (!this.running) {
|
||||
conn.close();
|
||||
break;
|
||||
}
|
||||
|
||||
// Wrap Deno.Conn in ConnectionWrapper for Socket compatibility
|
||||
const wrapper = new ConnectionWrapper(conn);
|
||||
|
||||
// Handle connection in the background
|
||||
this.handleConnection(wrapper as any).catch(error => {
|
||||
SmtpLogger.error(`Error handling connection: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
component: 'smtp-server',
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.running) {
|
||||
SmtpLogger.error(`Error in accept loop: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
component: 'smtp-server',
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single connection
|
||||
*/
|
||||
private async handleConnection(socket: plugins.net.Socket): Promise<void> {
|
||||
try {
|
||||
// Check IP reputation before handling connection
|
||||
const allowed = await this.securityHandler.checkIpReputation(socket);
|
||||
|
||||
if (allowed) {
|
||||
this.connectionManager.handleNewConnection(socket);
|
||||
} else {
|
||||
// Close connection if IP is not allowed
|
||||
socket.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
remoteAddress: socket.remoteAddress,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
|
||||
// Allow connection on error (fail open)
|
||||
this.connectionManager.handleNewConnection(socket);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the SMTP server
|
||||
* @returns Promise that resolves when server is stopped
|
||||
@@ -331,24 +364,27 @@ export class SmtpServer implements ISmtpServer {
|
||||
|
||||
// Close servers
|
||||
const closePromises: Promise<void>[] = [];
|
||||
|
||||
if (this.server) {
|
||||
|
||||
// Close Deno listener
|
||||
if (this.listener) {
|
||||
try {
|
||||
this.listener.close();
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`Error closing listener: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
component: 'smtp-server',
|
||||
});
|
||||
}
|
||||
this.listener = null;
|
||||
}
|
||||
|
||||
// Wait for accept loop to finish
|
||||
if (this.acceptLoop) {
|
||||
closePromises.push(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (!this.server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.server.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
this.acceptLoop.catch(() => {
|
||||
// Accept loop may throw when listener is closed, ignore
|
||||
})
|
||||
);
|
||||
this.acceptLoop = null;
|
||||
}
|
||||
|
||||
if (this.secureServer) {
|
||||
@@ -381,7 +417,6 @@ export class SmtpServer implements ISmtpServer {
|
||||
})
|
||||
]);
|
||||
|
||||
this.server = null;
|
||||
this.secureServer = null;
|
||||
this.running = false;
|
||||
|
||||
@@ -536,30 +571,25 @@ export class SmtpServer implements ISmtpServer {
|
||||
try {
|
||||
// Determine which server to restart
|
||||
const isStandardServer = serverType === 'standard';
|
||||
|
||||
|
||||
// Close the affected server
|
||||
if (isStandardServer && this.server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.server) {
|
||||
resolve();
|
||||
return;
|
||||
if (isStandardServer && this.listener) {
|
||||
try {
|
||||
this.listener.close();
|
||||
} catch (error) {
|
||||
SmtpLogger.warn(`Error during listener close in recovery: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
this.listener = null;
|
||||
|
||||
// Wait for accept loop to finish
|
||||
if (this.acceptLoop) {
|
||||
try {
|
||||
await this.acceptLoop;
|
||||
} catch {
|
||||
// Ignore errors from accept loop
|
||||
}
|
||||
|
||||
// First try a clean shutdown
|
||||
this.server.close((err) => {
|
||||
if (err) {
|
||||
SmtpLogger.warn(`Error during server close in recovery: ${err.message}`);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Set a timeout to force close
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
this.server = null;
|
||||
this.acceptLoop = null;
|
||||
}
|
||||
} else if (!isStandardServer && this.secureServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.secureServer) {
|
||||
@@ -593,57 +623,22 @@ export class SmtpServer implements ISmtpServer {
|
||||
|
||||
// Restart the affected server
|
||||
if (isStandardServer) {
|
||||
// Create and start the standard server
|
||||
this.server = plugins.net.createServer((socket) => {
|
||||
// Check IP reputation before handling connection
|
||||
this.securityHandler.checkIpReputation(socket)
|
||||
.then(allowed => {
|
||||
if (allowed) {
|
||||
this.connectionManager.handleNewConnection(socket);
|
||||
} else {
|
||||
// Close connection if IP is not allowed
|
||||
socket.destroy();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
remoteAddress: socket.remoteAddress,
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
});
|
||||
|
||||
// Allow connection on error (fail open)
|
||||
this.connectionManager.handleNewConnection(socket);
|
||||
});
|
||||
});
|
||||
|
||||
// Set up error handling with recovery
|
||||
this.server.on('error', (err) => {
|
||||
SmtpLogger.error(`SMTP server error after recovery: ${err.message}`, { error: err });
|
||||
|
||||
// Try to recover again if needed
|
||||
if (this.shouldAttemptRecovery(err)) {
|
||||
this.attemptServerRecovery('standard', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Start listening again
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!this.server) {
|
||||
reject(new Error('Server not initialized during recovery'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.server.listen(this.options.port, this.options.host, () => {
|
||||
SmtpLogger.info(`SMTP server recovered and listening on ${this.options.host || '0.0.0.0'}:${this.options.port}`);
|
||||
resolve();
|
||||
try {
|
||||
// Create Deno listener for recovery
|
||||
this.listener = Deno.listen({
|
||||
hostname: this.options.host || '0.0.0.0',
|
||||
port: this.options.port,
|
||||
transport: 'tcp',
|
||||
});
|
||||
|
||||
// Only use error event for startup issues during recovery
|
||||
this.server.once('error', (err) => {
|
||||
SmtpLogger.error(`Failed to restart server during recovery: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
SmtpLogger.info(`SMTP server recovered and listening on ${this.options.host || '0.0.0.0'}:${this.options.port}`);
|
||||
|
||||
// Start accepting connections again
|
||||
this.acceptLoop = this.acceptConnections();
|
||||
} catch (listenError) {
|
||||
SmtpLogger.error(`Failed to restart server during recovery: ${listenError instanceof Error ? listenError.message : String(listenError)}`);
|
||||
throw listenError;
|
||||
}
|
||||
} else if (this.options.securePort && this.tlsHandler.isTlsEnabled()) {
|
||||
// Try to recreate the secure server
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user