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:
2025-10-28 18:51:33 +00:00
parent 9cd15342e0
commit 6523c55516
14 changed files with 1328 additions and 429 deletions

View File

@@ -0,0 +1,298 @@
/**
* Connection Wrapper Utility
* Wraps Deno.Conn to provide Node.js net.Socket-compatible interface
* This allows the SMTP server to use Deno's native networking while maintaining
* compatibility with existing Socket-based code
*/
import { EventEmitter } from '../../../../plugins.ts';
/**
* Wraps a Deno.Conn or Deno.TlsConn to provide a Node.js Socket-compatible interface
*/
export class ConnectionWrapper extends EventEmitter {
private conn: Deno.Conn | Deno.TlsConn;
private _destroyed = false;
private _reading = false;
private _remoteAddr: Deno.NetAddr;
private _localAddr: Deno.NetAddr;
constructor(conn: Deno.Conn | Deno.TlsConn) {
super();
this.conn = conn;
this._remoteAddr = conn.remoteAddr as Deno.NetAddr;
this._localAddr = conn.localAddr as Deno.NetAddr;
// Start reading from the connection
this._reading = true;
this._startReading();
}
/**
* Get remote address (Node.js net.Socket compatible)
*/
get remoteAddress(): string {
return this._remoteAddr.hostname;
}
/**
* Get remote port (Node.js net.Socket compatible)
*/
get remotePort(): number {
return this._remoteAddr.port;
}
/**
* Get local address (Node.js net.Socket compatible)
*/
get localAddress(): string {
return this._localAddr.hostname;
}
/**
* Get local port (Node.js net.Socket compatible)
*/
get localPort(): number {
return this._localAddr.port;
}
/**
* Check if connection is destroyed
*/
get destroyed(): boolean {
return this._destroyed;
}
/**
* Check ready state (Node.js compatible)
*/
get readyState(): string {
if (this._destroyed) {
return 'closed';
}
return 'open';
}
/**
* Check if writable (Node.js compatible)
*/
get writable(): boolean {
return !this._destroyed;
}
/**
* Check if this is a secure (TLS) connection
*/
get encrypted(): boolean {
return 'handshake' in this.conn; // TlsConn has handshake property
}
/**
* Write data to the connection (Node.js net.Socket compatible)
*/
write(data: string | Uint8Array, encoding?: string | ((err?: Error) => void), callback?: (err?: Error) => void): boolean {
// Handle overloaded signatures (encoding is optional)
if (typeof encoding === 'function') {
callback = encoding;
encoding = undefined;
}
if (this._destroyed) {
const error = new Error('Connection is destroyed');
if (callback) {
setTimeout(() => callback(error), 0);
}
return false;
}
const bytes = typeof data === 'string'
? new TextEncoder().encode(data)
: data;
// Use a promise-based approach that Node.js compatibility expects
// Write happens async but we return true immediately (buffered)
this.conn.write(bytes)
.then(() => {
if (callback) {
callback();
}
})
.catch((err) => {
const error = err instanceof Error ? err : new Error(String(err));
if (callback) {
callback(error);
} else {
this.emit('error', error);
}
});
return true;
}
/**
* End the connection (Node.js net.Socket compatible)
*/
end(data?: string | Uint8Array, encoding?: string, callback?: () => void): void {
if (data) {
this.write(data, encoding, () => {
this.destroy();
if (callback) callback();
});
} else {
this.destroy();
if (callback) callback();
}
}
/**
* Destroy the connection (Node.js net.Socket compatible)
*/
destroy(error?: Error): void {
if (this._destroyed) {
return;
}
this._destroyed = true;
this._reading = false;
try {
this.conn.close();
} catch (closeError) {
// Ignore close errors
}
if (error) {
this.emit('error', error);
}
this.emit('close', !!error);
}
/**
* Set TCP_NODELAY option (Node.js net.Socket compatible)
*/
setNoDelay(noDelay: boolean = true): this {
try {
// @ts-ignore - Deno.Conn has setNoDelay
if (typeof this.conn.setNoDelay === 'function') {
// @ts-ignore
this.conn.setNoDelay(noDelay);
}
} catch {
// Ignore if not supported
}
return this;
}
/**
* Set keep-alive option (Node.js net.Socket compatible)
*/
setKeepAlive(enable: boolean = true, initialDelay?: number): this {
try {
// @ts-ignore - Deno.Conn has setKeepAlive
if (typeof this.conn.setKeepAlive === 'function') {
// @ts-ignore
this.conn.setKeepAlive(enable);
}
} catch {
// Ignore if not supported
}
return this;
}
/**
* Set timeout (Node.js net.Socket compatible)
*/
setTimeout(timeout: number, callback?: () => void): this {
// Deno doesn't have built-in socket timeout, but we can implement it
// For now, just accept the call without error (most timeout handling is done elsewhere)
if (callback) {
// If callback provided, we could set up a timer, but for now just ignore
// The SMTP server handles timeouts at a higher level
}
return this;
}
/**
* Pause reading from the connection
*/
pause(): this {
this._reading = false;
return this;
}
/**
* Resume reading from the connection
*/
resume(): this {
if (!this._reading && !this._destroyed) {
this._reading = true;
this._startReading();
}
return this;
}
/**
* Get the underlying Deno.Conn
*/
getDenoConn(): Deno.Conn | Deno.TlsConn {
return this.conn;
}
/**
* Replace the underlying connection (for STARTTLS upgrade)
*/
replaceConnection(newConn: Deno.TlsConn): void {
this.conn = newConn;
this._remoteAddr = newConn.remoteAddr as Deno.NetAddr;
this._localAddr = newConn.localAddr as Deno.NetAddr;
// Restart reading from the new TLS connection
if (!this._destroyed) {
this._reading = true;
this._startReading();
}
}
/**
* Internal method to read data from the connection
*/
private async _startReading(): Promise<void> {
if (!this._reading || this._destroyed) {
return;
}
try {
const buffer = new Uint8Array(4096);
while (this._reading && !this._destroyed) {
const n = await this.conn.read(buffer);
if (n === null) {
// EOF
this._destroyed = true;
this.emit('end');
this.emit('close', false);
break;
}
const data = buffer.subarray(0, n);
this.emit('data', data);
}
} catch (error) {
if (!this._destroyed) {
this._destroyed = true;
this.emit('error', error instanceof Error ? error : new Error(String(error)));
this.emit('close', true);
}
}
}
/**
* Remove all listeners (cleanup helper)
*/
removeAllListeners(event?: string): this {
super.removeAllListeners(event);
return this;
}
}