Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f601859f8b | |||
| eb2643de93 |
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.3.0 - feat(mailer-smtp)
|
||||||
|
add in-process security pipeline for SMTP delivery (DKIM/SPF/DMARC, content scanning, IP reputation)
|
||||||
|
|
||||||
|
- Integrate mailer_security verification (DKIM/SPF/DMARC) and IP reputation checks into the Rust SMTP server; run concurrently and wrapped with a 30s timeout.
|
||||||
|
- Add MIME parsing using mailparse and an extract_mime_parts helper to extract subject, text/html bodies and attachment filenames for content scanning.
|
||||||
|
- Wire MessageAuthenticator and TokioResolver into server and connection startup; pass them into the delivery pipeline and connection handlers.
|
||||||
|
- Run content scanning (mailer_security::content_scanner), combine results (dkim/spf/dmarc, contentScan, ipReputation) into a JSON object and attach as security_results on EmailReceived events.
|
||||||
|
- Update Rust crates (Cargo.toml/Cargo.lock) to include mailparse and resolver usage and add serde::Deserialize where required; add unit tests for MIME extraction.
|
||||||
|
- Remove the TypeScript SMTP server implementation and many TS tests; replace test helper (server.loader.ts) with a stub that points tests to use the Rust SMTP server and provide small utilities (getAvailablePort/isPortFree).
|
||||||
|
|
||||||
## 2026-02-10 - 2.2.1 - fix(readme)
|
## 2026-02-10 - 2.2.1 - fix(readme)
|
||||||
Clarify Rust-powered architecture and mandatory Rust bridge; expand README with Rust workspace details and project structure updates
|
Clarify Rust-powered architecture and mandatory Rust bridge; expand README with Rust workspace details and project structure updates
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartmta',
|
name: '@push.rocks/smartmta',
|
||||||
version: '2.1.0',
|
version: '2.2.1',
|
||||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=
|
||||||
3
dist_ts/mail/delivery/index.d.ts
vendored
3
dist_ts/mail/delivery/index.d.ts
vendored
@@ -8,5 +8,4 @@ export type { IRateLimitConfig } from './classes.ratelimiter.js';
|
|||||||
export * from './classes.unified.rate.limiter.js';
|
export * from './classes.unified.rate.limiter.js';
|
||||||
export * from './classes.mta.config.js';
|
export * from './classes.mta.config.js';
|
||||||
import * as smtpClientMod from './smtpclient/index.js';
|
import * as smtpClientMod from './smtpclient/index.js';
|
||||||
import * as smtpServerMod from './smtpserver/index.js';
|
export { smtpClientMod };
|
||||||
export { smtpClientMod, smtpServerMod };
|
|
||||||
|
|||||||
@@ -13,6 +13,5 @@ export * from './classes.unified.rate.limiter.js';
|
|||||||
export * from './classes.mta.config.js';
|
export * from './classes.mta.config.js';
|
||||||
// Import and export SMTP modules as namespaces to avoid conflicts
|
// Import and export SMTP modules as namespaces to avoid conflicts
|
||||||
import * as smtpClientMod from './smtpclient/index.js';
|
import * as smtpClientMod from './smtpclient/index.js';
|
||||||
import * as smtpServerMod from './smtpserver/index.js';
|
export { smtpClientMod };
|
||||||
export { smtpClientMod, smtpServerMod };
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDRCQUE0QjtBQUM1QixjQUFjLDJCQUEyQixDQUFDO0FBQzFDLGNBQWMsNkJBQTZCLENBQUM7QUFDNUMsY0FBYyw4QkFBOEIsQ0FBQztBQUU3Qyx1Q0FBdUM7QUFDdkMsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBQ3pELE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSw4QkFBOEIsQ0FBQztBQUU5RCw2Q0FBNkM7QUFDN0MsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBR3ZELHVCQUF1QjtBQUN2QixjQUFjLG1DQUFtQyxDQUFDO0FBRWxELGdDQUFnQztBQUNoQyxjQUFjLHlCQUF5QixDQUFDO0FBRXhDLGtFQUFrRTtBQUNsRSxPQUFPLEtBQUssYUFBYSxNQUFNLHVCQUF1QixDQUFDO0FBRXZELE9BQU8sRUFBRSxhQUFhLEVBQUUsQ0FBQyJ9
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDRCQUE0QjtBQUM1QixjQUFjLDJCQUEyQixDQUFDO0FBQzFDLGNBQWMsNkJBQTZCLENBQUM7QUFDNUMsY0FBYyw4QkFBOEIsQ0FBQztBQUU3Qyx1Q0FBdUM7QUFDdkMsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBQ3pELE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSw4QkFBOEIsQ0FBQztBQUU5RCw2Q0FBNkM7QUFDN0MsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBR3ZELHVCQUF1QjtBQUN2QixjQUFjLG1DQUFtQyxDQUFDO0FBRWxELGdDQUFnQztBQUNoQyxjQUFjLHlCQUF5QixDQUFDO0FBRXhDLGtFQUFrRTtBQUNsRSxPQUFPLEtBQUssYUFBYSxNQUFNLHVCQUF1QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxhQUFhLE1BQU0sdUJBQXVCLENBQUM7QUFFdkQsT0FBTyxFQUFFLGFBQWEsRUFBRSxhQUFhLEVBQUUsQ0FBQyJ9
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Certificate Utilities for SMTP Server
|
|
||||||
* Provides utilities for managing TLS certificates
|
|
||||||
*/
|
|
||||||
import * as tls from 'tls';
|
|
||||||
/**
|
|
||||||
* Certificate data
|
|
||||||
*/
|
|
||||||
export interface ICertificateData {
|
|
||||||
key: Buffer;
|
|
||||||
cert: Buffer;
|
|
||||||
ca?: Buffer;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Load certificates from PEM format strings
|
|
||||||
* @param options - Certificate options
|
|
||||||
* @returns Certificate data with Buffer format
|
|
||||||
*/
|
|
||||||
export declare function loadCertificatesFromString(options: {
|
|
||||||
key: string | Buffer;
|
|
||||||
cert: string | Buffer;
|
|
||||||
ca?: string | Buffer;
|
|
||||||
}): ICertificateData;
|
|
||||||
/**
|
|
||||||
* Load certificates from files
|
|
||||||
* @param options - Certificate file paths
|
|
||||||
* @returns Certificate data with Buffer format
|
|
||||||
*/
|
|
||||||
export declare function loadCertificatesFromFiles(options: {
|
|
||||||
keyPath: string;
|
|
||||||
certPath: string;
|
|
||||||
caPath?: string;
|
|
||||||
}): ICertificateData;
|
|
||||||
/**
|
|
||||||
* Generate self-signed certificates for testing
|
|
||||||
* @returns Certificate data with Buffer format
|
|
||||||
*/
|
|
||||||
export declare function generateSelfSignedCertificates(): ICertificateData;
|
|
||||||
/**
|
|
||||||
* Create TLS options for secure server or STARTTLS
|
|
||||||
* @param certificates - Certificate data
|
|
||||||
* @param isServer - Whether this is for server (true) or client (false)
|
|
||||||
* @returns TLS options
|
|
||||||
*/
|
|
||||||
export declare function createTlsOptions(certificates: ICertificateData, isServer?: boolean): tls.TlsOptions;
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,156 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Command Handler
|
|
||||||
* Responsible for parsing and handling SMTP commands
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { ISmtpSession } from './interfaces.js';
|
|
||||||
import type { ICommandHandler, ISmtpServer } from './interfaces.js';
|
|
||||||
import { SmtpCommand } from './constants.js';
|
|
||||||
/**
|
|
||||||
* Handles SMTP commands and responses
|
|
||||||
*/
|
|
||||||
export declare class CommandHandler implements ICommandHandler {
|
|
||||||
/**
|
|
||||||
* Reference to the SMTP server instance
|
|
||||||
*/
|
|
||||||
private smtpServer;
|
|
||||||
/**
|
|
||||||
* Creates a new command handler
|
|
||||||
* @param smtpServer - SMTP server instance
|
|
||||||
*/
|
|
||||||
constructor(smtpServer: ISmtpServer);
|
|
||||||
/**
|
|
||||||
* Process a command from the client
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param commandLine - Command line from client
|
|
||||||
*/
|
|
||||||
processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Send a response to the client
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param response - Response to send
|
|
||||||
*/
|
|
||||||
sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void;
|
|
||||||
/**
|
|
||||||
* Check if a socket error is potentially recoverable
|
|
||||||
* @param error - The error that occurred
|
|
||||||
* @returns Whether the error is potentially recoverable
|
|
||||||
*/
|
|
||||||
private isRecoverableSocketError;
|
|
||||||
/**
|
|
||||||
* Handle recoverable socket errors with retry logic
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param error - The error that occurred
|
|
||||||
* @param response - The response that failed to send
|
|
||||||
*/
|
|
||||||
private handleSocketError;
|
|
||||||
/**
|
|
||||||
* Handle EHLO command
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param clientHostname - Client hostname from EHLO command
|
|
||||||
*/
|
|
||||||
handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void;
|
|
||||||
/**
|
|
||||||
* Handle MAIL FROM command
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments
|
|
||||||
*/
|
|
||||||
handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void;
|
|
||||||
/**
|
|
||||||
* Handle RCPT TO command
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments
|
|
||||||
*/
|
|
||||||
handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void;
|
|
||||||
/**
|
|
||||||
* Handle DATA command
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Handle RSET command
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Handle NOOP command
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleNoop(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Handle QUIT command
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket, args?: string): void;
|
|
||||||
/**
|
|
||||||
* Handle AUTH command
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments
|
|
||||||
*/
|
|
||||||
private handleAuth;
|
|
||||||
/**
|
|
||||||
* Handle AUTH PLAIN authentication
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param session - Session
|
|
||||||
* @param initialResponse - Optional initial response
|
|
||||||
*/
|
|
||||||
private handleAuthPlain;
|
|
||||||
/**
|
|
||||||
* Handle AUTH LOGIN authentication
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param session - Session
|
|
||||||
* @param initialResponse - Optional initial response
|
|
||||||
*/
|
|
||||||
private handleAuthLogin;
|
|
||||||
/**
|
|
||||||
* Handle AUTH LOGIN response
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param session - Session
|
|
||||||
* @param response - Response from client
|
|
||||||
*/
|
|
||||||
private handleAuthLoginResponse;
|
|
||||||
/**
|
|
||||||
* Handle HELP command
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments
|
|
||||||
*/
|
|
||||||
private handleHelp;
|
|
||||||
/**
|
|
||||||
* Handle VRFY command (Verify user/mailbox)
|
|
||||||
* RFC 5321 Section 3.5.1: Server MAY respond with 252 to avoid disclosing sensitive information
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments (username to verify)
|
|
||||||
*/
|
|
||||||
private handleVrfy;
|
|
||||||
/**
|
|
||||||
* Handle EXPN command (Expand mailing list)
|
|
||||||
* RFC 5321 Section 3.5.2: Server MAY disable this for security
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments (mailing list to expand)
|
|
||||||
*/
|
|
||||||
private handleExpn;
|
|
||||||
/**
|
|
||||||
* Reset session to after-EHLO state
|
|
||||||
* @param session - SMTP session to reset
|
|
||||||
*/
|
|
||||||
private resetSession;
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
/**
|
|
||||||
* Handle an SMTP command (interface requirement)
|
|
||||||
*/
|
|
||||||
handleCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, command: SmtpCommand, args: string, session: ISmtpSession): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Get supported commands for current session state (interface requirement)
|
|
||||||
*/
|
|
||||||
getSupportedCommands(session: ISmtpSession): SmtpCommand[];
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,159 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Connection Manager
|
|
||||||
* Responsible for managing socket connections to the SMTP server
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { IConnectionManager, ISmtpServer } from './interfaces.js';
|
|
||||||
/**
|
|
||||||
* Manager for SMTP connections
|
|
||||||
* Handles connection setup, event listeners, and lifecycle management
|
|
||||||
* Provides resource management, connection tracking, and monitoring
|
|
||||||
*/
|
|
||||||
export declare class ConnectionManager implements IConnectionManager {
|
|
||||||
/**
|
|
||||||
* Reference to the SMTP server instance
|
|
||||||
*/
|
|
||||||
private smtpServer;
|
|
||||||
/**
|
|
||||||
* Set of active socket connections
|
|
||||||
*/
|
|
||||||
private activeConnections;
|
|
||||||
/**
|
|
||||||
* Connection tracking for resource management
|
|
||||||
*/
|
|
||||||
private connectionStats;
|
|
||||||
/**
|
|
||||||
* Per-IP connection tracking for rate limiting
|
|
||||||
*/
|
|
||||||
private ipConnections;
|
|
||||||
/**
|
|
||||||
* Resource monitoring interval
|
|
||||||
*/
|
|
||||||
private resourceCheckInterval;
|
|
||||||
/**
|
|
||||||
* Track cleanup timers so we can clear them
|
|
||||||
*/
|
|
||||||
private cleanupTimers;
|
|
||||||
/**
|
|
||||||
* SMTP server options with enhanced resource controls
|
|
||||||
*/
|
|
||||||
private options;
|
|
||||||
/**
|
|
||||||
* Creates a new connection manager with enhanced resource management
|
|
||||||
* @param smtpServer - SMTP server instance
|
|
||||||
*/
|
|
||||||
constructor(smtpServer: ISmtpServer);
|
|
||||||
/**
|
|
||||||
* Start resource monitoring interval to check resource usage
|
|
||||||
*/
|
|
||||||
private startResourceMonitoring;
|
|
||||||
/**
|
|
||||||
* Monitor resource usage and log statistics
|
|
||||||
*/
|
|
||||||
private monitorResourceUsage;
|
|
||||||
/**
|
|
||||||
* Clean up expired IP rate limits and perform additional resource monitoring
|
|
||||||
*/
|
|
||||||
private cleanupIpRateLimits;
|
|
||||||
/**
|
|
||||||
* Validate and repair resource tracking to prevent leaks
|
|
||||||
*/
|
|
||||||
private validateResourceTracking;
|
|
||||||
/**
|
|
||||||
* Handle a new connection with resource management
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleNewConnection(socket: plugins.net.Socket): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Check if an IP has exceeded the rate limit
|
|
||||||
* @param ip - Client IP address
|
|
||||||
* @returns True if rate limited
|
|
||||||
*/
|
|
||||||
private isIPRateLimited;
|
|
||||||
/**
|
|
||||||
* Track a new connection from an IP
|
|
||||||
* @param ip - Client IP address
|
|
||||||
*/
|
|
||||||
private trackIPConnection;
|
|
||||||
/**
|
|
||||||
* Check if an IP has reached its connection limit
|
|
||||||
* @param ip - Client IP address
|
|
||||||
* @returns True if limit reached
|
|
||||||
*/
|
|
||||||
private hasReachedIPConnectionLimit;
|
|
||||||
/**
|
|
||||||
* Handle a new secure TLS connection with resource management
|
|
||||||
* @param socket - Client TLS socket
|
|
||||||
*/
|
|
||||||
handleNewSecureConnection(socket: plugins.tls.TLSSocket): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Set up event handlers for a socket with enhanced resource management
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Get the current connection count
|
|
||||||
* @returns Number of active connections
|
|
||||||
*/
|
|
||||||
getConnectionCount(): number;
|
|
||||||
/**
|
|
||||||
* Check if the server has reached the maximum number of connections
|
|
||||||
* @returns True if max connections reached
|
|
||||||
*/
|
|
||||||
hasReachedMaxConnections(): boolean;
|
|
||||||
/**
|
|
||||||
* Close all active connections
|
|
||||||
*/
|
|
||||||
closeAllConnections(): void;
|
|
||||||
/**
|
|
||||||
* Handle socket close event
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param hadError - Whether the socket was closed due to error
|
|
||||||
*/
|
|
||||||
private handleSocketClose;
|
|
||||||
/**
|
|
||||||
* Handle socket error event
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param error - Error object
|
|
||||||
*/
|
|
||||||
private handleSocketError;
|
|
||||||
/**
|
|
||||||
* Handle socket timeout event
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
private handleSocketTimeout;
|
|
||||||
/**
|
|
||||||
* Reject a connection
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param reason - Reason for rejection
|
|
||||||
*/
|
|
||||||
private rejectConnection;
|
|
||||||
/**
|
|
||||||
* Send greeting message
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
private sendGreeting;
|
|
||||||
/**
|
|
||||||
* Send service closing notification
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
private sendServiceClosing;
|
|
||||||
/**
|
|
||||||
* Send response to client
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param response - Response to send
|
|
||||||
*/
|
|
||||||
private sendResponse;
|
|
||||||
/**
|
|
||||||
* Handle a new connection (interface requirement)
|
|
||||||
*/
|
|
||||||
handleConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Check if accepting new connections (interface requirement)
|
|
||||||
*/
|
|
||||||
canAcceptConnection(): boolean;
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
130
dist_ts/mail/delivery/smtpserver/constants.d.ts
vendored
130
dist_ts/mail/delivery/smtpserver/constants.d.ts
vendored
@@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Constants
|
|
||||||
* This file contains all constants and enums used by the SMTP server
|
|
||||||
*/
|
|
||||||
import { SmtpState } from '../interfaces.js';
|
|
||||||
export { SmtpState };
|
|
||||||
/**
|
|
||||||
* SMTP Response Codes
|
|
||||||
* Based on RFC 5321 and common SMTP practice
|
|
||||||
*/
|
|
||||||
export declare enum SmtpResponseCode {
|
|
||||||
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
|
|
||||||
MORE_INFO_NEEDED = 334,// Server challenge for authentication
|
|
||||||
START_MAIL_INPUT = 354,// Start mail input; end with <CRLF>.<CRLF>
|
|
||||||
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
|
|
||||||
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
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP Command Types
|
|
||||||
*/
|
|
||||||
export declare 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 declare 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 declare enum SecurityLogLevel {
|
|
||||||
DEBUG = "debug",
|
|
||||||
INFO = "info",
|
|
||||||
WARN = "warn",
|
|
||||||
ERROR = "error"
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP Server Defaults
|
|
||||||
*/
|
|
||||||
export declare const SMTP_DEFAULTS: {
|
|
||||||
CONNECTION_TIMEOUT: number;
|
|
||||||
SOCKET_TIMEOUT: number;
|
|
||||||
DATA_TIMEOUT: number;
|
|
||||||
CLEANUP_INTERVAL: number;
|
|
||||||
MAX_CONNECTIONS: number;
|
|
||||||
MAX_RECIPIENTS: number;
|
|
||||||
MAX_MESSAGE_SIZE: number;
|
|
||||||
SMTP_PORT: number;
|
|
||||||
SUBMISSION_PORT: number;
|
|
||||||
SECURE_PORT: number;
|
|
||||||
HOSTNAME: string;
|
|
||||||
CRLF: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* SMTP Command Patterns
|
|
||||||
* Regular expressions for parsing SMTP commands
|
|
||||||
*/
|
|
||||||
export declare const SMTP_PATTERNS: {
|
|
||||||
EHLO: RegExp;
|
|
||||||
MAIL_FROM: RegExp;
|
|
||||||
RCPT_TO: RegExp;
|
|
||||||
PARAM: RegExp;
|
|
||||||
EMAIL: RegExp;
|
|
||||||
END_DATA: RegExp;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* SMTP Extension List
|
|
||||||
* These extensions are advertised in the EHLO response
|
|
||||||
*/
|
|
||||||
export declare const SMTP_EXTENSIONS: {
|
|
||||||
PIPELINING: string;
|
|
||||||
SIZE: string;
|
|
||||||
EIGHTBITMIME: string;
|
|
||||||
STARTTLS: string;
|
|
||||||
AUTH: string;
|
|
||||||
ENHANCEDSTATUSCODES: string;
|
|
||||||
HELP: string;
|
|
||||||
CHUNKING: string;
|
|
||||||
DSN: string;
|
|
||||||
formatExtension(name: string, parameter?: string | number): string;
|
|
||||||
};
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 var SmtpResponseCode;
|
|
||||||
(function (SmtpResponseCode) {
|
|
||||||
// Success codes (2xx)
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SUCCESS"] = 250] = "SUCCESS";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SYSTEM_STATUS"] = 211] = "SYSTEM_STATUS";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["HELP_MESSAGE"] = 214] = "HELP_MESSAGE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SERVICE_READY"] = 220] = "SERVICE_READY";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SERVICE_CLOSING"] = 221] = "SERVICE_CLOSING";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["AUTHENTICATION_SUCCESSFUL"] = 235] = "AUTHENTICATION_SUCCESSFUL";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["OK"] = 250] = "OK";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["FORWARD"] = 251] = "FORWARD";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["CANNOT_VRFY"] = 252] = "CANNOT_VRFY";
|
|
||||||
// Intermediate codes (3xx)
|
|
||||||
SmtpResponseCode[SmtpResponseCode["MORE_INFO_NEEDED"] = 334] = "MORE_INFO_NEEDED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["START_MAIL_INPUT"] = 354] = "START_MAIL_INPUT";
|
|
||||||
// Temporary error codes (4xx)
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SERVICE_NOT_AVAILABLE"] = 421] = "SERVICE_NOT_AVAILABLE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["MAILBOX_TEMPORARILY_UNAVAILABLE"] = 450] = "MAILBOX_TEMPORARILY_UNAVAILABLE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["LOCAL_ERROR"] = 451] = "LOCAL_ERROR";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["INSUFFICIENT_STORAGE"] = 452] = "INSUFFICIENT_STORAGE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["TLS_UNAVAILABLE_TEMP"] = 454] = "TLS_UNAVAILABLE_TEMP";
|
|
||||||
// Permanent error codes (5xx)
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SYNTAX_ERROR"] = 500] = "SYNTAX_ERROR";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SYNTAX_ERROR_PARAMETERS"] = 501] = "SYNTAX_ERROR_PARAMETERS";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["COMMAND_NOT_IMPLEMENTED"] = 502] = "COMMAND_NOT_IMPLEMENTED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["BAD_SEQUENCE"] = 503] = "BAD_SEQUENCE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["COMMAND_PARAMETER_NOT_IMPLEMENTED"] = 504] = "COMMAND_PARAMETER_NOT_IMPLEMENTED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["AUTH_REQUIRED"] = 530] = "AUTH_REQUIRED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["AUTH_FAILED"] = 535] = "AUTH_FAILED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["MAILBOX_UNAVAILABLE"] = 550] = "MAILBOX_UNAVAILABLE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["USER_NOT_LOCAL"] = 551] = "USER_NOT_LOCAL";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["EXCEEDED_STORAGE"] = 552] = "EXCEEDED_STORAGE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["MAILBOX_NAME_INVALID"] = 553] = "MAILBOX_NAME_INVALID";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["TRANSACTION_FAILED"] = 554] = "TRANSACTION_FAILED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["MAIL_RCPT_PARAMETERS_INVALID"] = 555] = "MAIL_RCPT_PARAMETERS_INVALID";
|
|
||||||
})(SmtpResponseCode || (SmtpResponseCode = {}));
|
|
||||||
/**
|
|
||||||
* SMTP Command Types
|
|
||||||
*/
|
|
||||||
export var SmtpCommand;
|
|
||||||
(function (SmtpCommand) {
|
|
||||||
SmtpCommand["HELO"] = "HELO";
|
|
||||||
SmtpCommand["EHLO"] = "EHLO";
|
|
||||||
SmtpCommand["MAIL_FROM"] = "MAIL";
|
|
||||||
SmtpCommand["RCPT_TO"] = "RCPT";
|
|
||||||
SmtpCommand["DATA"] = "DATA";
|
|
||||||
SmtpCommand["RSET"] = "RSET";
|
|
||||||
SmtpCommand["NOOP"] = "NOOP";
|
|
||||||
SmtpCommand["QUIT"] = "QUIT";
|
|
||||||
SmtpCommand["STARTTLS"] = "STARTTLS";
|
|
||||||
SmtpCommand["AUTH"] = "AUTH";
|
|
||||||
SmtpCommand["HELP"] = "HELP";
|
|
||||||
SmtpCommand["VRFY"] = "VRFY";
|
|
||||||
SmtpCommand["EXPN"] = "EXPN";
|
|
||||||
})(SmtpCommand || (SmtpCommand = {}));
|
|
||||||
/**
|
|
||||||
* Security log event types
|
|
||||||
*/
|
|
||||||
export var SecurityEventType;
|
|
||||||
(function (SecurityEventType) {
|
|
||||||
SecurityEventType["CONNECTION"] = "connection";
|
|
||||||
SecurityEventType["AUTHENTICATION"] = "authentication";
|
|
||||||
SecurityEventType["COMMAND"] = "command";
|
|
||||||
SecurityEventType["DATA"] = "data";
|
|
||||||
SecurityEventType["IP_REPUTATION"] = "ip_reputation";
|
|
||||||
SecurityEventType["TLS_NEGOTIATION"] = "tls_negotiation";
|
|
||||||
SecurityEventType["DKIM"] = "dkim";
|
|
||||||
SecurityEventType["SPF"] = "spf";
|
|
||||||
SecurityEventType["DMARC"] = "dmarc";
|
|
||||||
SecurityEventType["EMAIL_VALIDATION"] = "email_validation";
|
|
||||||
SecurityEventType["SPAM"] = "spam";
|
|
||||||
SecurityEventType["ACCESS_CONTROL"] = "access_control";
|
|
||||||
})(SecurityEventType || (SecurityEventType = {}));
|
|
||||||
/**
|
|
||||||
* Security log levels
|
|
||||||
*/
|
|
||||||
export var SecurityLogLevel;
|
|
||||||
(function (SecurityLogLevel) {
|
|
||||||
SecurityLogLevel["DEBUG"] = "debug";
|
|
||||||
SecurityLogLevel["INFO"] = "info";
|
|
||||||
SecurityLogLevel["WARN"] = "warn";
|
|
||||||
SecurityLogLevel["ERROR"] = "error";
|
|
||||||
})(SecurityLogLevel || (SecurityLogLevel = {}));
|
|
||||||
/**
|
|
||||||
* 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"
|
|
||||||
// Made very permissive to handle various client implementations
|
|
||||||
EHLO: /^(?:EHLO|HELO)\s+(.+)$/i,
|
|
||||||
// Match MAIL FROM command: "MAIL FROM:<user@example.com> [PARAM=VALUE]"
|
|
||||||
// Made more permissive with whitespace and parameter formats
|
|
||||||
MAIL_FROM: /^MAIL\s+FROM\s*:\s*<([^>]*)>((?:\s+[a-zA-Z0-9][a-zA-Z0-9\-]*(?:=[^\s]+)?)*)$/i,
|
|
||||||
// Match RCPT TO command: "RCPT TO:<user@example.com> [PARAM=VALUE]"
|
|
||||||
// Made more permissive with whitespace and parameter formats
|
|
||||||
RCPT_TO: /^RCPT\s+TO\s*:\s*<([^>]*)>((?:\s+[a-zA-Z0-9][a-zA-Z0-9\-]*(?:=[^\s]+)?)*)$/i,
|
|
||||||
// Match parameter format: "PARAM=VALUE"
|
|
||||||
PARAM: /\s+([A-Za-z0-9][A-Za-z0-9\-]*)(?:=([^\s]+))?/g,
|
|
||||||
// Match email address format - basic validation
|
|
||||||
// This pattern rejects common invalid formats while being permissive for edge cases
|
|
||||||
// Checks: no spaces, has @, has domain with dot, no double dots, proper domain format
|
|
||||||
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
||||||
// Match end of DATA marker: \r\n.\r\n or just .\r\n at the start of a line (to handle various client implementations)
|
|
||||||
END_DATA: /(\r\n\.\r\n$)|(\n\.\r\n$)|(\r\n\.\n$)|(\n\.\n$)|^\.(\r\n|\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, parameter) {
|
|
||||||
return parameter !== undefined ? `${name} ${parameter}` : name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29uc3RhbnRzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vdHMvbWFpbC9kZWxpdmVyeS9zbXRwc2VydmVyL2NvbnN0YW50cy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7O0dBR0c7QUFFSCxPQUFPLEVBQUUsU0FBUyxFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFFN0MseURBQXlEO0FBQ3pELE9BQU8sRUFBRSxTQUFTLEVBQUUsQ0FBQztBQUVyQjs7O0dBR0c7QUFDSCxNQUFNLENBQU4sSUFBWSxnQkFxQ1g7QUFyQ0QsV0FBWSxnQkFBZ0I7SUFDMUIsc0JBQXNCO0lBQ3RCLCtEQUFhLENBQUE7SUFDYiwyRUFBbUIsQ0FBQTtJQUNuQix5RUFBa0IsQ0FBQTtJQUNsQiwyRUFBbUIsQ0FBQTtJQUNuQiwrRUFBcUIsQ0FBQTtJQUNyQixtR0FBK0IsQ0FBQTtJQUMvQixxREFBUSxDQUFBO0lBQ1IsK0RBQWEsQ0FBQTtJQUNiLHVFQUFpQixDQUFBO0lBRWpCLDJCQUEyQjtJQUMzQixpRkFBc0IsQ0FBQTtJQUN0QixpRkFBc0IsQ0FBQTtJQUV0Qiw4QkFBOEI7SUFDOUIsMkZBQTJCLENBQUE7SUFDM0IsK0dBQXFDLENBQUE7SUFDckMsdUVBQWlCLENBQUE7SUFDakIseUZBQTBCLENBQUE7SUFDMUIseUZBQTBCLENBQUE7SUFFMUIsOEJBQThCO0lBQzlCLHlFQUFrQixDQUFBO0lBQ2xCLCtGQUE2QixDQUFBO0lBQzdCLCtGQUE2QixDQUFBO0lBQzdCLHlFQUFrQixDQUFBO0lBQ2xCLG1IQUF1QyxDQUFBO0lBQ3ZDLDJFQUFtQixDQUFBO0lBQ25CLHVFQUFpQixDQUFBO0lBQ2pCLHVGQUF5QixDQUFBO0lBQ3pCLDZFQUFvQixDQUFBO0lBQ3BCLGlGQUFzQixDQUFBO0lBQ3RCLHlGQUEwQixDQUFBO0lBQzFCLHFGQUF3QixDQUFBO0lBQ3hCLHlHQUFrQyxDQUFBO0FBQ3BDLENBQUMsRUFyQ1csZ0JBQWdCLEtBQWhCLGdCQUFnQixRQXFDM0I7QUFFRDs7R0FFRztBQUNILE1BQU0sQ0FBTixJQUFZLFdBY1g7QUFkRCxXQUFZLFdBQVc7SUFDckIsNEJBQWEsQ0FBQTtJQUNiLDRCQUFhLENBQUE7SUFDYixpQ0FBa0IsQ0FBQTtJQUNsQiwrQkFBZ0IsQ0FBQTtJQUNoQiw0QkFBYSxDQUFBO0lBQ2IsNEJBQWEsQ0FBQTtJQUNiLDRCQUFhLENBQUE7SUFDYiw0QkFBYSxDQUFBO0lBQ2Isb0NBQXFCLENBQUE7SUFDckIsNEJBQWEsQ0FBQTtJQUNiLDRCQUFhLENBQUE7SUFDYiw0QkFBYSxDQUFBO0lBQ2IsNEJBQWEsQ0FBQTtBQUNmLENBQUMsRUFkVyxXQUFXLEtBQVgsV0FBVyxRQWN0QjtBQUVEOztHQUVHO0FBQ0gsTUFBTSxDQUFOLElBQVksaUJBYVg7QUFiRCxXQUFZLGlCQUFpQjtJQUMzQiw4Q0FBeUIsQ0FBQTtJQUN6QixzREFBaUMsQ0FBQTtJQUNqQyx3Q0FBbUIsQ0FBQTtJQUNuQixrQ0FBYSxDQUFBO0lBQ2Isb0RBQStCLENBQUE7SUFDL0Isd0RBQW1DLENBQUE7SUFDbkMsa0NBQWEsQ0FBQTtJQUNiLGdDQUFXLENBQUE7SUFDWCxvQ0FBZSxDQUFBO0lBQ2YsMERBQXFDLENBQUE7SUFDckMsa0NBQWEsQ0FBQTtJQUNiLHNEQUFpQyxDQUFBO0FBQ25DLENBQUMsRUFiVyxpQkFBaUIsS0FBakIsaUJBQWlCLFFBYTVCO0FBRUQ7O0dBRUc7QUFDSCxNQUFNLENBQU4sSUFBWSxnQkFLWDtBQUxELFdBQVksZ0JBQWdCO0lBQzFCLG1DQUFlLENBQUE7SUFDZixpQ0FBYSxDQUFBO0lBQ2IsaUNBQWEsQ0FBQTtJQUNiLG1DQUFlLENBQUE7QUFDakIsQ0FBQyxFQUxXLGdCQUFnQixLQUFoQixnQkFBZ0IsUUFLM0I7QUFFRDs7R0FFRztBQUNILE1BQU0sQ0FBQyxNQUFNLGFBQWEsR0FBRztJQUMzQixtQ0FBbUM7SUFDbkMsa0JBQWtCLEVBQUUsS0FBSyxFQUFRLGFBQWE7SUFDOUMsY0FBYyxFQUFFLE1BQU0sRUFBVyxZQUFZO0lBQzdDLFlBQVksRUFBRSxLQUFLLEVBQWMsV0FBVztJQUM1QyxnQkFBZ0IsRUFBRSxJQUFJLEVBQVcsWUFBWTtJQUU3QyxpQkFBaUI7SUFDakIsZUFBZSxFQUFFLEdBQUc7SUFDcEIsY0FBYyxFQUFFLEdBQUc7SUFDbkIsZ0JBQWdCLEVBQUUsUUFBUSxFQUFPLE9BQU87SUFFeEMsZ0JBQWdCO0lBQ2hCLFNBQVMsRUFBRSxFQUFFO0lBQ2IsZUFBZSxFQUFFLEdBQUc7SUFDcEIsV0FBVyxFQUFFLEdBQUc7SUFFaEIsbUJBQW1CO0lBQ25CLFFBQVEsRUFBRSxtQkFBbUI7SUFFN0IsNkNBQTZDO0lBQzdDLElBQUksRUFBRSxNQUFNO0NBQ2IsQ0FBQztBQUVGOzs7R0FHRztBQUNILE1BQU0sQ0FBQyxNQUFNLGFBQWEsR0FBRztJQUMzQiw4Q0FBOEM7SUFDOUMsZ0VBQWdFO0lBQ2hFLElBQUksRUFBRSx5QkFBeUI7SUFFL0Isd0VBQXdFO0lBQ3hFLDZEQUE2RDtJQUM3RCxTQUFTLEVBQUUsK0VBQStFO0lBRTFGLG9FQUFvRTtJQUNwRSw2REFBNkQ7SUFDN0QsT0FBTyxFQUFFLDZFQUE2RTtJQUV0Rix3Q0FBd0M7SUFDeEMsS0FBSyxFQUFFLCtDQUErQztJQUV0RCxnREFBZ0Q7SUFDaEQsb0ZBQW9GO0lBQ3BGLHNGQUFzRjtJQUN0RixLQUFLLEVBQUUsNEJBQTRCO0lBRW5DLHNIQUFzSDtJQUN0SCxRQUFRLEVBQUUsK0RBQStEO0NBQzFFLENBQUM7QUFFRjs7O0dBR0c7QUFDSCxNQUFNLENBQUMsTUFBTSxlQUFlLEdBQUc7SUFDN0IsOEJBQThCO0lBQzlCLFVBQVUsRUFBRSxZQUFZO0lBQ3hCLElBQUksRUFBRSxNQUFNO0lBQ1osWUFBWSxFQUFFLFVBQVU7SUFFeEIsc0JBQXNCO0lBQ3RCLFFBQVEsRUFBRSxVQUFVO0lBQ3BCLElBQUksRUFBRSxNQUFNO0lBRVosd0JBQXdCO0lBQ3hCLG1CQUFtQixFQUFFLHFCQUFxQjtJQUMxQyxJQUFJLEVBQUUsTUFBTTtJQUNaLFFBQVEsRUFBRSxVQUFVO0lBQ3BCLEdBQUcsRUFBRSxLQUFLO0lBRVYsdUNBQXVDO0lBQ3ZDLGVBQWUsQ0FBQyxJQUFZLEVBQUUsU0FBMkI7UUFDdkQsT0FBTyxTQUFTLEtBQUssU0FBUyxDQUFDLENBQUMsQ0FBQyxHQUFHLElBQUksSUFBSSxTQUFTLEVBQUUsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDO0lBQ2pFLENBQUM7Q0FDRixDQUFDIn0=
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Creation Factory
|
|
||||||
* Provides a simple way to create a complete SMTP server
|
|
||||||
*/
|
|
||||||
import { SmtpServer } from './smtp-server.js';
|
|
||||||
import type { ISmtpServerOptions } from './interfaces.js';
|
|
||||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
|
||||||
/**
|
|
||||||
* Create a complete SMTP server with all components
|
|
||||||
* @param emailServer - Email server reference
|
|
||||||
* @param options - SMTP server options
|
|
||||||
* @returns Configured SMTP server instance
|
|
||||||
*/
|
|
||||||
export declare function createSmtpServer(emailServer: UnifiedEmailServer, options: ISmtpServerOptions): SmtpServer;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Creation Factory
|
|
||||||
* Provides a simple way to create a complete SMTP server
|
|
||||||
*/
|
|
||||||
import { SmtpServer } from './smtp-server.js';
|
|
||||||
import { SessionManager } from './session-manager.js';
|
|
||||||
import { ConnectionManager } from './connection-manager.js';
|
|
||||||
import { CommandHandler } from './command-handler.js';
|
|
||||||
import { DataHandler } from './data-handler.js';
|
|
||||||
import { TlsHandler } from './tls-handler.js';
|
|
||||||
import { SecurityHandler } from './security-handler.js';
|
|
||||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
|
||||||
/**
|
|
||||||
* Create a complete SMTP server with all components
|
|
||||||
* @param emailServer - Email server reference
|
|
||||||
* @param options - SMTP server options
|
|
||||||
* @returns Configured SMTP server instance
|
|
||||||
*/
|
|
||||||
export function createSmtpServer(emailServer, options) {
|
|
||||||
// First create the SMTP server instance
|
|
||||||
const smtpServer = new SmtpServer({
|
|
||||||
emailServer,
|
|
||||||
options
|
|
||||||
});
|
|
||||||
// Return the configured server
|
|
||||||
return smtpServer;
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY3JlYXRlLXNlcnZlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3RzL21haWwvZGVsaXZlcnkvc210cHNlcnZlci9jcmVhdGUtc2VydmVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7R0FHRztBQUVILE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUM5QyxPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDdEQsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFDNUQsT0FBTyxFQUFFLGNBQWMsRUFBRSxNQUFNLHNCQUFzQixDQUFDO0FBQ3RELE9BQU8sRUFBRSxXQUFXLEVBQUUsTUFBTSxtQkFBbUIsQ0FBQztBQUNoRCxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDOUMsT0FBTyxFQUFFLGVBQWUsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBRXhELE9BQU8sRUFBRSxrQkFBa0IsRUFBRSxNQUFNLCtDQUErQyxDQUFDO0FBRW5GOzs7OztHQUtHO0FBQ0gsTUFBTSxVQUFVLGdCQUFnQixDQUFDLFdBQStCLEVBQUUsT0FBMkI7SUFDM0Ysd0NBQXdDO0lBQ3hDLE1BQU0sVUFBVSxHQUFHLElBQUksVUFBVSxDQUFDO1FBQ2hDLFdBQVc7UUFDWCxPQUFPO0tBQ1IsQ0FBQyxDQUFDO0lBRUgsK0JBQStCO0lBQy9CLE9BQU8sVUFBVSxDQUFDO0FBQ3BCLENBQUMifQ==
|
|
||||||
123
dist_ts/mail/delivery/smtpserver/data-handler.d.ts
vendored
123
dist_ts/mail/delivery/smtpserver/data-handler.d.ts
vendored
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Data Handler
|
|
||||||
* Responsible for processing email data during and after DATA command
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { ISmtpSession, ISmtpTransactionResult } from './interfaces.js';
|
|
||||||
import type { IDataHandler, ISmtpServer } from './interfaces.js';
|
|
||||||
import { Email } from '../../core/classes.email.js';
|
|
||||||
/**
|
|
||||||
* Handles SMTP DATA command and email data processing
|
|
||||||
*/
|
|
||||||
export declare class DataHandler implements IDataHandler {
|
|
||||||
/**
|
|
||||||
* Reference to the SMTP server instance
|
|
||||||
*/
|
|
||||||
private smtpServer;
|
|
||||||
/**
|
|
||||||
* Creates a new data handler
|
|
||||||
* @param smtpServer - SMTP server instance
|
|
||||||
*/
|
|
||||||
constructor(smtpServer: ISmtpServer);
|
|
||||||
/**
|
|
||||||
* Process incoming email data
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param data - Data chunk
|
|
||||||
* @returns Promise that resolves when the data is processed
|
|
||||||
*/
|
|
||||||
processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Handle raw data chunks during DATA mode (optimized for large messages)
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param data - Raw data chunk
|
|
||||||
*/
|
|
||||||
handleDataReceived(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Process email data chunks efficiently for large messages
|
|
||||||
* @param chunks - Array of email data chunks
|
|
||||||
* @returns Processed email data string
|
|
||||||
*/
|
|
||||||
private processEmailDataStreaming;
|
|
||||||
/**
|
|
||||||
* Process a complete email
|
|
||||||
* @param rawData - Raw email data
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @returns Promise that resolves with the Email object
|
|
||||||
*/
|
|
||||||
processEmail(rawData: string, session: ISmtpSession): Promise<Email>;
|
|
||||||
/**
|
|
||||||
* Parse email from raw data
|
|
||||||
* @param rawData - Raw email data
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @returns Email object
|
|
||||||
*/
|
|
||||||
private parseEmailFromData;
|
|
||||||
/**
|
|
||||||
* Process a complete email (legacy method)
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @returns Promise that resolves with the result of the transaction
|
|
||||||
*/
|
|
||||||
processEmailLegacy(session: ISmtpSession): Promise<ISmtpTransactionResult>;
|
|
||||||
/**
|
|
||||||
* Save an email to disk
|
|
||||||
* @param session - SMTP session
|
|
||||||
*/
|
|
||||||
saveEmail(session: ISmtpSession): void;
|
|
||||||
/**
|
|
||||||
* Parse an email into an Email object
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @returns Promise that resolves with the parsed Email object
|
|
||||||
*/
|
|
||||||
parseEmail(session: ISmtpSession): Promise<Email>;
|
|
||||||
/**
|
|
||||||
* Basic fallback method for parsing emails
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @returns The parsed Email object
|
|
||||||
*/
|
|
||||||
private parseEmailBasic;
|
|
||||||
/**
|
|
||||||
* Handle multipart content parsing
|
|
||||||
* @param email - Email object to update
|
|
||||||
* @param bodyText - Body text to parse
|
|
||||||
* @param boundary - MIME boundary
|
|
||||||
*/
|
|
||||||
private handleMultipartContent;
|
|
||||||
/**
|
|
||||||
* Handle end of data marker received
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param session - SMTP session
|
|
||||||
*/
|
|
||||||
private handleEndOfData;
|
|
||||||
/**
|
|
||||||
* Reset session after email processing
|
|
||||||
* @param session - SMTP session
|
|
||||||
*/
|
|
||||||
private resetSession;
|
|
||||||
/**
|
|
||||||
* Send a response to the client
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param response - Response message
|
|
||||||
*/
|
|
||||||
private sendResponse;
|
|
||||||
/**
|
|
||||||
* Check if a socket error is potentially recoverable
|
|
||||||
* @param error - The error that occurred
|
|
||||||
* @returns Whether the error is potentially recoverable
|
|
||||||
*/
|
|
||||||
private isRecoverableSocketError;
|
|
||||||
/**
|
|
||||||
* Handle recoverable socket errors with retry logic
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param error - The error that occurred
|
|
||||||
* @param response - The response that failed to send
|
|
||||||
*/
|
|
||||||
private handleSocketError;
|
|
||||||
/**
|
|
||||||
* Handle email data (interface requirement)
|
|
||||||
*/
|
|
||||||
handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string, session: ISmtpSession): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
20
dist_ts/mail/delivery/smtpserver/index.d.ts
vendored
20
dist_ts/mail/delivery/smtpserver/index.d.ts
vendored
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Module Exports
|
|
||||||
* This file exports all components of the refactored SMTP server
|
|
||||||
*/
|
|
||||||
export * from './interfaces.js';
|
|
||||||
export { SmtpServer } from './smtp-server.js';
|
|
||||||
export { SessionManager } from './session-manager.js';
|
|
||||||
export { ConnectionManager } from './connection-manager.js';
|
|
||||||
export { CommandHandler } from './command-handler.js';
|
|
||||||
export { DataHandler } from './data-handler.js';
|
|
||||||
export { TlsHandler } from './tls-handler.js';
|
|
||||||
export { SecurityHandler } from './security-handler.js';
|
|
||||||
export * from './constants.js';
|
|
||||||
export { SmtpLogger } from './utils/logging.js';
|
|
||||||
export * from './utils/validation.js';
|
|
||||||
export * from './utils/helpers.js';
|
|
||||||
export * from './certificate-utils.js';
|
|
||||||
export * from './secure-server.js';
|
|
||||||
export * from './starttls-handler.js';
|
|
||||||
export { createSmtpServer } from './create-server.js';
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Module Exports
|
|
||||||
* This file exports all components of the refactored SMTP server
|
|
||||||
*/
|
|
||||||
// Export interfaces
|
|
||||||
export * from './interfaces.js';
|
|
||||||
// Export server classes
|
|
||||||
export { SmtpServer } from './smtp-server.js';
|
|
||||||
export { SessionManager } from './session-manager.js';
|
|
||||||
export { ConnectionManager } from './connection-manager.js';
|
|
||||||
export { CommandHandler } from './command-handler.js';
|
|
||||||
export { DataHandler } from './data-handler.js';
|
|
||||||
export { TlsHandler } from './tls-handler.js';
|
|
||||||
export { SecurityHandler } from './security-handler.js';
|
|
||||||
// Export constants
|
|
||||||
export * from './constants.js';
|
|
||||||
// Export utilities
|
|
||||||
export { SmtpLogger } from './utils/logging.js';
|
|
||||||
export * from './utils/validation.js';
|
|
||||||
export * from './utils/helpers.js';
|
|
||||||
// Export TLS and certificate utilities
|
|
||||||
export * from './certificate-utils.js';
|
|
||||||
export * from './secure-server.js';
|
|
||||||
export * from './starttls-handler.js';
|
|
||||||
// Factory function to create a complete SMTP server with default components
|
|
||||||
export { createSmtpServer } from './create-server.js';
|
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L3NtdHBzZXJ2ZXIvaW5kZXgudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7OztHQUdHO0FBRUgsb0JBQW9CO0FBQ3BCLGNBQWMsaUJBQWlCLENBQUM7QUFFaEMsd0JBQXdCO0FBQ3hCLE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUM5QyxPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDdEQsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFDNUQsT0FBTyxFQUFFLGNBQWMsRUFBRSxNQUFNLHNCQUFzQixDQUFDO0FBQ3RELE9BQU8sRUFBRSxXQUFXLEVBQUUsTUFBTSxtQkFBbUIsQ0FBQztBQUNoRCxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDOUMsT0FBTyxFQUFFLGVBQWUsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBRXhELG1CQUFtQjtBQUNuQixjQUFjLGdCQUFnQixDQUFDO0FBRS9CLG1CQUFtQjtBQUNuQixPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sb0JBQW9CLENBQUM7QUFDaEQsY0FBYyx1QkFBdUIsQ0FBQztBQUN0QyxjQUFjLG9CQUFvQixDQUFDO0FBRW5DLHVDQUF1QztBQUN2QyxjQUFjLHdCQUF3QixDQUFDO0FBQ3ZDLGNBQWMsb0JBQW9CLENBQUM7QUFDbkMsY0FBYyx1QkFBdUIsQ0FBQztBQUV0Qyw0RUFBNEU7QUFDNUUsT0FBTyxFQUFFLGdCQUFnQixFQUFFLE1BQU0sb0JBQW9CLENBQUMifQ==
|
|
||||||
530
dist_ts/mail/delivery/smtpserver/interfaces.d.ts
vendored
530
dist_ts/mail/delivery/smtpserver/interfaces.d.ts
vendored
@@ -1,530 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Interfaces
|
|
||||||
* Defines all the interfaces used by the 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 } from '../interfaces.js';
|
|
||||||
import { SmtpCommand } from './constants.js';
|
|
||||||
export { SmtpState, SmtpCommand };
|
|
||||||
export type { IEnvelopeRecipient } from '../interfaces.js';
|
|
||||||
/**
|
|
||||||
* Interface for components that need cleanup
|
|
||||||
*/
|
|
||||||
export interface IDestroyable {
|
|
||||||
/**
|
|
||||||
* Clean up all resources (timers, listeners, etc)
|
|
||||||
*/
|
|
||||||
destroy(): void | Promise<void>;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP authentication credentials
|
|
||||||
*/
|
|
||||||
export interface ISmtpAuth {
|
|
||||||
/**
|
|
||||||
* Username for authentication
|
|
||||||
*/
|
|
||||||
username: string;
|
|
||||||
/**
|
|
||||||
* Password for authentication
|
|
||||||
*/
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP envelope (sender and recipients)
|
|
||||||
*/
|
|
||||||
export interface ISmtpEnvelope {
|
|
||||||
/**
|
|
||||||
* Mail from address
|
|
||||||
*/
|
|
||||||
mailFrom: {
|
|
||||||
address: string;
|
|
||||||
args?: Record<string, string>;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Recipients list
|
|
||||||
*/
|
|
||||||
rcptTo: Array<{
|
|
||||||
address: string;
|
|
||||||
args?: Record<string, string>;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP session representing a client connection
|
|
||||||
*/
|
|
||||||
export interface ISmtpSession {
|
|
||||||
/**
|
|
||||||
* Unique session identifier
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* Current state of the SMTP session
|
|
||||||
*/
|
|
||||||
state: SmtpState;
|
|
||||||
/**
|
|
||||||
* Client's hostname from EHLO/HELO
|
|
||||||
*/
|
|
||||||
clientHostname: string | null;
|
|
||||||
/**
|
|
||||||
* Whether TLS is active for this session
|
|
||||||
*/
|
|
||||||
secure: boolean;
|
|
||||||
/**
|
|
||||||
* Authentication status
|
|
||||||
*/
|
|
||||||
authenticated: boolean;
|
|
||||||
/**
|
|
||||||
* Authentication username if authenticated
|
|
||||||
*/
|
|
||||||
username?: string;
|
|
||||||
/**
|
|
||||||
* Transaction envelope
|
|
||||||
*/
|
|
||||||
envelope: ISmtpEnvelope;
|
|
||||||
/**
|
|
||||||
* When the session was created
|
|
||||||
*/
|
|
||||||
createdAt: Date;
|
|
||||||
/**
|
|
||||||
* Last activity timestamp
|
|
||||||
*/
|
|
||||||
lastActivity: number;
|
|
||||||
/**
|
|
||||||
* Client's IP address
|
|
||||||
*/
|
|
||||||
remoteAddress: string;
|
|
||||||
/**
|
|
||||||
* Client's port
|
|
||||||
*/
|
|
||||||
remotePort: number;
|
|
||||||
/**
|
|
||||||
* Additional session data
|
|
||||||
*/
|
|
||||||
data?: Record<string, any>;
|
|
||||||
/**
|
|
||||||
* Message size if SIZE extension is used
|
|
||||||
*/
|
|
||||||
messageSize?: number;
|
|
||||||
/**
|
|
||||||
* Server capabilities advertised to client
|
|
||||||
*/
|
|
||||||
capabilities?: string[];
|
|
||||||
/**
|
|
||||||
* Buffer for incomplete data
|
|
||||||
*/
|
|
||||||
dataBuffer?: string;
|
|
||||||
/**
|
|
||||||
* Flag to track if we're currently receiving DATA
|
|
||||||
*/
|
|
||||||
receivingData?: boolean;
|
|
||||||
/**
|
|
||||||
* The raw email data being received
|
|
||||||
*/
|
|
||||||
rawData?: string;
|
|
||||||
/**
|
|
||||||
* Greeting sent to client
|
|
||||||
*/
|
|
||||||
greeting?: string;
|
|
||||||
/**
|
|
||||||
* Whether EHLO has been sent
|
|
||||||
*/
|
|
||||||
ehloSent?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether HELO has been sent
|
|
||||||
*/
|
|
||||||
heloSent?: boolean;
|
|
||||||
/**
|
|
||||||
* TLS options for this session
|
|
||||||
*/
|
|
||||||
tlsOptions?: any;
|
|
||||||
/**
|
|
||||||
* Whether TLS is being used
|
|
||||||
*/
|
|
||||||
useTLS?: boolean;
|
|
||||||
/**
|
|
||||||
* Mail from address for this transaction
|
|
||||||
*/
|
|
||||||
mailFrom?: string;
|
|
||||||
/**
|
|
||||||
* Recipients for this transaction
|
|
||||||
*/
|
|
||||||
rcptTo?: string[];
|
|
||||||
/**
|
|
||||||
* Email data being received
|
|
||||||
*/
|
|
||||||
emailData?: string;
|
|
||||||
/**
|
|
||||||
* Chunks of email data
|
|
||||||
*/
|
|
||||||
emailDataChunks?: string[];
|
|
||||||
/**
|
|
||||||
* Timeout ID for data reception
|
|
||||||
*/
|
|
||||||
dataTimeoutId?: NodeJS.Timeout;
|
|
||||||
/**
|
|
||||||
* Whether connection has ended
|
|
||||||
*/
|
|
||||||
connectionEnded?: boolean;
|
|
||||||
/**
|
|
||||||
* Size of email data being received
|
|
||||||
*/
|
|
||||||
emailDataSize?: number;
|
|
||||||
/**
|
|
||||||
* Processing mode for this session
|
|
||||||
*/
|
|
||||||
processingMode?: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Session manager interface
|
|
||||||
*/
|
|
||||||
export interface ISessionManager extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Create a new session for a socket
|
|
||||||
*/
|
|
||||||
createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure?: boolean): ISmtpSession;
|
|
||||||
/**
|
|
||||||
* Get session by socket
|
|
||||||
*/
|
|
||||||
getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined;
|
|
||||||
/**
|
|
||||||
* Update session state
|
|
||||||
*/
|
|
||||||
updateSessionState(session: ISmtpSession, newState: SmtpState): void;
|
|
||||||
/**
|
|
||||||
* Remove a session
|
|
||||||
*/
|
|
||||||
removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Clear all sessions
|
|
||||||
*/
|
|
||||||
clearAllSessions(): void;
|
|
||||||
/**
|
|
||||||
* Get all active sessions
|
|
||||||
*/
|
|
||||||
getAllSessions(): ISmtpSession[];
|
|
||||||
/**
|
|
||||||
* Get session count
|
|
||||||
*/
|
|
||||||
getSessionCount(): number;
|
|
||||||
/**
|
|
||||||
* Update last activity for a session
|
|
||||||
*/
|
|
||||||
updateLastActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Check for timed out sessions
|
|
||||||
*/
|
|
||||||
checkTimeouts(timeoutMs: number): ISmtpSession[];
|
|
||||||
/**
|
|
||||||
* Update session activity timestamp
|
|
||||||
*/
|
|
||||||
updateSessionActivity(session: ISmtpSession): void;
|
|
||||||
/**
|
|
||||||
* Replace socket in session (for TLS upgrade)
|
|
||||||
*/
|
|
||||||
replaceSocket(oldSocket: plugins.net.Socket | plugins.tls.TLSSocket, newSocket: plugins.net.Socket | plugins.tls.TLSSocket): boolean;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Connection manager interface
|
|
||||||
*/
|
|
||||||
export interface IConnectionManager extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Handle a new connection
|
|
||||||
*/
|
|
||||||
handleConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Close all active connections
|
|
||||||
*/
|
|
||||||
closeAllConnections(): void;
|
|
||||||
/**
|
|
||||||
* Get active connection count
|
|
||||||
*/
|
|
||||||
getConnectionCount(): number;
|
|
||||||
/**
|
|
||||||
* Check if accepting new connections
|
|
||||||
*/
|
|
||||||
canAcceptConnection(): boolean;
|
|
||||||
/**
|
|
||||||
* Handle new connection (legacy method name)
|
|
||||||
*/
|
|
||||||
handleNewConnection(socket: plugins.net.Socket): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Handle new secure connection (legacy method name)
|
|
||||||
*/
|
|
||||||
handleNewSecureConnection(socket: plugins.tls.TLSSocket): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Setup socket event handlers
|
|
||||||
*/
|
|
||||||
setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Command handler interface
|
|
||||||
*/
|
|
||||||
export interface ICommandHandler extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Handle an SMTP command
|
|
||||||
*/
|
|
||||||
handleCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, command: SmtpCommand, args: string, session: ISmtpSession): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Get supported commands for current session state
|
|
||||||
*/
|
|
||||||
getSupportedCommands(session: ISmtpSession): SmtpCommand[];
|
|
||||||
/**
|
|
||||||
* Process command (legacy method name)
|
|
||||||
*/
|
|
||||||
processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, command: string): Promise<void>;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Data handler interface
|
|
||||||
*/
|
|
||||||
export interface IDataHandler extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Handle email data
|
|
||||||
*/
|
|
||||||
handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string, session: ISmtpSession): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Process a complete email
|
|
||||||
*/
|
|
||||||
processEmail(rawData: string, session: ISmtpSession): Promise<Email>;
|
|
||||||
/**
|
|
||||||
* Handle data received (legacy method name)
|
|
||||||
*/
|
|
||||||
handleDataReceived(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Process email data (legacy method name)
|
|
||||||
*/
|
|
||||||
processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* TLS handler interface
|
|
||||||
*/
|
|
||||||
export interface ITlsHandler extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Handle STARTTLS command
|
|
||||||
*/
|
|
||||||
handleStartTls(socket: plugins.net.Socket, session: ISmtpSession): Promise<plugins.tls.TLSSocket | null>;
|
|
||||||
/**
|
|
||||||
* Check if TLS is available
|
|
||||||
*/
|
|
||||||
isTlsAvailable(): boolean;
|
|
||||||
/**
|
|
||||||
* Get TLS options
|
|
||||||
*/
|
|
||||||
getTlsOptions(): plugins.tls.TlsOptions;
|
|
||||||
/**
|
|
||||||
* Check if TLS is enabled
|
|
||||||
*/
|
|
||||||
isTlsEnabled(): boolean;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Security handler interface
|
|
||||||
*/
|
|
||||||
export interface ISecurityHandler extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Check IP reputation
|
|
||||||
*/
|
|
||||||
checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<boolean>;
|
|
||||||
/**
|
|
||||||
* Validate email address
|
|
||||||
*/
|
|
||||||
isValidEmail(email: string): boolean;
|
|
||||||
/**
|
|
||||||
* Authenticate user
|
|
||||||
*/
|
|
||||||
authenticate(auth: ISmtpAuth): Promise<boolean>;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP server options
|
|
||||||
*/
|
|
||||||
export interface ISmtpServerOptions {
|
|
||||||
/**
|
|
||||||
* Port to listen on
|
|
||||||
*/
|
|
||||||
port: number;
|
|
||||||
/**
|
|
||||||
* Hostname of the server
|
|
||||||
*/
|
|
||||||
hostname: string;
|
|
||||||
/**
|
|
||||||
* Host to bind to (optional, defaults to 0.0.0.0)
|
|
||||||
*/
|
|
||||||
host?: string;
|
|
||||||
/**
|
|
||||||
* Secure port for TLS connections
|
|
||||||
*/
|
|
||||||
securePort?: number;
|
|
||||||
/**
|
|
||||||
* TLS/SSL private key (PEM format)
|
|
||||||
*/
|
|
||||||
key?: string;
|
|
||||||
/**
|
|
||||||
* TLS/SSL certificate (PEM format)
|
|
||||||
*/
|
|
||||||
cert?: string;
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
auth?: {
|
|
||||||
/**
|
|
||||||
* Whether authentication is required
|
|
||||||
*/
|
|
||||||
required: boolean;
|
|
||||||
/**
|
|
||||||
* Allowed authentication methods
|
|
||||||
*/
|
|
||||||
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Socket timeout in milliseconds (default: 5 minutes / 300000ms)
|
|
||||||
*/
|
|
||||||
socketTimeout?: number;
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Result of SMTP transaction
|
|
||||||
*/
|
|
||||||
export interface ISmtpTransactionResult {
|
|
||||||
/**
|
|
||||||
* Whether the transaction was successful
|
|
||||||
*/
|
|
||||||
success: boolean;
|
|
||||||
/**
|
|
||||||
* Error message if failed
|
|
||||||
*/
|
|
||||||
error?: string;
|
|
||||||
/**
|
|
||||||
* Message ID if successful
|
|
||||||
*/
|
|
||||||
messageId?: string;
|
|
||||||
/**
|
|
||||||
* Resulting email if successful
|
|
||||||
*/
|
|
||||||
email?: Email;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP Server interface
|
|
||||||
*/
|
|
||||||
export interface ISmtpServer extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* 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 SMTP server
|
|
||||||
*/
|
|
||||||
export interface ISmtpServerConfig {
|
|
||||||
/**
|
|
||||||
* Email server instance
|
|
||||||
*/
|
|
||||||
emailServer: UnifiedEmailServer;
|
|
||||||
/**
|
|
||||||
* Server options
|
|
||||||
*/
|
|
||||||
options: ISmtpServerOptions;
|
|
||||||
/**
|
|
||||||
* Optional custom session manager
|
|
||||||
*/
|
|
||||||
sessionManager?: ISessionManager;
|
|
||||||
/**
|
|
||||||
* Optional custom connection manager
|
|
||||||
*/
|
|
||||||
connectionManager?: IConnectionManager;
|
|
||||||
/**
|
|
||||||
* Optional custom command handler
|
|
||||||
*/
|
|
||||||
commandHandler?: ICommandHandler;
|
|
||||||
/**
|
|
||||||
* Optional custom data handler
|
|
||||||
*/
|
|
||||||
dataHandler?: IDataHandler;
|
|
||||||
/**
|
|
||||||
* Optional custom TLS handler
|
|
||||||
*/
|
|
||||||
tlsHandler?: ITlsHandler;
|
|
||||||
/**
|
|
||||||
* Optional custom security handler
|
|
||||||
*/
|
|
||||||
securityHandler?: ISecurityHandler;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Interfaces
|
|
||||||
* Defines all the interfaces used by the SMTP server implementation
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
// Re-export types from other modules
|
|
||||||
import { SmtpState } from '../interfaces.js';
|
|
||||||
import { SmtpCommand } from './constants.js';
|
|
||||||
export { SmtpState, SmtpCommand };
|
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW50ZXJmYWNlcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3RzL21haWwvZGVsaXZlcnkvc210cHNlcnZlci9pbnRlcmZhY2VzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7R0FHRztBQUVILE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFJL0MsdUNBQXVDO0FBQ3ZDLE9BQU8sRUFBRSxTQUFTLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUM3QyxPQUFPLEVBQUUsV0FBVyxFQUFFLE1BQU0sZ0JBQWdCLENBQUM7QUFDN0MsT0FBTyxFQUFFLFNBQVMsRUFBRSxXQUFXLEVBQUUsQ0FBQyJ9
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Secure SMTP Server Utility Functions
|
|
||||||
* Provides helper functions for creating and managing secure TLS server
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
/**
|
|
||||||
* Create a secure TLS server for direct TLS connections
|
|
||||||
* @param options - TLS certificate options
|
|
||||||
* @returns A configured TLS server or undefined if TLS is not available
|
|
||||||
*/
|
|
||||||
export declare function createSecureTlsServer(options: {
|
|
||||||
key: string;
|
|
||||||
cert: string;
|
|
||||||
ca?: string;
|
|
||||||
}): plugins.tls.Server | undefined;
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* Secure SMTP Server Utility Functions
|
|
||||||
* Provides helper functions for creating and managing secure TLS server
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import { loadCertificatesFromString, generateSelfSignedCertificates, createTlsOptions } from './certificate-utils.js';
|
|
||||||
import { SmtpLogger } from './utils/logging.js';
|
|
||||||
/**
|
|
||||||
* Create a secure TLS server for direct TLS connections
|
|
||||||
* @param options - TLS certificate options
|
|
||||||
* @returns A configured TLS server or undefined if TLS is not available
|
|
||||||
*/
|
|
||||||
export function createSecureTlsServer(options) {
|
|
||||||
try {
|
|
||||||
// Log the creation attempt
|
|
||||||
SmtpLogger.info('Creating secure TLS server for direct connections');
|
|
||||||
// Load certificates from strings
|
|
||||||
let certificates;
|
|
||||||
try {
|
|
||||||
certificates = loadCertificatesFromString({
|
|
||||||
key: options.key,
|
|
||||||
cert: options.cert,
|
|
||||||
ca: options.ca
|
|
||||||
});
|
|
||||||
SmtpLogger.info('Successfully loaded TLS certificates for secure server');
|
|
||||||
}
|
|
||||||
catch (certificateError) {
|
|
||||||
SmtpLogger.warn(`Failed to load certificates, using self-signed: ${certificateError instanceof Error ? certificateError.message : String(certificateError)}`);
|
|
||||||
certificates = generateSelfSignedCertificates();
|
|
||||||
}
|
|
||||||
// Create server-side TLS options
|
|
||||||
const tlsOptions = createTlsOptions(certificates, true);
|
|
||||||
// Log details for debugging
|
|
||||||
SmtpLogger.debug('Creating secure server with options', {
|
|
||||||
certificates: {
|
|
||||||
keyLength: certificates.key.length,
|
|
||||||
certLength: certificates.cert.length,
|
|
||||||
caLength: certificates.ca ? certificates.ca.length : 0
|
|
||||||
},
|
|
||||||
tlsOptions: {
|
|
||||||
minVersion: tlsOptions.minVersion,
|
|
||||||
maxVersion: tlsOptions.maxVersion,
|
|
||||||
ciphers: tlsOptions.ciphers?.substring(0, 50) + '...' // Truncate long cipher list
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Create the TLS server
|
|
||||||
const server = new plugins.tls.Server(tlsOptions);
|
|
||||||
// Set up error handlers
|
|
||||||
server.on('error', (err) => {
|
|
||||||
SmtpLogger.error(`Secure server error: ${err.message}`, {
|
|
||||||
component: 'secure-server',
|
|
||||||
error: err,
|
|
||||||
stack: err.stack
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Log secure connections
|
|
||||||
server.on('secureConnection', (socket) => {
|
|
||||||
const protocol = socket.getProtocol();
|
|
||||||
const cipher = socket.getCipher();
|
|
||||||
SmtpLogger.info('New direct TLS connection established', {
|
|
||||||
component: 'secure-server',
|
|
||||||
remoteAddress: socket.remoteAddress,
|
|
||||||
remotePort: socket.remotePort,
|
|
||||||
protocol: protocol || 'unknown',
|
|
||||||
cipher: cipher?.name || 'unknown'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
SmtpLogger.error(`Failed to create secure TLS server: ${error instanceof Error ? error.message : String(error)}`, {
|
|
||||||
component: 'secure-server',
|
|
||||||
error: error instanceof Error ? error : new Error(String(error)),
|
|
||||||
stack: error instanceof Error ? error.stack : 'No stack trace available'
|
|
||||||
});
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2VjdXJlLXNlcnZlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3RzL21haWwvZGVsaXZlcnkvc210cHNlcnZlci9zZWN1cmUtc2VydmVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7R0FHRztBQUVILE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFDL0MsT0FBTyxFQUNMLDBCQUEwQixFQUMxQiw4QkFBOEIsRUFDOUIsZ0JBQWdCLEVBRWpCLE1BQU0sd0JBQXdCLENBQUM7QUFDaEMsT0FBTyxFQUFFLFVBQVUsRUFBRSxNQUFNLG9CQUFvQixDQUFDO0FBRWhEOzs7O0dBSUc7QUFDSCxNQUFNLFVBQVUscUJBQXFCLENBQUMsT0FJckM7SUFDQyxJQUFJLENBQUM7UUFDSCwyQkFBMkI7UUFDM0IsVUFBVSxDQUFDLElBQUksQ0FBQyxtREFBbUQsQ0FBQyxDQUFDO1FBRXJFLGlDQUFpQztRQUNqQyxJQUFJLFlBQThCLENBQUM7UUFDbkMsSUFBSSxDQUFDO1lBQ0gsWUFBWSxHQUFHLDBCQUEwQixDQUFDO2dCQUN4QyxHQUFHLEVBQUUsT0FBTyxDQUFDLEdBQUc7Z0JBQ2hCLElBQUksRUFBRSxPQUFPLENBQUMsSUFBSTtnQkFDbEIsRUFBRSxFQUFFLE9BQU8sQ0FBQyxFQUFFO2FBQ2YsQ0FBQyxDQUFDO1lBRUgsVUFBVSxDQUFDLElBQUksQ0FBQyx3REFBd0QsQ0FBQyxDQUFDO1FBQzVFLENBQUM7UUFBQyxPQUFPLGdCQUFnQixFQUFFLENBQUM7WUFDMUIsVUFBVSxDQUFDLElBQUksQ0FBQyxtREFBbUQsZ0JBQWdCLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxnQkFBZ0IsQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxnQkFBZ0IsQ0FBQyxFQUFFLENBQUMsQ0FBQztZQUM5SixZQUFZLEdBQUcsOEJBQThCLEVBQUUsQ0FBQztRQUNsRCxDQUFDO1FBRUQsaUNBQWlDO1FBQ2pDLE1BQU0sVUFBVSxHQUFHLGdCQUFnQixDQUFDLFlBQVksRUFBRSxJQUFJLENBQUMsQ0FBQztRQUV4RCw0QkFBNEI7UUFDNUIsVUFBVSxDQUFDLEtBQUssQ0FBQyxxQ0FBcUMsRUFBRTtZQUN0RCxZQUFZLEVBQUU7Z0JBQ1osU0FBUyxFQUFFLFlBQVksQ0FBQyxHQUFHLENBQUMsTUFBTTtnQkFDbEMsVUFBVSxFQUFFLFlBQVksQ0FBQyxJQUFJLENBQUMsTUFBTTtnQkFDcEMsUUFBUSxFQUFFLFlBQVksQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLFlBQVksQ0FBQyxFQUFFLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQyxDQUFDO2FBQ3ZEO1lBQ0QsVUFBVSxFQUFFO2dCQUNWLFVBQVUsRUFBRSxVQUFVLENBQUMsVUFBVTtnQkFDakMsVUFBVSxFQUFFLFVBQVUsQ0FBQyxVQUFVO2dCQUNqQyxPQUFPLEVBQUUsVUFBVSxDQUFDLE9BQU8sRUFBRSxTQUFTLENBQUMsQ0FBQyxFQUFFLEVBQUUsQ0FBQyxHQUFHLEtBQUssQ0FBQyw0QkFBNEI7YUFDbkY7U0FDRixDQUFDLENBQUM7UUFFSCx3QkFBd0I7UUFDeEIsTUFBTSxNQUFNLEdBQUcsSUFBSSxPQUFPLENBQUMsR0FBRyxDQUFDLE1BQU0sQ0FBQyxVQUFVLENBQUMsQ0FBQztRQUVsRCx3QkFBd0I7UUFDeEIsTUFBTSxDQUFDLEVBQUUsQ0FBQyxPQUFPLEVBQUUsQ0FBQyxHQUFHLEVBQUUsRUFBRTtZQUN6QixVQUFVLENBQUMsS0FBSyxDQUFDLHdCQUF3QixHQUFHLENBQUMsT0FBTyxFQUFFLEVBQUU7Z0JBQ3RELFNBQVMsRUFBRSxlQUFlO2dCQUMxQixLQUFLLEVBQUUsR0FBRztnQkFDVixLQUFLLEVBQUUsR0FBRyxDQUFDLEtBQUs7YUFDakIsQ0FBQyxDQUFDO1FBQ0wsQ0FBQyxDQUFDLENBQUM7UUFFSCx5QkFBeUI7UUFDekIsTUFBTSxDQUFDLEVBQUUsQ0FBQyxrQkFBa0IsRUFBRSxDQUFDLE1BQU0sRUFBRSxFQUFFO1lBQ3ZDLE1BQU0sUUFBUSxHQUFHLE1BQU0sQ0FBQyxXQUFXLEVBQUUsQ0FBQztZQUN0QyxNQUFNLE1BQU0sR0FBRyxNQUFNLENBQUMsU0FBUyxFQUFFLENBQUM7WUFFbEMsVUFBVSxDQUFDLElBQUksQ0FBQyx1Q0FBdUMsRUFBRTtnQkFDdkQsU0FBUyxFQUFFLGVBQWU7Z0JBQzFCLGFBQWEsRUFBRSxNQUFNLENBQUMsYUFBYTtnQkFDbkMsVUFBVSxFQUFFLE1BQU0sQ0FBQyxVQUFVO2dCQUM3QixRQUFRLEVBQUUsUUFBUSxJQUFJLFNBQVM7Z0JBQy9CLE1BQU0sRUFBRSxNQUFNLEVBQUUsSUFBSSxJQUFJLFNBQVM7YUFDbEMsQ0FBQyxDQUFDO1FBQ0wsQ0FBQyxDQUFDLENBQUM7UUFFSCxPQUFPLE1BQU0sQ0FBQztJQUNoQixDQUFDO0lBQUMsT0FBTyxLQUFLLEVBQUUsQ0FBQztRQUNmLFVBQVUsQ0FBQyxLQUFLLENBQUMsdUNBQXVDLEtBQUssWUFBWSxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsRUFBRSxFQUFFO1lBQ2hILFNBQVMsRUFBRSxlQUFlO1lBQzFCLEtBQUssRUFBRSxLQUFLLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLElBQUksS0FBSyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQztZQUNoRSxLQUFLLEVBQUUsS0FBSyxZQUFZLEtBQUssQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsMEJBQTBCO1NBQ3pFLENBQUMsQ0FBQztRQUVILE9BQU8sU0FBUyxDQUFDO0lBQ25CLENBQUM7QUFDSCxDQUFDIn0=
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Security Handler
|
|
||||||
* Responsible for security aspects including IP reputation checking,
|
|
||||||
* email validation, and authentication
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { ISmtpAuth } from './interfaces.js';
|
|
||||||
import type { ISecurityHandler, ISmtpServer } from './interfaces.js';
|
|
||||||
/**
|
|
||||||
* Handles security aspects for SMTP server
|
|
||||||
*/
|
|
||||||
export declare class SecurityHandler implements ISecurityHandler {
|
|
||||||
/**
|
|
||||||
* Reference to the SMTP server instance
|
|
||||||
*/
|
|
||||||
private smtpServer;
|
|
||||||
/**
|
|
||||||
* IP reputation checker service
|
|
||||||
*/
|
|
||||||
private ipReputationService;
|
|
||||||
/**
|
|
||||||
* Simple in-memory IP denylist
|
|
||||||
*/
|
|
||||||
private ipDenylist;
|
|
||||||
/**
|
|
||||||
* Cleanup interval timer
|
|
||||||
*/
|
|
||||||
private cleanupInterval;
|
|
||||||
/**
|
|
||||||
* Creates a new security handler
|
|
||||||
* @param smtpServer - SMTP server instance
|
|
||||||
*/
|
|
||||||
constructor(smtpServer: ISmtpServer);
|
|
||||||
/**
|
|
||||||
* Check IP reputation for a connection
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @returns Promise that resolves to true if IP is allowed, false if blocked
|
|
||||||
*/
|
|
||||||
checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<boolean>;
|
|
||||||
/**
|
|
||||||
* Validate an email address
|
|
||||||
* @param email - Email address to validate
|
|
||||||
* @returns Whether the email address is valid
|
|
||||||
*/
|
|
||||||
isValidEmail(email: string): boolean;
|
|
||||||
/**
|
|
||||||
* Validate authentication credentials
|
|
||||||
* @param auth - Authentication credentials
|
|
||||||
* @returns Promise that resolves to true if authenticated
|
|
||||||
*/
|
|
||||||
authenticate(auth: ISmtpAuth): Promise<boolean>;
|
|
||||||
/**
|
|
||||||
* Log a security event
|
|
||||||
* @param event - Event type
|
|
||||||
* @param level - Log level
|
|
||||||
* @param details - Event details
|
|
||||||
*/
|
|
||||||
logSecurityEvent(event: string, level: string, message: string, details: Record<string, any>): void;
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
/**
|
|
||||||
* Check if an IP is denylisted
|
|
||||||
* @param ip - IP address
|
|
||||||
* @returns Whether the IP is denylisted
|
|
||||||
*/
|
|
||||||
private isIpDenylisted;
|
|
||||||
/**
|
|
||||||
* Get the reason an IP was denylisted
|
|
||||||
* @param ip - IP address
|
|
||||||
* @returns Reason for denylisting or undefined if not denylisted
|
|
||||||
*/
|
|
||||||
private getDenylistReason;
|
|
||||||
/**
|
|
||||||
* Clean expired denylist entries
|
|
||||||
*/
|
|
||||||
private cleanExpiredDenylistEntries;
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,140 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Session Manager
|
|
||||||
* Responsible for creating, managing, and cleaning up SMTP sessions
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import { SmtpState } from './interfaces.js';
|
|
||||||
import type { ISmtpSession } from './interfaces.js';
|
|
||||||
import type { ISessionManager, ISessionEvents } from './interfaces.js';
|
|
||||||
/**
|
|
||||||
* Manager for SMTP sessions
|
|
||||||
* Handles session creation, tracking, timeout management, and cleanup
|
|
||||||
*/
|
|
||||||
export declare class SessionManager implements ISessionManager {
|
|
||||||
/**
|
|
||||||
* Map of socket ID to session
|
|
||||||
*/
|
|
||||||
private sessions;
|
|
||||||
/**
|
|
||||||
* Map of socket to socket ID
|
|
||||||
*/
|
|
||||||
private socketIds;
|
|
||||||
/**
|
|
||||||
* SMTP server options
|
|
||||||
*/
|
|
||||||
private options;
|
|
||||||
/**
|
|
||||||
* Event listeners
|
|
||||||
*/
|
|
||||||
private eventListeners;
|
|
||||||
/**
|
|
||||||
* Timer for cleanup interval
|
|
||||||
*/
|
|
||||||
private cleanupTimer;
|
|
||||||
/**
|
|
||||||
* Creates a new session manager
|
|
||||||
* @param options - Session manager options
|
|
||||||
*/
|
|
||||||
constructor(options?: {
|
|
||||||
socketTimeout?: number;
|
|
||||||
connectionTimeout?: number;
|
|
||||||
cleanupInterval?: number;
|
|
||||||
});
|
|
||||||
/**
|
|
||||||
* Creates a new session for a socket connection
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param secure - Whether the connection is secure (TLS)
|
|
||||||
* @returns New SMTP session
|
|
||||||
*/
|
|
||||||
createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): ISmtpSession;
|
|
||||||
/**
|
|
||||||
* Updates the session state
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @param newState - New state
|
|
||||||
*/
|
|
||||||
updateSessionState(session: ISmtpSession, newState: SmtpState): void;
|
|
||||||
/**
|
|
||||||
* Updates the session's last activity timestamp
|
|
||||||
* @param session - SMTP session
|
|
||||||
*/
|
|
||||||
updateSessionActivity(session: ISmtpSession): void;
|
|
||||||
/**
|
|
||||||
* Removes a session
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Gets a session for a socket
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @returns SMTP session or undefined if not found
|
|
||||||
*/
|
|
||||||
getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined;
|
|
||||||
/**
|
|
||||||
* Cleans up idle sessions
|
|
||||||
*/
|
|
||||||
cleanupIdleSessions(): void;
|
|
||||||
/**
|
|
||||||
* Gets the current number of active sessions
|
|
||||||
* @returns Number of active sessions
|
|
||||||
*/
|
|
||||||
getSessionCount(): number;
|
|
||||||
/**
|
|
||||||
* Clears all sessions (used when shutting down)
|
|
||||||
*/
|
|
||||||
clearAllSessions(): void;
|
|
||||||
/**
|
|
||||||
* Register an event listener
|
|
||||||
* @param event - Event name
|
|
||||||
* @param listener - Event listener function
|
|
||||||
*/
|
|
||||||
on<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void;
|
|
||||||
/**
|
|
||||||
* Remove an event listener
|
|
||||||
* @param event - Event name
|
|
||||||
* @param listener - Event listener function
|
|
||||||
*/
|
|
||||||
off<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void;
|
|
||||||
/**
|
|
||||||
* Emit an event to registered listeners
|
|
||||||
* @param event - Event name
|
|
||||||
* @param args - Event arguments
|
|
||||||
*/
|
|
||||||
private emitEvent;
|
|
||||||
/**
|
|
||||||
* Start the cleanup timer
|
|
||||||
*/
|
|
||||||
private startCleanupTimer;
|
|
||||||
/**
|
|
||||||
* Stop the cleanup timer
|
|
||||||
*/
|
|
||||||
private stopCleanupTimer;
|
|
||||||
/**
|
|
||||||
* Replace socket mapping for STARTTLS upgrades
|
|
||||||
* @param oldSocket - Original plain socket
|
|
||||||
* @param newSocket - New TLS socket
|
|
||||||
* @returns Whether the replacement was successful
|
|
||||||
*/
|
|
||||||
replaceSocket(oldSocket: plugins.net.Socket | plugins.tls.TLSSocket, newSocket: plugins.net.Socket | plugins.tls.TLSSocket): boolean;
|
|
||||||
/**
|
|
||||||
* Gets a unique key for a socket
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @returns Socket key
|
|
||||||
*/
|
|
||||||
private getSocketKey;
|
|
||||||
/**
|
|
||||||
* Get all active sessions
|
|
||||||
*/
|
|
||||||
getAllSessions(): ISmtpSession[];
|
|
||||||
/**
|
|
||||||
* Update last activity for a session by socket
|
|
||||||
*/
|
|
||||||
updateLastActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Check for timed out sessions
|
|
||||||
*/
|
|
||||||
checkTimeouts(timeoutMs: number): ISmtpSession[];
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
137
dist_ts/mail/delivery/smtpserver/smtp-server.d.ts
vendored
137
dist_ts/mail/delivery/smtpserver/smtp-server.d.ts
vendored
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server
|
|
||||||
* Core implementation for the refactored SMTP server
|
|
||||||
*/
|
|
||||||
import type { ISmtpServerOptions } from './interfaces.js';
|
|
||||||
import type { ISmtpServer, ISmtpServerConfig, ISessionManager, IConnectionManager, ICommandHandler, IDataHandler, ITlsHandler, ISecurityHandler } from './interfaces.js';
|
|
||||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
|
||||||
/**
|
|
||||||
* SMTP Server implementation
|
|
||||||
* The main server class that coordinates all components
|
|
||||||
*/
|
|
||||||
export declare class SmtpServer implements ISmtpServer {
|
|
||||||
/**
|
|
||||||
* Email server reference
|
|
||||||
*/
|
|
||||||
private emailServer;
|
|
||||||
/**
|
|
||||||
* Session manager
|
|
||||||
*/
|
|
||||||
private sessionManager;
|
|
||||||
/**
|
|
||||||
* Connection manager
|
|
||||||
*/
|
|
||||||
private connectionManager;
|
|
||||||
/**
|
|
||||||
* Command handler
|
|
||||||
*/
|
|
||||||
private commandHandler;
|
|
||||||
/**
|
|
||||||
* Data handler
|
|
||||||
*/
|
|
||||||
private dataHandler;
|
|
||||||
/**
|
|
||||||
* TLS handler
|
|
||||||
*/
|
|
||||||
private tlsHandler;
|
|
||||||
/**
|
|
||||||
* Security handler
|
|
||||||
*/
|
|
||||||
private securityHandler;
|
|
||||||
/**
|
|
||||||
* SMTP server options
|
|
||||||
*/
|
|
||||||
private options;
|
|
||||||
/**
|
|
||||||
* Net server instance
|
|
||||||
*/
|
|
||||||
private server;
|
|
||||||
/**
|
|
||||||
* Secure server instance
|
|
||||||
*/
|
|
||||||
private secureServer;
|
|
||||||
/**
|
|
||||||
* Whether the server is running
|
|
||||||
*/
|
|
||||||
private running;
|
|
||||||
/**
|
|
||||||
* Server recovery state
|
|
||||||
*/
|
|
||||||
private recoveryState;
|
|
||||||
/**
|
|
||||||
* Creates a new SMTP server
|
|
||||||
* @param config - Server configuration
|
|
||||||
*/
|
|
||||||
constructor(config: ISmtpServerConfig);
|
|
||||||
/**
|
|
||||||
* Start the SMTP server
|
|
||||||
* @returns Promise that resolves when server is started
|
|
||||||
*/
|
|
||||||
listen(): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Stop the SMTP server
|
|
||||||
* @returns Promise that resolves when server is stopped
|
|
||||||
*/
|
|
||||||
close(): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Get the session manager
|
|
||||||
* @returns Session manager instance
|
|
||||||
*/
|
|
||||||
getSessionManager(): ISessionManager;
|
|
||||||
/**
|
|
||||||
* Get the connection manager
|
|
||||||
* @returns Connection manager instance
|
|
||||||
*/
|
|
||||||
getConnectionManager(): IConnectionManager;
|
|
||||||
/**
|
|
||||||
* Get the command handler
|
|
||||||
* @returns Command handler instance
|
|
||||||
*/
|
|
||||||
getCommandHandler(): ICommandHandler;
|
|
||||||
/**
|
|
||||||
* Get the data handler
|
|
||||||
* @returns Data handler instance
|
|
||||||
*/
|
|
||||||
getDataHandler(): IDataHandler;
|
|
||||||
/**
|
|
||||||
* Get the TLS handler
|
|
||||||
* @returns TLS handler instance
|
|
||||||
*/
|
|
||||||
getTlsHandler(): ITlsHandler;
|
|
||||||
/**
|
|
||||||
* Get the security handler
|
|
||||||
* @returns Security handler instance
|
|
||||||
*/
|
|
||||||
getSecurityHandler(): ISecurityHandler;
|
|
||||||
/**
|
|
||||||
* Get the server options
|
|
||||||
* @returns SMTP server options
|
|
||||||
*/
|
|
||||||
getOptions(): ISmtpServerOptions;
|
|
||||||
/**
|
|
||||||
* Get the email server reference
|
|
||||||
* @returns Email server instance
|
|
||||||
*/
|
|
||||||
getEmailServer(): UnifiedEmailServer;
|
|
||||||
/**
|
|
||||||
* Check if the server is running
|
|
||||||
* @returns Whether the server is running
|
|
||||||
*/
|
|
||||||
isRunning(): boolean;
|
|
||||||
/**
|
|
||||||
* Check if we should attempt to recover from an error
|
|
||||||
* @param error - The error that occurred
|
|
||||||
* @returns Whether recovery should be attempted
|
|
||||||
*/
|
|
||||||
private shouldAttemptRecovery;
|
|
||||||
/**
|
|
||||||
* Attempt to recover the server after a critical error
|
|
||||||
* @param serverType - The type of server to recover ('standard' or 'secure')
|
|
||||||
* @param error - The error that triggered recovery
|
|
||||||
*/
|
|
||||||
private attemptServerRecovery;
|
|
||||||
/**
|
|
||||||
* Clean up all component resources
|
|
||||||
*/
|
|
||||||
destroy(): Promise<void>;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* STARTTLS Implementation
|
|
||||||
* Provides an improved implementation for STARTTLS upgrades
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { ISmtpSession, ISessionManager, IConnectionManager } from './interfaces.js';
|
|
||||||
import { SmtpState } from '../interfaces.js';
|
|
||||||
/**
|
|
||||||
* Enhanced STARTTLS handler for more reliable TLS upgrades
|
|
||||||
*/
|
|
||||||
export declare function performStartTLS(socket: plugins.net.Socket, options: {
|
|
||||||
key: string;
|
|
||||||
cert: string;
|
|
||||||
ca?: string;
|
|
||||||
session?: ISmtpSession;
|
|
||||||
sessionManager?: ISessionManager;
|
|
||||||
connectionManager?: IConnectionManager;
|
|
||||||
onSuccess?: (tlsSocket: plugins.tls.TLSSocket) => void;
|
|
||||||
onFailure?: (error: Error) => void;
|
|
||||||
updateSessionState?: (session: ISmtpSession, state: SmtpState) => void;
|
|
||||||
}): Promise<plugins.tls.TLSSocket | undefined>;
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,66 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP TLS Handler
|
|
||||||
* Responsible for handling TLS-related SMTP functionality
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { ITlsHandler, ISmtpServer, ISmtpSession } from './interfaces.js';
|
|
||||||
/**
|
|
||||||
* Handles TLS functionality for SMTP server
|
|
||||||
*/
|
|
||||||
export declare class TlsHandler implements ITlsHandler {
|
|
||||||
/**
|
|
||||||
* Reference to the SMTP server instance
|
|
||||||
*/
|
|
||||||
private smtpServer;
|
|
||||||
/**
|
|
||||||
* Certificate data
|
|
||||||
*/
|
|
||||||
private certificates;
|
|
||||||
/**
|
|
||||||
* TLS options
|
|
||||||
*/
|
|
||||||
private options;
|
|
||||||
/**
|
|
||||||
* Creates a new TLS handler
|
|
||||||
* @param smtpServer - SMTP server instance
|
|
||||||
*/
|
|
||||||
constructor(smtpServer: ISmtpServer);
|
|
||||||
/**
|
|
||||||
* Handle STARTTLS command
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleStartTls(socket: plugins.net.Socket, session: ISmtpSession): Promise<plugins.tls.TLSSocket | null>;
|
|
||||||
/**
|
|
||||||
* Upgrade a connection to TLS
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
startTLS(socket: plugins.net.Socket): Promise<plugins.tls.TLSSocket>;
|
|
||||||
/**
|
|
||||||
* Create a secure server
|
|
||||||
* @returns TLS server instance or undefined if TLS is not enabled
|
|
||||||
*/
|
|
||||||
createSecureServer(): plugins.tls.Server | undefined;
|
|
||||||
/**
|
|
||||||
* Check if TLS is enabled
|
|
||||||
* @returns Whether TLS is enabled
|
|
||||||
*/
|
|
||||||
isTlsEnabled(): boolean;
|
|
||||||
/**
|
|
||||||
* Send a response to the client
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param response - Response message
|
|
||||||
*/
|
|
||||||
private sendResponse;
|
|
||||||
/**
|
|
||||||
* Check if TLS is available (interface requirement)
|
|
||||||
*/
|
|
||||||
isTlsAvailable(): boolean;
|
|
||||||
/**
|
|
||||||
* Get TLS options (interface requirement)
|
|
||||||
*/
|
|
||||||
getTlsOptions(): plugins.tls.TlsOptions;
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,117 +0,0 @@
|
|||||||
/**
|
|
||||||
* Adaptive SMTP Logging System
|
|
||||||
* Automatically switches between logging modes based on server load (active connections)
|
|
||||||
* to maintain performance during high-concurrency scenarios
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../../plugins.js';
|
|
||||||
import { SecurityLogLevel, SecurityEventType } from '../constants.js';
|
|
||||||
import type { ISmtpSession } from '../interfaces.js';
|
|
||||||
import type { LogLevel, ISmtpLogOptions } from './logging.js';
|
|
||||||
/**
|
|
||||||
* Log modes based on server load
|
|
||||||
*/
|
|
||||||
export declare enum LogMode {
|
|
||||||
VERBOSE = "VERBOSE",// < 20 connections: Full detailed logging
|
|
||||||
REDUCED = "REDUCED",// 20-40 connections: Limited command/response logging, full error logging
|
|
||||||
MINIMAL = "MINIMAL"
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Configuration for adaptive logging thresholds
|
|
||||||
*/
|
|
||||||
export interface IAdaptiveLogConfig {
|
|
||||||
verboseThreshold: number;
|
|
||||||
reducedThreshold: number;
|
|
||||||
aggregationInterval: number;
|
|
||||||
maxAggregatedEntries: number;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Connection metadata for aggregation tracking
|
|
||||||
*/
|
|
||||||
interface IConnectionTracker {
|
|
||||||
activeConnections: number;
|
|
||||||
peakConnections: number;
|
|
||||||
totalConnections: number;
|
|
||||||
connectionsPerSecond: number;
|
|
||||||
lastConnectionTime: number;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Adaptive SMTP Logger that scales logging based on server load
|
|
||||||
*/
|
|
||||||
export declare class AdaptiveSmtpLogger {
|
|
||||||
private static instance;
|
|
||||||
private currentMode;
|
|
||||||
private config;
|
|
||||||
private aggregatedEntries;
|
|
||||||
private aggregationTimer;
|
|
||||||
private connectionTracker;
|
|
||||||
private constructor();
|
|
||||||
/**
|
|
||||||
* Get singleton instance
|
|
||||||
*/
|
|
||||||
static getInstance(config?: Partial<IAdaptiveLogConfig>): AdaptiveSmtpLogger;
|
|
||||||
/**
|
|
||||||
* Update active connection count and adjust log mode if needed
|
|
||||||
*/
|
|
||||||
updateConnectionCount(activeConnections: number): void;
|
|
||||||
/**
|
|
||||||
* Track new connection for rate calculation
|
|
||||||
*/
|
|
||||||
trackConnection(): void;
|
|
||||||
/**
|
|
||||||
* Get current logging mode
|
|
||||||
*/
|
|
||||||
getCurrentMode(): LogMode;
|
|
||||||
/**
|
|
||||||
* Get connection statistics
|
|
||||||
*/
|
|
||||||
getConnectionStats(): IConnectionTracker;
|
|
||||||
/**
|
|
||||||
* Log a message with adaptive behavior
|
|
||||||
*/
|
|
||||||
log(level: LogLevel, message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log command with adaptive behavior
|
|
||||||
*/
|
|
||||||
logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void;
|
|
||||||
/**
|
|
||||||
* Log response with adaptive behavior
|
|
||||||
*/
|
|
||||||
logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Log connection event with adaptive behavior
|
|
||||||
*/
|
|
||||||
logConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, eventType: 'connect' | 'close' | 'error', session?: ISmtpSession, error?: Error): void;
|
|
||||||
/**
|
|
||||||
* Log security event (always logged regardless of mode)
|
|
||||||
*/
|
|
||||||
logSecurityEvent(level: SecurityLogLevel, type: SecurityEventType, message: string, details: Record<string, any>, ipAddress?: string, domain?: string, success?: boolean): void;
|
|
||||||
/**
|
|
||||||
* Determine appropriate log mode based on connection count
|
|
||||||
*/
|
|
||||||
private determineLogMode;
|
|
||||||
/**
|
|
||||||
* Switch to a new log mode
|
|
||||||
*/
|
|
||||||
private switchLogMode;
|
|
||||||
/**
|
|
||||||
* Add entry to aggregation buffer
|
|
||||||
*/
|
|
||||||
private aggregateEntry;
|
|
||||||
/**
|
|
||||||
* Start the aggregation timer
|
|
||||||
*/
|
|
||||||
private startAggregationTimer;
|
|
||||||
/**
|
|
||||||
* Flush aggregated entries to logs
|
|
||||||
*/
|
|
||||||
private flushAggregatedEntries;
|
|
||||||
/**
|
|
||||||
* Cleanup resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Default instance for easy access
|
|
||||||
*/
|
|
||||||
export declare const adaptiveLogger: AdaptiveSmtpLogger;
|
|
||||||
export {};
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Helper Functions
|
|
||||||
* Provides utility functions for SMTP server implementation
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../../plugins.js';
|
|
||||||
import type { 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 declare function formatMultilineResponse(code: number, lines: string[]): string;
|
|
||||||
/**
|
|
||||||
* Generates a unique session ID
|
|
||||||
* @returns Unique session ID
|
|
||||||
*/
|
|
||||||
export declare function generateSessionId(): string;
|
|
||||||
/**
|
|
||||||
* 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 declare function safeParseInt(value: string | undefined, defaultValue: number): number;
|
|
||||||
/**
|
|
||||||
* Safely gets the socket details
|
|
||||||
* @param socket - Socket to get details from
|
|
||||||
* @returns Socket details object
|
|
||||||
*/
|
|
||||||
export declare function getSocketDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): {
|
|
||||||
remoteAddress: string;
|
|
||||||
remotePort: number;
|
|
||||||
remoteFamily: string;
|
|
||||||
localAddress: string;
|
|
||||||
localPort: number;
|
|
||||||
encrypted: boolean;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Gets TLS details if socket is TLS
|
|
||||||
* @param socket - Socket to get TLS details from
|
|
||||||
* @returns TLS details or undefined if not TLS
|
|
||||||
*/
|
|
||||||
export declare function getTlsDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): {
|
|
||||||
protocol?: string;
|
|
||||||
cipher?: string;
|
|
||||||
authorized?: boolean;
|
|
||||||
} | undefined;
|
|
||||||
/**
|
|
||||||
* Merges default options with provided options
|
|
||||||
* @param options - User provided options
|
|
||||||
* @returns Merged options with defaults
|
|
||||||
*/
|
|
||||||
export declare function mergeWithDefaults(options: Partial<ISmtpServerOptions>): ISmtpServerOptions;
|
|
||||||
/**
|
|
||||||
* Creates a text response formatter for the SMTP server
|
|
||||||
* @param socket - Socket to send responses to
|
|
||||||
* @returns Function to send formatted response
|
|
||||||
*/
|
|
||||||
export declare function createResponseFormatter(socket: plugins.net.Socket | plugins.tls.TLSSocket): (response: string) => void;
|
|
||||||
/**
|
|
||||||
* Extracts SMTP command name from a command line
|
|
||||||
* @param commandLine - Full command line
|
|
||||||
* @returns Command name in uppercase
|
|
||||||
*/
|
|
||||||
export declare function extractCommandName(commandLine: string): string;
|
|
||||||
/**
|
|
||||||
* Extracts SMTP command arguments from a command line
|
|
||||||
* @param commandLine - Full command line
|
|
||||||
* @returns Arguments string
|
|
||||||
*/
|
|
||||||
export declare function extractCommandArgs(commandLine: string): string;
|
|
||||||
/**
|
|
||||||
* Sanitizes data for logging (hides sensitive info)
|
|
||||||
* @param data - Data to sanitize
|
|
||||||
* @returns Sanitized data
|
|
||||||
*/
|
|
||||||
export declare function sanitizeForLogging(data: any): any;
|
|
||||||
File diff suppressed because one or more lines are too long
106
dist_ts/mail/delivery/smtpserver/utils/logging.d.ts
vendored
106
dist_ts/mail/delivery/smtpserver/utils/logging.d.ts
vendored
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Logging Utilities
|
|
||||||
* Provides structured logging for SMTP server components
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../../plugins.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 declare class SmtpLogger {
|
|
||||||
/**
|
|
||||||
* Log a message with context
|
|
||||||
* @param level - Log level
|
|
||||||
* @param message - Log message
|
|
||||||
* @param options - Additional log options
|
|
||||||
*/
|
|
||||||
static log(level: LogLevel, message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log debug level message
|
|
||||||
* @param message - Log message
|
|
||||||
* @param options - Additional log options
|
|
||||||
*/
|
|
||||||
static debug(message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log info level message
|
|
||||||
* @param message - Log message
|
|
||||||
* @param options - Additional log options
|
|
||||||
*/
|
|
||||||
static info(message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log warning level message
|
|
||||||
* @param message - Log message
|
|
||||||
* @param options - Additional log options
|
|
||||||
*/
|
|
||||||
static warn(message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log error level message
|
|
||||||
* @param message - Log message
|
|
||||||
* @param options - Additional log options
|
|
||||||
*/
|
|
||||||
static error(message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log command received from client
|
|
||||||
* @param command - The command string
|
|
||||||
* @param socket - The client socket
|
|
||||||
* @param session - The SMTP session
|
|
||||||
*/
|
|
||||||
static logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void;
|
|
||||||
/**
|
|
||||||
* Log response sent to client
|
|
||||||
* @param response - The response string
|
|
||||||
* @param socket - The client socket
|
|
||||||
*/
|
|
||||||
static logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
static logConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, eventType: 'connect' | 'close' | 'error', session?: ISmtpSession, error?: Error): void;
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
static logSecurityEvent(level: SecurityLogLevel, type: SecurityEventType, message: string, details: Record<string, any>, ipAddress?: string, domain?: string, success?: boolean): void;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Default instance for backward compatibility
|
|
||||||
*/
|
|
||||||
export declare const smtpLogger: typeof SmtpLogger;
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Validation Utilities
|
|
||||||
* Provides validation functions for SMTP server
|
|
||||||
*/
|
|
||||||
import { SmtpState } from '../interfaces.js';
|
|
||||||
/**
|
|
||||||
* Detects header injection attempts in input strings
|
|
||||||
* @param input - The input string to check
|
|
||||||
* @param context - The context where this input is being used ('smtp-command' or 'email-header')
|
|
||||||
* @returns true if header injection is detected, false otherwise
|
|
||||||
*/
|
|
||||||
export declare function detectHeaderInjection(input: string, context?: 'smtp-command' | 'email-header'): boolean;
|
|
||||||
/**
|
|
||||||
* Sanitizes input by removing or escaping potentially dangerous characters
|
|
||||||
* @param input - The input string to sanitize
|
|
||||||
* @returns Sanitized string
|
|
||||||
*/
|
|
||||||
export declare function sanitizeInput(input: string): string;
|
|
||||||
/**
|
|
||||||
* Validates an email address
|
|
||||||
* @param email - Email address to validate
|
|
||||||
* @returns Whether the email address is valid
|
|
||||||
*/
|
|
||||||
export declare function isValidEmail(email: string): boolean;
|
|
||||||
/**
|
|
||||||
* Validates the MAIL FROM command syntax
|
|
||||||
* @param args - Arguments string from the MAIL FROM command
|
|
||||||
* @returns Object with validation result and extracted data
|
|
||||||
*/
|
|
||||||
export declare function validateMailFrom(args: string): {
|
|
||||||
isValid: boolean;
|
|
||||||
address?: string;
|
|
||||||
params?: Record<string, string>;
|
|
||||||
errorMessage?: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Validates the RCPT TO command syntax
|
|
||||||
* @param args - Arguments string from the RCPT TO command
|
|
||||||
* @returns Object with validation result and extracted data
|
|
||||||
*/
|
|
||||||
export declare function validateRcptTo(args: string): {
|
|
||||||
isValid: boolean;
|
|
||||||
address?: string;
|
|
||||||
params?: Record<string, string>;
|
|
||||||
errorMessage?: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Validates the EHLO command syntax
|
|
||||||
* @param args - Arguments string from the EHLO command
|
|
||||||
* @returns Object with validation result and extracted data
|
|
||||||
*/
|
|
||||||
export declare function validateEhlo(args: string): {
|
|
||||||
isValid: boolean;
|
|
||||||
hostname?: string;
|
|
||||||
errorMessage?: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* 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 declare function isValidCommandSequence(command: string, currentState: SmtpState): boolean;
|
|
||||||
/**
|
|
||||||
* Validates if a hostname is valid according to RFC 5321
|
|
||||||
* @param hostname - Hostname to validate
|
|
||||||
* @returns Whether the hostname is valid
|
|
||||||
*/
|
|
||||||
export declare function isValidHostname(hostname: string): boolean;
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,3 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||||
interface IIPWarmupConfig {
|
interface IIPWarmupConfig {
|
||||||
@@ -154,12 +153,6 @@ export declare class UnifiedEmailServer extends EventEmitter {
|
|||||||
* Start the unified email server
|
* Start the unified email server
|
||||||
*/
|
*/
|
||||||
start(): Promise<void>;
|
start(): Promise<void>;
|
||||||
/**
|
|
||||||
* Handle a socket from smartproxy in socket-handler mode
|
|
||||||
* @param socket The socket to handle
|
|
||||||
* @param port The port this connection is for (25, 587, 465)
|
|
||||||
*/
|
|
||||||
handleSocket(socket: plugins.net.Socket | plugins.tls.TLSSocket, port: number): Promise<void>;
|
|
||||||
/**
|
/**
|
||||||
* Stop the unified email server
|
* Stop the unified email server
|
||||||
*/
|
*/
|
||||||
@@ -176,8 +169,8 @@ export declare class UnifiedEmailServer extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private handleRustAuthRequest;
|
private handleRustAuthRequest;
|
||||||
/**
|
/**
|
||||||
* Verify inbound email security (DKIM/SPF/DMARC) using the Rust bridge.
|
* Verify inbound email security (DKIM/SPF/DMARC) using pre-computed Rust results
|
||||||
* Falls back gracefully if the bridge is not running.
|
* or falling back to IPC call if no pre-computed results are available.
|
||||||
*/
|
*/
|
||||||
private verifyInboundSecurity;
|
private verifyInboundSecurity;
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,16 @@
|
|||||||
{
|
{
|
||||||
"@git.zone/tsrust": {
|
"@git.zone/tsrust": {
|
||||||
"targets": ["linux_amd64", "linux_arm64"]
|
"targets": [
|
||||||
|
"linux_amd64",
|
||||||
|
"linux_arm64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@git.zone/cli": {
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartmta",
|
"name": "@push.rocks/smartmta",
|
||||||
"version": "2.2.1",
|
"version": "2.3.0",
|
||||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mta",
|
"mta",
|
||||||
|
|||||||
1
rust/Cargo.lock
generated
1
rust/Cargo.lock
generated
@@ -1075,6 +1075,7 @@ dependencies = [
|
|||||||
"hickory-resolver 0.25.2",
|
"hickory-resolver 0.25.2",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
"mailer-security",
|
"mailer-security",
|
||||||
|
"mailparse",
|
||||||
"regex",
|
"regex",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
//! script injection, and sensitive data patterns.
|
//! script injection, and sensitive data patterns.
|
||||||
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Result types
|
// Result types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ContentScanResult {
|
pub struct ContentScanResult {
|
||||||
pub threat_score: u32,
|
pub threat_score: u32,
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ base64.workspace = true
|
|||||||
rustls-pki-types.workspace = true
|
rustls-pki-types.workspace = true
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
rustls-pemfile = "2"
|
rustls-pemfile = "2"
|
||||||
|
mailparse.workspace = true
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ use crate::validation;
|
|||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use hickory_resolver::TokioResolver;
|
||||||
|
use mailer_security::MessageAuthenticator;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::IpAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
@@ -152,6 +155,8 @@ pub async fn handle_connection(
|
|||||||
tls_acceptor: Option<Arc<tokio_rustls::TlsAcceptor>>,
|
tls_acceptor: Option<Arc<tokio_rustls::TlsAcceptor>>,
|
||||||
remote_addr: String,
|
remote_addr: String,
|
||||||
is_secure: bool,
|
is_secure: bool,
|
||||||
|
authenticator: Arc<MessageAuthenticator>,
|
||||||
|
resolver: Arc<TokioResolver>,
|
||||||
) {
|
) {
|
||||||
let mut session = SmtpSession::new(remote_addr.clone(), is_secure);
|
let mut session = SmtpSession::new(remote_addr.clone(), is_secure);
|
||||||
|
|
||||||
@@ -217,6 +222,8 @@ pub async fn handle_connection(
|
|||||||
&event_tx,
|
&event_tx,
|
||||||
callback_register.as_ref(),
|
callback_register.as_ref(),
|
||||||
&tls_acceptor,
|
&tls_acceptor,
|
||||||
|
&authenticator,
|
||||||
|
&resolver,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -327,6 +334,8 @@ async fn process_line(
|
|||||||
event_tx: &mpsc::Sender<ConnectionEvent>,
|
event_tx: &mpsc::Sender<ConnectionEvent>,
|
||||||
callback_registry: &dyn CallbackRegistry,
|
callback_registry: &dyn CallbackRegistry,
|
||||||
tls_acceptor: &Option<Arc<tokio_rustls::TlsAcceptor>>,
|
tls_acceptor: &Option<Arc<tokio_rustls::TlsAcceptor>>,
|
||||||
|
authenticator: &Arc<MessageAuthenticator>,
|
||||||
|
resolver: &Arc<TokioResolver>,
|
||||||
) -> LineResult {
|
) -> LineResult {
|
||||||
// Handle AUTH intermediate states (waiting for username/password)
|
// Handle AUTH intermediate states (waiting for username/password)
|
||||||
match &session.auth_state {
|
match &session.auth_state {
|
||||||
@@ -375,7 +384,7 @@ async fn process_line(
|
|||||||
}
|
}
|
||||||
|
|
||||||
SmtpCommand::Data => {
|
SmtpCommand::Data => {
|
||||||
handle_data(session, stream, config, event_tx, callback_registry).await
|
handle_data(session, stream, config, event_tx, callback_registry, authenticator, resolver).await
|
||||||
}
|
}
|
||||||
|
|
||||||
SmtpCommand::Rset => {
|
SmtpCommand::Rset => {
|
||||||
@@ -558,6 +567,8 @@ async fn handle_data(
|
|||||||
config: &SmtpServerConfig,
|
config: &SmtpServerConfig,
|
||||||
event_tx: &mpsc::Sender<ConnectionEvent>,
|
event_tx: &mpsc::Sender<ConnectionEvent>,
|
||||||
callback_registry: &dyn CallbackRegistry,
|
callback_registry: &dyn CallbackRegistry,
|
||||||
|
authenticator: &Arc<MessageAuthenticator>,
|
||||||
|
resolver: &Arc<TokioResolver>,
|
||||||
) -> LineResult {
|
) -> LineResult {
|
||||||
if !session.state.can_data() {
|
if !session.state.can_data() {
|
||||||
return LineResult::Response(SmtpResponse::bad_sequence(
|
return LineResult::Response(SmtpResponse::bad_sequence(
|
||||||
@@ -622,6 +633,18 @@ async fn handle_data(
|
|||||||
let raw_message = accumulator.into_message().unwrap_or_default();
|
let raw_message = accumulator.into_message().unwrap_or_default();
|
||||||
let correlation_id = uuid::Uuid::new_v4().to_string();
|
let correlation_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// --- In-process security pipeline (30s timeout) ---
|
||||||
|
let security_results = run_security_pipeline(
|
||||||
|
&raw_message,
|
||||||
|
&session.remote_addr,
|
||||||
|
session.client_hostname.as_deref().unwrap_or("unknown"),
|
||||||
|
&config.hostname,
|
||||||
|
&session.envelope.mail_from,
|
||||||
|
authenticator,
|
||||||
|
resolver,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Determine transport: inline base64 or temp file
|
// Determine transport: inline base64 or temp file
|
||||||
let email_data = if raw_message.len() <= 256 * 1024 {
|
let email_data = if raw_message.len() <= 256 * 1024 {
|
||||||
EmailData::Inline {
|
EmailData::Inline {
|
||||||
@@ -656,7 +679,7 @@ async fn handle_data(
|
|||||||
client_hostname: session.client_hostname.clone(),
|
client_hostname: session.client_hostname.clone(),
|
||||||
secure: session.secure,
|
secure: session.secure,
|
||||||
authenticated_user: session.authenticated_user().map(|s| s.to_string()),
|
authenticated_user: session.authenticated_user().map(|s| s.to_string()),
|
||||||
security_results: None, // Will be populated by server.rs when in-process security is added
|
security_results
|
||||||
};
|
};
|
||||||
|
|
||||||
if event_tx.send(event).await.is_err() {
|
if event_tx.send(event).await.is_err() {
|
||||||
@@ -991,6 +1014,166 @@ async fn validate_credentials(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract MIME parts from a raw email message for content scanning.
|
||||||
|
///
|
||||||
|
/// Returns `(subject, text_body, html_body, attachment_filenames)`.
|
||||||
|
fn extract_mime_parts(raw_message: &[u8]) -> (Option<String>, Option<String>, Option<String>, Vec<String>) {
|
||||||
|
let parsed = match mailparse::parse_mail(raw_message) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
debug!(error = %e, "Failed to parse MIME for content scanning");
|
||||||
|
return (None, None, None, Vec::new());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract Subject header
|
||||||
|
let subject = parsed
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.find(|h| h.get_key().eq_ignore_ascii_case("subject"))
|
||||||
|
.map(|h| h.get_value());
|
||||||
|
|
||||||
|
let mut text_body: Option<String> = None;
|
||||||
|
let mut html_body: Option<String> = None;
|
||||||
|
let mut attachments: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Walk the MIME tree
|
||||||
|
fn walk_parts(
|
||||||
|
part: &mailparse::ParsedMail<'_>,
|
||||||
|
text_body: &mut Option<String>,
|
||||||
|
html_body: &mut Option<String>,
|
||||||
|
attachments: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
let content_type = part.ctype.mimetype.to_lowercase();
|
||||||
|
let disposition = part.get_content_disposition();
|
||||||
|
|
||||||
|
// Check if this is an attachment
|
||||||
|
if disposition.disposition == mailparse::DispositionType::Attachment {
|
||||||
|
if let Some(filename) = disposition.params.get("filename") {
|
||||||
|
attachments.push(filename.clone());
|
||||||
|
}
|
||||||
|
} else if content_type == "text/plain" && text_body.is_none() {
|
||||||
|
if let Ok(body) = part.get_body() {
|
||||||
|
*text_body = Some(body);
|
||||||
|
}
|
||||||
|
} else if content_type == "text/html" && html_body.is_none() {
|
||||||
|
if let Ok(body) = part.get_body() {
|
||||||
|
*html_body = Some(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into subparts
|
||||||
|
for sub in &part.subparts {
|
||||||
|
walk_parts(sub, text_body, html_body, attachments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk_parts(&parsed, &mut text_body, &mut html_body, &mut attachments);
|
||||||
|
|
||||||
|
(subject, text_body, html_body, attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the full security pipeline: DKIM/SPF/DMARC + content scan + IP reputation.
|
||||||
|
///
|
||||||
|
/// Returns `Some(json_value)` on success or `None` if the pipeline fails or times out.
|
||||||
|
async fn run_security_pipeline(
|
||||||
|
raw_message: &[u8],
|
||||||
|
remote_addr: &str,
|
||||||
|
helo_domain: &str,
|
||||||
|
hostname: &str,
|
||||||
|
mail_from: &str,
|
||||||
|
authenticator: &Arc<MessageAuthenticator>,
|
||||||
|
resolver: &Arc<TokioResolver>,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
let security_timeout = Duration::from_secs(30);
|
||||||
|
|
||||||
|
match timeout(security_timeout, run_security_pipeline_inner(
|
||||||
|
raw_message, remote_addr, helo_domain, hostname, mail_from, authenticator, resolver,
|
||||||
|
)).await {
|
||||||
|
Ok(Ok(value)) => {
|
||||||
|
debug!("In-process security pipeline completed");
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
warn!(error = %e, "Security pipeline error — emitting event without results");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!("Security pipeline timed out (30s) — emitting event without results");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner implementation of the security pipeline (no timeout wrapper).
|
||||||
|
async fn run_security_pipeline_inner(
|
||||||
|
raw_message: &[u8],
|
||||||
|
remote_addr: &str,
|
||||||
|
helo_domain: &str,
|
||||||
|
hostname: &str,
|
||||||
|
mail_from: &str,
|
||||||
|
authenticator: &Arc<MessageAuthenticator>,
|
||||||
|
resolver: &Arc<TokioResolver>,
|
||||||
|
) -> std::result::Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
// Parse the remote IP address
|
||||||
|
let ip: IpAddr = remote_addr.parse().unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
|
||||||
|
|
||||||
|
// Run DKIM/SPF/DMARC and IP reputation concurrently
|
||||||
|
let (email_security, reputation) = tokio::join!(
|
||||||
|
mailer_security::verify_email_security(
|
||||||
|
raw_message, ip, helo_domain, hostname, mail_from, authenticator,
|
||||||
|
),
|
||||||
|
mailer_security::check_reputation(
|
||||||
|
ip, mailer_security::DEFAULT_DNSBL_SERVERS, resolver,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract MIME parts for content scanning (synchronous)
|
||||||
|
let (subject, text_body, html_body, attachment_names) = extract_mime_parts(raw_message);
|
||||||
|
|
||||||
|
// Run content scan (synchronous)
|
||||||
|
let content_scan = mailer_security::content_scanner::scan_content(
|
||||||
|
subject.as_deref(),
|
||||||
|
text_body.as_deref(),
|
||||||
|
html_body.as_deref(),
|
||||||
|
&attachment_names,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the combined results JSON
|
||||||
|
let mut results = serde_json::Map::new();
|
||||||
|
|
||||||
|
// DKIM/SPF/DMARC
|
||||||
|
match email_security {
|
||||||
|
Ok(sec) => {
|
||||||
|
results.insert("dkim".into(), serde_json::to_value(&sec.dkim)?);
|
||||||
|
results.insert("spf".into(), serde_json::to_value(&sec.spf)?);
|
||||||
|
results.insert("dmarc".into(), serde_json::to_value(&sec.dmarc)?);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "Email security verification failed");
|
||||||
|
results.insert("dkim".into(), serde_json::Value::Array(vec![]));
|
||||||
|
results.insert("spf".into(), serde_json::Value::Null);
|
||||||
|
results.insert("dmarc".into(), serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content scan
|
||||||
|
results.insert("contentScan".into(), serde_json::to_value(&content_scan)?);
|
||||||
|
|
||||||
|
// IP reputation
|
||||||
|
match reputation {
|
||||||
|
Ok(rep) => {
|
||||||
|
results.insert("ipReputation".into(), serde_json::to_value(&rep)?);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "IP reputation check failed");
|
||||||
|
results.insert("ipReputation".into(), serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::Value::Object(results))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1020,4 +1203,106 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&result).unwrap();
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
assert!(json.contains("accepted"));
|
assert!(json.contains("accepted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_mime_parts_simple() {
|
||||||
|
let raw = b"From: sender@example.com\r\n\
|
||||||
|
To: rcpt@example.com\r\n\
|
||||||
|
Subject: Test Subject\r\n\
|
||||||
|
Content-Type: text/plain\r\n\
|
||||||
|
\r\n\
|
||||||
|
Hello, this is a test body.\r\n";
|
||||||
|
|
||||||
|
let (subject, text, html, attachments) = extract_mime_parts(raw);
|
||||||
|
assert_eq!(subject.as_deref(), Some("Test Subject"));
|
||||||
|
assert!(text.is_some());
|
||||||
|
assert!(text.unwrap().contains("Hello, this is a test body."));
|
||||||
|
assert!(html.is_none());
|
||||||
|
assert!(attachments.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_mime_parts_multipart() {
|
||||||
|
let raw = b"From: sender@example.com\r\n\
|
||||||
|
To: rcpt@example.com\r\n\
|
||||||
|
Subject: Multipart Test\r\n\
|
||||||
|
Content-Type: multipart/mixed; boundary=\"boundary123\"\r\n\
|
||||||
|
\r\n\
|
||||||
|
--boundary123\r\n\
|
||||||
|
Content-Type: text/plain\r\n\
|
||||||
|
\r\n\
|
||||||
|
Plain text body\r\n\
|
||||||
|
--boundary123\r\n\
|
||||||
|
Content-Type: text/html\r\n\
|
||||||
|
\r\n\
|
||||||
|
<html><body>HTML body</body></html>\r\n\
|
||||||
|
--boundary123\r\n\
|
||||||
|
Content-Type: application/octet-stream\r\n\
|
||||||
|
Content-Disposition: attachment; filename=\"report.pdf\"\r\n\
|
||||||
|
\r\n\
|
||||||
|
binary data here\r\n\
|
||||||
|
--boundary123--\r\n";
|
||||||
|
|
||||||
|
let (subject, text, html, attachments) = extract_mime_parts(raw);
|
||||||
|
assert_eq!(subject.as_deref(), Some("Multipart Test"));
|
||||||
|
assert!(text.is_some());
|
||||||
|
assert!(text.unwrap().contains("Plain text body"));
|
||||||
|
assert!(html.is_some());
|
||||||
|
assert!(html.unwrap().contains("HTML body"));
|
||||||
|
assert_eq!(attachments.len(), 1);
|
||||||
|
assert_eq!(attachments[0], "report.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_mime_parts_no_subject() {
|
||||||
|
let raw = b"From: sender@example.com\r\n\
|
||||||
|
To: rcpt@example.com\r\n\
|
||||||
|
Content-Type: text/plain\r\n\
|
||||||
|
\r\n\
|
||||||
|
Body without subject\r\n";
|
||||||
|
|
||||||
|
let (subject, text, _html, _attachments) = extract_mime_parts(raw);
|
||||||
|
assert!(subject.is_none());
|
||||||
|
assert!(text.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_mime_parts_invalid() {
|
||||||
|
let raw = b"this is not a valid email";
|
||||||
|
let (subject, text, html, attachments) = extract_mime_parts(raw);
|
||||||
|
// Should not panic, may or may not parse partially
|
||||||
|
// The key property is that it doesn't crash
|
||||||
|
let _ = (subject, text, html, attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_mime_parts_multiple_attachments() {
|
||||||
|
let raw = b"From: sender@example.com\r\n\
|
||||||
|
To: rcpt@example.com\r\n\
|
||||||
|
Subject: Attachments\r\n\
|
||||||
|
Content-Type: multipart/mixed; boundary=\"bound\"\r\n\
|
||||||
|
\r\n\
|
||||||
|
--bound\r\n\
|
||||||
|
Content-Type: text/plain\r\n\
|
||||||
|
\r\n\
|
||||||
|
See attached\r\n\
|
||||||
|
--bound\r\n\
|
||||||
|
Content-Type: application/pdf\r\n\
|
||||||
|
Content-Disposition: attachment; filename=\"doc1.pdf\"\r\n\
|
||||||
|
\r\n\
|
||||||
|
pdf data\r\n\
|
||||||
|
--bound\r\n\
|
||||||
|
Content-Type: application/vnd.ms-excel\r\n\
|
||||||
|
Content-Disposition: attachment; filename=\"data.xlsx\"\r\n\
|
||||||
|
\r\n\
|
||||||
|
excel data\r\n\
|
||||||
|
--bound--\r\n";
|
||||||
|
|
||||||
|
let (subject, text, _html, attachments) = extract_mime_parts(raw);
|
||||||
|
assert_eq!(subject.as_deref(), Some("Attachments"));
|
||||||
|
assert!(text.is_some());
|
||||||
|
assert_eq!(attachments.len(), 2);
|
||||||
|
assert!(attachments.contains(&"doc1.pdf".to_string()));
|
||||||
|
assert!(attachments.contains(&"data.xlsx".to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use crate::connection::{
|
|||||||
};
|
};
|
||||||
use crate::rate_limiter::{RateLimitConfig, RateLimiter};
|
use crate::rate_limiter::{RateLimitConfig, RateLimiter};
|
||||||
|
|
||||||
|
use hickory_resolver::TokioResolver;
|
||||||
|
use mailer_security::MessageAuthenticator;
|
||||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
@@ -63,6 +65,17 @@ pub async fn start_server(
|
|||||||
|
|
||||||
let (event_tx, event_rx) = mpsc::channel::<ConnectionEvent>(1024);
|
let (event_tx, event_rx) = mpsc::channel::<ConnectionEvent>(1024);
|
||||||
|
|
||||||
|
// Create shared security resources for in-process email verification
|
||||||
|
let authenticator: Arc<MessageAuthenticator> = Arc::new(
|
||||||
|
mailer_security::default_authenticator()
|
||||||
|
.map_err(|e| format!("Failed to create MessageAuthenticator: {e}"))?
|
||||||
|
);
|
||||||
|
let resolver: Arc<TokioResolver> = Arc::new(
|
||||||
|
TokioResolver::builder_tokio()
|
||||||
|
.map(|b| b.build())
|
||||||
|
.map_err(|e| format!("Failed to create TokioResolver: {e}"))?
|
||||||
|
);
|
||||||
|
|
||||||
// Build TLS acceptor if configured
|
// Build TLS acceptor if configured
|
||||||
let tls_acceptor = if config.has_tls() {
|
let tls_acceptor = if config.has_tls() {
|
||||||
Some(Arc::new(build_tls_acceptor(&config)?))
|
Some(Arc::new(build_tls_acceptor(&config)?))
|
||||||
@@ -87,6 +100,8 @@ pub async fn start_server(
|
|||||||
callback_registry.clone(),
|
callback_registry.clone(),
|
||||||
tls_acceptor.clone(),
|
tls_acceptor.clone(),
|
||||||
false, // not implicit TLS
|
false, // not implicit TLS
|
||||||
|
authenticator.clone(),
|
||||||
|
resolver.clone(),
|
||||||
));
|
));
|
||||||
handles.push(handle);
|
handles.push(handle);
|
||||||
}
|
}
|
||||||
@@ -108,6 +123,8 @@ pub async fn start_server(
|
|||||||
callback_registry.clone(),
|
callback_registry.clone(),
|
||||||
tls_acceptor.clone(),
|
tls_acceptor.clone(),
|
||||||
true, // implicit TLS
|
true, // implicit TLS
|
||||||
|
authenticator.clone(),
|
||||||
|
resolver.clone(),
|
||||||
));
|
));
|
||||||
handles.push(handle);
|
handles.push(handle);
|
||||||
} else {
|
} else {
|
||||||
@@ -153,6 +170,8 @@ async fn accept_loop(
|
|||||||
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
|
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
|
||||||
tls_acceptor: Option<Arc<tokio_rustls::TlsAcceptor>>,
|
tls_acceptor: Option<Arc<tokio_rustls::TlsAcceptor>>,
|
||||||
implicit_tls: bool,
|
implicit_tls: bool,
|
||||||
|
authenticator: Arc<MessageAuthenticator>,
|
||||||
|
resolver: Arc<TokioResolver>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
if shutdown.load(Ordering::SeqCst) {
|
if shutdown.load(Ordering::SeqCst) {
|
||||||
@@ -194,6 +213,8 @@ async fn accept_loop(
|
|||||||
let callback_registry = callback_registry.clone();
|
let callback_registry = callback_registry.clone();
|
||||||
let tls_acceptor = tls_acceptor.clone();
|
let tls_acceptor = tls_acceptor.clone();
|
||||||
let active_connections = active_connections.clone();
|
let active_connections = active_connections.clone();
|
||||||
|
let authenticator = authenticator.clone();
|
||||||
|
let resolver = resolver.clone();
|
||||||
|
|
||||||
active_connections.fetch_add(1, Ordering::SeqCst);
|
active_connections.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
@@ -232,6 +253,8 @@ async fn accept_loop(
|
|||||||
tls_acceptor,
|
tls_acceptor,
|
||||||
remote_addr,
|
remote_addr,
|
||||||
implicit_tls,
|
implicit_tls,
|
||||||
|
authenticator,
|
||||||
|
resolver,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import * as plugins from '../../ts/plugins.js';
|
import * as plugins from '../../ts/plugins.js';
|
||||||
import { UnifiedEmailServer } from '../../ts/mail/routing/classes.unified.email.server.js';
|
|
||||||
import { createSmtpServer } from '../../ts/mail/delivery/smtpserver/index.js';
|
|
||||||
import type { ISmtpServerOptions } from '../../ts/mail/delivery/smtpserver/interfaces.js';
|
|
||||||
import type { net } from '../../ts/plugins.js';
|
|
||||||
|
|
||||||
export interface ITestServerConfig {
|
export interface ITestServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -27,165 +23,18 @@ export interface ITestServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a test SMTP server with the given configuration
|
* Starts a test SMTP server with the given configuration.
|
||||||
|
*
|
||||||
|
* NOTE: The TS SMTP server implementation was removed in Phase 7B
|
||||||
|
* (replaced by the Rust SMTP server). This stub preserves the interface
|
||||||
|
* for smtpclient tests that import it, but those tests require `node-forge`
|
||||||
|
* which is not installed (pre-existing issue).
|
||||||
*/
|
*/
|
||||||
export async function startTestServer(config: ITestServerConfig): Promise<ITestServer> {
|
export async function startTestServer(_config: ITestServerConfig): Promise<ITestServer> {
|
||||||
// Find a free port if one wasn't specified
|
throw new Error(
|
||||||
// Using smartnetwork to find an available port in the range 10000-60000
|
'startTestServer is no longer available — the TS SMTP server was removed in Phase 7B. ' +
|
||||||
let port = config.port;
|
'Use the Rust SMTP server (via UnifiedEmailServer) for integration testing.'
|
||||||
if (port === undefined || port === 0) {
|
);
|
||||||
const network = new plugins.smartnetwork.Network();
|
|
||||||
port = await network.findFreePort(10000, 60000, { randomize: true });
|
|
||||||
if (!port) {
|
|
||||||
throw new Error('No free ports available in range 10000-60000');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverConfig = {
|
|
||||||
port: port, // Use the found free port
|
|
||||||
hostname: config.hostname || 'localhost',
|
|
||||||
tlsEnabled: config.tlsEnabled || false,
|
|
||||||
authRequired: config.authRequired || false,
|
|
||||||
timeout: config.timeout || 30000,
|
|
||||||
maxConnections: config.maxConnections || 100,
|
|
||||||
size: config.size || 10 * 1024 * 1024, // 10MB default
|
|
||||||
maxRecipients: config.maxRecipients || 100
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create a mock email server for testing
|
|
||||||
const mockEmailServer = {
|
|
||||||
processEmailByMode: async (emailData: any) => {
|
|
||||||
console.log(`📧 [Test Server] Processing email:`, emailData.subject || 'No subject');
|
|
||||||
return emailData;
|
|
||||||
},
|
|
||||||
getRateLimiter: () => {
|
|
||||||
// Return a mock rate limiter for testing
|
|
||||||
return {
|
|
||||||
recordConnection: (_ip: string) => ({ allowed: true, remaining: 100 }),
|
|
||||||
checkConnectionLimit: async (_ip: string) => ({ allowed: true, remaining: 100 }),
|
|
||||||
checkMessageLimit: (_senderAddress: string, _ip: string, _recipientCount?: number, _pattern?: string, _domain?: string) => ({ allowed: true, remaining: 1000 }),
|
|
||||||
checkRecipientLimit: async (_session: any) => ({ allowed: true, remaining: 50 }),
|
|
||||||
recordAuthenticationFailure: async (_ip: string) => {},
|
|
||||||
recordSyntaxError: async (_ip: string) => {},
|
|
||||||
recordCommandError: async (_ip: string) => {},
|
|
||||||
recordError: (_key: string) => false, // Return false to not block during tests
|
|
||||||
isBlocked: async (_ip: string) => false,
|
|
||||||
cleanup: async () => {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
// Load test certificates
|
|
||||||
let key: string;
|
|
||||||
let cert: string;
|
|
||||||
|
|
||||||
if (serverConfig.tlsEnabled) {
|
|
||||||
try {
|
|
||||||
const certPath = config.testCertPath || './test/fixtures/test-cert.pem';
|
|
||||||
const keyPath = config.testKeyPath || './test/fixtures/test-key.pem';
|
|
||||||
|
|
||||||
cert = await plugins.fs.promises.readFile(certPath, 'utf8');
|
|
||||||
key = await plugins.fs.promises.readFile(keyPath, 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('⚠️ Failed to load TLS certificates, falling back to self-signed');
|
|
||||||
// Generate self-signed certificate for testing
|
|
||||||
const forge = await import('node-forge');
|
|
||||||
const pki = forge.default.pki;
|
|
||||||
|
|
||||||
// Generate key pair
|
|
||||||
const keys = pki.rsa.generateKeyPair(2048);
|
|
||||||
|
|
||||||
// Create certificate
|
|
||||||
const certificate = pki.createCertificate();
|
|
||||||
certificate.publicKey = keys.publicKey;
|
|
||||||
certificate.serialNumber = '01';
|
|
||||||
certificate.validity.notBefore = new Date();
|
|
||||||
certificate.validity.notAfter = new Date();
|
|
||||||
certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1);
|
|
||||||
|
|
||||||
const attrs = [{
|
|
||||||
name: 'commonName',
|
|
||||||
value: serverConfig.hostname
|
|
||||||
}];
|
|
||||||
certificate.setSubject(attrs);
|
|
||||||
certificate.setIssuer(attrs);
|
|
||||||
certificate.sign(keys.privateKey);
|
|
||||||
|
|
||||||
// Convert to PEM
|
|
||||||
cert = pki.certificateToPem(certificate);
|
|
||||||
key = pki.privateKeyToPem(keys.privateKey);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Always provide a self-signed certificate for non-TLS servers
|
|
||||||
// This is required by the interface
|
|
||||||
const forge = await import('node-forge');
|
|
||||||
const pki = forge.default.pki;
|
|
||||||
|
|
||||||
// Generate key pair
|
|
||||||
const keys = pki.rsa.generateKeyPair(2048);
|
|
||||||
|
|
||||||
// Create certificate
|
|
||||||
const certificate = pki.createCertificate();
|
|
||||||
certificate.publicKey = keys.publicKey;
|
|
||||||
certificate.serialNumber = '01';
|
|
||||||
certificate.validity.notBefore = new Date();
|
|
||||||
certificate.validity.notAfter = new Date();
|
|
||||||
certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1);
|
|
||||||
|
|
||||||
const attrs = [{
|
|
||||||
name: 'commonName',
|
|
||||||
value: serverConfig.hostname
|
|
||||||
}];
|
|
||||||
certificate.setSubject(attrs);
|
|
||||||
certificate.setIssuer(attrs);
|
|
||||||
certificate.sign(keys.privateKey);
|
|
||||||
|
|
||||||
// Convert to PEM
|
|
||||||
cert = pki.certificateToPem(certificate);
|
|
||||||
key = pki.privateKeyToPem(keys.privateKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SMTP server options
|
|
||||||
const smtpOptions: ISmtpServerOptions = {
|
|
||||||
port: serverConfig.port,
|
|
||||||
hostname: serverConfig.hostname,
|
|
||||||
key: key,
|
|
||||||
cert: cert,
|
|
||||||
maxConnections: serverConfig.maxConnections,
|
|
||||||
size: serverConfig.size,
|
|
||||||
maxRecipients: serverConfig.maxRecipients,
|
|
||||||
socketTimeout: serverConfig.timeout,
|
|
||||||
connectionTimeout: serverConfig.timeout * 2,
|
|
||||||
cleanupInterval: 300000,
|
|
||||||
auth: serverConfig.authRequired ? ({
|
|
||||||
required: true,
|
|
||||||
methods: ['PLAIN', 'LOGIN'] as ('PLAIN' | 'LOGIN' | 'OAUTH2')[],
|
|
||||||
validateUser: async (username: string, password: string) => {
|
|
||||||
// Test server accepts these credentials
|
|
||||||
return username === 'testuser' && password === 'testpass';
|
|
||||||
}
|
|
||||||
} as any) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create SMTP server
|
|
||||||
const smtpServer = await createSmtpServer(mockEmailServer, smtpOptions);
|
|
||||||
|
|
||||||
// Start the server
|
|
||||||
await smtpServer.listen();
|
|
||||||
|
|
||||||
// Wait for server to be ready
|
|
||||||
await waitForServerReady(serverConfig.hostname, serverConfig.port);
|
|
||||||
|
|
||||||
console.log(`✅ Test SMTP server started on ${serverConfig.hostname}:${serverConfig.port}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
server: mockEmailServer,
|
|
||||||
smtpServer: smtpServer,
|
|
||||||
port: serverConfig.port, // Return the port we already know
|
|
||||||
hostname: serverConfig.hostname,
|
|
||||||
config: serverConfig,
|
|
||||||
startTime: Date.now()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,77 +42,29 @@ export async function startTestServer(config: ITestServerConfig): Promise<ITestS
|
|||||||
*/
|
*/
|
||||||
export async function stopTestServer(testServer: ITestServer): Promise<void> {
|
export async function stopTestServer(testServer: ITestServer): Promise<void> {
|
||||||
if (!testServer || !testServer.smtpServer) {
|
if (!testServer || !testServer.smtpServer) {
|
||||||
console.warn('⚠️ No test server to stop');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`🛑 Stopping test SMTP server on ${testServer.hostname}:${testServer.port}`);
|
|
||||||
|
|
||||||
// Stop the SMTP server
|
|
||||||
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
|
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
|
||||||
await testServer.smtpServer.close();
|
await testServer.smtpServer.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for port to be free
|
|
||||||
await waitForPortFree(testServer.port);
|
|
||||||
|
|
||||||
console.log(`✅ Test SMTP server stopped`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error stopping test server:', error);
|
console.error('Error stopping test server:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for server to be ready to accept connections
|
* Get an available port for testing
|
||||||
*/
|
*/
|
||||||
async function waitForServerReady(hostname: string, port: number, timeout: number = 10000): Promise<void> {
|
export async function getAvailablePort(startPort: number = 25000): Promise<number> {
|
||||||
const startTime = Date.now();
|
for (let port = startPort; port < startPort + 1000; port++) {
|
||||||
|
if (await isPortFree(port)) {
|
||||||
while (Date.now() - startTime < timeout) {
|
return port;
|
||||||
try {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const socket = plugins.net.createConnection({ port, host: hostname });
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
socket.end();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', reject);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
reject(new Error('Connection timeout'));
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
return; // Server is ready
|
|
||||||
} catch {
|
|
||||||
// Server not ready yet, wait and retry
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
throw new Error(`No available ports found starting from ${startPort}`);
|
||||||
throw new Error(`Server did not become ready within ${timeout}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for port to be free
|
|
||||||
*/
|
|
||||||
async function waitForPortFree(port: number, timeout: number = 5000): Promise<void> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
while (Date.now() - startTime < timeout) {
|
|
||||||
const isFree = await isPortFree(port);
|
|
||||||
if (isFree) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`⚠️ Port ${port} still in use after ${timeout}ms`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -281,18 +82,6 @@ async function isPortFree(port: number): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an available port for testing
|
|
||||||
*/
|
|
||||||
export async function getAvailablePort(startPort: number = 25000): Promise<number> {
|
|
||||||
for (let port = startPort; port < startPort + 1000; port++) {
|
|
||||||
if (await isPortFree(port)) {
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`No available ports found starting from ${startPort}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create test email data
|
* Create test email data
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-01: EHLO Command - server responds with proper capabilities', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
// Parse response - only lines that start with 250
|
|
||||||
const lines = receivedData.split('\r\n')
|
|
||||||
.filter(line => line.startsWith('250'))
|
|
||||||
.filter(line => line.length > 0);
|
|
||||||
|
|
||||||
// Check for required ESMTP extensions
|
|
||||||
const capabilities = lines.map(line => line.substring(4).trim());
|
|
||||||
console.log('📋 Server capabilities:', capabilities);
|
|
||||||
|
|
||||||
// Verify essential capabilities
|
|
||||||
expect(capabilities.some(cap => cap.includes('SIZE'))).toBeTruthy();
|
|
||||||
expect(capabilities.some(cap => cap.includes('8BITMIME'))).toBeTruthy();
|
|
||||||
|
|
||||||
// The last line should be "250 " (without hyphen)
|
|
||||||
const lastLine = lines[lines.length - 1];
|
|
||||||
expect(lastLine.startsWith('250 ')).toBeTruthy();
|
|
||||||
|
|
||||||
currentStep = 'quit';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-01: EHLO with invalid hostname - server handles gracefully', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let testIndex = 0;
|
|
||||||
|
|
||||||
const invalidHostnames = [
|
|
||||||
'', // Empty hostname
|
|
||||||
' ', // Whitespace only
|
|
||||||
'invalid..hostname', // Double dots
|
|
||||||
'.invalid', // Leading dot
|
|
||||||
'invalid.', // Trailing dot
|
|
||||||
'very-long-hostname-that-exceeds-reasonable-limits-' + 'x'.repeat(200)
|
|
||||||
];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'testing';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
console.log(`Testing invalid hostname: "${invalidHostnames[testIndex]}"`);
|
|
||||||
socket.write(`EHLO ${invalidHostnames[testIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'testing' && (receivedData.includes('250') || receivedData.includes('5'))) {
|
|
||||||
// Server should either accept with warning or reject with 5xx
|
|
||||||
expect(receivedData).toMatch(/^(250|5\d\d)/);
|
|
||||||
|
|
||||||
testIndex++;
|
|
||||||
if (testIndex < invalidHostnames.length) {
|
|
||||||
currentStep = 'reset';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'reset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'testing';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
console.log(`Testing invalid hostname: "${invalidHostnames[testIndex]}"`);
|
|
||||||
socket.write(`EHLO ${invalidHostnames[testIndex]}\r\n`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-01: EHLO command pipelining - multiple EHLO commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'first_ehlo';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('EHLO first.example.com\r\n');
|
|
||||||
} else if (currentStep === 'first_ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'second_ehlo';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
// Second EHLO (should reset session)
|
|
||||||
socket.write('EHLO second.example.com\r\n');
|
|
||||||
} else if (currentStep === 'second_ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
// Verify session was reset by trying MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<test@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-02: MAIL FROM - accepts valid sender addresses', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let testIndex = 0;
|
|
||||||
|
|
||||||
const validAddresses = [
|
|
||||||
'sender@example.com',
|
|
||||||
'test.user+tag@example.com',
|
|
||||||
'user@[192.168.1.1]', // IP literal
|
|
||||||
'user@subdomain.example.com',
|
|
||||||
'user@very-long-domain-name-that-is-still-valid.example.com',
|
|
||||||
'test_user@example.com' // underscore in local part
|
|
||||||
];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
console.log(`Testing valid address: ${validAddresses[testIndex]}`);
|
|
||||||
socket.write(`MAIL FROM:<${validAddresses[testIndex]}>\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
testIndex++;
|
|
||||||
if (testIndex < validAddresses.length) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
console.log(`Testing valid address: ${validAddresses[testIndex]}`);
|
|
||||||
socket.write(`MAIL FROM:<${validAddresses[testIndex]}>\r\n`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-02: MAIL FROM - rejects invalid sender addresses', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let testIndex = 0;
|
|
||||||
|
|
||||||
const invalidAddresses = [
|
|
||||||
'notanemail', // No @ symbol
|
|
||||||
'@example.com', // Missing local part
|
|
||||||
'user@', // Missing domain
|
|
||||||
'user@.com', // Invalid domain
|
|
||||||
'user@domain..com', // Double dot
|
|
||||||
'user with spaces@example.com', // Unquoted spaces
|
|
||||||
'user@<example.com>', // Invalid characters
|
|
||||||
'user@@example.com', // Double @
|
|
||||||
'user@localhost' // localhost not valid domain
|
|
||||||
];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
console.log(`Testing invalid address: "${invalidAddresses[testIndex]}"`);
|
|
||||||
socket.write(`MAIL FROM:<${invalidAddresses[testIndex]}>\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && (receivedData.includes('250') || receivedData.includes('5'))) {
|
|
||||||
// Server might accept some addresses or reject with 5xx error
|
|
||||||
// For this test, we just verify the server responds appropriately
|
|
||||||
console.log(` Response: ${receivedData.trim()}`);
|
|
||||||
|
|
||||||
testIndex++;
|
|
||||||
if (testIndex < invalidAddresses.length) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
console.log(`Testing invalid address: "${invalidAddresses[testIndex]}"`);
|
|
||||||
socket.write(`MAIL FROM:<${invalidAddresses[testIndex]}>\r\n`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-02: MAIL FROM with SIZE parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from_small';
|
|
||||||
receivedData = '';
|
|
||||||
// Test small size
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> SIZE=1024\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_small' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_large';
|
|
||||||
receivedData = '';
|
|
||||||
// Test large size (should be rejected if exceeds limit)
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> SIZE=99999999\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_large') {
|
|
||||||
// Should get either 250 (accepted) or 552 (message size exceeds limit)
|
|
||||||
expect(receivedData).toMatch(/^(250|552)/);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-02: MAIL FROM with parameters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from_8bitmime';
|
|
||||||
receivedData = '';
|
|
||||||
// Test BODY=8BITMIME
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> BODY=8BITMIME\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_8bitmime' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_unknown';
|
|
||||||
receivedData = '';
|
|
||||||
// Test unknown parameter (should be ignored or rejected)
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> UNKNOWN=value\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_unknown') {
|
|
||||||
// Should get either 250 (ignored) or 555 (parameter not recognized)
|
|
||||||
expect(receivedData).toMatch(/^(250|555|501)/);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-02: MAIL FROM sequence violations', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'mail_without_ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
// Try MAIL FROM without EHLO/HELO first
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_without_ehlo' && receivedData.includes('503')) {
|
|
||||||
// Should get 503 (bad sequence)
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'first_mail';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<first@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'first_mail' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'second_mail';
|
|
||||||
receivedData = '';
|
|
||||||
// Try second MAIL FROM without RSET
|
|
||||||
socket.write('MAIL FROM:<second@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'second_mail' && (receivedData.includes('503') || receivedData.includes('250'))) {
|
|
||||||
// Server might accept or reject the second MAIL FROM
|
|
||||||
// Some servers allow resetting the sender, others require RSET
|
|
||||||
console.log(`Second MAIL FROM response: ${receivedData.trim()}`);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RCPT TO - should accept valid recipient after MAIL FROM', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RCPT TO - should reject without MAIL FROM', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'rcpt_to_without_mail';
|
|
||||||
receivedData = '';
|
|
||||||
// Try RCPT TO without MAIL FROM
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to_without_mail' && receivedData.includes('503')) {
|
|
||||||
// Should get 503 (bad sequence)
|
|
||||||
expect(receivedData).toInclude('503');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RCPT TO - should accept multiple recipients', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let recipientCount = 0;
|
|
||||||
const maxRecipients = 3;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write(`RCPT TO:<recipient${recipientCount + 1}@example.com>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
recipientCount++;
|
|
||||||
receivedData = '';
|
|
||||||
|
|
||||||
if (recipientCount < maxRecipients) {
|
|
||||||
socket.write(`RCPT TO:<recipient${recipientCount + 1}@example.com>\r\n`);
|
|
||||||
} else {
|
|
||||||
expect(recipientCount).toEqual(maxRecipients);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RCPT TO - should reject invalid email format', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let testIndex = 0;
|
|
||||||
|
|
||||||
const invalidRecipients = [
|
|
||||||
'notanemail',
|
|
||||||
'@example.com',
|
|
||||||
'user@',
|
|
||||||
'user@.com',
|
|
||||||
'user@domain..com'
|
|
||||||
];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
console.log(`Testing invalid recipient: "${invalidRecipients[testIndex]}"`);
|
|
||||||
socket.write(`RCPT TO:<${invalidRecipients[testIndex]}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to' && (receivedData.includes('501') || receivedData.includes('5'))) {
|
|
||||||
// Should reject with 5xx error
|
|
||||||
console.log(` Response: ${receivedData.trim()}`);
|
|
||||||
|
|
||||||
testIndex++;
|
|
||||||
if (testIndex < invalidRecipients.length) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RCPT TO - should handle SIZE parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to_with_size';
|
|
||||||
receivedData = '';
|
|
||||||
// RCPT TO doesn't typically have SIZE parameter, but test server response
|
|
||||||
socket.write('RCPT TO:<recipient@example.com> SIZE=1024\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to_with_size') {
|
|
||||||
// Server might accept or reject the parameter
|
|
||||||
expect(receivedData).toMatch(/^(250|555|501)/);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 15000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should accept email data after RCPT TO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_command';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'message_body';
|
|
||||||
receivedData = '';
|
|
||||||
// Send email content
|
|
||||||
socket.write('From: sender@example.com\r\n');
|
|
||||||
socket.write('To: recipient@example.com\r\n');
|
|
||||||
socket.write('Subject: Test message\r\n');
|
|
||||||
socket.write('\r\n'); // Empty line to separate headers from body
|
|
||||||
socket.write('This is a test message.\r\n');
|
|
||||||
socket.write('.\r\n'); // End of message
|
|
||||||
} else if (currentStep === 'message_body' && receivedData.includes('250')) {
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should reject without RCPT TO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'data_without_rcpt';
|
|
||||||
receivedData = '';
|
|
||||||
// Try DATA without MAIL FROM or RCPT TO
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_without_rcpt' && receivedData.includes('503')) {
|
|
||||||
// Should get 503 (bad sequence)
|
|
||||||
expect(receivedData).toInclude('503');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should accept empty message body', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_command';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'empty_message';
|
|
||||||
receivedData = '';
|
|
||||||
// Send only the terminator
|
|
||||||
socket.write('.\r\n');
|
|
||||||
} else if (currentStep === 'empty_message') {
|
|
||||||
// Server should accept empty message
|
|
||||||
expect(receivedData).toMatch(/^(250|5\d\d)/);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should handle dot stuffing correctly', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_command';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'dot_stuffed_message';
|
|
||||||
receivedData = '';
|
|
||||||
// Send message with dots that need stuffing
|
|
||||||
socket.write('This line is normal.\r\n');
|
|
||||||
socket.write('..This line starts with two dots (one will be removed).\r\n');
|
|
||||||
socket.write('.This line starts with a single dot.\r\n');
|
|
||||||
socket.write('...This line starts with three dots.\r\n');
|
|
||||||
socket.write('.\r\n'); // End of message
|
|
||||||
} else if (currentStep === 'dot_stuffed_message' && receivedData.includes('250')) {
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should handle large messages', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_command';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'large_message';
|
|
||||||
receivedData = '';
|
|
||||||
// Send a large message (100KB)
|
|
||||||
socket.write('From: sender@example.com\r\n');
|
|
||||||
socket.write('To: recipient@example.com\r\n');
|
|
||||||
socket.write('Subject: Large test message\r\n');
|
|
||||||
socket.write('\r\n');
|
|
||||||
|
|
||||||
// Generate 100KB of data
|
|
||||||
const lineContent = 'This is a test line that will be repeated many times. ';
|
|
||||||
const linesNeeded = Math.ceil(100000 / lineContent.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < linesNeeded; i++) {
|
|
||||||
socket.write(lineContent + '\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('.\r\n'); // End of message
|
|
||||||
} else if (currentStep === 'large_message' && receivedData.includes('250')) {
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should handle binary data in message', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_command';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'binary_message';
|
|
||||||
receivedData = '';
|
|
||||||
// Send message with binary data (base64 encoded attachment)
|
|
||||||
socket.write('From: sender@example.com\r\n');
|
|
||||||
socket.write('To: recipient@example.com\r\n');
|
|
||||||
socket.write('Subject: Binary test message\r\n');
|
|
||||||
socket.write('MIME-Version: 1.0\r\n');
|
|
||||||
socket.write('Content-Type: multipart/mixed; boundary="boundary123"\r\n');
|
|
||||||
socket.write('\r\n');
|
|
||||||
socket.write('--boundary123\r\n');
|
|
||||||
socket.write('Content-Type: text/plain\r\n');
|
|
||||||
socket.write('\r\n');
|
|
||||||
socket.write('This message contains binary data.\r\n');
|
|
||||||
socket.write('--boundary123\r\n');
|
|
||||||
socket.write('Content-Type: application/octet-stream\r\n');
|
|
||||||
socket.write('Content-Transfer-Encoding: base64\r\n');
|
|
||||||
socket.write('\r\n');
|
|
||||||
socket.write('SGVsbG8gV29ybGQhIFRoaXMgaXMgYmluYXJ5IGRhdGEu\r\n');
|
|
||||||
socket.write('--boundary123--\r\n');
|
|
||||||
socket.write('.\r\n'); // End of message
|
|
||||||
} else if (currentStep === 'binary_message' && receivedData.includes('250')) {
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
import * as plugins from '@git.zone/tstest/tapbundle';
|
|
||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic NOOP command
|
|
||||||
tap.test('NOOP - should accept NOOP command', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'noop';
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else if (currentStep === 'noop' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250'); // NOOP response
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Multiple NOOP commands
|
|
||||||
tap.test('NOOP - should handle multiple consecutive NOOP commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let noopCount = 0;
|
|
||||||
const maxNoops = 3;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = ''; // Clear buffer after processing
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'noop';
|
|
||||||
receivedData = ''; // Clear buffer after processing
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else if (currentStep === 'noop' && receivedData.includes('250 OK')) {
|
|
||||||
noopCount++;
|
|
||||||
receivedData = ''; // Clear buffer after processing
|
|
||||||
|
|
||||||
if (noopCount < maxNoops) {
|
|
||||||
// Send another NOOP command
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(noopCount).toEqual(maxNoops);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: NOOP during transaction
|
|
||||||
tap.test('NOOP - should work during email transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'noop_after_mail';
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else if (currentStep === 'noop_after_mail' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'noop_after_rcpt';
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else if (currentStep === 'noop_after_rcpt' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: NOOP with parameter (should be ignored)
|
|
||||||
tap.test('NOOP - should handle NOOP with parameters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'noop_with_param';
|
|
||||||
socket.write('NOOP ignored parameter\r\n'); // Parameters should be ignored
|
|
||||||
} else if (currentStep === 'noop_with_param' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: NOOP before EHLO/HELO
|
|
||||||
tap.test('NOOP - should work before EHLO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'noop_before_ehlo';
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else if (currentStep === 'noop_before_ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Rapid NOOP commands (stress test)
|
|
||||||
tap.test('NOOP - should handle rapid NOOP commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let noopsSent = 0;
|
|
||||||
let noopsReceived = 0;
|
|
||||||
const rapidNoops = 10;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rapid_noop';
|
|
||||||
// Send multiple NOOPs rapidly
|
|
||||||
for (let i = 0; i < rapidNoops; i++) {
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
noopsSent++;
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rapid_noop') {
|
|
||||||
// Count 250 responses
|
|
||||||
const matches = receivedData.match(/250 /g);
|
|
||||||
if (matches) {
|
|
||||||
noopsReceived = matches.length - 1; // -1 for EHLO response
|
|
||||||
}
|
|
||||||
|
|
||||||
if (noopsReceived >= rapidNoops) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(noopsReceived).toBeGreaterThan(rapidNoops - 1);
|
|
||||||
done.resolve();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic RSET command
|
|
||||||
tap.test('RSET - should reset transaction after MAIL FROM', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
// RSET successful, try to send MAIL FROM again to verify reset
|
|
||||||
currentStep = 'mail_from_after_rset';
|
|
||||||
socket.write('MAIL FROM:<newsender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_after_rset' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250 OK'); // RSET response
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RSET after RCPT TO
|
|
||||||
tap.test('RSET - should reset transaction after RCPT TO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
// After RSET, should need MAIL FROM before RCPT TO
|
|
||||||
currentStep = 'rcpt_to_after_rset';
|
|
||||||
socket.write('RCPT TO:<newrecipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to_after_rset' && receivedData.includes('503')) {
|
|
||||||
// Should get 503 bad sequence
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('503'); // Bad sequence after RSET
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RSET during DATA
|
|
||||||
tap.test('RSET - should reset transaction during DATA phase', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
// Start sending data but then RSET
|
|
||||||
currentStep = 'rset_during_data';
|
|
||||||
socket.write('Subject: Test\r\n\r\nPartial message...\r\n');
|
|
||||||
socket.write('RSET\r\n'); // This should be treated as part of data
|
|
||||||
socket.write('\r\n.\r\n'); // End data
|
|
||||||
} else if (currentStep === 'rset_during_data' && receivedData.includes('250')) {
|
|
||||||
// Message accepted, now send actual RSET
|
|
||||||
currentStep = 'rset_after_data';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset_after_data' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Multiple RSET commands
|
|
||||||
tap.test('RSET - should handle multiple consecutive RSET commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let rsetCount = 0;
|
|
||||||
const maxRsets = 3;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'multiple_rsets';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'multiple_rsets' && receivedData.includes('250')) {
|
|
||||||
rsetCount++;
|
|
||||||
receivedData = ''; // Clear buffer after processing
|
|
||||||
|
|
||||||
if (rsetCount < maxRsets) {
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(rsetCount).toEqual(maxRsets);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RSET without transaction
|
|
||||||
tap.test('RSET - should work without active transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset_without_transaction';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset_without_transaction' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250'); // RSET should work even without transaction
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RSET with multiple recipients
|
|
||||||
tap.test('RSET - should clear all recipients', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let recipientCount = 0;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'add_recipients';
|
|
||||||
recipientCount++;
|
|
||||||
socket.write(`RCPT TO:<recipient${recipientCount}@example.com>\r\n`);
|
|
||||||
} else if (currentStep === 'add_recipients' && receivedData.includes('250')) {
|
|
||||||
if (recipientCount < 3) {
|
|
||||||
recipientCount++;
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`RCPT TO:<recipient${recipientCount}@example.com>\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'rset';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
// After RSET, all recipients should be cleared
|
|
||||||
currentStep = 'data_after_rset';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_after_rset' && receivedData.includes('503')) {
|
|
||||||
// Should get 503 bad sequence (no recipients)
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('503');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RSET with parameter (should be ignored)
|
|
||||||
tap.test('RSET - should ignore parameters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset_with_param';
|
|
||||||
socket.write('RSET ignored parameter\r\n'); // Parameters should be ignored
|
|
||||||
} else if (currentStep === 'rset_with_param' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic VRFY command
|
|
||||||
tap.test('VRFY - should respond to VRFY command', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write('VRFY postmaster\r\n');
|
|
||||||
} else if (currentStep === 'vrfy' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const vrfyResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = vrfyResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// VRFY may be:
|
|
||||||
// 250/251 - User found/will forward
|
|
||||||
// 252 - Cannot verify but will try
|
|
||||||
// 502 - Command not implemented (common for security)
|
|
||||||
// 503 - Bad sequence of commands (this server rejects VRFY due to sequence validation)
|
|
||||||
// 550 - User not found
|
|
||||||
expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: VRFY multiple users
|
|
||||||
tap.test('VRFY - should handle multiple VRFY requests', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const testUsers = ['postmaster', 'admin', 'test', 'nonexistent'];
|
|
||||||
let currentUserIndex = 0;
|
|
||||||
const vrfyResults: Array<{ user: string; responseCode: string; supported: boolean }> = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write(`VRFY ${testUsers[currentUserIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'vrfy' && receivedData.includes('503') && currentUserIndex < testUsers.length) {
|
|
||||||
// This server always returns 503 for VRFY
|
|
||||||
vrfyResults.push({
|
|
||||||
user: testUsers[currentUserIndex],
|
|
||||||
responseCode: '503',
|
|
||||||
supported: false
|
|
||||||
});
|
|
||||||
|
|
||||||
currentUserIndex++;
|
|
||||||
|
|
||||||
if (currentUserIndex < testUsers.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`VRFY ${testUsers[currentUserIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should have results for all users
|
|
||||||
expect(vrfyResults.length).toEqual(testUsers.length);
|
|
||||||
|
|
||||||
// All responses should be valid SMTP codes
|
|
||||||
vrfyResults.forEach(result => {
|
|
||||||
expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: VRFY without parameter
|
|
||||||
tap.test('VRFY - should reject VRFY without parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy_empty';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write('VRFY\r\n'); // No user specified
|
|
||||||
} else if (currentStep === 'vrfy_empty' && receivedData.includes(' ')) {
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should be 501 (syntax error), 502 (not implemented), or 503 (bad sequence)
|
|
||||||
expect(responseCode).toMatch(/^(501|502|503)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: VRFY during transaction
|
|
||||||
tap.test('VRFY - should work during mail transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy_during_transaction';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write('VRFY test@example.com\r\n');
|
|
||||||
} else if (currentStep === 'vrfy_during_transaction' && receivedData.includes('503')) {
|
|
||||||
const responseCode = '503'; // We know this server always returns 503
|
|
||||||
|
|
||||||
// VRFY may be rejected with 503 during transaction in this server
|
|
||||||
expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: VRFY special addresses
|
|
||||||
tap.test('VRFY - should handle special addresses', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const specialAddresses = [
|
|
||||||
'postmaster',
|
|
||||||
'postmaster@localhost',
|
|
||||||
'abuse',
|
|
||||||
'abuse@localhost',
|
|
||||||
'noreply',
|
|
||||||
'<postmaster@localhost>' // With angle brackets
|
|
||||||
];
|
|
||||||
let currentIndex = 0;
|
|
||||||
const results: Array<{ address: string; responseCode: string }> = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy_special';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write(`VRFY ${specialAddresses[currentIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'vrfy_special' && receivedData.includes('503') && currentIndex < specialAddresses.length) {
|
|
||||||
// This server always returns 503 for VRFY
|
|
||||||
results.push({
|
|
||||||
address: specialAddresses[currentIndex],
|
|
||||||
responseCode: '503'
|
|
||||||
});
|
|
||||||
|
|
||||||
currentIndex++;
|
|
||||||
|
|
||||||
if (currentIndex < specialAddresses.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`VRFY ${specialAddresses[currentIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// All addresses should get valid responses
|
|
||||||
results.forEach(result => {
|
|
||||||
expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: VRFY security considerations
|
|
||||||
tap.test('VRFY - verify security behavior', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let commandDisabled = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy_security';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write('VRFY randomuser123\r\n');
|
|
||||||
} else if (currentStep === 'vrfy_security' && receivedData.includes(' ')) {
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
|
|
||||||
// Check if command is disabled for security or sequence validation
|
|
||||||
if (responseCode === '502' || responseCode === '252' || responseCode === '503') {
|
|
||||||
commandDisabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Note: Many servers disable VRFY for security reasons
|
|
||||||
// Both enabled and disabled are valid configurations
|
|
||||||
// This server rejects VRFY with 503 due to sequence validation
|
|
||||||
if (responseCode === '503' || commandDisabled) {
|
|
||||||
expect(responseCode).toMatch(/^(502|252|503)$/);
|
|
||||||
} else {
|
|
||||||
expect(responseCode).toMatch(/^(250|251|550)$/);
|
|
||||||
}
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic EXPN command
|
|
||||||
tap.test('EXPN - should respond to EXPN command', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write('EXPN postmaster\r\n');
|
|
||||||
} else if (currentStep === 'expn' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const expnResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = expnResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// EXPN may be:
|
|
||||||
// 250/251 - List expanded
|
|
||||||
// 252 - Cannot expand but will try to deliver
|
|
||||||
// 502 - Command not implemented (common for security)
|
|
||||||
// 503 - Bad sequence of commands (this server rejects EXPN due to sequence validation)
|
|
||||||
// 550 - List not found
|
|
||||||
expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN multiple lists
|
|
||||||
tap.test('EXPN - should handle multiple EXPN requests', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const testLists = ['postmaster', 'admin', 'staff', 'all', 'users'];
|
|
||||||
let currentListIndex = 0;
|
|
||||||
const expnResults: Array<{ list: string; responseCode: string; supported: boolean }> = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write(`EXPN ${testLists[currentListIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'expn' && receivedData.includes('503') && currentListIndex < testLists.length) {
|
|
||||||
// This server always returns 503 for EXPN
|
|
||||||
const responseCode = '503';
|
|
||||||
expnResults.push({
|
|
||||||
list: testLists[currentListIndex],
|
|
||||||
responseCode: responseCode,
|
|
||||||
supported: responseCode.startsWith('2')
|
|
||||||
});
|
|
||||||
|
|
||||||
currentListIndex++;
|
|
||||||
|
|
||||||
if (currentListIndex < testLists.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`EXPN ${testLists[currentListIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should have results for all lists
|
|
||||||
expect(expnResults.length).toEqual(testLists.length);
|
|
||||||
|
|
||||||
// All responses should be valid SMTP codes
|
|
||||||
expnResults.forEach(result => {
|
|
||||||
expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN without parameter
|
|
||||||
tap.test('EXPN - should reject EXPN without parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn_empty';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write('EXPN\r\n'); // No list specified
|
|
||||||
} else if (currentStep === 'expn_empty' && receivedData.includes(' ')) {
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should be 501 (syntax error), 502 (not implemented), or 503 (bad sequence)
|
|
||||||
expect(responseCode).toMatch(/^(501|502|503)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN during transaction
|
|
||||||
tap.test('EXPN - should work during mail transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn_during_transaction';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write('EXPN admin\r\n');
|
|
||||||
} else if (currentStep === 'expn_during_transaction' && receivedData.includes('503')) {
|
|
||||||
const responseCode = '503'; // We know this server always returns 503
|
|
||||||
|
|
||||||
// EXPN may be rejected with 503 during transaction in this server
|
|
||||||
expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN special lists
|
|
||||||
tap.test('EXPN - should handle special mailing lists', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const specialLists = [
|
|
||||||
'postmaster',
|
|
||||||
'postmaster@localhost',
|
|
||||||
'abuse',
|
|
||||||
'webmaster',
|
|
||||||
'noreply',
|
|
||||||
'<admin@localhost>' // With angle brackets
|
|
||||||
];
|
|
||||||
let currentIndex = 0;
|
|
||||||
const results: Array<{ list: string; responseCode: string }> = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn_special';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write(`EXPN ${specialLists[currentIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'expn_special' && receivedData.includes('503') && currentIndex < specialLists.length) {
|
|
||||||
// This server always returns 503 for EXPN
|
|
||||||
results.push({
|
|
||||||
list: specialLists[currentIndex],
|
|
||||||
responseCode: '503'
|
|
||||||
});
|
|
||||||
|
|
||||||
currentIndex++;
|
|
||||||
|
|
||||||
if (currentIndex < specialLists.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`EXPN ${specialLists[currentIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// All lists should get valid responses
|
|
||||||
results.forEach(result => {
|
|
||||||
expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN security considerations
|
|
||||||
tap.test('EXPN - verify security behavior', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let commandDisabled = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn_security';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write('EXPN randomlist123\r\n');
|
|
||||||
} else if (currentStep === 'expn_security' && receivedData.includes(' ')) {
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
|
|
||||||
// Check if command is disabled for security or sequence validation
|
|
||||||
if (responseCode === '502' || responseCode === '252' || responseCode === '503') {
|
|
||||||
commandDisabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Note: Many servers disable EXPN for security reasons
|
|
||||||
// to prevent email address harvesting
|
|
||||||
// Both enabled and disabled are valid configurations
|
|
||||||
// This server rejects EXPN with 503 due to sequence validation
|
|
||||||
if (responseCode === '503' || commandDisabled) {
|
|
||||||
expect(responseCode).toMatch(/^(502|252|503)$/);
|
|
||||||
console.log('EXPN disabled - good security practice');
|
|
||||||
} else {
|
|
||||||
expect(responseCode).toMatch(/^(250|251|550)$/);
|
|
||||||
}
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN response format
|
|
||||||
tap.test('EXPN - verify proper response format when supported', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn_format';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write('EXPN postmaster\r\n');
|
|
||||||
} else if (currentStep === 'expn_format' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
|
|
||||||
// This server returns 503 for EXPN commands
|
|
||||||
if (receivedData.includes('503')) {
|
|
||||||
// Server doesn't support EXPN in the current state
|
|
||||||
expect(receivedData).toInclude('503');
|
|
||||||
} else if (receivedData.includes('250-') || receivedData.includes('250 ')) {
|
|
||||||
// Multi-line response format check
|
|
||||||
const expansionLines = lines.filter(l => l.startsWith('250'));
|
|
||||||
expect(expansionLines.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 15000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SIZE extension advertised in EHLO
|
|
||||||
tap.test('SIZE Extension - should advertise SIZE in EHLO response', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let sizeSupported = false;
|
|
||||||
let maxMessageSize: number | null = null;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
// Check if SIZE extension is advertised
|
|
||||||
if (receivedData.includes('SIZE')) {
|
|
||||||
sizeSupported = true;
|
|
||||||
|
|
||||||
// Extract maximum message size if specified
|
|
||||||
const sizeMatch = receivedData.match(/SIZE\s+(\d+)/);
|
|
||||||
if (sizeMatch) {
|
|
||||||
maxMessageSize = parseInt(sizeMatch[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(sizeSupported).toEqual(true);
|
|
||||||
if (maxMessageSize !== null) {
|
|
||||||
expect(maxMessageSize).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: MAIL FROM with SIZE parameter
|
|
||||||
tap.test('SIZE Extension - should accept MAIL FROM with SIZE parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const messageSize = 1000;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_size';
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${messageSize}\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from_size' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250 OK');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SIZE parameter with various sizes
|
|
||||||
tap.test('SIZE Extension - should handle different message sizes', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const testSizes = [1000, 10000, 100000, 1000000]; // 1KB, 10KB, 100KB, 1MB
|
|
||||||
let currentSizeIndex = 0;
|
|
||||||
const sizeResults: Array<{ size: number; accepted: boolean; response: string }> = [];
|
|
||||||
|
|
||||||
const testNextSize = () => {
|
|
||||||
if (currentSizeIndex < testSizes.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
const size = testSizes[currentSizeIndex];
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${size}\r\n`);
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// At least some sizes should be accepted
|
|
||||||
const acceptedCount = sizeResults.filter(r => r.accepted).length;
|
|
||||||
expect(acceptedCount).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Verify larger sizes may be rejected
|
|
||||||
const largeRejected = sizeResults
|
|
||||||
.filter(r => r.size >= 1000000 && !r.accepted)
|
|
||||||
.length;
|
|
||||||
expect(largeRejected + acceptedCount).toEqual(sizeResults.length);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_sizes';
|
|
||||||
testNextSize();
|
|
||||||
} else if (currentStep === 'mail_from_sizes') {
|
|
||||||
if (receivedData.includes('250')) {
|
|
||||||
// Size accepted
|
|
||||||
sizeResults.push({
|
|
||||||
size: testSizes[currentSizeIndex],
|
|
||||||
accepted: true,
|
|
||||||
response: receivedData.trim()
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
currentSizeIndex++;
|
|
||||||
currentStep = 'rset';
|
|
||||||
} else if (receivedData.includes('552') || receivedData.includes('5')) {
|
|
||||||
// Size rejected
|
|
||||||
sizeResults.push({
|
|
||||||
size: testSizes[currentSizeIndex],
|
|
||||||
accepted: false,
|
|
||||||
response: receivedData.trim()
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
currentSizeIndex++;
|
|
||||||
currentStep = 'rset';
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_sizes';
|
|
||||||
testNextSize();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SIZE parameter exceeding limit
|
|
||||||
tap.test('SIZE Extension - should reject SIZE exceeding server limit', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let maxSize: number | null = null;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
// Extract max size if advertised
|
|
||||||
const sizeMatch = receivedData.match(/SIZE\s+(\d+)/);
|
|
||||||
if (sizeMatch) {
|
|
||||||
maxSize = parseInt(sizeMatch[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentStep = 'mail_from_oversized';
|
|
||||||
// Try to send a message larger than any reasonable limit
|
|
||||||
const oversizedValue = maxSize ? maxSize + 1 : 100000000; // 100MB or maxSize+1
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${oversizedValue}\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from_oversized') {
|
|
||||||
if (receivedData.includes('552') || receivedData.includes('5')) {
|
|
||||||
// Size limit exceeded - expected
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toMatch(/552|5\d{2}/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
} else if (receivedData.includes('250')) {
|
|
||||||
// If accepted, server has very high or no limit
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SIZE=0 (empty message)
|
|
||||||
tap.test('SIZE Extension - should handle SIZE=0', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_zero_size';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> SIZE=0\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_zero_size' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Invalid SIZE parameter
|
|
||||||
tap.test('SIZE Extension - should reject invalid SIZE values', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const invalidSizes = ['abc', '-1', '1.5', '']; // Invalid size values
|
|
||||||
let currentIndex = 0;
|
|
||||||
const results: Array<{ value: string; rejected: boolean }> = [];
|
|
||||||
|
|
||||||
const testNextInvalidSize = () => {
|
|
||||||
if (currentIndex < invalidSizes.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
const invalidSize = invalidSizes[currentIndex];
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${invalidSize}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// This server accepts invalid SIZE values without strict validation
|
|
||||||
// This is permissive but not necessarily incorrect
|
|
||||||
// Just verify we got responses for all test cases
|
|
||||||
expect(results.length).toEqual(invalidSizes.length);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'invalid_sizes';
|
|
||||||
testNextInvalidSize();
|
|
||||||
} else if (currentStep === 'invalid_sizes' && currentIndex < invalidSizes.length) {
|
|
||||||
if (receivedData.includes('250')) {
|
|
||||||
// This server accepts invalid size values
|
|
||||||
results.push({
|
|
||||||
value: invalidSizes[currentIndex],
|
|
||||||
rejected: false
|
|
||||||
});
|
|
||||||
} else if (receivedData.includes('501') || receivedData.includes('552')) {
|
|
||||||
// Invalid parameter - proper validation
|
|
||||||
results.push({
|
|
||||||
value: invalidSizes[currentIndex],
|
|
||||||
rejected: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
currentIndex++;
|
|
||||||
currentStep = 'rset';
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'invalid_sizes';
|
|
||||||
testNextInvalidSize();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SIZE with actual message data
|
|
||||||
tap.test('SIZE Extension - should enforce SIZE during DATA phase', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const declaredSize = 100; // Declare 100 bytes
|
|
||||||
const actualMessage = 'X'.repeat(200); // Send 200 bytes (more than declared)
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${declaredSize}\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'message';
|
|
||||||
// Send message larger than declared size
|
|
||||||
socket.write(`Subject: Size Test\r\n\r\n${actualMessage}\r\n.\r\n`);
|
|
||||||
} else if (currentStep === 'message') {
|
|
||||||
// Server may accept or reject based on enforcement
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Either accepted (250) or rejected (552)
|
|
||||||
expect(receivedData).toMatch(/250|552/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic HELP command
|
|
||||||
tap.test('HELP - should respond to general HELP command', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help';
|
|
||||||
receivedData = ''; // Clear buffer before sending HELP
|
|
||||||
socket.write('HELP\r\n');
|
|
||||||
} else if (currentStep === 'help' && receivedData.includes('214')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const helpResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = helpResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// HELP may return:
|
|
||||||
// 214 - Help message
|
|
||||||
// 502 - Command not implemented
|
|
||||||
// 504 - Command parameter not implemented
|
|
||||||
expect(responseCode).toMatch(/^(214|502|504)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP with specific topics
|
|
||||||
tap.test('HELP - should respond to HELP with specific command topics', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const helpTopics = ['EHLO', 'MAIL', 'RCPT', 'DATA', 'QUIT'];
|
|
||||||
let currentTopicIndex = 0;
|
|
||||||
const helpResults: Array<{ topic: string; responseCode: string; supported: boolean }> = [];
|
|
||||||
|
|
||||||
const getLastResponse = (data: string): string => {
|
|
||||||
const lines = data.split('\r\n');
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const line = lines[i].trim();
|
|
||||||
if (line && /^\d{3}/.test(line)) {
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help_topics';
|
|
||||||
receivedData = ''; // Clear buffer before sending first HELP topic
|
|
||||||
socket.write(`HELP ${helpTopics[currentTopicIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'help_topics' && (receivedData.includes('214') || receivedData.includes('502') || receivedData.includes('504'))) {
|
|
||||||
const lastResponse = getLastResponse(receivedData);
|
|
||||||
|
|
||||||
if (lastResponse && lastResponse.match(/^\d{3}/)) {
|
|
||||||
const responseCode = lastResponse.substring(0, 3);
|
|
||||||
helpResults.push({
|
|
||||||
topic: helpTopics[currentTopicIndex],
|
|
||||||
responseCode: responseCode,
|
|
||||||
supported: responseCode === '214'
|
|
||||||
});
|
|
||||||
|
|
||||||
currentTopicIndex++;
|
|
||||||
|
|
||||||
if (currentTopicIndex < helpTopics.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`HELP ${helpTopics[currentTopicIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should have results for all topics
|
|
||||||
expect(helpResults.length).toEqual(helpTopics.length);
|
|
||||||
|
|
||||||
// All responses should be valid
|
|
||||||
helpResults.forEach(result => {
|
|
||||||
expect(result.responseCode).toMatch(/^(214|502|504)$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP response format
|
|
||||||
tap.test('HELP - should return properly formatted help text', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let helpResponse = '';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help';
|
|
||||||
receivedData = ''; // Clear to capture only HELP response
|
|
||||||
socket.write('HELP\r\n');
|
|
||||||
} else if (currentStep === 'help') {
|
|
||||||
helpResponse = receivedData;
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
|
|
||||||
if (responseCode === '214') {
|
|
||||||
// Help is supported - check format
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const helpLines = lines.filter(l => l.startsWith('214'));
|
|
||||||
|
|
||||||
// Should have at least one help line
|
|
||||||
expect(helpLines.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Multi-line help should use 214- prefix
|
|
||||||
if (helpLines.length > 1) {
|
|
||||||
const hasMultilineFormat = helpLines.some(l => l.startsWith('214-'));
|
|
||||||
expect(hasMultilineFormat).toEqual(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP during transaction
|
|
||||||
tap.test('HELP - should work during mail transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help_during_transaction';
|
|
||||||
receivedData = ''; // Clear buffer before sending HELP
|
|
||||||
socket.write('HELP RCPT\r\n');
|
|
||||||
} else if (currentStep === 'help_during_transaction' && receivedData.includes('214')) {
|
|
||||||
const responseCode = '214'; // We know HELP works on this server
|
|
||||||
|
|
||||||
// HELP should work even during transaction
|
|
||||||
expect(responseCode).toMatch(/^(214|502|504)$/);
|
|
||||||
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP with invalid topic
|
|
||||||
tap.test('HELP - should handle HELP with invalid topic', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help_invalid';
|
|
||||||
receivedData = ''; // Clear buffer before sending HELP
|
|
||||||
socket.write('HELP INVALID_COMMAND_XYZ\r\n');
|
|
||||||
} else if (currentStep === 'help_invalid' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const helpResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = helpResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should return 504 (command parameter not implemented) or
|
|
||||||
// 214 (general help) or 502 (not implemented)
|
|
||||||
expect(responseCode).toMatch(/^(214|502|504)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP availability check
|
|
||||||
tap.test('HELP - verify HELP command optional status', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let helpSupported = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
// Check if HELP is advertised in EHLO response
|
|
||||||
if (receivedData.includes('HELP')) {
|
|
||||||
console.log('HELP command advertised in EHLO response');
|
|
||||||
}
|
|
||||||
|
|
||||||
currentStep = 'help_test';
|
|
||||||
receivedData = ''; // Clear buffer before sending HELP
|
|
||||||
socket.write('HELP\r\n');
|
|
||||||
} else if (currentStep === 'help_test' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const helpResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = helpResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
if (responseCode === '214') {
|
|
||||||
helpSupported = true;
|
|
||||||
console.log('HELP command is supported');
|
|
||||||
} else if (responseCode === '502') {
|
|
||||||
console.log('HELP command not implemented (optional per RFC 5321)');
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Both supported and not supported are valid
|
|
||||||
expect(responseCode).toMatch(/^(214|502)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP content usefulness
|
|
||||||
tap.test('HELP - check if help content is useful when supported', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help_data';
|
|
||||||
receivedData = ''; // Clear buffer before sending HELP
|
|
||||||
socket.write('HELP DATA\r\n');
|
|
||||||
} else if (currentStep === 'help_data' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const helpResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = helpResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
if (responseCode === '214') {
|
|
||||||
// Check if help text mentions relevant DATA command info
|
|
||||||
const helpText = receivedData.toLowerCase();
|
|
||||||
if (helpText.includes('data') || helpText.includes('message') || helpText.includes('354')) {
|
|
||||||
console.log('HELP provides relevant information about DATA command');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Command Pipelining - should advertise PIPELINING in EHLO response', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
const banner = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(banner).toInclude('220');
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('EHLO response:', ehloResponse);
|
|
||||||
|
|
||||||
// Check if PIPELINING is advertised
|
|
||||||
const pipeliningAdvertised = ehloResponse.includes('250-PIPELINING') || ehloResponse.includes('250 PIPELINING');
|
|
||||||
console.log('PIPELINING advertised:', pipeliningAdvertised);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
// Note: PIPELINING is optional per RFC 2920
|
|
||||||
expect(ehloResponse).toInclude('250');
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Command Pipelining - should handle pipelined MAIL FROM and RCPT TO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send pipelined commands (all at once)
|
|
||||||
const pipelinedCommands =
|
|
||||||
'MAIL FROM:<sender@example.com>\r\n' +
|
|
||||||
'RCPT TO:<recipient@example.com>\r\n';
|
|
||||||
|
|
||||||
console.log('Sending pipelined commands...');
|
|
||||||
socket.write(pipelinedCommands);
|
|
||||||
|
|
||||||
// Collect responses
|
|
||||||
const responses = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
let responseCount = 0;
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
const lines = data.split('\r\n').filter(line => line.trim());
|
|
||||||
|
|
||||||
// Count responses that look like complete SMTP responses
|
|
||||||
const completeResponses = lines.filter(line => /^[0-9]{3}(\s|-)/.test(line));
|
|
||||||
|
|
||||||
// We expect 2 responses (one for MAIL FROM, one for RCPT TO)
|
|
||||||
if (completeResponses.length >= 2) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
|
|
||||||
// Timeout if we don't get responses
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Pipelined command responses:', responses);
|
|
||||||
|
|
||||||
// Parse responses
|
|
||||||
const responseLines = responses.split('\r\n').filter(line => line.trim());
|
|
||||||
const mailFromResponse = responseLines.find(line => line.match(/^250.*/) && responseLines.indexOf(line) === 0);
|
|
||||||
const rcptToResponse = responseLines.find(line => line.match(/^250.*/) && responseLines.indexOf(line) === 1);
|
|
||||||
|
|
||||||
// Both commands should succeed
|
|
||||||
expect(mailFromResponse).toBeDefined();
|
|
||||||
expect(rcptToResponse).toBeDefined();
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Command Pipelining - should handle pipelined commands with DATA', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send pipelined MAIL FROM, RCPT TO, and DATA commands
|
|
||||||
const pipelinedCommands =
|
|
||||||
'MAIL FROM:<sender@example.com>\r\n' +
|
|
||||||
'RCPT TO:<recipient@example.com>\r\n' +
|
|
||||||
'DATA\r\n';
|
|
||||||
|
|
||||||
console.log('Sending pipelined commands with DATA...');
|
|
||||||
socket.write(pipelinedCommands);
|
|
||||||
|
|
||||||
// Collect responses
|
|
||||||
const responses = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
|
|
||||||
// Look for the DATA prompt (354)
|
|
||||||
if (data.includes('354')) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Responses including DATA:', responses);
|
|
||||||
|
|
||||||
// Should get 250 for MAIL FROM, 250 for RCPT TO, and 354 for DATA
|
|
||||||
expect(responses).toInclude('250'); // MAIL FROM OK
|
|
||||||
expect(responses).toInclude('354'); // Start mail input
|
|
||||||
|
|
||||||
// Send email content
|
|
||||||
const emailContent = 'Subject: Pipelining Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\nTest email with pipelining.\r\n.\r\n';
|
|
||||||
socket.write(emailContent);
|
|
||||||
|
|
||||||
// Get final response
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Final response:', finalResponse);
|
|
||||||
expect(finalResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Command Pipelining - should handle pipelined NOOP commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send multiple pipelined NOOP commands
|
|
||||||
const pipelinedNoops =
|
|
||||||
'NOOP\r\n' +
|
|
||||||
'NOOP\r\n' +
|
|
||||||
'NOOP\r\n';
|
|
||||||
|
|
||||||
console.log('Sending pipelined NOOP commands...');
|
|
||||||
socket.write(pipelinedNoops);
|
|
||||||
|
|
||||||
// Collect responses
|
|
||||||
const responses = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
const responseCount = (data.match(/^250.*OK/gm) || []).length;
|
|
||||||
|
|
||||||
// We expect 3 NOOP responses
|
|
||||||
if (responseCount >= 3) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('NOOP responses:', responses);
|
|
||||||
|
|
||||||
// Count OK responses
|
|
||||||
const okResponses = (responses.match(/^250.*OK/gm) || []).length;
|
|
||||||
expect(okResponses).toBeGreaterThanOrEqual(3);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic HELO command
|
|
||||||
tap.test('HELO - should accept HELO command', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo';
|
|
||||||
socket.write('HELO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'helo' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELO without hostname
|
|
||||||
tap.test('HELO - should reject HELO without hostname', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo_no_hostname';
|
|
||||||
socket.write('HELO\r\n'); // Missing hostname
|
|
||||||
} else if (currentStep === 'helo_no_hostname' && receivedData.includes('501')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('501'); // Syntax error
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Multiple HELO commands
|
|
||||||
tap.test('HELO - should accept multiple HELO commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let heloCount = 0;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'first_helo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('HELO test1.example.com\r\n');
|
|
||||||
} else if (currentStep === 'first_helo' && receivedData.includes('250 ')) {
|
|
||||||
heloCount++;
|
|
||||||
currentStep = 'second_helo';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('HELO test2.example.com\r\n');
|
|
||||||
} else if (currentStep === 'second_helo' && receivedData.includes('250 ')) {
|
|
||||||
heloCount++;
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(heloCount).toEqual(2);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELO after EHLO
|
|
||||||
tap.test('HELO - should accept HELO after EHLO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'helo_after_ehlo';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('HELO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'helo_after_ehlo' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELO response format
|
|
||||||
tap.test('HELO - should return simple 250 response', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let heloResponse = '';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo';
|
|
||||||
receivedData = ''; // Clear to capture only HELO response
|
|
||||||
socket.write('HELO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'helo' && receivedData.includes('250')) {
|
|
||||||
heloResponse = receivedData.trim();
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// This server returns multi-line response even for HELO
|
|
||||||
// (technically incorrect per RFC, but we test actual behavior)
|
|
||||||
expect(heloResponse).toStartWith('250');
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SMTP commands after HELO
|
|
||||||
tap.test('HELO - should process SMTP commands after HELO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo';
|
|
||||||
socket.write('HELO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'helo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELO with special characters
|
|
||||||
tap.test('HELO - should handle hostnames with special characters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const specialHostnames = [
|
|
||||||
'test-host.example.com', // Hyphen
|
|
||||||
'test_host.example.com', // Underscore (technically invalid but common)
|
|
||||||
'192.168.1.1', // IP address
|
|
||||||
'[192.168.1.1]', // Bracketed IP
|
|
||||||
'localhost', // Single label
|
|
||||||
'UPPERCASE.EXAMPLE.COM' // Uppercase
|
|
||||||
];
|
|
||||||
let currentIndex = 0;
|
|
||||||
const results: Array<{ hostname: string; accepted: boolean }> = [];
|
|
||||||
|
|
||||||
const testNextHostname = () => {
|
|
||||||
if (currentIndex < specialHostnames.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`HELO ${specialHostnames[currentIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Most hostnames should be accepted
|
|
||||||
const acceptedCount = results.filter(r => r.accepted).length;
|
|
||||||
expect(acceptedCount).toBeGreaterThan(specialHostnames.length / 2);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo_special';
|
|
||||||
testNextHostname();
|
|
||||||
} else if (currentStep === 'helo_special') {
|
|
||||||
if (receivedData.includes('250')) {
|
|
||||||
results.push({
|
|
||||||
hostname: specialHostnames[currentIndex],
|
|
||||||
accepted: true
|
|
||||||
});
|
|
||||||
} else if (receivedData.includes('501')) {
|
|
||||||
results.push({
|
|
||||||
hostname: specialHostnames[currentIndex],
|
|
||||||
accepted: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
currentIndex++;
|
|
||||||
testNextHostname();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELO vs EHLO feature availability
|
|
||||||
tap.test('HELO - verify no extensions with HELO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo';
|
|
||||||
socket.write('HELO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'helo' && receivedData.includes('250')) {
|
|
||||||
// Note: This server returns ESMTP extensions even for HELO commands
|
|
||||||
// This differs from strict RFC compliance but matches the server's behavior
|
|
||||||
// expect(receivedData).not.toInclude('SIZE');
|
|
||||||
// expect(receivedData).not.toInclude('STARTTLS');
|
|
||||||
// expect(receivedData).not.toInclude('AUTH');
|
|
||||||
// expect(receivedData).not.toInclude('8BITMIME');
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic QUIT command
|
|
||||||
tap.test('QUIT - should close connection gracefully', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let connectionClosed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
// Don't destroy immediately, wait for server to close connection
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!connectionClosed) {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('221'); // Closing connection message
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
connectionClosed = true;
|
|
||||||
expect(receivedData).toInclude('221');
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: QUIT during transaction
|
|
||||||
tap.test('QUIT - should work during active transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('221');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: QUIT immediately after connect
|
|
||||||
tap.test('QUIT - should work immediately after connection', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('221');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: QUIT with parameters (should be ignored or rejected)
|
|
||||||
tap.test('QUIT - should handle QUIT with parameters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'quit_with_param';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('QUIT unexpected parameter\r\n');
|
|
||||||
} else if (currentStep === 'quit_with_param' && (receivedData.includes('221') || receivedData.includes('501'))) {
|
|
||||||
// Server may accept (221) or reject (501) QUIT with parameters
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
socket.destroy();
|
|
||||||
expect(['221', '501']).toInclude(responseCode);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Multiple QUITs (second should fail)
|
|
||||||
tap.test('QUIT - second QUIT should fail after connection closed', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let quitSent = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
quitSent = true;
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
// Try to send another QUIT
|
|
||||||
try {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
// If write succeeds, wait a bit to see if we get a response
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve(); // Test passes either way
|
|
||||||
}, 500);
|
|
||||||
} catch (err) {
|
|
||||||
// Write failed because connection closed - this is expected
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
if (quitSent) {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
if (quitSent && error.message.includes('EPIPE')) {
|
|
||||||
// Expected error when writing to closed socket
|
|
||||||
done.resolve();
|
|
||||||
} else {
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: QUIT response format
|
|
||||||
tap.test('QUIT - should return proper 221 response', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let quitResponse = '';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
receivedData = ''; // Clear buffer to capture only QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
quitResponse = receivedData.trim();
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(quitResponse).toStartWith('221');
|
|
||||||
expect(quitResponse.toLowerCase()).toInclude('closing');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Connection cleanup after QUIT
|
|
||||||
tap.test('QUIT - verify clean connection shutdown', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let closeEventFired = false;
|
|
||||||
let endEventFired = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
// Wait for clean shutdown
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!closeEventFired && !endEventFired) {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('end', () => {
|
|
||||||
endEventFired = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
closeEventFired = true;
|
|
||||||
if (currentStep === 'quit') {
|
|
||||||
expect(endEventFired || closeEventFired).toEqual(true);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
import { connectToSmtp, performSmtpHandshake, closeSmtpConnection } from '../../helpers/utils.js';
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server with TLS support', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: 2525,
|
|
||||||
tlsEnabled: true // Enable TLS support
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
expect(testServer.port).toEqual(2525);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CM-01: TLS Connection Test - server should advertise STARTTLS capability', async () => {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Connect to SMTP server
|
|
||||||
const socket = await connectToSmtp(testServer.hostname, testServer.port);
|
|
||||||
expect(socket).toBeInstanceOf(Object);
|
|
||||||
|
|
||||||
// Perform handshake and get capabilities
|
|
||||||
const capabilities = await performSmtpHandshake(socket, 'test.example.com');
|
|
||||||
expect(capabilities).toBeArray();
|
|
||||||
|
|
||||||
// Check for STARTTLS support
|
|
||||||
const supportsStarttls = capabilities.some(cap => cap.toUpperCase().includes('STARTTLS'));
|
|
||||||
expect(supportsStarttls).toEqual(true);
|
|
||||||
|
|
||||||
// Close connection gracefully
|
|
||||||
await closeSmtpConnection(socket);
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
console.log(`✅ TLS capability test completed in ${duration}ms`);
|
|
||||||
console.log(`📋 Server capabilities: ${capabilities.join(', ')}`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
console.error(`❌ TLS connection test failed after ${duration}ms:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CM-01: TLS Connection Test - verify TLS certificate configuration', async () => {
|
|
||||||
// This test verifies that the server has TLS certificates configured
|
|
||||||
expect(testServer.config.tlsEnabled).toEqual(true);
|
|
||||||
|
|
||||||
// The server should have loaded certificates during startup
|
|
||||||
// In production, this would validate actual certificate properties
|
|
||||||
console.log('✅ TLS configuration verified');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
console.log('✅ Test server stopped');
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
import { createConcurrentConnections, performSmtpHandshake, closeSmtpConnection } from '../../helpers/utils.js';
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
const CONCURRENT_COUNT = 10;
|
|
||||||
const TEST_PORT = 2527;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: 2526
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
expect(testServer.port).toEqual(2526);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CM-02: Multiple Simultaneous Connections - server handles concurrent connections', async () => {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create multiple concurrent connections
|
|
||||||
console.log(`🔄 Creating ${CONCURRENT_COUNT} concurrent connections...`);
|
|
||||||
const sockets = await createConcurrentConnections(
|
|
||||||
testServer.hostname,
|
|
||||||
testServer.port,
|
|
||||||
CONCURRENT_COUNT
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(sockets).toBeArray();
|
|
||||||
expect(sockets.length).toEqual(CONCURRENT_COUNT);
|
|
||||||
|
|
||||||
// Verify all connections are active
|
|
||||||
let activeCount = 0;
|
|
||||||
for (const socket of sockets) {
|
|
||||||
if (socket && !socket.destroyed) {
|
|
||||||
activeCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expect(activeCount).toEqual(CONCURRENT_COUNT);
|
|
||||||
|
|
||||||
// Perform handshake on all connections
|
|
||||||
console.log('🤝 Performing handshake on all connections...');
|
|
||||||
const handshakePromises = sockets.map(socket =>
|
|
||||||
performSmtpHandshake(socket).catch(err => ({ error: err.message }))
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await Promise.all(handshakePromises);
|
|
||||||
const successCount = results.filter(r => Array.isArray(r)).length;
|
|
||||||
|
|
||||||
expect(successCount).toBeGreaterThan(0);
|
|
||||||
console.log(`✅ ${successCount}/${CONCURRENT_COUNT} connections completed handshake`);
|
|
||||||
|
|
||||||
// Close all connections
|
|
||||||
console.log('🔚 Closing all connections...');
|
|
||||||
await Promise.all(
|
|
||||||
sockets.map(socket => closeSmtpConnection(socket).catch(() => {}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
console.log(`✅ Multiple connection test completed in ${duration}ms`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Multiple connection test failed:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Enable this test when connection limits are implemented in the server
|
|
||||||
// tap.test('CM-02: Connection limit enforcement - verify max connections', async () => {
|
|
||||||
// const maxConnections = 5;
|
|
||||||
//
|
|
||||||
// // Start a new server with lower connection limit
|
|
||||||
// const limitedServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
//
|
|
||||||
// await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
//
|
|
||||||
// try {
|
|
||||||
// // Try to create more connections than allowed
|
|
||||||
// const attemptCount = maxConnections + 5;
|
|
||||||
// console.log(`🔄 Attempting ${attemptCount} connections (limit: ${maxConnections})...`);
|
|
||||||
//
|
|
||||||
// const connectionPromises = [];
|
|
||||||
// for (let i = 0; i < attemptCount; i++) {
|
|
||||||
// connectionPromises.push(
|
|
||||||
// createConcurrentConnections(limitedServer.hostname, limitedServer.port, 1)
|
|
||||||
// .then(() => ({ success: true, index: i }))
|
|
||||||
// .catch(err => ({ success: false, index: i, error: err.message }))
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// const results = await Promise.all(connectionPromises);
|
|
||||||
// const successfulConnections = results.filter(r => r.success).length;
|
|
||||||
// const failedConnections = results.filter(r => !r.success).length;
|
|
||||||
//
|
|
||||||
// console.log(`✅ Successful connections: ${successfulConnections}`);
|
|
||||||
// console.log(`❌ Failed connections: ${failedConnections}`);
|
|
||||||
//
|
|
||||||
// // Some connections should fail due to limit
|
|
||||||
// expect(failedConnections).toBeGreaterThan(0);
|
|
||||||
//
|
|
||||||
// } finally {
|
|
||||||
// await stopTestServer(limitedServer);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
console.log('✅ Test server stopped');
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
import * as plugins from '../../../ts/plugins.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server with short timeout', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 5000 // 5 second timeout for this test
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CM-03: Connection Timeout - idle connections are closed after timeout', async (tools) => {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Create connection
|
|
||||||
const socket = await new Promise<plugins.net.Socket>((resolve, reject) => {
|
|
||||||
const client = plugins.net.createConnection({
|
|
||||||
host: testServer.hostname,
|
|
||||||
port: testServer.port
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('connect', () => resolve(client));
|
|
||||||
client.on('error', reject);
|
|
||||||
|
|
||||||
setTimeout(() => reject(new Error('Connection timeout')), 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (data) => {
|
|
||||||
const response = data.toString();
|
|
||||||
expect(response).toInclude('220');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Connected and received greeting');
|
|
||||||
|
|
||||||
// Now stay idle and wait for server to timeout the connection
|
|
||||||
const disconnectPromise = new Promise<number>((resolve) => {
|
|
||||||
socket.on('close', () => {
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
resolve(duration);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('end', () => {
|
|
||||||
console.log('📡 Server initiated connection close');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.log('⚠️ Socket error:', err.message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for timeout (should be around 5 seconds)
|
|
||||||
const duration = await disconnectPromise;
|
|
||||||
|
|
||||||
console.log(`⏱️ Connection closed after ${duration}ms`);
|
|
||||||
|
|
||||||
// Verify timeout happened within expected range (4-6 seconds)
|
|
||||||
expect(duration).toBeGreaterThan(4000);
|
|
||||||
expect(duration).toBeLessThan(7000);
|
|
||||||
|
|
||||||
console.log('✅ Connection timeout test passed');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CM-03: Active connection should not timeout', async () => {
|
|
||||||
// Create new connection
|
|
||||||
const socket = plugins.net.createConnection({
|
|
||||||
host: testServer.hostname,
|
|
||||||
port: testServer.port
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.on('connect', resolve);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', resolve);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep connection active with NOOP commands
|
|
||||||
let isConnected = true;
|
|
||||||
socket.on('close', () => {
|
|
||||||
isConnected = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send NOOP every 2 seconds for 8 seconds
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
if (!isConnected) break;
|
|
||||||
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
|
|
||||||
// Wait for response
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (data) => {
|
|
||||||
const response = data.toString();
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`✅ NOOP ${i + 1}/4 successful`);
|
|
||||||
|
|
||||||
// Wait 2 seconds before next NOOP
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection should still be active
|
|
||||||
expect(isConnected).toEqual(true);
|
|
||||||
|
|
||||||
// Close connection gracefully
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => {
|
|
||||||
socket.end();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Active connection did not timeout');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts'
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
const TEST_TIMEOUT = 5000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('setup - start SMTP server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic connection limit enforcement
|
|
||||||
tap.test('Connection Limits - should handle multiple connections gracefully', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const maxConnections = 20; // Test with reasonable number
|
|
||||||
const testConnections = maxConnections + 5; // Try 5 more than limit
|
|
||||||
const connections: net.Socket[] = [];
|
|
||||||
const connectionPromises: Promise<{ index: number; success: boolean; error?: string }>[] = [];
|
|
||||||
|
|
||||||
// Helper to create a connection with index
|
|
||||||
const createConnectionWithIndex = (index: number): Promise<{ index: number; success: boolean; error?: string }> => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let timeoutHandle: NodeJS.Timeout;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
connections[index] = socket;
|
|
||||||
|
|
||||||
// Wait for server greeting
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (data.toString().includes('220')) {
|
|
||||||
resolve({ index, success: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
resolve({ index, success: false, error: err.message });
|
|
||||||
});
|
|
||||||
|
|
||||||
timeoutHandle = setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve({ index, success: false, error: 'Connection timeout' });
|
|
||||||
}, TEST_TIMEOUT);
|
|
||||||
} catch (err: any) {
|
|
||||||
resolve({ index, success: false, error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create connections
|
|
||||||
for (let i = 0; i < testConnections; i++) {
|
|
||||||
connectionPromises.push(createConnectionWithIndex(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(connectionPromises);
|
|
||||||
|
|
||||||
// Count successful connections
|
|
||||||
const successfulConnections = results.filter(r => r.success).length;
|
|
||||||
const failedConnections = results.filter(r => !r.success).length;
|
|
||||||
|
|
||||||
// Clean up connections
|
|
||||||
for (const socket of connections) {
|
|
||||||
if (socket && !socket.destroyed) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => socket.destroy(), 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
expect(successfulConnections).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// If some connections were rejected, that's good (limit enforced)
|
|
||||||
// If all connections succeeded, that's also acceptable (high/no limit)
|
|
||||||
if (failedConnections > 0) {
|
|
||||||
console.log(`Server enforced connection limit: ${successfulConnections} accepted, ${failedConnections} rejected`);
|
|
||||||
} else {
|
|
||||||
console.log(`Server accepted all ${successfulConnections} connections`);
|
|
||||||
}
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Connection limit recovery
|
|
||||||
tap.test('Connection Limits - should accept new connections after closing old ones', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const batchSize = 10;
|
|
||||||
const firstBatch: net.Socket[] = [];
|
|
||||||
const secondBatch: net.Socket[] = [];
|
|
||||||
|
|
||||||
// Create first batch of connections
|
|
||||||
const firstBatchPromises = [];
|
|
||||||
for (let i = 0; i < batchSize; i++) {
|
|
||||||
firstBatchPromises.push(
|
|
||||||
new Promise<boolean>((resolve) => {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
firstBatch.push(socket);
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (data.toString().includes('220')) {
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', () => resolve(false));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstResults = await Promise.all(firstBatchPromises);
|
|
||||||
const firstSuccessCount = firstResults.filter(r => r).length;
|
|
||||||
|
|
||||||
// Close first batch
|
|
||||||
for (const socket of firstBatch) {
|
|
||||||
if (socket && !socket.destroyed) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for connections to close
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
// Destroy sockets
|
|
||||||
for (const socket of firstBatch) {
|
|
||||||
if (socket && !socket.destroyed) {
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create second batch
|
|
||||||
const secondBatchPromises = [];
|
|
||||||
for (let i = 0; i < batchSize; i++) {
|
|
||||||
secondBatchPromises.push(
|
|
||||||
new Promise<boolean>((resolve) => {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
secondBatch.push(socket);
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (data.toString().includes('220')) {
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', () => resolve(false));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondResults = await Promise.all(secondBatchPromises);
|
|
||||||
const secondSuccessCount = secondResults.filter(r => r).length;
|
|
||||||
|
|
||||||
// Clean up second batch
|
|
||||||
for (const socket of secondBatch) {
|
|
||||||
if (socket && !socket.destroyed) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => socket.destroy(), 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both batches should have successful connections
|
|
||||||
expect(firstSuccessCount).toBeGreaterThan(0);
|
|
||||||
expect(secondSuccessCount).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Rapid connection attempts
|
|
||||||
tap.test('Connection Limits - should handle rapid connection attempts', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const rapidConnections = 50;
|
|
||||||
const connections: net.Socket[] = [];
|
|
||||||
let successCount = 0;
|
|
||||||
let errorCount = 0;
|
|
||||||
|
|
||||||
// Create connections as fast as possible
|
|
||||||
for (let i = 0; i < rapidConnections; i++) {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
connections.push(socket);
|
|
||||||
successCount++;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', () => {
|
|
||||||
errorCount++;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all connection attempts to settle
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
for (const socket of connections) {
|
|
||||||
if (socket && !socket.destroyed) {
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should handle at least some connections
|
|
||||||
expect(successCount).toBeGreaterThan(0);
|
|
||||||
console.log(`Rapid connections: ${successCount} succeeded, ${errorCount} failed`);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Connection limit with different client IPs (simulated)
|
|
||||||
tap.test('Connection Limits - should track connections per IP or globally', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Note: In real test, this would use different source IPs
|
|
||||||
// For now, we test from same IP but document the behavior
|
|
||||||
const connectionsPerIP = 5;
|
|
||||||
const connections: net.Socket[] = [];
|
|
||||||
const results: boolean[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < connectionsPerIP; i++) {
|
|
||||||
const result = await new Promise<boolean>((resolve) => {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
connections.push(socket);
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (data.toString().includes('220')) {
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', () => resolve(false));
|
|
||||||
});
|
|
||||||
|
|
||||||
results.push(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
const successCount = results.filter(r => r).length;
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
for (const socket of connections) {
|
|
||||||
if (socket && !socket.destroyed) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => socket.destroy(), 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should accept connections from same IP
|
|
||||||
expect(successCount).toBeGreaterThan(0);
|
|
||||||
console.log(`Per-IP connections: ${successCount} of ${connectionsPerIP} succeeded`);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Connection limit error messages
|
|
||||||
tap.test('Connection Limits - should provide meaningful error when limit reached', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const manyConnections = 100;
|
|
||||||
const connections: net.Socket[] = [];
|
|
||||||
const errors: string[] = [];
|
|
||||||
let rejected = false;
|
|
||||||
|
|
||||||
// Create many connections to try to hit limit
|
|
||||||
const promises = [];
|
|
||||||
for (let i = 0; i < manyConnections; i++) {
|
|
||||||
promises.push(
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 1000
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
connections.push(socket);
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
const response = data.toString();
|
|
||||||
// Check if server sends connection limit message
|
|
||||||
if (response.includes('421') || response.includes('too many connections')) {
|
|
||||||
rejected = true;
|
|
||||||
errors.push(response);
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
if (err.message.includes('ECONNREFUSED') || err.message.includes('ECONNRESET')) {
|
|
||||||
rejected = true;
|
|
||||||
errors.push(err.message);
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
for (const socket of connections) {
|
|
||||||
if (socket && !socket.destroyed) {
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log results
|
|
||||||
console.log(`Connection limit test: ${connections.length} connected, ${errors.length} rejected`);
|
|
||||||
if (rejected) {
|
|
||||||
console.log(`Sample rejection: ${errors[0]}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have handled connections (either accepted or properly rejected)
|
|
||||||
expect(connections.length + errors.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('teardown - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts'
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server for connection rejection tests', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Connection Rejection - should handle suspicious domains', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
const banner = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(banner).toInclude('220');
|
|
||||||
|
|
||||||
// Send EHLO with suspicious domain
|
|
||||||
socket.write('EHLO blocked.spammer.com\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n')) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
|
|
||||||
// Timeout after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data || 'TIMEOUT');
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to suspicious domain:', response);
|
|
||||||
|
|
||||||
// Server might reject with 421, 550, or accept (depends on configuration)
|
|
||||||
// We just verify it responds appropriately
|
|
||||||
const validResponses = ['250', '421', '550', '501'];
|
|
||||||
const hasValidResponse = validResponses.some(code => response.includes(code));
|
|
||||||
expect(hasValidResponse).toEqual(true);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
if (!socket.destroyed) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Connection Rejection - should handle overload conditions', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const connections: net.Socket[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create many connections rapidly
|
|
||||||
const rapidConnectionCount = 20; // Reduced from 50 to be more reasonable
|
|
||||||
const connectionPromises: Promise<net.Socket | null>[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < rapidConnectionCount; i++) {
|
|
||||||
connectionPromises.push(
|
|
||||||
new Promise((resolve) => {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
connections.push(socket);
|
|
||||||
resolve(socket);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', () => {
|
|
||||||
// Connection rejected - this is OK during overload
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Timeout individual connections
|
|
||||||
setTimeout(() => resolve(null), 2000);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all connection attempts
|
|
||||||
const results = await Promise.all(connectionPromises);
|
|
||||||
const successfulConnections = results.filter(r => r !== null).length;
|
|
||||||
|
|
||||||
console.log(`Created ${successfulConnections}/${rapidConnectionCount} connections`);
|
|
||||||
|
|
||||||
// Now try one more connection
|
|
||||||
let overloadRejected = false;
|
|
||||||
try {
|
|
||||||
const testSocket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
testSocket.once('connect', () => {
|
|
||||||
testSocket.end();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
testSocket.once('error', (err) => {
|
|
||||||
overloadRejected = true;
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
testSocket.destroy();
|
|
||||||
resolve();
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Additional connection was rejected:', error);
|
|
||||||
overloadRejected = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Overload test results:
|
|
||||||
- Successful connections: ${successfulConnections}
|
|
||||||
- Additional connection rejected: ${overloadRejected}
|
|
||||||
- Server behavior: ${overloadRejected ? 'Properly rejected under load' : 'Accepted all connections'}`);
|
|
||||||
|
|
||||||
// Either behavior is acceptable - rejection shows overload protection,
|
|
||||||
// acceptance shows high capacity
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
// Clean up all connections
|
|
||||||
for (const socket of connections) {
|
|
||||||
try {
|
|
||||||
if (!socket.destroyed) {
|
|
||||||
socket.end();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Connection Rejection - should reject invalid protocol', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner first
|
|
||||||
const banner = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Got banner:', banner);
|
|
||||||
|
|
||||||
// Send HTTP request instead of SMTP
|
|
||||||
socket.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
|
|
||||||
// Wait for response or connection close
|
|
||||||
socket.on('close', () => {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Timeout
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
socket.destroy();
|
|
||||||
resolve(data || 'CLOSED_WITHOUT_RESPONSE');
|
|
||||||
}, 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to HTTP request:', response);
|
|
||||||
|
|
||||||
// Server should either:
|
|
||||||
// - Send error response (500, 501, 502, 421)
|
|
||||||
// - Close connection immediately
|
|
||||||
// - Send nothing and close
|
|
||||||
const errorResponses = ['500', '501', '502', '421'];
|
|
||||||
const hasErrorResponse = errorResponses.some(code => response.includes(code));
|
|
||||||
const closedWithoutResponse = response === 'CLOSED_WITHOUT_RESPONSE' || response === '';
|
|
||||||
|
|
||||||
expect(hasErrorResponse || closedWithoutResponse).toEqual(true);
|
|
||||||
|
|
||||||
if (hasErrorResponse) {
|
|
||||||
console.log('Server properly rejected with error response');
|
|
||||||
} else if (closedWithoutResponse) {
|
|
||||||
console.log('Server closed connection without response (also valid)');
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Connection Rejection - should handle invalid commands gracefully', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send completely invalid command
|
|
||||||
socket.write('INVALID_COMMAND_12345\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to invalid command:', response);
|
|
||||||
|
|
||||||
// Should get 500 or 502 error
|
|
||||||
expect(response).toMatch(/^5\d{2}/);
|
|
||||||
|
|
||||||
// Server should still be responsive
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
|
|
||||||
const noopResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('NOOP response after error:', noopResponse);
|
|
||||||
expect(noopResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as tls from 'tls';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
const TEST_TIMEOUT = 30000; // Increased timeout for TLS handshake
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('setup - start SMTP server with STARTTLS support', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: TEST_PORT,
|
|
||||||
tlsEnabled: true // Enable TLS to advertise STARTTLS
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
expect(testServer.port).toEqual(TEST_PORT);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic STARTTLS upgrade
|
|
||||||
tap.test('STARTTLS - should upgrade plain connection to TLS', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let tlsSocket: tls.TLSSocket | null = null;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
// Check if STARTTLS is advertised
|
|
||||||
if (receivedData.includes('STARTTLS')) {
|
|
||||||
currentStep = 'starttls';
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
} else {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error('STARTTLS not advertised in EHLO response'));
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'starttls' && receivedData.includes('220')) {
|
|
||||||
// Server accepted STARTTLS - upgrade to TLS
|
|
||||||
currentStep = 'tls_handshake';
|
|
||||||
|
|
||||||
const tlsOptions: tls.ConnectionOptions = {
|
|
||||||
socket: socket,
|
|
||||||
servername: 'localhost',
|
|
||||||
rejectUnauthorized: false // Accept self-signed certificates for testing
|
|
||||||
};
|
|
||||||
|
|
||||||
tlsSocket = tls.connect(tlsOptions);
|
|
||||||
|
|
||||||
tlsSocket.on('secureConnect', () => {
|
|
||||||
// TLS handshake successful
|
|
||||||
currentStep = 'tls_ehlo';
|
|
||||||
tlsSocket!.write('EHLO test.example.com\r\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('data', (tlsData) => {
|
|
||||||
const tlsResponse = tlsData.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'tls_ehlo' && tlsResponse.includes('250')) {
|
|
||||||
tlsSocket!.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
tlsSocket!.destroy();
|
|
||||||
expect(tlsSocket!.encrypted).toEqual(true);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
if (tlsSocket) tlsSocket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: STARTTLS with commands after upgrade
|
|
||||||
tap.test('STARTTLS - should process SMTP commands after TLS upgrade', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let tlsSocket: tls.TLSSocket | null = null;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
if (receivedData.includes('STARTTLS')) {
|
|
||||||
currentStep = 'starttls';
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'starttls' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'tls_handshake';
|
|
||||||
|
|
||||||
tlsSocket = tls.connect({
|
|
||||||
socket: socket,
|
|
||||||
servername: 'localhost',
|
|
||||||
rejectUnauthorized: false
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('secureConnect', () => {
|
|
||||||
currentStep = 'tls_ehlo';
|
|
||||||
tlsSocket!.write('EHLO test.example.com\r\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('data', (tlsData) => {
|
|
||||||
const tlsResponse = tlsData.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'tls_ehlo' && tlsResponse.includes('250')) {
|
|
||||||
currentStep = 'tls_mail_from';
|
|
||||||
tlsSocket!.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'tls_mail_from' && tlsResponse.includes('250')) {
|
|
||||||
currentStep = 'tls_rcpt_to';
|
|
||||||
tlsSocket!.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'tls_rcpt_to' && tlsResponse.includes('250')) {
|
|
||||||
currentStep = 'tls_data';
|
|
||||||
tlsSocket!.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'tls_data' && tlsResponse.includes('354')) {
|
|
||||||
currentStep = 'tls_message';
|
|
||||||
tlsSocket!.write('Subject: Test over TLS\r\n\r\nSecure message\r\n.\r\n');
|
|
||||||
} else if (currentStep === 'tls_message' && tlsResponse.includes('250')) {
|
|
||||||
tlsSocket!.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
const protocol = tlsSocket!.getProtocol();
|
|
||||||
const cipher = tlsSocket!.getCipher();
|
|
||||||
tlsSocket!.destroy();
|
|
||||||
// Protocol and cipher might be null in some cases
|
|
||||||
if (protocol) {
|
|
||||||
expect(typeof protocol).toEqual('string');
|
|
||||||
}
|
|
||||||
if (cipher) {
|
|
||||||
expect(cipher).toBeDefined();
|
|
||||||
expect(cipher.name).toBeDefined();
|
|
||||||
}
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
if (tlsSocket) tlsSocket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: STARTTLS rejected after MAIL FROM
|
|
||||||
tap.test('STARTTLS - should reject STARTTLS after transaction started', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'starttls_after_mail';
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
} else if (currentStep === 'starttls_after_mail') {
|
|
||||||
if (receivedData.includes('503')) {
|
|
||||||
// Server correctly rejected STARTTLS after MAIL FROM
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('503'); // Bad sequence
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
} else if (receivedData.includes('220')) {
|
|
||||||
// Server incorrectly accepted STARTTLS - this is a bug
|
|
||||||
// For now, let's accept this behavior but log it
|
|
||||||
console.log('WARNING: Server accepted STARTTLS after MAIL FROM - this violates RFC 3207');
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Multiple STARTTLS attempts
|
|
||||||
tap.test('STARTTLS - should not allow STARTTLS after TLS is established', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let tlsSocket: tls.TLSSocket | null = null;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
if (receivedData.includes('STARTTLS')) {
|
|
||||||
currentStep = 'starttls';
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'starttls' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'tls_handshake';
|
|
||||||
|
|
||||||
tlsSocket = tls.connect({
|
|
||||||
socket: socket,
|
|
||||||
servername: 'localhost',
|
|
||||||
rejectUnauthorized: false
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('secureConnect', () => {
|
|
||||||
currentStep = 'tls_ehlo';
|
|
||||||
tlsSocket!.write('EHLO test.example.com\r\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('data', (tlsData) => {
|
|
||||||
const tlsResponse = tlsData.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'tls_ehlo') {
|
|
||||||
// Check that STARTTLS is NOT advertised after TLS upgrade
|
|
||||||
expect(tlsResponse).not.toInclude('STARTTLS');
|
|
||||||
tlsSocket!.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
tlsSocket!.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
if (tlsSocket) tlsSocket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: STARTTLS with invalid command
|
|
||||||
tap.test('STARTTLS - should handle commands during TLS negotiation', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
if (receivedData.includes('STARTTLS')) {
|
|
||||||
currentStep = 'starttls';
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'starttls' && receivedData.includes('220')) {
|
|
||||||
// Send invalid data instead of starting TLS handshake
|
|
||||||
currentStep = 'invalid_after_starttls';
|
|
||||||
socket.write('EHLO should.not.work\r\n');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve(); // Connection should close or timeout
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
if (currentStep === 'invalid_after_starttls') {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
if (currentStep === 'invalid_after_starttls') {
|
|
||||||
done.resolve(); // Expected error
|
|
||||||
} else {
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
if (currentStep === 'invalid_after_starttls') {
|
|
||||||
done.resolve();
|
|
||||||
} else {
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: STARTTLS TLS version and cipher info
|
|
||||||
tap.test('STARTTLS - should use secure TLS version and ciphers', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let tlsSocket: tls.TLSSocket | null = null;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
if (receivedData.includes('STARTTLS')) {
|
|
||||||
currentStep = 'starttls';
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'starttls' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'tls_handshake';
|
|
||||||
|
|
||||||
tlsSocket = tls.connect({
|
|
||||||
socket: socket,
|
|
||||||
servername: 'localhost',
|
|
||||||
rejectUnauthorized: false,
|
|
||||||
minVersion: 'TLSv1.2' // Require at least TLS 1.2
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('secureConnect', () => {
|
|
||||||
const protocol = tlsSocket!.getProtocol();
|
|
||||||
const cipher = tlsSocket!.getCipher();
|
|
||||||
|
|
||||||
// Verify TLS version
|
|
||||||
expect(typeof protocol).toEqual('string');
|
|
||||||
expect(['TLSv1.2', 'TLSv1.3']).toInclude(protocol!);
|
|
||||||
|
|
||||||
// Verify cipher info
|
|
||||||
expect(cipher).toBeDefined();
|
|
||||||
expect(cipher.name).toBeDefined();
|
|
||||||
expect(typeof cipher.name).toEqual('string');
|
|
||||||
|
|
||||||
tlsSocket!.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
tlsSocket!.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
if (tlsSocket) tlsSocket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('teardown - stop SMTP server', async () => {
|
|
||||||
if (testServer) {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts'
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server for abrupt disconnection tests', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Abrupt Disconnection - should handle socket destruction without QUIT', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
const banner = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(banner).toInclude('220');
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Abruptly disconnect without QUIT
|
|
||||||
console.log('Destroying socket without QUIT...');
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Wait a moment for server to handle the disconnection
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
// Test server recovery - try new connection
|
|
||||||
console.log('Testing server recovery with new connection...');
|
|
||||||
const recoverySocket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
const recoveryConnected = await new Promise<boolean>((resolve) => {
|
|
||||||
recoverySocket.once('connect', () => resolve(true));
|
|
||||||
recoverySocket.once('error', () => resolve(false));
|
|
||||||
setTimeout(() => resolve(false), 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(recoveryConnected).toEqual(true);
|
|
||||||
|
|
||||||
if (recoveryConnected) {
|
|
||||||
// Get banner from recovery connection
|
|
||||||
const recoveryBanner = await new Promise<string>((resolve) => {
|
|
||||||
recoverySocket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(recoveryBanner).toInclude('220');
|
|
||||||
console.log('Server recovered successfully, accepting new connections');
|
|
||||||
|
|
||||||
// Clean up recovery connection properly
|
|
||||||
recoverySocket.write('QUIT\r\n');
|
|
||||||
recoverySocket.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Abrupt Disconnection - should handle multiple simultaneous abrupt disconnections', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const connections = 5;
|
|
||||||
const sockets: net.Socket[] = [];
|
|
||||||
|
|
||||||
// Create multiple connections
|
|
||||||
for (let i = 0; i < connections; i++) {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
sockets.push(socket);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Created ${connections} connections`);
|
|
||||||
|
|
||||||
// Abruptly disconnect all at once
|
|
||||||
console.log('Destroying all sockets simultaneously...');
|
|
||||||
sockets.forEach(socket => socket.destroy());
|
|
||||||
|
|
||||||
// Wait for server to handle disconnections
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
|
|
||||||
// Test that server still accepts new connections
|
|
||||||
console.log('Testing server stability after multiple abrupt disconnections...');
|
|
||||||
const testSocket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
const stillAccepting = await new Promise<boolean>((resolve) => {
|
|
||||||
testSocket.once('connect', () => resolve(true));
|
|
||||||
testSocket.once('error', () => resolve(false));
|
|
||||||
setTimeout(() => resolve(false), 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(stillAccepting).toEqual(true);
|
|
||||||
|
|
||||||
if (stillAccepting) {
|
|
||||||
const banner = await new Promise<string>((resolve) => {
|
|
||||||
testSocket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(banner).toInclude('220');
|
|
||||||
console.log('Server remained stable after multiple abrupt disconnections');
|
|
||||||
|
|
||||||
testSocket.write('QUIT\r\n');
|
|
||||||
testSocket.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Abrupt Disconnection - should handle disconnection during DATA transfer', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start DATA
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Send partial email data then disconnect abruptly
|
|
||||||
socket.write('From: sender@example.com\r\n');
|
|
||||||
socket.write('To: recipient@example.com\r\n');
|
|
||||||
socket.write('Subject: Test ');
|
|
||||||
|
|
||||||
console.log('Disconnecting during DATA transfer...');
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Wait for server to handle disconnection
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
||||||
|
|
||||||
// Verify server can handle new connections
|
|
||||||
const newSocket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
const canConnect = await new Promise<boolean>((resolve) => {
|
|
||||||
newSocket.once('connect', () => resolve(true));
|
|
||||||
newSocket.once('error', () => resolve(false));
|
|
||||||
setTimeout(() => resolve(false), 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(canConnect).toEqual(true);
|
|
||||||
|
|
||||||
if (canConnect) {
|
|
||||||
const banner = await new Promise<string>((resolve) => {
|
|
||||||
newSocket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(banner).toInclude('220');
|
|
||||||
console.log('Server recovered from disconnection during DATA transfer');
|
|
||||||
|
|
||||||
newSocket.write('QUIT\r\n');
|
|
||||||
newSocket.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Abrupt Disconnection - should timeout idle connections', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
const banner = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(banner).toInclude('220');
|
|
||||||
console.log('Connected, now testing idle timeout...');
|
|
||||||
|
|
||||||
// Don't send any commands and wait for server to potentially timeout
|
|
||||||
// Most servers have a timeout of 5-10 minutes, so we'll test shorter
|
|
||||||
let disconnectedByServer = false;
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
disconnectedByServer = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('end', () => {
|
|
||||||
disconnectedByServer = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait 10 seconds to see if server has a short idle timeout
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
||||||
|
|
||||||
if (!disconnectedByServer) {
|
|
||||||
console.log('Server maintains idle connections (no short timeout detected)');
|
|
||||||
// Send QUIT to close gracefully
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
} else {
|
|
||||||
console.log('Server disconnected idle connection');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Either behavior is acceptable
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as tls from 'tls';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server with TLS support for version tests', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: TEST_PORT,
|
|
||||||
tlsEnabled: true
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('TLS Versions - should support STARTTLS capability', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('EHLO response:', ehloResponse);
|
|
||||||
|
|
||||||
// Check for STARTTLS support
|
|
||||||
const supportsStarttls = ehloResponse.includes('250-STARTTLS') || ehloResponse.includes('250 STARTTLS');
|
|
||||||
console.log('STARTTLS supported:', supportsStarttls);
|
|
||||||
|
|
||||||
if (supportsStarttls) {
|
|
||||||
// Test STARTTLS upgrade
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
|
|
||||||
const starttlsResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(starttlsResponse).toInclude('220');
|
|
||||||
console.log('STARTTLS ready response received');
|
|
||||||
|
|
||||||
// Would upgrade to TLS here in a real implementation
|
|
||||||
// For testing, we just verify the capability
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
// STARTTLS is optional but common
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('TLS Versions - should support modern TLS versions via STARTTLS', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Test TLS 1.2 via STARTTLS
|
|
||||||
console.log('Testing TLS 1.2 support via STARTTLS...');
|
|
||||||
const tls12Result = await testTlsVersionViaStartTls('TLSv1.2', TEST_PORT);
|
|
||||||
console.log('TLS 1.2 result:', tls12Result);
|
|
||||||
|
|
||||||
// Test TLS 1.3 via STARTTLS
|
|
||||||
console.log('Testing TLS 1.3 support via STARTTLS...');
|
|
||||||
const tls13Result = await testTlsVersionViaStartTls('TLSv1.3', TEST_PORT);
|
|
||||||
console.log('TLS 1.3 result:', tls13Result);
|
|
||||||
|
|
||||||
// At least one modern version should be supported
|
|
||||||
const supportsModernTls = tls12Result.success || tls13Result.success;
|
|
||||||
expect(supportsModernTls).toEqual(true);
|
|
||||||
|
|
||||||
if (tls12Result.success) {
|
|
||||||
console.log('TLS 1.2 supported with cipher:', tls12Result.cipher);
|
|
||||||
}
|
|
||||||
if (tls13Result.success) {
|
|
||||||
console.log('TLS 1.3 supported with cipher:', tls13Result.cipher);
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('TLS Versions - should reject obsolete TLS versions via STARTTLS', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Test TLS 1.0 (should be rejected by modern servers)
|
|
||||||
console.log('Testing TLS 1.0 (obsolete) via STARTTLS...');
|
|
||||||
const tls10Result = await testTlsVersionViaStartTls('TLSv1', TEST_PORT);
|
|
||||||
|
|
||||||
// Test TLS 1.1 (should be rejected by modern servers)
|
|
||||||
console.log('Testing TLS 1.1 (obsolete) via STARTTLS...');
|
|
||||||
const tls11Result = await testTlsVersionViaStartTls('TLSv1.1', TEST_PORT);
|
|
||||||
|
|
||||||
// Modern servers should reject these old versions
|
|
||||||
// But some might still support them for compatibility
|
|
||||||
console.log(`TLS 1.0 ${tls10Result.success ? 'accepted (legacy support)' : 'rejected (good)'}`);
|
|
||||||
console.log(`TLS 1.1 ${tls11Result.success ? 'accepted (legacy support)' : 'rejected (good)'}`);
|
|
||||||
|
|
||||||
// Either behavior is acceptable - log the results
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('TLS Versions - should provide cipher information via STARTTLS', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Connect to plain SMTP port
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for STARTTLS
|
|
||||||
if (!ehloResponse.includes('STARTTLS')) {
|
|
||||||
console.log('Server does not support STARTTLS - skipping cipher info test');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send STARTTLS
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Upgrade to TLS
|
|
||||||
const tlsSocket = tls.connect({
|
|
||||||
socket: socket,
|
|
||||||
servername: 'localhost',
|
|
||||||
rejectUnauthorized: false
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
tlsSocket.once('secureConnect', () => resolve());
|
|
||||||
tlsSocket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get connection details
|
|
||||||
const cipher = tlsSocket.getCipher();
|
|
||||||
const protocol = tlsSocket.getProtocol();
|
|
||||||
const authorized = tlsSocket.authorized;
|
|
||||||
|
|
||||||
console.log('TLS connection established via STARTTLS:');
|
|
||||||
console.log('- Protocol:', protocol);
|
|
||||||
console.log('- Cipher:', cipher?.name);
|
|
||||||
console.log('- Key exchange:', cipher?.standardName);
|
|
||||||
console.log('- Authorized:', authorized);
|
|
||||||
|
|
||||||
if (protocol) {
|
|
||||||
expect(typeof protocol).toEqual('string');
|
|
||||||
}
|
|
||||||
if (cipher) {
|
|
||||||
expect(cipher.name).toBeDefined();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
tlsSocket.write('QUIT\r\n');
|
|
||||||
tlsSocket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper function to test specific TLS version via STARTTLS
|
|
||||||
async function testTlsVersionViaStartTls(version: string, port: number): Promise<{success: boolean, cipher?: any, error?: string}> {
|
|
||||||
return new Promise(async (resolve) => {
|
|
||||||
try {
|
|
||||||
// Connect to plain SMTP port
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: port,
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((socketResolve, socketReject) => {
|
|
||||||
socket.once('connect', () => socketResolve());
|
|
||||||
socket.once('error', socketReject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((bannerResolve) => {
|
|
||||||
socket.once('data', (chunk) => bannerResolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((ehloResolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
ehloResolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for STARTTLS
|
|
||||||
if (!ehloResponse.includes('STARTTLS')) {
|
|
||||||
socket.destroy();
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
error: 'STARTTLS not supported'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send STARTTLS
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((starttlsResolve) => {
|
|
||||||
socket.once('data', (chunk) => starttlsResolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up TLS options with version constraints
|
|
||||||
const tlsOptions: any = {
|
|
||||||
socket: socket,
|
|
||||||
servername: 'localhost',
|
|
||||||
rejectUnauthorized: false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set version constraints based on requested version
|
|
||||||
switch (version) {
|
|
||||||
case 'TLSv1':
|
|
||||||
tlsOptions.minVersion = 'TLSv1';
|
|
||||||
tlsOptions.maxVersion = 'TLSv1';
|
|
||||||
break;
|
|
||||||
case 'TLSv1.1':
|
|
||||||
tlsOptions.minVersion = 'TLSv1.1';
|
|
||||||
tlsOptions.maxVersion = 'TLSv1.1';
|
|
||||||
break;
|
|
||||||
case 'TLSv1.2':
|
|
||||||
tlsOptions.minVersion = 'TLSv1.2';
|
|
||||||
tlsOptions.maxVersion = 'TLSv1.2';
|
|
||||||
break;
|
|
||||||
case 'TLSv1.3':
|
|
||||||
tlsOptions.minVersion = 'TLSv1.3';
|
|
||||||
tlsOptions.maxVersion = 'TLSv1.3';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upgrade to TLS
|
|
||||||
const tlsSocket = tls.connect(tlsOptions);
|
|
||||||
|
|
||||||
tlsSocket.once('secureConnect', () => {
|
|
||||||
const cipher = tlsSocket.getCipher();
|
|
||||||
const protocol = tlsSocket.getProtocol();
|
|
||||||
|
|
||||||
tlsSocket.destroy();
|
|
||||||
resolve({
|
|
||||||
success: true,
|
|
||||||
cipher: {
|
|
||||||
name: cipher?.name,
|
|
||||||
standardName: cipher?.standardName,
|
|
||||||
protocol: protocol
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.once('error', (error) => {
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
tlsSocket.destroy();
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
error: 'TLS handshake timeout'
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,556 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as tls from 'tls';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
tap.test('TLS Ciphers - should advertise STARTTLS for cipher negotiation', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for STARTTLS support
|
|
||||||
const supportsStarttls = ehloResponse.includes('250-STARTTLS') || ehloResponse.includes('250 STARTTLS');
|
|
||||||
console.log('STARTTLS supported:', supportsStarttls);
|
|
||||||
|
|
||||||
if (supportsStarttls) {
|
|
||||||
console.log('Server supports STARTTLS - cipher negotiation available');
|
|
||||||
} else {
|
|
||||||
console.log('Server does not advertise STARTTLS - direct TLS connections may be required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
// Either behavior is acceptable
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('TLS Ciphers - should negotiate secure cipher suites via STARTTLS', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for STARTTLS
|
|
||||||
if (!ehloResponse.includes('STARTTLS')) {
|
|
||||||
console.log('Server does not support STARTTLS - skipping cipher test');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send STARTTLS
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
|
|
||||||
const starttlsResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(starttlsResponse).toInclude('220');
|
|
||||||
|
|
||||||
// Upgrade to TLS
|
|
||||||
const tlsSocket = tls.connect({
|
|
||||||
socket: socket,
|
|
||||||
servername: 'localhost',
|
|
||||||
rejectUnauthorized: false
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
tlsSocket.once('secureConnect', () => resolve());
|
|
||||||
tlsSocket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get cipher information
|
|
||||||
const cipher = tlsSocket.getCipher();
|
|
||||||
console.log('Negotiated cipher suite:');
|
|
||||||
console.log('- Name:', cipher.name);
|
|
||||||
console.log('- Standard name:', cipher.standardName);
|
|
||||||
console.log('- Version:', cipher.version);
|
|
||||||
|
|
||||||
// Check cipher security
|
|
||||||
const cipherSecurity = checkCipherSecurity(cipher);
|
|
||||||
console.log('Cipher security analysis:', cipherSecurity);
|
|
||||||
|
|
||||||
expect(cipher.name).toBeDefined();
|
|
||||||
expect(cipherSecurity.secure).toEqual(true);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
tlsSocket.write('QUIT\r\n');
|
|
||||||
tlsSocket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('TLS Ciphers - should reject weak cipher suites', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for STARTTLS
|
|
||||||
if (!ehloResponse.includes('STARTTLS')) {
|
|
||||||
console.log('Server does not support STARTTLS - skipping weak cipher test');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send STARTTLS
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to connect with weak ciphers only
|
|
||||||
const weakCiphers = [
|
|
||||||
'DES-CBC3-SHA',
|
|
||||||
'RC4-MD5',
|
|
||||||
'RC4-SHA',
|
|
||||||
'NULL-SHA',
|
|
||||||
'EXPORT-DES40-CBC-SHA'
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log('Testing connection with weak ciphers only...');
|
|
||||||
|
|
||||||
const connectionResult = await new Promise<{success: boolean, error?: string}>((resolve) => {
|
|
||||||
const tlsSocket = tls.connect({
|
|
||||||
socket: socket,
|
|
||||||
servername: 'localhost',
|
|
||||||
rejectUnauthorized: false,
|
|
||||||
ciphers: weakCiphers.join(':')
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.once('secureConnect', () => {
|
|
||||||
// If connection succeeds, server accepts weak ciphers
|
|
||||||
const cipher = tlsSocket.getCipher();
|
|
||||||
tlsSocket.destroy();
|
|
||||||
resolve({
|
|
||||||
success: true,
|
|
||||||
error: `Server accepted weak cipher: ${cipher.name}`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.once('error', (err) => {
|
|
||||||
// Connection failed - good, server rejects weak ciphers
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
error: err.message
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
tlsSocket.destroy();
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
error: 'Connection timeout'
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!connectionResult.success) {
|
|
||||||
console.log('Good: Server rejected weak ciphers');
|
|
||||||
} else {
|
|
||||||
console.log('Warning:', connectionResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Either behavior is logged - some servers may support legacy ciphers
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('TLS Ciphers - should support forward secrecy', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for STARTTLS
|
|
||||||
if (!ehloResponse.includes('STARTTLS')) {
|
|
||||||
console.log('Server does not support STARTTLS - skipping forward secrecy test');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send STARTTLS
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prefer ciphers with forward secrecy (ECDHE, DHE)
|
|
||||||
const forwardSecrecyCiphers = [
|
|
||||||
'ECDHE-RSA-AES128-GCM-SHA256',
|
|
||||||
'ECDHE-RSA-AES256-GCM-SHA384',
|
|
||||||
'ECDHE-ECDSA-AES128-GCM-SHA256',
|
|
||||||
'ECDHE-ECDSA-AES256-GCM-SHA384',
|
|
||||||
'DHE-RSA-AES128-GCM-SHA256',
|
|
||||||
'DHE-RSA-AES256-GCM-SHA384'
|
|
||||||
];
|
|
||||||
|
|
||||||
const tlsSocket = tls.connect({
|
|
||||||
socket: socket,
|
|
||||||
servername: 'localhost',
|
|
||||||
rejectUnauthorized: false,
|
|
||||||
ciphers: forwardSecrecyCiphers.join(':')
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
tlsSocket.once('secureConnect', () => resolve());
|
|
||||||
tlsSocket.once('error', reject);
|
|
||||||
setTimeout(() => reject(new Error('TLS connection timeout')), 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
const cipher = tlsSocket.getCipher();
|
|
||||||
console.log('Forward secrecy cipher negotiated:', cipher.name);
|
|
||||||
|
|
||||||
// Check if cipher provides forward secrecy
|
|
||||||
const hasForwardSecrecy = cipher.name.includes('ECDHE') || cipher.name.includes('DHE');
|
|
||||||
console.log('Forward secrecy:', hasForwardSecrecy ? 'YES' : 'NO');
|
|
||||||
|
|
||||||
if (hasForwardSecrecy) {
|
|
||||||
console.log('Good: Server supports forward secrecy');
|
|
||||||
} else {
|
|
||||||
console.log('Warning: Negotiated cipher does not provide forward secrecy');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
tlsSocket.write('QUIT\r\n');
|
|
||||||
tlsSocket.end();
|
|
||||||
|
|
||||||
// Forward secrecy is recommended but not required
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('TLS Ciphers - should list all supported ciphers', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get list of ciphers supported by Node.js
|
|
||||||
const supportedCiphers = tls.getCiphers();
|
|
||||||
console.log(`Node.js supports ${supportedCiphers.length} cipher suites`);
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for STARTTLS
|
|
||||||
if (!ehloResponse.includes('STARTTLS')) {
|
|
||||||
console.log('Server does not support STARTTLS - skipping cipher list test');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send STARTTLS
|
|
||||||
socket.write('STARTTLS\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test connection with default ciphers
|
|
||||||
const tlsSocket = tls.connect({
|
|
||||||
socket: socket,
|
|
||||||
servername: 'localhost',
|
|
||||||
rejectUnauthorized: false
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
tlsSocket.once('secureConnect', () => resolve());
|
|
||||||
tlsSocket.once('error', reject);
|
|
||||||
setTimeout(() => reject(new Error('TLS connection timeout')), 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
const negotiatedCipher = tlsSocket.getCipher();
|
|
||||||
console.log('\nServer selected cipher:', negotiatedCipher.name);
|
|
||||||
|
|
||||||
// Categorize the cipher
|
|
||||||
const categories = {
|
|
||||||
'AEAD': negotiatedCipher.name.includes('GCM') || negotiatedCipher.name.includes('CCM') || negotiatedCipher.name.includes('POLY1305'),
|
|
||||||
'Forward Secrecy': negotiatedCipher.name.includes('ECDHE') || negotiatedCipher.name.includes('DHE'),
|
|
||||||
'Strong Encryption': negotiatedCipher.name.includes('AES') && (negotiatedCipher.name.includes('128') || negotiatedCipher.name.includes('256'))
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Cipher properties:');
|
|
||||||
Object.entries(categories).forEach(([property, value]) => {
|
|
||||||
console.log(`- ${property}: ${value ? 'YES' : 'NO'}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
tlsSocket.end();
|
|
||||||
|
|
||||||
expect(negotiatedCipher.name).toBeDefined();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper function to check cipher security
|
|
||||||
function checkCipherSecurity(cipher: any): {secure: boolean, reason?: string, recommendations?: string[]} {
|
|
||||||
if (!cipher || !cipher.name) {
|
|
||||||
return {
|
|
||||||
secure: false,
|
|
||||||
reason: 'No cipher information available'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const cipherName = cipher.name.toUpperCase();
|
|
||||||
const recommendations: string[] = [];
|
|
||||||
|
|
||||||
// Check for insecure ciphers
|
|
||||||
const insecureCiphers = ['NULL', 'EXPORT', 'DES', '3DES', 'RC4', 'MD5'];
|
|
||||||
|
|
||||||
for (const insecure of insecureCiphers) {
|
|
||||||
if (cipherName.includes(insecure)) {
|
|
||||||
return {
|
|
||||||
secure: false,
|
|
||||||
reason: `Insecure cipher detected: ${insecure} in ${cipherName}`,
|
|
||||||
recommendations: ['Use AEAD ciphers like AES-GCM or ChaCha20-Poly1305']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for recommended secure ciphers
|
|
||||||
const secureCiphers = [
|
|
||||||
'AES128-GCM', 'AES256-GCM', 'CHACHA20-POLY1305',
|
|
||||||
'AES128-CCM', 'AES256-CCM'
|
|
||||||
];
|
|
||||||
|
|
||||||
const hasSecureCipher = secureCiphers.some(secure =>
|
|
||||||
cipherName.includes(secure.replace('-', '_')) || cipherName.includes(secure)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasSecureCipher) {
|
|
||||||
return {
|
|
||||||
secure: true,
|
|
||||||
recommendations: ['Cipher suite is considered secure']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for acceptable but not ideal ciphers
|
|
||||||
if (cipherName.includes('AES') && !cipherName.includes('CBC')) {
|
|
||||||
return {
|
|
||||||
secure: true,
|
|
||||||
recommendations: ['Consider upgrading to AEAD ciphers for better security']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for weak but sometimes acceptable ciphers
|
|
||||||
if (cipherName.includes('AES') && cipherName.includes('CBC')) {
|
|
||||||
recommendations.push('CBC mode ciphers are vulnerable to padding oracle attacks');
|
|
||||||
recommendations.push('Consider upgrading to GCM or other AEAD modes');
|
|
||||||
return {
|
|
||||||
secure: true, // Still acceptable but not ideal
|
|
||||||
recommendations: recommendations
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to secure if it's a modern cipher we don't recognize
|
|
||||||
return {
|
|
||||||
secure: true,
|
|
||||||
recommendations: [`Unknown cipher ${cipherName} - verify security manually`]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
tap.test('Plain Connection - should establish basic TCP connection', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
const connected = await new Promise<boolean>((resolve) => {
|
|
||||||
socket.once('connect', () => resolve(true));
|
|
||||||
socket.once('error', () => resolve(false));
|
|
||||||
setTimeout(() => resolve(false), 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(connected).toEqual(true);
|
|
||||||
|
|
||||||
if (connected) {
|
|
||||||
console.log('Plain connection established:');
|
|
||||||
console.log('- Local:', `${socket.localAddress}:${socket.localPort}`);
|
|
||||||
console.log('- Remote:', `${socket.remoteAddress}:${socket.remotePort}`);
|
|
||||||
|
|
||||||
// Close connection
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Plain Connection - should receive SMTP banner on plain connection', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
const banner = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Received banner:', banner.trim());
|
|
||||||
|
|
||||||
expect(banner).toInclude('220');
|
|
||||||
expect(banner).toInclude('ESMTP');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Plain Connection - should complete full SMTP transaction on plain connection', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(ehloResponse).toInclude('250');
|
|
||||||
console.log('EHLO successful on plain connection');
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
|
|
||||||
const mailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mailResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
|
|
||||||
const rcptResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(rcptResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send DATA
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Send email content
|
|
||||||
const emailContent =
|
|
||||||
'From: sender@example.com\r\n' +
|
|
||||||
'To: recipient@example.com\r\n' +
|
|
||||||
'Subject: Plain Connection Test\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
'This email was sent over a plain connection.\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
|
|
||||||
socket.write(emailContent);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(finalResponse).toInclude('250');
|
|
||||||
console.log('Email sent successfully over plain connection');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
|
|
||||||
const quitResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(quitResponse).toInclude('221');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Plain Connection - should handle multiple plain connections', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const connectionCount = 3;
|
|
||||||
const connections: net.Socket[] = [];
|
|
||||||
|
|
||||||
// Create multiple connections
|
|
||||||
for (let i = 0; i < connectionCount; i++) {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => {
|
|
||||||
connections.push(socket);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
const banner = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(banner).toInclude('220');
|
|
||||||
console.log(`Connection ${i + 1} established`);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(connections.length).toEqual(connectionCount);
|
|
||||||
console.log(`All ${connectionCount} plain connections established successfully`);
|
|
||||||
|
|
||||||
// Clean up all connections
|
|
||||||
for (const socket of connections) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Plain Connection - should work on standard SMTP port 25', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Test port 25 (standard SMTP port)
|
|
||||||
const SMTP_PORT = 25;
|
|
||||||
|
|
||||||
// Note: Port 25 might require special permissions or might be blocked
|
|
||||||
// We'll test the connection but handle failures gracefully
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: SMTP_PORT,
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await new Promise<{connected: boolean, error?: string}>((resolve) => {
|
|
||||||
socket.once('connect', () => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve({ connected: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.once('error', (err) => {
|
|
||||||
resolve({
|
|
||||||
connected: false,
|
|
||||||
error: err.message
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve({
|
|
||||||
connected: false,
|
|
||||||
error: 'Connection timeout'
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.connected) {
|
|
||||||
console.log('Successfully connected to port 25 (standard SMTP)');
|
|
||||||
} else {
|
|
||||||
console.log(`Could not connect to port 25: ${result.error}`);
|
|
||||||
console.log('This is expected if port 25 is blocked or requires privileges');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test passes regardless - port 25 connectivity is environment-dependent
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
const TEST_TIMEOUT = 60000; // Longer timeout for keepalive tests
|
|
||||||
|
|
||||||
tap.test('Keepalive - should maintain TCP keepalive', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enable TCP keepalive
|
|
||||||
const keepAliveDelay = 1000; // 1 second
|
|
||||||
socket.setKeepAlive(true, keepAliveDelay);
|
|
||||||
console.log(`TCP keepalive enabled with ${keepAliveDelay}ms delay`);
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
const banner = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(banner).toInclude('220');
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(ehloResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Wait for keepalive duration + buffer
|
|
||||||
console.log('Waiting for keepalive period...');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, keepAliveDelay + 500));
|
|
||||||
|
|
||||||
// Verify connection is still alive by sending NOOP
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
|
|
||||||
const noopResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(noopResponse).toInclude('250');
|
|
||||||
console.log('Connection maintained after keepalive period');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Keepalive - should maintain idle connection for extended period', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enable keepalive
|
|
||||||
socket.setKeepAlive(true, 1000);
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test multiple keepalive periods
|
|
||||||
const periods = 3;
|
|
||||||
const periodDuration = 1000; // 1 second each
|
|
||||||
|
|
||||||
for (let i = 0; i < periods; i++) {
|
|
||||||
console.log(`Keepalive period ${i + 1}/${periods}...`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, periodDuration));
|
|
||||||
|
|
||||||
// Send NOOP to verify connection
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
console.log(`Connection alive after ${(i + 1) * periodDuration}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Connection maintained for ${periods * periodDuration}ms total`);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Keepalive - should detect connection loss', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enable keepalive with short interval
|
|
||||||
socket.setKeepAlive(true, 1000);
|
|
||||||
|
|
||||||
// Track connection state
|
|
||||||
let connectionLost = false;
|
|
||||||
socket.on('close', () => {
|
|
||||||
connectionLost = true;
|
|
||||||
console.log('Connection closed');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
connectionLost = true;
|
|
||||||
console.log('Connection error:', err.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Connection established, now simulating server shutdown...');
|
|
||||||
|
|
||||||
// Shutdown server to simulate connection loss
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
|
|
||||||
// Wait for keepalive to detect connection loss
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
||||||
|
|
||||||
// Connection should be detected as lost
|
|
||||||
expect(connectionLost).toEqual(true);
|
|
||||||
console.log('Keepalive detected connection loss');
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
// Server already shutdown, just resolve
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Keepalive - should handle long-running SMTP session', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enable keepalive
|
|
||||||
socket.setKeepAlive(true, 2000);
|
|
||||||
|
|
||||||
const sessionStart = Date.now();
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulate a long-running session with periodic activity
|
|
||||||
const activities = [
|
|
||||||
{ command: 'MAIL FROM:<sender1@example.com>', delay: 500 },
|
|
||||||
{ command: 'RSET', delay: 500 },
|
|
||||||
{ command: 'MAIL FROM:<sender2@example.com>', delay: 500 },
|
|
||||||
{ command: 'RSET', delay: 500 }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const activity of activities) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, activity.delay));
|
|
||||||
|
|
||||||
console.log(`Sending: ${activity.command}`);
|
|
||||||
socket.write(`${activity.command}\r\n`);
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionDuration = Date.now() - sessionStart;
|
|
||||||
console.log(`Long-running session maintained for ${sessionDuration}ms`);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
|
|
||||||
const quitResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(quitResponse).toInclude('221');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Keepalive - should handle NOOP as keepalive mechanism', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use NOOP as application-level keepalive
|
|
||||||
const noopInterval = 1000; // 1 second
|
|
||||||
const noopCount = 3;
|
|
||||||
|
|
||||||
console.log(`Sending ${noopCount} NOOP commands as keepalive...`);
|
|
||||||
|
|
||||||
for (let i = 0; i < noopCount; i++) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, noopInterval));
|
|
||||||
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
console.log(`NOOP ${i + 1}/${noopCount} successful`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Application-level keepalive successful');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
import { connectToSmtp, waitForGreeting, sendSmtpCommand, closeSmtpConnection, generateRandomEmail } from '../../helpers/utils.js';
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server with large size limit', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: 2532,
|
|
||||||
hostname: 'localhost',
|
|
||||||
size: 100 * 1024 * 1024 // 100MB limit for testing
|
|
||||||
});
|
|
||||||
expect(testServer).toBeInstanceOf(Object);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('EDGE-01: Very Large Email - test size limits and handling', async () => {
|
|
||||||
const testCases = [
|
|
||||||
{ size: 1 * 1024 * 1024, label: '1MB', shouldPass: true },
|
|
||||||
{ size: 10 * 1024 * 1024, label: '10MB', shouldPass: true },
|
|
||||||
{ size: 50 * 1024 * 1024, label: '50MB', shouldPass: true },
|
|
||||||
{ size: 101 * 1024 * 1024, label: '101MB', shouldPass: false } // Over limit
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const testCase of testCases) {
|
|
||||||
console.log(`\n📧 Testing ${testCase.label} email...`);
|
|
||||||
const socket = await connectToSmtp(testServer.hostname, testServer.port);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForGreeting(socket);
|
|
||||||
await sendSmtpCommand(socket, 'EHLO test.example.com', '250');
|
|
||||||
|
|
||||||
// Check SIZE extension
|
|
||||||
await sendSmtpCommand(socket, `MAIL FROM:<large@example.com> SIZE=${testCase.size}`,
|
|
||||||
testCase.shouldPass ? '250' : '552');
|
|
||||||
|
|
||||||
if (testCase.shouldPass) {
|
|
||||||
// Continue with transaction
|
|
||||||
await sendSmtpCommand(socket, 'RCPT TO:<recipient@example.com>', '250');
|
|
||||||
await sendSmtpCommand(socket, 'DATA', '354');
|
|
||||||
|
|
||||||
// Send large content in chunks
|
|
||||||
const chunkSize = 65536; // 64KB chunks
|
|
||||||
const totalChunks = Math.ceil(testCase.size / chunkSize);
|
|
||||||
|
|
||||||
console.log(` Sending ${totalChunks} chunks...`);
|
|
||||||
|
|
||||||
// Headers
|
|
||||||
socket.write('From: large@example.com\r\n');
|
|
||||||
socket.write('To: recipient@example.com\r\n');
|
|
||||||
socket.write(`Subject: ${testCase.label} Test Email\r\n`);
|
|
||||||
socket.write('Content-Type: text/plain\r\n');
|
|
||||||
socket.write('\r\n');
|
|
||||||
|
|
||||||
// Body in chunks
|
|
||||||
let bytesSent = 100; // Approximate header size
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
for (let i = 0; i < totalChunks; i++) {
|
|
||||||
const chunk = generateRandomEmail(Math.min(chunkSize, testCase.size - bytesSent));
|
|
||||||
socket.write(chunk);
|
|
||||||
bytesSent += chunk.length;
|
|
||||||
|
|
||||||
// Progress indicator every 10%
|
|
||||||
if (i % Math.floor(totalChunks / 10) === 0) {
|
|
||||||
const progress = (i / totalChunks * 100).toFixed(0);
|
|
||||||
console.log(` Progress: ${progress}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay to avoid overwhelming
|
|
||||||
if (i % 100 === 0) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// End of data
|
|
||||||
socket.write('\r\n.\r\n');
|
|
||||||
|
|
||||||
// Wait for response with longer timeout for large emails
|
|
||||||
const response = await new Promise<string>((resolve, reject) => {
|
|
||||||
let buffer = '';
|
|
||||||
const timeout = setTimeout(() => reject(new Error('Timeout')), 60000);
|
|
||||||
|
|
||||||
const onData = (data: Buffer) => {
|
|
||||||
buffer += data.toString();
|
|
||||||
if (buffer.includes('250') || buffer.includes('5')) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
socket.removeListener('data', onData);
|
|
||||||
resolve(buffer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('data', onData);
|
|
||||||
});
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
const throughputMBps = (testCase.size / 1024 / 1024) / (duration / 1000);
|
|
||||||
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
console.log(` ✅ ${testCase.label} email accepted in ${duration}ms`);
|
|
||||||
console.log(` Throughput: ${throughputMBps.toFixed(2)} MB/s`);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
console.log(` ✅ ${testCase.label} email properly rejected (over size limit)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (!testCase.shouldPass && error.message.includes('552')) {
|
|
||||||
console.log(` ✅ ${testCase.label} email properly rejected: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await closeSmtpConnection(socket).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('EDGE-01: Email size enforcement - SIZE parameter', async () => {
|
|
||||||
const socket = await connectToSmtp(testServer.hostname, testServer.port);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForGreeting(socket);
|
|
||||||
const ehloResponse = await sendSmtpCommand(socket, 'EHLO test.example.com', '250');
|
|
||||||
|
|
||||||
// Extract SIZE limit from capabilities
|
|
||||||
const sizeMatch = ehloResponse.match(/250[- ]SIZE (\d+)/);
|
|
||||||
const sizeLimit = sizeMatch ? parseInt(sizeMatch[1]) : 0;
|
|
||||||
|
|
||||||
console.log(`📏 Server advertises SIZE limit: ${sizeLimit} bytes`);
|
|
||||||
expect(sizeLimit).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Test SIZE parameter enforcement
|
|
||||||
const testSizes = [
|
|
||||||
{ size: 1000, shouldPass: true },
|
|
||||||
{ size: sizeLimit - 1000, shouldPass: true },
|
|
||||||
{ size: sizeLimit + 1000, shouldPass: false }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const test of testSizes) {
|
|
||||||
try {
|
|
||||||
const response = await sendSmtpCommand(
|
|
||||||
socket,
|
|
||||||
`MAIL FROM:<test@example.com> SIZE=${test.size}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (test.shouldPass) {
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
console.log(` ✅ SIZE=${test.size} accepted`);
|
|
||||||
await sendSmtpCommand(socket, 'RSET', '250');
|
|
||||||
} else {
|
|
||||||
expect(response).toInclude('552');
|
|
||||||
console.log(` ✅ SIZE=${test.size} rejected`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!test.shouldPass) {
|
|
||||||
console.log(` ✅ SIZE=${test.size} rejected: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await closeSmtpConnection(socket);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('EDGE-01: Memory efficiency with large emails', async () => {
|
|
||||||
// Get initial memory usage
|
|
||||||
const initialMemory = process.memoryUsage();
|
|
||||||
console.log('📊 Initial memory usage:', {
|
|
||||||
heapUsed: `${(initialMemory.heapUsed / 1024 / 1024).toFixed(2)} MB`,
|
|
||||||
rss: `${(initialMemory.rss / 1024 / 1024).toFixed(2)} MB`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send a moderately large email
|
|
||||||
const socket = await connectToSmtp(testServer.hostname, testServer.port);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForGreeting(socket);
|
|
||||||
await sendSmtpCommand(socket, 'EHLO test.example.com', '250');
|
|
||||||
await sendSmtpCommand(socket, 'MAIL FROM:<memory@test.com>', '250');
|
|
||||||
await sendSmtpCommand(socket, 'RCPT TO:<recipient@example.com>', '250');
|
|
||||||
await sendSmtpCommand(socket, 'DATA', '354');
|
|
||||||
|
|
||||||
// Send 20MB email
|
|
||||||
const size = 20 * 1024 * 1024;
|
|
||||||
const chunkSize = 1024 * 1024; // 1MB chunks
|
|
||||||
|
|
||||||
socket.write('From: memory@test.com\r\n');
|
|
||||||
socket.write('To: recipient@example.com\r\n');
|
|
||||||
socket.write('Subject: Memory Test\r\n\r\n');
|
|
||||||
|
|
||||||
for (let i = 0; i < size / chunkSize; i++) {
|
|
||||||
socket.write(generateRandomEmail(chunkSize));
|
|
||||||
// Force garbage collection if available
|
|
||||||
if (global.gc) {
|
|
||||||
global.gc();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('\r\n.\r\n');
|
|
||||||
|
|
||||||
// Wait for response
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const onData = (data: Buffer) => {
|
|
||||||
if (data.toString().includes('250')) {
|
|
||||||
socket.removeListener('data', onData);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', onData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check memory after processing
|
|
||||||
const finalMemory = process.memoryUsage();
|
|
||||||
const memoryIncrease = (finalMemory.heapUsed - initialMemory.heapUsed) / 1024 / 1024;
|
|
||||||
|
|
||||||
console.log('📊 Final memory usage:', {
|
|
||||||
heapUsed: `${(finalMemory.heapUsed / 1024 / 1024).toFixed(2)} MB`,
|
|
||||||
rss: `${(finalMemory.rss / 1024 / 1024).toFixed(2)} MB`,
|
|
||||||
increase: `${memoryIncrease.toFixed(2)} MB`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Memory increase should be reasonable (not storing entire email in memory)
|
|
||||||
expect(memoryIncrease).toBeLessThan(50); // Less than 50MB increase for 20MB email
|
|
||||||
console.log('✅ Memory efficiency test passed');
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await closeSmtpConnection(socket);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
console.log('✅ Test server stopped');
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 30034;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
tap.test('Very Small Email - should handle minimal email with single character body', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
const testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
const mailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(mailResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
const rcptResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(rcptResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send DATA
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Send minimal email - just required headers and single character body
|
|
||||||
const minimalEmail = 'From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: \r\n\r\nX\r\n.\r\n';
|
|
||||||
socket.write(minimalEmail);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(finalResponse).toInclude('250');
|
|
||||||
console.log(`Minimal email (${minimalEmail.length} bytes) processed successfully`);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Very Small Email - should handle email with empty body', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
const testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Complete envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send email with empty body
|
|
||||||
const emptyBodyEmail = 'From: sender@example.com\r\nTo: recipient@example.com\r\n\r\n.\r\n';
|
|
||||||
socket.write(emptyBodyEmail);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(finalResponse).toInclude('250');
|
|
||||||
console.log('Email with empty body processed successfully');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Very Small Email - should handle email with minimal headers only', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
const testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner and send EHLO
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Complete envelope - use valid email addresses
|
|
||||||
socket.write('MAIL FROM:<a@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<b@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send absolutely minimal valid email
|
|
||||||
const minimalHeaders = 'From: a@example.com\r\n\r\n.\r\n';
|
|
||||||
socket.write(minimalHeaders);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(finalResponse).toInclude('250');
|
|
||||||
console.log(`Ultra-minimal email (${minimalHeaders.length} bytes) processed`);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Very Small Email - should handle single dot line correctly', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
const testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup connection
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Complete envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Test edge case: just the terminating dot
|
|
||||||
socket.write('.\r\n');
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Server should accept this as an email with no headers or body
|
|
||||||
expect(finalResponse).toMatch(/^[2-5]\d{2}/);
|
|
||||||
console.log('Single dot terminator handled:', finalResponse.trim());
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Very Small Email - should handle email with empty subject', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Start test server
|
|
||||||
const testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup connection
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Complete envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send email with empty subject line
|
|
||||||
const emptySubjectEmail =
|
|
||||||
'From: sender@example.com\r\n' +
|
|
||||||
'To: recipient@example.com\r\n' +
|
|
||||||
'Subject: \r\n' +
|
|
||||||
'Date: ' + new Date().toUTCString() + '\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
'Email with empty subject.\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
|
|
||||||
socket.write(emptySubjectEmail);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(finalResponse).toInclude('250');
|
|
||||||
console.log('Email with empty subject processed successfully');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,479 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 30035;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server for invalid character tests', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: TEST_PORT,
|
|
||||||
hostname: 'localhost'
|
|
||||||
});
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Invalid Character Handling - should handle control characters in email', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Test with control characters
|
|
||||||
const controlChars = [
|
|
||||||
'\x00', // NULL
|
|
||||||
'\x01', // SOH
|
|
||||||
'\x02', // STX
|
|
||||||
'\x03', // ETX
|
|
||||||
'\x7F' // DEL
|
|
||||||
];
|
|
||||||
|
|
||||||
const emailWithControlChars =
|
|
||||||
'From: sender@example.com\r\n' +
|
|
||||||
'To: recipient@example.com\r\n' +
|
|
||||||
`Subject: Control Character Test ${controlChars.join('')}\r\n` +
|
|
||||||
'\r\n' +
|
|
||||||
`This email contains control characters: ${controlChars.join('')}\r\n` +
|
|
||||||
'Null byte: \x00\r\n' +
|
|
||||||
'Delete char: \x7F\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
|
|
||||||
socket.write(emailWithControlChars);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to control characters:', finalResponse);
|
|
||||||
|
|
||||||
// Server might accept or reject based on security settings
|
|
||||||
const accepted = finalResponse.includes('250');
|
|
||||||
const rejected = finalResponse.includes('550') || finalResponse.includes('554');
|
|
||||||
|
|
||||||
expect(accepted || rejected).toEqual(true);
|
|
||||||
|
|
||||||
if (rejected) {
|
|
||||||
console.log('Server rejected control characters (strict security)');
|
|
||||||
} else {
|
|
||||||
console.log('Server accepted control characters (may sanitize internally)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Invalid Character Handling - should handle high-byte characters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test with high-byte characters
|
|
||||||
const highByteChars = [
|
|
||||||
'\xFF', // 255
|
|
||||||
'\xFE', // 254
|
|
||||||
'\xFD', // 253
|
|
||||||
'\xFC', // 252
|
|
||||||
'\xFB' // 251
|
|
||||||
];
|
|
||||||
|
|
||||||
const emailWithHighBytes =
|
|
||||||
'From: sender@example.com\r\n' +
|
|
||||||
'To: recipient@example.com\r\n' +
|
|
||||||
'Subject: High-byte Character Test\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
`High-byte characters: ${highByteChars.join('')}\r\n` +
|
|
||||||
'Extended ASCII: \xE0\xE1\xE2\xE3\xE4\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
|
|
||||||
socket.write(emailWithHighBytes);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to high-byte characters:', finalResponse);
|
|
||||||
|
|
||||||
// Both acceptance and rejection are valid
|
|
||||||
expect(finalResponse).toMatch(/^[2-5]\d{2}/);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Invalid Character Handling - should handle Unicode special characters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test with Unicode special characters
|
|
||||||
const unicodeSpecials = [
|
|
||||||
'\u2000', // EN QUAD
|
|
||||||
'\u2028', // LINE SEPARATOR
|
|
||||||
'\u2029', // PARAGRAPH SEPARATOR
|
|
||||||
'\uFEFF', // ZERO WIDTH NO-BREAK SPACE (BOM)
|
|
||||||
'\u200B', // ZERO WIDTH SPACE
|
|
||||||
'\u200C', // ZERO WIDTH NON-JOINER
|
|
||||||
'\u200D' // ZERO WIDTH JOINER
|
|
||||||
];
|
|
||||||
|
|
||||||
const emailWithUnicode =
|
|
||||||
'From: sender@example.com\r\n' +
|
|
||||||
'To: recipient@example.com\r\n' +
|
|
||||||
'Subject: Unicode Special Characters Test\r\n' +
|
|
||||||
'Content-Type: text/plain; charset=utf-8\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
`Unicode specials: ${unicodeSpecials.join('')}\r\n` +
|
|
||||||
'Line separator: \u2028\r\n' +
|
|
||||||
'Paragraph separator: \u2029\r\n' +
|
|
||||||
'Zero-width space: word\u200Bword\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
|
|
||||||
socket.write(emailWithUnicode);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to Unicode special characters:', finalResponse);
|
|
||||||
|
|
||||||
// Most servers should accept Unicode with proper charset declaration
|
|
||||||
expect(finalResponse).toMatch(/^[2-5]\d{2}/);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Invalid Character Handling - should handle bare LF and CR', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test with bare LF and CR (not allowed in SMTP)
|
|
||||||
const emailWithBareLfCr =
|
|
||||||
'From: sender@example.com\r\n' +
|
|
||||||
'To: recipient@example.com\r\n' +
|
|
||||||
'Subject: Bare LF and CR Test\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
'Line with bare LF:\nThis should not be allowed\r\n' +
|
|
||||||
'Line with bare CR:\rThis should also not be allowed\r\n' +
|
|
||||||
'Correct line ending\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
|
|
||||||
socket.write(emailWithBareLfCr);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to bare LF/CR:', finalResponse);
|
|
||||||
|
|
||||||
// Servers may accept and fix, or reject
|
|
||||||
const accepted = finalResponse.includes('250');
|
|
||||||
const rejected = finalResponse.includes('550') || finalResponse.includes('554');
|
|
||||||
|
|
||||||
if (accepted) {
|
|
||||||
console.log('Server accepted bare LF/CR (may convert to CRLF)');
|
|
||||||
} else if (rejected) {
|
|
||||||
console.log('Server rejected bare LF/CR (strict SMTP compliance)');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(accepted || rejected).toEqual(true);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Invalid Character Handling - should handle long lines without proper folding', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a line that exceeds RFC 5322 limit (998 characters)
|
|
||||||
const longLine = 'X'.repeat(1500);
|
|
||||||
|
|
||||||
const emailWithLongLine =
|
|
||||||
'From: sender@example.com\r\n' +
|
|
||||||
'To: recipient@example.com\r\n' +
|
|
||||||
'Subject: Long Line Test\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
'Normal line\r\n' +
|
|
||||||
longLine + '\r\n' +
|
|
||||||
'Another normal line\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
|
|
||||||
socket.write(emailWithLongLine);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to long line:', finalResponse);
|
|
||||||
console.log(`Line length: ${longLine.length} characters`);
|
|
||||||
|
|
||||||
// Server should handle this (accept, wrap, or reject)
|
|
||||||
expect(finalResponse).toMatch(/^[2-5]\d{2}/);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 30036;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server for empty command tests', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: TEST_PORT,
|
|
||||||
hostname: 'localhost'
|
|
||||||
});
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Empty Commands - should reject empty line (just CRLF)', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO first
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send empty line (just CRLF)
|
|
||||||
socket.write('\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
setTimeout(() => resolve('TIMEOUT'), 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to empty line:', response);
|
|
||||||
|
|
||||||
// Should get syntax error (500, 501, or 502)
|
|
||||||
if (response !== 'TIMEOUT') {
|
|
||||||
expect(response).toMatch(/^5\d{2}/);
|
|
||||||
} else {
|
|
||||||
// Server might ignore empty lines
|
|
||||||
console.log('Server ignored empty line');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test server is still responsive
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
const noopResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(noopResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Empty Commands - should reject commands with only whitespace', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner and send EHLO
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test various whitespace-only commands
|
|
||||||
const whitespaceCommands = [
|
|
||||||
' \r\n', // Spaces only
|
|
||||||
'\t\r\n', // Tab only
|
|
||||||
' \t \r\n', // Mixed whitespace
|
|
||||||
' \r\n' // Multiple spaces
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const cmd of whitespaceCommands) {
|
|
||||||
socket.write(cmd);
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
setTimeout(() => resolve('TIMEOUT'), 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Response to whitespace "${cmd.trim()}"\\r\\n:`, response);
|
|
||||||
|
|
||||||
if (response !== 'TIMEOUT') {
|
|
||||||
// Should get syntax error
|
|
||||||
expect(response).toMatch(/^5\d{2}/);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify server still works
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
const noopResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(noopResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Empty Commands - should reject MAIL FROM with empty parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup connection
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM with empty parameter
|
|
||||||
socket.write('MAIL FROM:\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to empty MAIL FROM:', response);
|
|
||||||
|
|
||||||
// Should get syntax error (501 or 550)
|
|
||||||
expect(response).toMatch(/^5\d{2}/);
|
|
||||||
expect(response).toMatch(/syntax|parameter|address/i);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Empty Commands - should reject RCPT TO with empty parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup connection
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send valid MAIL FROM first
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send RCPT TO with empty parameter
|
|
||||||
socket.write('RCPT TO:\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to empty RCPT TO:', response);
|
|
||||||
|
|
||||||
// Should get syntax error
|
|
||||||
expect(response).toMatch(/^5\d{2}/);
|
|
||||||
expect(response).toMatch(/syntax|parameter|address/i);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Empty Commands - should reject EHLO/HELO without hostname', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO without hostname
|
|
||||||
socket.write('EHLO\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to EHLO without hostname:', ehloResponse);
|
|
||||||
|
|
||||||
// Should get syntax error
|
|
||||||
expect(ehloResponse).toMatch(/^5\d{2}/);
|
|
||||||
|
|
||||||
// Try HELO without hostname
|
|
||||||
socket.write('HELO\r\n');
|
|
||||||
|
|
||||||
const heloResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to HELO without hostname:', heloResponse);
|
|
||||||
|
|
||||||
// Should get syntax error
|
|
||||||
expect(heloResponse).toMatch(/^5\d{2}/);
|
|
||||||
|
|
||||||
// Send valid EHLO to establish session
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Empty Commands - server should remain stable after empty commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send multiple empty/invalid commands
|
|
||||||
const invalidCommands = [
|
|
||||||
'\r\n',
|
|
||||||
' \r\n',
|
|
||||||
'MAIL FROM:\r\n',
|
|
||||||
'RCPT TO:\r\n',
|
|
||||||
'EHLO\r\n',
|
|
||||||
'\t\r\n'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const cmd of invalidCommands) {
|
|
||||||
socket.write(cmd);
|
|
||||||
|
|
||||||
// Read response but don't fail if error
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
setTimeout(() => resolve('TIMEOUT'), 1000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now test that server is still functional
|
|
||||||
socket.write('MAIL FROM:<test@example.com>\r\n');
|
|
||||||
const mailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mailResponse).toInclude('250');
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
const rcptResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(rcptResponse).toInclude('250');
|
|
||||||
|
|
||||||
console.log('Server remained stable after multiple empty commands');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 30037;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server for extremely long lines tests', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: TEST_PORT,
|
|
||||||
hostname: 'localhost'
|
|
||||||
});
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Extremely Long Lines - should handle lines exceeding RFC 5321 limit', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Create line exceeding RFC 5321 limit (1000 chars including CRLF)
|
|
||||||
const longLine = 'X'.repeat(2000); // 2000 character line
|
|
||||||
|
|
||||||
const emailWithLongLine =
|
|
||||||
'From: sender@example.com\r\n' +
|
|
||||||
'To: recipient@example.com\r\n' +
|
|
||||||
'Subject: Long Line Test\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
'This email contains an extremely long line:\r\n' +
|
|
||||||
longLine + '\r\n' +
|
|
||||||
'End of test.\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
|
|
||||||
socket.write(emailWithLongLine);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Response to ${longLine.length} character line:`, finalResponse);
|
|
||||||
|
|
||||||
// Server should handle gracefully (accept, wrap, or reject)
|
|
||||||
const accepted = finalResponse.includes('250');
|
|
||||||
const rejected = finalResponse.includes('552') || finalResponse.includes('500') || finalResponse.includes('554');
|
|
||||||
|
|
||||||
expect(accepted || rejected).toEqual(true);
|
|
||||||
|
|
||||||
if (accepted) {
|
|
||||||
console.log('Server accepted long line (may wrap internally)');
|
|
||||||
} else {
|
|
||||||
console.log('Server rejected long line');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Extremely Long Lines - should handle extremely long subject header', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup connection
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create extremely long subject (3000 characters)
|
|
||||||
const longSubject = 'A'.repeat(3000);
|
|
||||||
|
|
||||||
const emailWithLongSubject =
|
|
||||||
'From: sender@example.com\r\n' +
|
|
||||||
'To: recipient@example.com\r\n' +
|
|
||||||
`Subject: ${longSubject}\r\n` +
|
|
||||||
'\r\n' +
|
|
||||||
'Body of email with extremely long subject.\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
|
|
||||||
socket.write(emailWithLongSubject);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Response to ${longSubject.length} character subject:`, finalResponse);
|
|
||||||
|
|
||||||
// Server should handle this
|
|
||||||
expect(finalResponse).toMatch(/^[2-5]\d{2}/);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Extremely Long Lines - should handle multiple consecutive long lines', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup connection
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create multiple long lines
|
|
||||||
const longLine1 = 'A'.repeat(1500);
|
|
||||||
const longLine2 = 'B'.repeat(1800);
|
|
||||||
const longLine3 = 'C'.repeat(2000);
|
|
||||||
|
|
||||||
const emailWithMultipleLongLines =
|
|
||||||
'From: sender@example.com\r\n' +
|
|
||||||
'To: recipient@example.com\r\n' +
|
|
||||||
'Subject: Multiple Long Lines Test\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
'First long line:\r\n' +
|
|
||||||
longLine1 + '\r\n' +
|
|
||||||
'Second long line:\r\n' +
|
|
||||||
longLine2 + '\r\n' +
|
|
||||||
'Third long line:\r\n' +
|
|
||||||
longLine3 + '\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
|
|
||||||
socket.write(emailWithMultipleLongLines);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to multiple long lines:', finalResponse);
|
|
||||||
|
|
||||||
// Server should handle this
|
|
||||||
expect(finalResponse).toMatch(/^[2-5]\d{2}/);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Extremely Long Lines - should handle extremely long MAIL FROM parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup connection
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create extremely long email address (technically invalid but testing limits)
|
|
||||||
const longLocalPart = 'a'.repeat(500);
|
|
||||||
const longDomain = 'b'.repeat(500) + '.com';
|
|
||||||
const longEmail = `${longLocalPart}@${longDomain}`;
|
|
||||||
|
|
||||||
socket.write(`MAIL FROM:<${longEmail}>\r\n`);
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Response to ${longEmail.length} character email address:`, response);
|
|
||||||
|
|
||||||
// Should get error response
|
|
||||||
expect(response).toMatch(/^5\d{2}/);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Extremely Long Lines - should handle line exactly at RFC limit', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup connection
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send envelope
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create line exactly at RFC 5321 limit (998 chars + CRLF = 1000)
|
|
||||||
const rfcLimitLine = 'X'.repeat(998);
|
|
||||||
|
|
||||||
const emailWithRfcLimitLine =
|
|
||||||
'From: sender@example.com\r\n' +
|
|
||||||
'To: recipient@example.com\r\n' +
|
|
||||||
'Subject: RFC Limit Test\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
'Line at RFC 5321 limit:\r\n' +
|
|
||||||
rfcLimitLine + '\r\n' +
|
|
||||||
'This should be accepted.\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
|
|
||||||
socket.write(emailWithRfcLimitLine);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Response to ${rfcLimitLine.length} character line (RFC limit):`, finalResponse);
|
|
||||||
|
|
||||||
// This should be accepted
|
|
||||||
expect(finalResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start test server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Extremely Long Headers - should handle single extremely long header', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
const mailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(mailResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
const rcptResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(rcptResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send DATA
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Send email with extremely long header (3000 characters)
|
|
||||||
const longValue = 'X'.repeat(3000);
|
|
||||||
const emailContent = [
|
|
||||||
`Subject: Test Email`,
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`X-Long-Header: ${longValue}`,
|
|
||||||
'',
|
|
||||||
'This email has an extremely long header.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(emailContent);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Server might accept or reject - both are valid for extremely long headers
|
|
||||||
const accepted = finalResponse.includes('250');
|
|
||||||
const rejected = finalResponse.includes('552') || finalResponse.includes('554') || finalResponse.includes('500');
|
|
||||||
|
|
||||||
console.log(`Long header test ${accepted ? 'accepted' : 'rejected'}: ${finalResponse.trim()}`);
|
|
||||||
expect(accepted || rejected).toEqual(true);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Extremely Long Headers - should handle multi-line header with many segments', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
const mailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(mailResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
const rcptResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(rcptResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send DATA
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Create multi-line header with 50 segments (RFC 5322 folding)
|
|
||||||
const segments = [];
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
segments.push(` Segment ${i}: ${' '.repeat(60)}value`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailContent = [
|
|
||||||
`Subject: Test Email`,
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`X-Multi-Line: Initial value`,
|
|
||||||
...segments,
|
|
||||||
'',
|
|
||||||
'This email has a multi-line header with many segments.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(emailContent);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
const accepted = finalResponse.includes('250');
|
|
||||||
const rejected = finalResponse.includes('552') || finalResponse.includes('554') || finalResponse.includes('500');
|
|
||||||
|
|
||||||
console.log(`Multi-line header test ${accepted ? 'accepted' : 'rejected'}: ${finalResponse.trim()}`);
|
|
||||||
expect(accepted || rejected).toEqual(true);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Extremely Long Headers - should handle multiple long headers in one email', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
const mailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(mailResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
const rcptResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(rcptResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send DATA
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Create multiple long headers
|
|
||||||
const header1 = 'A'.repeat(1000);
|
|
||||||
const header2 = 'B'.repeat(1500);
|
|
||||||
const header3 = 'C'.repeat(2000);
|
|
||||||
|
|
||||||
const emailContent = [
|
|
||||||
`Subject: Test Email with Multiple Long Headers`,
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`X-Long-Header-1: ${header1}`,
|
|
||||||
`X-Long-Header-2: ${header2}`,
|
|
||||||
`X-Long-Header-3: ${header3}`,
|
|
||||||
'',
|
|
||||||
'This email has multiple long headers.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
const totalHeaderSize = header1.length + header2.length + header3.length;
|
|
||||||
console.log(`Total header size: ${totalHeaderSize} bytes`);
|
|
||||||
|
|
||||||
socket.write(emailContent);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
const accepted = finalResponse.includes('250');
|
|
||||||
const rejected = finalResponse.includes('552') || finalResponse.includes('554') || finalResponse.includes('500');
|
|
||||||
|
|
||||||
console.log(`Multiple long headers test ${accepted ? 'accepted' : 'rejected'}: ${finalResponse.trim()}`);
|
|
||||||
expect(accepted || rejected).toEqual(true);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Extremely Long Headers - should handle header with exactly RFC limit', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
const mailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(mailResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
const rcptResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(rcptResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send DATA
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Create header line exactly at RFC 5322 limit (998 chars excluding CRLF)
|
|
||||||
// Header name and colon take some space
|
|
||||||
const headerName = 'X-RFC-Limit';
|
|
||||||
const colonSpace = ': ';
|
|
||||||
const remainingSpace = 998 - headerName.length - colonSpace.length;
|
|
||||||
const headerValue = 'X'.repeat(remainingSpace);
|
|
||||||
|
|
||||||
const emailContent = [
|
|
||||||
`Subject: Test Email`,
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`${headerName}${colonSpace}${headerValue}`,
|
|
||||||
'',
|
|
||||||
'This email has a header at exactly the RFC limit.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(emailContent);
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// This should be accepted since it's exactly at the limit
|
|
||||||
const accepted = finalResponse.includes('250');
|
|
||||||
const rejected = finalResponse.includes('552') || finalResponse.includes('554') || finalResponse.includes('500');
|
|
||||||
|
|
||||||
console.log(`RFC limit header test ${accepted ? 'accepted' : 'rejected'}: ${finalResponse.trim()}`);
|
|
||||||
expect(accepted || rejected).toEqual(true);
|
|
||||||
|
|
||||||
// RFC compliant servers should accept headers exactly at the limit
|
|
||||||
if (accepted) {
|
|
||||||
console.log('✓ Server correctly accepts headers at RFC limit');
|
|
||||||
} else {
|
|
||||||
console.log('⚠ Server rejected header at RFC limit (may be overly strict)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop test server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 30041;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start test server', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: TEST_PORT,
|
|
||||||
hostname: 'localhost'
|
|
||||||
});
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Unusual MIME Types - should handle email with various unusual MIME types', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
// Create multipart email with unusual MIME types
|
|
||||||
const boundary = '----=_Part_1_' + Date.now();
|
|
||||||
const unusualMimeTypes = [
|
|
||||||
{ type: 'text/plain', content: 'This is plain text content.' },
|
|
||||||
{ type: 'application/x-custom-unusual-type', content: 'Custom proprietary format data' },
|
|
||||||
{ type: 'model/vrml', content: '#VRML V2.0 utf8\nShape { geometry Box {} }' },
|
|
||||||
{ type: 'chemical/x-mdl-molfile', content: 'Molecule data\n -ISIS- 04249412312D\n\n 3 2 0 0 0 0 0 0 0 0999 V2000' },
|
|
||||||
{ type: 'application/vnd.ms-fontobject', content: 'Font binary data simulation' },
|
|
||||||
{ type: 'application/x-doom', content: 'IWAD game data simulation' }
|
|
||||||
];
|
|
||||||
|
|
||||||
let emailContent = [
|
|
||||||
'Subject: Email with Unusual MIME Types',
|
|
||||||
'From: sender@example.com',
|
|
||||||
'To: recipient@example.com',
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
'This is a multipart message with unusual MIME types.',
|
|
||||||
''
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add each unusual MIME type as a part
|
|
||||||
unusualMimeTypes.forEach((mime, index) => {
|
|
||||||
emailContent.push(`--${boundary}`);
|
|
||||||
emailContent.push(`Content-Type: ${mime.type}`);
|
|
||||||
emailContent.push(`Content-Disposition: attachment; filename="part${index + 1}"`);
|
|
||||||
emailContent.push('');
|
|
||||||
emailContent.push(mime.content);
|
|
||||||
emailContent.push('');
|
|
||||||
});
|
|
||||||
|
|
||||||
emailContent.push(`--${boundary}--`);
|
|
||||||
emailContent.push('.');
|
|
||||||
emailContent.push('');
|
|
||||||
|
|
||||||
const fullEmail = emailContent.join('\r\n');
|
|
||||||
console.log(`Sending email with ${unusualMimeTypes.length} unusual MIME types`);
|
|
||||||
|
|
||||||
socket.write(fullEmail);
|
|
||||||
currentStep = 'waiting_response';
|
|
||||||
receivedData = '';
|
|
||||||
} else if (currentStep === 'waiting_response' && (receivedData.includes('250 ') ||
|
|
||||||
receivedData.includes('552 ') ||
|
|
||||||
receivedData.includes('554 ') ||
|
|
||||||
receivedData.includes('500 '))) {
|
|
||||||
// Either accepted or gracefully rejected
|
|
||||||
const accepted = receivedData.includes('250 ');
|
|
||||||
console.log(`Unusual MIME types test ${accepted ? 'accepted' : 'rejected'}`);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
console.error('Socket timeout');
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error('Socket timeout'));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Unusual MIME Types - should handle email with deeply nested multipart structure', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
// Create nested multipart structure
|
|
||||||
const boundary1 = '----=_Part_Outer_' + Date.now();
|
|
||||||
const boundary2 = '----=_Part_Inner_' + Date.now();
|
|
||||||
|
|
||||||
let emailContent = [
|
|
||||||
'Subject: Nested Multipart Email',
|
|
||||||
'From: sender@example.com',
|
|
||||||
'To: recipient@example.com',
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary1}"`,
|
|
||||||
'',
|
|
||||||
'This is a nested multipart message.',
|
|
||||||
'',
|
|
||||||
`--${boundary1}`,
|
|
||||||
'Content-Type: text/plain',
|
|
||||||
'',
|
|
||||||
'First level plain text.',
|
|
||||||
'',
|
|
||||||
`--${boundary1}`,
|
|
||||||
`Content-Type: multipart/alternative; boundary="${boundary2}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary2}`,
|
|
||||||
'Content-Type: text/richtext',
|
|
||||||
'',
|
|
||||||
'<bold>Rich text content</bold>',
|
|
||||||
'',
|
|
||||||
`--${boundary2}`,
|
|
||||||
'Content-Type: application/rtf',
|
|
||||||
'',
|
|
||||||
'{\\rtf1 RTF content}',
|
|
||||||
'',
|
|
||||||
`--${boundary2}--`,
|
|
||||||
'',
|
|
||||||
`--${boundary1}`,
|
|
||||||
'Content-Type: audio/x-aiff',
|
|
||||||
'Content-Disposition: attachment; filename="sound.aiff"',
|
|
||||||
'',
|
|
||||||
'AIFF audio data simulation',
|
|
||||||
'',
|
|
||||||
`--${boundary1}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(emailContent);
|
|
||||||
currentStep = 'waiting_response';
|
|
||||||
receivedData = '';
|
|
||||||
} else if (currentStep === 'waiting_response' && (receivedData.includes('250 ') ||
|
|
||||||
receivedData.includes('552 ') ||
|
|
||||||
receivedData.includes('554 ') ||
|
|
||||||
receivedData.includes('500 '))) {
|
|
||||||
const accepted = receivedData.includes('250 ');
|
|
||||||
console.log(`Nested multipart test ${accepted ? 'accepted' : 'rejected'}`);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
console.error('Socket timeout');
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error('Socket timeout'));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Unusual MIME Types - should handle email with non-standard charset encodings', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
// Create email with various charset encodings
|
|
||||||
const boundary = '----=_Part_Charset_' + Date.now();
|
|
||||||
|
|
||||||
let emailContent = [
|
|
||||||
'Subject: Email with Various Charset Encodings',
|
|
||||||
'From: sender@example.com',
|
|
||||||
'To: recipient@example.com',
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
'This email contains various charset encodings.',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/plain; charset="iso-2022-jp"',
|
|
||||||
'',
|
|
||||||
'Japanese text simulation',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/plain; charset="windows-1251"',
|
|
||||||
'',
|
|
||||||
'Cyrillic text simulation',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/plain; charset="koi8-r"',
|
|
||||||
'',
|
|
||||||
'Russian KOI8-R text',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/plain; charset="gb2312"',
|
|
||||||
'',
|
|
||||||
'Chinese GB2312 text',
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(emailContent);
|
|
||||||
currentStep = 'waiting_response';
|
|
||||||
receivedData = '';
|
|
||||||
} else if (currentStep === 'waiting_response' && (receivedData.includes('250 ') ||
|
|
||||||
receivedData.includes('552 ') ||
|
|
||||||
receivedData.includes('554 ') ||
|
|
||||||
receivedData.includes('500 '))) {
|
|
||||||
const accepted = receivedData.includes('250 ');
|
|
||||||
console.log(`Various charset test ${accepted ? 'accepted' : 'rejected'}`);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
console.error('Socket timeout');
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error('Socket timeout'));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop test server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as plugins from '../../../ts/plugins.js';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts'
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
let testServer: ITestServer;
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
tap.test('setup - start test server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Nested MIME Structures - should handle deeply nested multipart structure', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let state = 'initial';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (dataBuffer.includes('220 ') && state === 'initial') {
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
state = 'ehlo_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('250 ') && state === 'ehlo_sent') {
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
state = 'mail_from_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('250 ') && state === 'mail_from_sent') {
|
|
||||||
// Send RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
state = 'rcpt_to_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('250 ') && state === 'rcpt_to_sent') {
|
|
||||||
// Send DATA
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
state = 'data_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('354 ') && state === 'data_sent') {
|
|
||||||
// Create deeply nested MIME structure (4 levels)
|
|
||||||
const outerBoundary = '----=_Outer_Boundary_' + Date.now();
|
|
||||||
const middleBoundary = '----=_Middle_Boundary_' + Date.now();
|
|
||||||
const innerBoundary = '----=_Inner_Boundary_' + Date.now();
|
|
||||||
const deepBoundary = '----=_Deep_Boundary_' + Date.now();
|
|
||||||
|
|
||||||
let emailContent = [
|
|
||||||
'Subject: Deeply Nested MIME Structure Test',
|
|
||||||
'From: sender@example.com',
|
|
||||||
'To: recipient@example.com',
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
`Content-Type: multipart/mixed; boundary="${outerBoundary}"`,
|
|
||||||
'',
|
|
||||||
'This is a multipart message with deeply nested structure.',
|
|
||||||
'',
|
|
||||||
// Level 1: Outer boundary
|
|
||||||
`--${outerBoundary}`,
|
|
||||||
'Content-Type: text/plain',
|
|
||||||
'',
|
|
||||||
'This is the first part at the outer level.',
|
|
||||||
'',
|
|
||||||
`--${outerBoundary}`,
|
|
||||||
`Content-Type: multipart/alternative; boundary="${middleBoundary}"`,
|
|
||||||
'',
|
|
||||||
// Level 2: Middle boundary
|
|
||||||
`--${middleBoundary}`,
|
|
||||||
'Content-Type: text/plain',
|
|
||||||
'',
|
|
||||||
'Alternative plain text version.',
|
|
||||||
'',
|
|
||||||
`--${middleBoundary}`,
|
|
||||||
`Content-Type: multipart/related; boundary="${innerBoundary}"`,
|
|
||||||
'',
|
|
||||||
// Level 3: Inner boundary
|
|
||||||
`--${innerBoundary}`,
|
|
||||||
'Content-Type: text/html',
|
|
||||||
'',
|
|
||||||
'<html><body><h1>HTML with related content</h1><img src="cid:image1"></body></html>',
|
|
||||||
'',
|
|
||||||
`--${innerBoundary}`,
|
|
||||||
'Content-Type: image/png',
|
|
||||||
'Content-ID: <image1>',
|
|
||||||
'Content-Transfer-Encoding: base64',
|
|
||||||
'',
|
|
||||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
|
||||||
'',
|
|
||||||
`--${innerBoundary}`,
|
|
||||||
`Content-Type: multipart/mixed; boundary="${deepBoundary}"`,
|
|
||||||
'',
|
|
||||||
// Level 4: Deep boundary
|
|
||||||
`--${deepBoundary}`,
|
|
||||||
'Content-Type: application/octet-stream',
|
|
||||||
'Content-Disposition: attachment; filename="data.bin"',
|
|
||||||
'',
|
|
||||||
'Binary data simulation',
|
|
||||||
'',
|
|
||||||
`--${deepBoundary}`,
|
|
||||||
'Content-Type: message/rfc822',
|
|
||||||
'',
|
|
||||||
'Subject: Embedded Message',
|
|
||||||
'From: embedded@example.com',
|
|
||||||
'To: recipient@example.com',
|
|
||||||
'',
|
|
||||||
'This is an embedded email message.',
|
|
||||||
'',
|
|
||||||
`--${deepBoundary}--`,
|
|
||||||
'',
|
|
||||||
`--${innerBoundary}--`,
|
|
||||||
'',
|
|
||||||
`--${middleBoundary}--`,
|
|
||||||
'',
|
|
||||||
`--${outerBoundary}`,
|
|
||||||
'Content-Type: application/pdf',
|
|
||||||
'Content-Disposition: attachment; filename="document.pdf"',
|
|
||||||
'',
|
|
||||||
'PDF document data simulation',
|
|
||||||
'',
|
|
||||||
`--${outerBoundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
console.log('Sending email with 4-level nested MIME structure');
|
|
||||||
socket.write(emailContent);
|
|
||||||
state = 'email_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if ((dataBuffer.includes('250 OK') && state === 'email_sent') ||
|
|
||||||
dataBuffer.includes('552 ') ||
|
|
||||||
dataBuffer.includes('554 ') ||
|
|
||||||
dataBuffer.includes('500 ')) {
|
|
||||||
// Either accepted or gracefully rejected
|
|
||||||
const accepted = dataBuffer.includes('250 ');
|
|
||||||
console.log(`Nested MIME structure test ${accepted ? 'accepted' : 'rejected'}`);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
console.error('Socket timeout');
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error('Socket timeout'));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Nested MIME Structures - should handle circular references in multipart structure', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let state = 'initial';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (dataBuffer.includes('220 ') && state === 'initial') {
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
state = 'ehlo_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('250 ') && state === 'ehlo_sent') {
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
state = 'mail_from_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('250 ') && state === 'mail_from_sent') {
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
state = 'rcpt_to_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('250 ') && state === 'rcpt_to_sent') {
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
state = 'data_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('354 ') && state === 'data_sent') {
|
|
||||||
// Create structure with references between parts
|
|
||||||
const boundary1 = '----=_Boundary1_' + Date.now();
|
|
||||||
const boundary2 = '----=_Boundary2_' + Date.now();
|
|
||||||
|
|
||||||
let emailContent = [
|
|
||||||
'Subject: Multipart with Cross-References',
|
|
||||||
'From: sender@example.com',
|
|
||||||
'To: recipient@example.com',
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
`Content-Type: multipart/related; boundary="${boundary1}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary1}`,
|
|
||||||
`Content-Type: multipart/alternative; boundary="${boundary2}"`,
|
|
||||||
'Content-ID: <part1>',
|
|
||||||
'',
|
|
||||||
`--${boundary2}`,
|
|
||||||
'Content-Type: text/html',
|
|
||||||
'',
|
|
||||||
'<html><body>See related part: <a href="cid:part2">Link</a></body></html>',
|
|
||||||
'',
|
|
||||||
`--${boundary2}`,
|
|
||||||
'Content-Type: text/plain',
|
|
||||||
'',
|
|
||||||
'Plain text with reference to part2',
|
|
||||||
'',
|
|
||||||
`--${boundary2}--`,
|
|
||||||
'',
|
|
||||||
`--${boundary1}`,
|
|
||||||
'Content-Type: application/xml',
|
|
||||||
'Content-ID: <part2>',
|
|
||||||
'',
|
|
||||||
'<?xml version="1.0"?><root><reference href="cid:part1"/></root>',
|
|
||||||
'',
|
|
||||||
`--${boundary1}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(emailContent);
|
|
||||||
state = 'email_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if ((dataBuffer.includes('250 OK') && state === 'email_sent') ||
|
|
||||||
dataBuffer.includes('552 ') ||
|
|
||||||
dataBuffer.includes('554 ') ||
|
|
||||||
dataBuffer.includes('500 ')) {
|
|
||||||
const accepted = dataBuffer.includes('250 ');
|
|
||||||
console.log(`Cross-reference test ${accepted ? 'accepted' : 'rejected'}`);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
console.error('Socket timeout');
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error('Socket timeout'));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Nested MIME Structures - should handle mixed nesting with various encodings', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let state = 'initial';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (dataBuffer.includes('220 ') && state === 'initial') {
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
state = 'ehlo_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('250 ') && state === 'ehlo_sent') {
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
state = 'mail_from_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('250 ') && state === 'mail_from_sent') {
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
state = 'rcpt_to_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('250 ') && state === 'rcpt_to_sent') {
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
state = 'data_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('354 ') && state === 'data_sent') {
|
|
||||||
// Create structure with various encodings
|
|
||||||
const boundary1 = '----=_Encoding_Outer_' + Date.now();
|
|
||||||
const boundary2 = '----=_Encoding_Inner_' + Date.now();
|
|
||||||
|
|
||||||
let emailContent = [
|
|
||||||
'Subject: Mixed Encodings in Nested Structure',
|
|
||||||
'From: sender@example.com',
|
|
||||||
'To: recipient@example.com',
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary1}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary1}`,
|
|
||||||
'Content-Type: text/plain; charset="utf-8"',
|
|
||||||
'Content-Transfer-Encoding: quoted-printable',
|
|
||||||
'',
|
|
||||||
'This is quoted-printable encoded: =C3=A9=C3=A8=C3=AA',
|
|
||||||
'',
|
|
||||||
`--${boundary1}`,
|
|
||||||
`Content-Type: multipart/alternative; boundary="${boundary2}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary2}`,
|
|
||||||
'Content-Type: text/plain; charset="iso-8859-1"',
|
|
||||||
'Content-Transfer-Encoding: 8bit',
|
|
||||||
'',
|
|
||||||
'Text with 8-bit characters: ñáéíóú',
|
|
||||||
'',
|
|
||||||
`--${boundary2}`,
|
|
||||||
'Content-Type: text/html; charset="utf-16"',
|
|
||||||
'Content-Transfer-Encoding: base64',
|
|
||||||
'',
|
|
||||||
'//48AGgAdABtAGwAPgA8AGIAbwBkAHkAPgBVAFQARgAtADEANgAgAHQAZQB4AHQAPAAvAGIAbwBkAHkAPgA8AC8AaAB0AG0AbAA+',
|
|
||||||
'',
|
|
||||||
`--${boundary2}--`,
|
|
||||||
'',
|
|
||||||
`--${boundary1}`,
|
|
||||||
'Content-Type: application/octet-stream',
|
|
||||||
'Content-Transfer-Encoding: base64',
|
|
||||||
'Content-Disposition: attachment; filename="binary.dat"',
|
|
||||||
'',
|
|
||||||
'VGhpcyBpcyBiaW5hcnkgZGF0YQ==',
|
|
||||||
'',
|
|
||||||
`--${boundary1}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(emailContent);
|
|
||||||
state = 'email_sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if ((dataBuffer.includes('250 OK') && state === 'email_sent') ||
|
|
||||||
dataBuffer.includes('552 ') ||
|
|
||||||
dataBuffer.includes('554 ') ||
|
|
||||||
dataBuffer.includes('500 ')) {
|
|
||||||
const accepted = dataBuffer.includes('250 ');
|
|
||||||
console.log(`Mixed encodings test ${accepted ? 'accepted' : 'rejected'}`);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
console.error('Socket timeout');
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error('Socket timeout'));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop test server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts'
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
const TEST_TIMEOUT = 15000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('setup - start SMTP server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Complete email sending flow
|
|
||||||
tap.test('Basic Email Sending - should send email through complete SMTP flow', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const fromAddress = 'sender@example.com';
|
|
||||||
const toAddress = 'recipient@example.com';
|
|
||||||
const emailContent = `Subject: Production Test Email\r\nFrom: ${fromAddress}\r\nTo: ${toAddress}\r\nDate: ${new Date().toUTCString()}\r\n\r\nThis is a test email sent during production testing.\r\nTest ID: EP-01\r\nTimestamp: ${Date.now()}\r\n`;
|
|
||||||
|
|
||||||
const steps: string[] = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
steps.push('CONNECT');
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
steps.push('EHLO');
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write(`MAIL FROM:<${fromAddress}>\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
steps.push('MAIL FROM');
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write(`RCPT TO:<${toAddress}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
steps.push('RCPT TO');
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
steps.push('DATA');
|
|
||||||
currentStep = 'email_content';
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n'); // End of data marker
|
|
||||||
} else if (currentStep === 'email_content' && receivedData.includes('250')) {
|
|
||||||
steps.push('CONTENT');
|
|
||||||
currentStep = 'quit';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
steps.push('QUIT');
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Verify all steps completed
|
|
||||||
expect(steps).toInclude('CONNECT');
|
|
||||||
expect(steps).toInclude('EHLO');
|
|
||||||
expect(steps).toInclude('MAIL FROM');
|
|
||||||
expect(steps).toInclude('RCPT TO');
|
|
||||||
expect(steps).toInclude('DATA');
|
|
||||||
expect(steps).toInclude('CONTENT');
|
|
||||||
expect(steps).toInclude('QUIT');
|
|
||||||
expect(steps.length).toEqual(7);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
} else if (receivedData.match(/\r\n5\d{2}\s/)) {
|
|
||||||
// Server error (5xx response codes)
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Email sending failed at step ${currentStep}: ${receivedData}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Send email with attachments (MIME)
|
|
||||||
tap.test('Basic Email Sending - should send email with MIME attachment', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const fromAddress = 'sender@example.com';
|
|
||||||
const toAddress = 'recipient@example.com';
|
|
||||||
const boundary = '----=_Part_0_1234567890';
|
|
||||||
|
|
||||||
const emailContent = `Subject: Email with Attachment\r\nFrom: ${fromAddress}\r\nTo: ${toAddress}\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n--${boundary}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nThis email contains an attachment.\r\n\r\n--${boundary}\r\nContent-Type: text/plain; name="test.txt"\r\nContent-Disposition: attachment; filename="test.txt"\r\nContent-Transfer-Encoding: base64\r\n\r\nVGhpcyBpcyBhIHRlc3QgZmlsZS4=\r\n\r\n--${boundary}--\r\n`;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write(`MAIL FROM:<${fromAddress}>\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write(`RCPT TO:<${toAddress}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'email_content';
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n'); // End of data marker
|
|
||||||
} else if (currentStep === 'email_content' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Send HTML email
|
|
||||||
tap.test('Basic Email Sending - should send HTML email', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const fromAddress = 'sender@example.com';
|
|
||||||
const toAddress = 'recipient@example.com';
|
|
||||||
const boundary = '----=_Part_0_987654321';
|
|
||||||
|
|
||||||
const emailContent = `Subject: HTML Email Test\r\nFrom: ${fromAddress}\r\nTo: ${toAddress}\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n--${boundary}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nThis is the plain text version.\r\n\r\n--${boundary}\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n<html><body><h1>HTML Email</h1><p>This is the <strong>HTML</strong> version.</p></body></html>\r\n\r\n--${boundary}--\r\n`;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write(`MAIL FROM:<${fromAddress}>\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write(`RCPT TO:<${toAddress}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'email_content';
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n'); // End of data marker
|
|
||||||
} else if (currentStep === 'email_content' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Send email with custom headers
|
|
||||||
tap.test('Basic Email Sending - should send email with custom headers', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const fromAddress = 'sender@example.com';
|
|
||||||
const toAddress = 'recipient@example.com';
|
|
||||||
|
|
||||||
const emailContent = `Subject: Custom Headers Test\r\nFrom: ${fromAddress}\r\nTo: ${toAddress}\r\nX-Custom-Header: CustomValue\r\nX-Priority: 1\r\nX-Mailer: SMTP Test Suite\r\nReply-To: noreply@example.com\r\nOrganization: Test Organization\r\n\r\nThis email contains custom headers.\r\n`;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write(`MAIL FROM:<${fromAddress}>\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write(`RCPT TO:<${toAddress}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'email_content';
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n'); // End of data marker
|
|
||||||
} else if (currentStep === 'email_content' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Minimal email (only required headers)
|
|
||||||
tap.test('Basic Email Sending - should send minimal email', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const fromAddress = 'sender@example.com';
|
|
||||||
const toAddress = 'recipient@example.com';
|
|
||||||
|
|
||||||
// Minimal email - just a body, no headers
|
|
||||||
const emailContent = 'This is a minimal email with no headers.\r\n';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write(`MAIL FROM:<${fromAddress}>\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write(`RCPT TO:<${toAddress}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'email_content';
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n'); // End of data marker
|
|
||||||
} else if (currentStep === 'email_content' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('teardown - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts'
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
const TEST_TIMEOUT = 20000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('setup - start SMTP server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Invalid email address validation
|
|
||||||
tap.test('Invalid Email Addresses - should reject various invalid email formats', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const invalidAddresses = [
|
|
||||||
'invalid-email',
|
|
||||||
'@example.com',
|
|
||||||
'user@',
|
|
||||||
'user..name@example.com',
|
|
||||||
'user@.example.com',
|
|
||||||
'user@example..com',
|
|
||||||
'user@example.',
|
|
||||||
'user name@example.com',
|
|
||||||
'user@exam ple.com',
|
|
||||||
'user@[invalid]',
|
|
||||||
'a'.repeat(65) + '@example.com', // Local part too long
|
|
||||||
'user@' + 'a'.repeat(250) + '.com' // Domain too long
|
|
||||||
];
|
|
||||||
|
|
||||||
const results: Array<{
|
|
||||||
address: string;
|
|
||||||
response: string;
|
|
||||||
responseCode: string;
|
|
||||||
properlyRejected: boolean;
|
|
||||||
accepted: boolean;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentIndex = 0;
|
|
||||||
let state = 'connecting';
|
|
||||||
let buffer = '';
|
|
||||||
let lastResponseCode = '';
|
|
||||||
const fromAddress = 'test@example.com';
|
|
||||||
|
|
||||||
const processNextAddress = () => {
|
|
||||||
if (currentIndex < invalidAddresses.length) {
|
|
||||||
socket.write(`RCPT TO:<${invalidAddresses[currentIndex]}>\r\n`);
|
|
||||||
state = 'rcpt';
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
state = 'quit';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
buffer += data.toString();
|
|
||||||
const lines = buffer.split('\r\n');
|
|
||||||
|
|
||||||
// Process complete lines
|
|
||||||
for (let i = 0; i < lines.length - 1; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
if (line.match(/^\d{3}/)) {
|
|
||||||
lastResponseCode = line.substring(0, 3);
|
|
||||||
|
|
||||||
if (state === 'connecting' && line.startsWith('220')) {
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
state = 'ehlo';
|
|
||||||
} else if (state === 'ehlo' && line.startsWith('250') && !line.includes('250-')) {
|
|
||||||
socket.write(`MAIL FROM:<${fromAddress}>\r\n`);
|
|
||||||
state = 'mail';
|
|
||||||
} else if (state === 'mail' && line.startsWith('250')) {
|
|
||||||
processNextAddress();
|
|
||||||
} else if (state === 'rcpt') {
|
|
||||||
// Record result
|
|
||||||
const rejected = lastResponseCode.startsWith('5') || lastResponseCode.startsWith('4');
|
|
||||||
results.push({
|
|
||||||
address: invalidAddresses[currentIndex],
|
|
||||||
response: line,
|
|
||||||
responseCode: lastResponseCode,
|
|
||||||
properlyRejected: rejected,
|
|
||||||
accepted: lastResponseCode.startsWith('2')
|
|
||||||
});
|
|
||||||
|
|
||||||
currentIndex++;
|
|
||||||
|
|
||||||
if (currentIndex < invalidAddresses.length) {
|
|
||||||
// Reset and test next
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
state = 'rset';
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
state = 'quit';
|
|
||||||
}
|
|
||||||
} else if (state === 'rset' && line.startsWith('250')) {
|
|
||||||
socket.write(`MAIL FROM:<${fromAddress}>\r\n`);
|
|
||||||
state = 'mail';
|
|
||||||
} else if (state === 'quit' && line.startsWith('221')) {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Analyze results
|
|
||||||
const rejected = results.filter(r => r.properlyRejected).length;
|
|
||||||
const rate = results.length > 0 ? rejected / results.length : 0;
|
|
||||||
|
|
||||||
// Log results for debugging
|
|
||||||
results.forEach(r => {
|
|
||||||
if (!r.properlyRejected) {
|
|
||||||
console.log(`WARNING: Invalid address accepted: ${r.address}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// We expect at least 70% rejection rate for invalid addresses
|
|
||||||
expect(rate).toBeGreaterThan(0.7);
|
|
||||||
expect(results.length).toEqual(invalidAddresses.length);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep incomplete line in buffer
|
|
||||||
buffer = lines[lines.length - 1];
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error('Test timeout'));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Edge case email addresses that might be valid
|
|
||||||
tap.test('Invalid Email Addresses - should handle edge case addresses', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const edgeCaseAddresses = [
|
|
||||||
'user+tag@example.com', // Valid - with plus addressing
|
|
||||||
'user.name@example.com', // Valid - with dot
|
|
||||||
'user@sub.example.com', // Valid - subdomain
|
|
||||||
'user@192.168.1.1', // Valid - IP address
|
|
||||||
'user@[192.168.1.1]', // Valid - IP in brackets
|
|
||||||
'"user name"@example.com', // Valid - quoted local part
|
|
||||||
'user\\@name@example.com', // Valid - escaped character
|
|
||||||
'user@localhost', // Might be valid depending on server config
|
|
||||||
];
|
|
||||||
|
|
||||||
const results: Array<{
|
|
||||||
address: string;
|
|
||||||
accepted: boolean;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentIndex = 0;
|
|
||||||
let state = 'connecting';
|
|
||||||
let buffer = '';
|
|
||||||
const fromAddress = 'test@example.com';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
buffer += data.toString();
|
|
||||||
const lines = buffer.split('\r\n');
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length - 1; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
if (line.match(/^\d{3}/)) {
|
|
||||||
const responseCode = line.substring(0, 3);
|
|
||||||
|
|
||||||
if (state === 'connecting' && line.startsWith('220')) {
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
state = 'ehlo';
|
|
||||||
} else if (state === 'ehlo' && line.startsWith('250') && !line.includes('250-')) {
|
|
||||||
socket.write(`MAIL FROM:<${fromAddress}>\r\n`);
|
|
||||||
state = 'mail';
|
|
||||||
} else if (state === 'mail' && line.startsWith('250')) {
|
|
||||||
if (currentIndex < edgeCaseAddresses.length) {
|
|
||||||
socket.write(`RCPT TO:<${edgeCaseAddresses[currentIndex]}>\r\n`);
|
|
||||||
state = 'rcpt';
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
state = 'quit';
|
|
||||||
}
|
|
||||||
} else if (state === 'rcpt') {
|
|
||||||
results.push({
|
|
||||||
address: edgeCaseAddresses[currentIndex],
|
|
||||||
accepted: responseCode.startsWith('2')
|
|
||||||
});
|
|
||||||
|
|
||||||
currentIndex++;
|
|
||||||
|
|
||||||
if (currentIndex < edgeCaseAddresses.length) {
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
state = 'rset';
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
state = 'quit';
|
|
||||||
}
|
|
||||||
} else if (state === 'rset' && line.startsWith('250')) {
|
|
||||||
socket.write(`MAIL FROM:<${fromAddress}>\r\n`);
|
|
||||||
state = 'mail';
|
|
||||||
} else if (state === 'quit' && line.startsWith('221')) {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Just verify we tested all addresses
|
|
||||||
expect(results.length).toEqual(edgeCaseAddresses.length);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer = lines[lines.length - 1];
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error('Test timeout'));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Empty and null addresses
|
|
||||||
tap.test('Invalid Email Addresses - should handle empty addresses', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<test@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_empty';
|
|
||||||
socket.write('RCPT TO:<>\r\n'); // Empty address
|
|
||||||
} else if (currentStep === 'rcpt_empty') {
|
|
||||||
if (receivedData.includes('250')) {
|
|
||||||
// Empty recipient allowed (for bounces)
|
|
||||||
currentStep = 'rset';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (receivedData.match(/[45]\d{2}/)) {
|
|
||||||
// Empty recipient rejected
|
|
||||||
currentStep = 'rset';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_empty';
|
|
||||||
socket.write('MAIL FROM:<>\r\n'); // Empty sender (bounce)
|
|
||||||
} else if (currentStep === 'mail_empty' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_after_empty';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_after_empty' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Empty MAIL FROM should be accepted for bounces
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('teardown - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,493 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 30049;
|
|
||||||
const TEST_TIMEOUT = 15000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('setup - start SMTP server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT, hostname: 'localhost' });
|
|
||||||
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
expect(testServer.port).toEqual(TEST_PORT);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic multiple recipients
|
|
||||||
tap.test('Multiple Recipients - should accept multiple valid recipients', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let recipientCount = 0;
|
|
||||||
const recipients = [
|
|
||||||
'recipient1@example.com',
|
|
||||||
'recipient2@example.com',
|
|
||||||
'recipient3@example.com'
|
|
||||||
];
|
|
||||||
let acceptedRecipients = 0;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to') {
|
|
||||||
if (receivedData.includes('250')) {
|
|
||||||
acceptedRecipients++;
|
|
||||||
recipientCount++;
|
|
||||||
|
|
||||||
if (recipientCount < recipients.length) {
|
|
||||||
receivedData = ''; // Clear buffer for next response
|
|
||||||
socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'email_content';
|
|
||||||
const emailContent = `Subject: Multiple Recipients Test\r\nFrom: sender@example.com\r\nTo: ${recipients.join(', ')}\r\n\r\nThis email was sent to ${acceptedRecipients} recipients.\r\n`;
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n');
|
|
||||||
} else if (currentStep === 'email_content' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(acceptedRecipients).toEqual(recipients.length);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Mixed valid and invalid recipients
|
|
||||||
tap.test('Multiple Recipients - should handle mix of valid and invalid recipients', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let recipientIndex = 0;
|
|
||||||
const recipients = [
|
|
||||||
'valid@example.com',
|
|
||||||
'invalid-email', // Invalid format
|
|
||||||
'another.valid@example.com',
|
|
||||||
'@example.com', // Invalid format
|
|
||||||
'third.valid@example.com'
|
|
||||||
];
|
|
||||||
const recipientResults: Array<{ email: string, accepted: boolean }> = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write(`RCPT TO:<${recipients[recipientIndex]}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to') {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const lastLine = lines[lines.length - 2] || lines[lines.length - 1];
|
|
||||||
|
|
||||||
if (lastLine.match(/^\d{3}/)) {
|
|
||||||
const accepted = lastLine.startsWith('250');
|
|
||||||
recipientResults.push({
|
|
||||||
email: recipients[recipientIndex],
|
|
||||||
accepted: accepted
|
|
||||||
});
|
|
||||||
|
|
||||||
recipientIndex++;
|
|
||||||
|
|
||||||
if (recipientIndex < recipients.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`RCPT TO:<${recipients[recipientIndex]}>\r\n`);
|
|
||||||
} else {
|
|
||||||
const acceptedCount = recipientResults.filter(r => r.accepted).length;
|
|
||||||
|
|
||||||
if (acceptedCount > 0) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(acceptedCount).toEqual(0);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'email_content';
|
|
||||||
const acceptedEmails = recipientResults.filter(r => r.accepted).map(r => r.email);
|
|
||||||
const emailContent = `Subject: Mixed Recipients Test\r\nFrom: sender@example.com\r\nTo: ${acceptedEmails.join(', ')}\r\n\r\nDelivered to ${acceptedEmails.length} valid recipients.\r\n`;
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n');
|
|
||||||
} else if (currentStep === 'email_content' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
const acceptedCount = recipientResults.filter(r => r.accepted).length;
|
|
||||||
const rejectedCount = recipientResults.filter(r => !r.accepted).length;
|
|
||||||
expect(acceptedCount).toEqual(3); // 3 valid recipients
|
|
||||||
expect(rejectedCount).toEqual(2); // 2 invalid recipients
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Large number of recipients
|
|
||||||
tap.test('Multiple Recipients - should handle many recipients', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let recipientCount = 0;
|
|
||||||
const totalRecipients = 10;
|
|
||||||
const recipients: string[] = [];
|
|
||||||
for (let i = 1; i <= totalRecipients; i++) {
|
|
||||||
recipients.push(`recipient${i}@example.com`);
|
|
||||||
}
|
|
||||||
let acceptedCount = 0;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to') {
|
|
||||||
if (receivedData.includes('250')) {
|
|
||||||
acceptedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
recipientCount++;
|
|
||||||
|
|
||||||
if (recipientCount < recipients.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'email_content';
|
|
||||||
const emailContent = `Subject: Large Recipients Test\r\nFrom: sender@example.com\r\n\r\nSent to ${acceptedCount} recipients.\r\n`;
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n');
|
|
||||||
} else if (currentStep === 'email_content' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(acceptedCount).toBeGreaterThan(0);
|
|
||||||
expect(acceptedCount).toBeLessThan(totalRecipients + 1);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Duplicate recipients
|
|
||||||
tap.test('Multiple Recipients - should handle duplicate recipients', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let recipientCount = 0;
|
|
||||||
const recipients = [
|
|
||||||
'duplicate@example.com',
|
|
||||||
'unique@example.com',
|
|
||||||
'duplicate@example.com', // Duplicate
|
|
||||||
'another@example.com',
|
|
||||||
'duplicate@example.com' // Another duplicate
|
|
||||||
];
|
|
||||||
const results: boolean[] = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to') {
|
|
||||||
if (receivedData.match(/[245]\d{2}/)) {
|
|
||||||
results.push(receivedData.includes('250'));
|
|
||||||
recipientCount++;
|
|
||||||
|
|
||||||
if (recipientCount < recipients.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'email_content';
|
|
||||||
const emailContent = `Subject: Duplicate Recipients Test\r\nFrom: sender@example.com\r\n\r\nTesting duplicate recipient handling.\r\n`;
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n');
|
|
||||||
} else if (currentStep === 'email_content' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(results.length).toEqual(recipients.length);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: No recipients (should fail DATA)
|
|
||||||
tap.test('Multiple Recipients - DATA should fail with no recipients', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
// Skip RCPT TO, go directly to DATA
|
|
||||||
currentStep = 'data_no_recipients';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_no_recipients') {
|
|
||||||
if (receivedData.includes('503')) {
|
|
||||||
// Expected: bad sequence error
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('503'); // Bad sequence
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
} else if (receivedData.includes('354')) {
|
|
||||||
// Some servers accept DATA without recipients and fail later
|
|
||||||
// Send empty data to trigger the error
|
|
||||||
socket.write('.\r\n');
|
|
||||||
currentStep = 'data_sent';
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'data_sent' && receivedData.match(/[45]\d{2}/)) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Should get an error when trying to send without recipients
|
|
||||||
expect(receivedData).toMatch(/[45]\d{2}/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Recipients with different domains
|
|
||||||
tap.test('Multiple Recipients - should handle recipients from different domains', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let recipientCount = 0;
|
|
||||||
const recipients = [
|
|
||||||
'user1@example.com',
|
|
||||||
'user2@test.com',
|
|
||||||
'user3@localhost',
|
|
||||||
'user4@example.org',
|
|
||||||
'user5@subdomain.example.com'
|
|
||||||
];
|
|
||||||
let acceptedCount = 0;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to') {
|
|
||||||
if (receivedData.includes('250')) {
|
|
||||||
acceptedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
recipientCount++;
|
|
||||||
|
|
||||||
if (recipientCount < recipients.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`);
|
|
||||||
} else {
|
|
||||||
if (acceptedCount > 0) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'email_content';
|
|
||||||
const emailContent = `Subject: Multi-domain Test\r\nFrom: sender@example.com\r\n\r\nDelivered to ${acceptedCount} recipients across different domains.\r\n`;
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n');
|
|
||||||
} else if (currentStep === 'email_content' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(acceptedCount).toBeGreaterThan(0);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('teardown - stop SMTP server', async () => {
|
|
||||||
if (testServer) {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
}
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 30048;
|
|
||||||
const TEST_TIMEOUT = 60000; // Increased for large email handling
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('setup - start SMTP server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT, hostname: 'localhost' });
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Moderately large email (1MB)
|
|
||||||
tap.test('Large Email - should handle 1MB email', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
// Generate 1MB of content
|
|
||||||
const largeBody = 'X'.repeat(1024 * 1024); // 1MB
|
|
||||||
const emailContent = `Subject: 1MB Email Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\n${largeBody}\r\n`;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'sending_large_email';
|
|
||||||
|
|
||||||
// Send in chunks to avoid overwhelming
|
|
||||||
const chunkSize = 64 * 1024; // 64KB chunks
|
|
||||||
let sent = 0;
|
|
||||||
|
|
||||||
const sendChunk = () => {
|
|
||||||
if (sent < emailContent.length) {
|
|
||||||
const chunk = emailContent.slice(sent, sent + chunkSize);
|
|
||||||
socket.write(chunk);
|
|
||||||
sent += chunk.length;
|
|
||||||
|
|
||||||
// Small delay between chunks
|
|
||||||
if (sent < emailContent.length) {
|
|
||||||
setTimeout(sendChunk, 10);
|
|
||||||
} else {
|
|
||||||
// End of data
|
|
||||||
socket.write('.\r\n');
|
|
||||||
currentStep = 'sent';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sendChunk();
|
|
||||||
} else if (currentStep === 'sent' && (receivedData.includes('250') || receivedData.includes('552'))) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Either accepted (250) or size exceeded (552)
|
|
||||||
expect(receivedData).toMatch(/250|552/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Large email with MIME attachments
|
|
||||||
tap.test('Large Email - should handle multi-part MIME message', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
const boundary = '----=_Part_0_123456789';
|
|
||||||
const attachment1 = 'A'.repeat(500 * 1024); // 500KB
|
|
||||||
const attachment2 = 'B'.repeat(300 * 1024); // 300KB
|
|
||||||
|
|
||||||
const emailContent = [
|
|
||||||
'Subject: Large MIME Email Test',
|
|
||||||
'From: sender@example.com',
|
|
||||||
'To: recipient@example.com',
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
'This is a multi-part message in MIME format.',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/plain; charset=utf-8',
|
|
||||||
'',
|
|
||||||
'This email contains large attachments.',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/plain; charset=utf-8',
|
|
||||||
'Content-Disposition: attachment; filename="file1.txt"',
|
|
||||||
'',
|
|
||||||
attachment1,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: application/octet-stream',
|
|
||||||
'Content-Disposition: attachment; filename="file2.bin"',
|
|
||||||
'Content-Transfer-Encoding: base64',
|
|
||||||
'',
|
|
||||||
Buffer.from(attachment2).toString('base64'),
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'sending_mime';
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n');
|
|
||||||
currentStep = 'sent';
|
|
||||||
} else if (currentStep === 'sent' && (receivedData.includes('250') || receivedData.includes('552'))) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toMatch(/250|552/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Email size limits with SIZE extension
|
|
||||||
tap.test('Large Email - should respect SIZE limits if advertised', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let maxSize: number | null = null;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
// Check for SIZE extension
|
|
||||||
const sizeMatch = receivedData.match(/SIZE\s+(\d+)/);
|
|
||||||
if (sizeMatch) {
|
|
||||||
maxSize = parseInt(sizeMatch[1]);
|
|
||||||
console.log(`Server advertises max size: ${maxSize} bytes`);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
const emailSize = maxSize ? maxSize + 1000 : 5000000; // Over limit or 5MB
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${emailSize}\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from') {
|
|
||||||
if (maxSize && receivedData.includes('552')) {
|
|
||||||
// Size rejected - expected
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('552');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
} else if (receivedData.includes('250')) {
|
|
||||||
// Size accepted or no limit
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Very large email handling (5MB)
|
|
||||||
tap.test('Large Email - should handle or reject very large emails gracefully', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
// Generate 5MB email
|
|
||||||
const largeContent = 'X'.repeat(5 * 1024 * 1024); // 5MB
|
|
||||||
const emailContent = `Subject: 5MB Email Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\n${largeContent}\r\n`;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'sending_5mb';
|
|
||||||
|
|
||||||
console.log('Sending 5MB email...');
|
|
||||||
|
|
||||||
// Send in larger chunks for efficiency
|
|
||||||
const chunkSize = 256 * 1024; // 256KB chunks
|
|
||||||
let sent = 0;
|
|
||||||
|
|
||||||
const sendChunk = () => {
|
|
||||||
if (sent < emailContent.length) {
|
|
||||||
const chunk = emailContent.slice(sent, sent + chunkSize);
|
|
||||||
socket.write(chunk);
|
|
||||||
sent += chunk.length;
|
|
||||||
|
|
||||||
if (sent < emailContent.length) {
|
|
||||||
setImmediate(sendChunk); // Use setImmediate for better performance
|
|
||||||
} else {
|
|
||||||
socket.write('.\r\n');
|
|
||||||
currentStep = 'sent';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sendChunk();
|
|
||||||
} else if (currentStep === 'sent' && receivedData.match(/[245]\d{2}/)) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
// Extract the last response code
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
let responseCode = '';
|
|
||||||
|
|
||||||
// Look for the most recent response code
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const match = lines[i].match(/^([245]\d{2})[\s-]/);
|
|
||||||
if (match) {
|
|
||||||
responseCode = match[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we couldn't extract, but we know there's a response, default to 250
|
|
||||||
if (!responseCode && receivedData.includes('250 OK message queued')) {
|
|
||||||
responseCode = '250';
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Accept various responses: 250 (accepted), 552 (size exceeded), 554 (failed)
|
|
||||||
expect(responseCode).toMatch(/^(250|552|554|451|452)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
// Connection errors during large transfers are acceptable
|
|
||||||
if (currentStep === 'sending_5mb' || currentStep === 'sent') {
|
|
||||||
done.resolve();
|
|
||||||
} else {
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Chunked transfer handling
|
|
||||||
tap.test('Large Email - should handle chunked transfers properly', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let chunksSent = 0;
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'chunked_sending';
|
|
||||||
|
|
||||||
// Send headers
|
|
||||||
socket.write('Subject: Chunked Transfer Test\r\n');
|
|
||||||
socket.write('From: sender@example.com\r\n');
|
|
||||||
socket.write('To: recipient@example.com\r\n');
|
|
||||||
socket.write('\r\n');
|
|
||||||
|
|
||||||
// Send body in multiple chunks with delays
|
|
||||||
const chunks = [
|
|
||||||
'First chunk of data\r\n',
|
|
||||||
'Second chunk of data\r\n',
|
|
||||||
'Third chunk of data\r\n',
|
|
||||||
'Fourth chunk of data\r\n',
|
|
||||||
'Final chunk of data\r\n'
|
|
||||||
];
|
|
||||||
|
|
||||||
const sendNextChunk = () => {
|
|
||||||
if (chunksSent < chunks.length) {
|
|
||||||
socket.write(chunks[chunksSent]);
|
|
||||||
chunksSent++;
|
|
||||||
setTimeout(sendNextChunk, 100); // 100ms delay between chunks
|
|
||||||
} else {
|
|
||||||
socket.write('.\r\n');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sendNextChunk();
|
|
||||||
} else if (currentStep === 'chunked_sending' && receivedData.includes('250')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(chunksSent).toEqual(5);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Email with very long lines
|
|
||||||
tap.test('Large Email - should handle emails with very long lines', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
// Create a very long line (10KB)
|
|
||||||
const veryLongLine = 'A'.repeat(10 * 1024);
|
|
||||||
const emailContent = `Subject: Long Line Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\n${veryLongLine}\r\nNormal line after long line.\r\n`;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'long_line';
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('.\r\n');
|
|
||||||
currentStep = 'sent';
|
|
||||||
} else if (currentStep === 'sent') {
|
|
||||||
// Extract the last response code from the received data
|
|
||||||
// Look for response codes that are at the beginning of a line
|
|
||||||
const responseMatches = receivedData.split('\r\n').filter(line => /^\d{3}\s/.test(line));
|
|
||||||
const lastResponseLine = responseMatches[responseMatches.length - 1];
|
|
||||||
const responseCode = lastResponseLine?.match(/^(\d{3})/)?.[1];
|
|
||||||
if (responseCode && !completed) {
|
|
||||||
completed = true;
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// May accept or reject based on line length limits
|
|
||||||
expect(responseCode).toMatch(/^(250|500|501|552)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('teardown - stop SMTP server', async () => {
|
|
||||||
if (testServer) {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
}
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,515 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts'
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start test server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('MIME Handling - Comprehensive multipart message', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
// Create comprehensive MIME test email
|
|
||||||
const boundary = 'mime-test-boundary-12345';
|
|
||||||
const innerBoundary = 'inner-mime-boundary-67890';
|
|
||||||
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: MIME Handling Test - Comprehensive`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <mime-test-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
'This is a multi-part message in MIME format.',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=utf-8`,
|
|
||||||
`Content-Transfer-Encoding: 7bit`,
|
|
||||||
'',
|
|
||||||
'This is the plain text part of the email.',
|
|
||||||
'It tests basic MIME text handling.',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/html; charset=utf-8`,
|
|
||||||
`Content-Transfer-Encoding: quoted-printable`,
|
|
||||||
'',
|
|
||||||
'<html>',
|
|
||||||
'<head><title>MIME Test</title></head>',
|
|
||||||
'<body>',
|
|
||||||
'<h1>HTML MIME Content</h1>',
|
|
||||||
'<p>This tests HTML MIME content handling.</p>',
|
|
||||||
'<p>Special chars: =E2=98=85 =E2=9C=93 =E2=9D=A4</p>',
|
|
||||||
'</body>',
|
|
||||||
'</html>',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: multipart/alternative; boundary="${innerBoundary}"`,
|
|
||||||
'',
|
|
||||||
`--${innerBoundary}`,
|
|
||||||
`Content-Type: text/plain; charset=iso-8859-1`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
'VGhpcyBpcyBiYXNlNjQgZW5jb2RlZCB0ZXh0IGNvbnRlbnQu',
|
|
||||||
'',
|
|
||||||
`--${innerBoundary}`,
|
|
||||||
`Content-Type: application/json; charset=utf-8`,
|
|
||||||
'',
|
|
||||||
'{"message": "JSON MIME content", "test": true, "special": "àáâãäå"}',
|
|
||||||
'',
|
|
||||||
`--${innerBoundary}--`,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: image/png`,
|
|
||||||
`Content-Disposition: attachment; filename="test.png"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/csv`,
|
|
||||||
`Content-Disposition: attachment; filename="data.csv"`,
|
|
||||||
'',
|
|
||||||
'Name,Age,Email',
|
|
||||||
'John,25,john@example.com',
|
|
||||||
'Jane,30,jane@example.com',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/pdf`,
|
|
||||||
`Content-Disposition: attachment; filename="document.pdf"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
'JVBERi0xLjQKJcOkw7zDtsOVDQo=',
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
console.log('Sending comprehensive MIME email with multiple parts and encodings');
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Complex MIME message accepted successfully');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('MIME Handling - Quoted-printable encoding', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: =?UTF-8?Q?Quoted=2DPrintable=20Test=20=F0=9F=8C=9F?=`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <qp-test-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: text/plain; charset=utf-8`,
|
|
||||||
`Content-Transfer-Encoding: quoted-printable`,
|
|
||||||
'',
|
|
||||||
'This is a test of quoted-printable encoding.',
|
|
||||||
'Special characters: =C3=A9 =C3=A8 =C3=AA =C3=AB',
|
|
||||||
'Long line that needs to be wrapped with soft line breaks at 76 character=',
|
|
||||||
's per line to comply with MIME standards for quoted-printable encoding.',
|
|
||||||
'Emoji: =F0=9F=98=80 =F0=9F=91=8D =F0=9F=8C=9F',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Quoted-printable encoded email accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('MIME Handling - Base64 encoding', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const boundary = 'base64-test-boundary';
|
|
||||||
const textContent = 'This is a test of base64 encoding with various content types.\nSpecial chars: éèêë\nEmoji: 😀 👍 🌟';
|
|
||||||
const base64Content = Buffer.from(textContent).toString('base64');
|
|
||||||
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: Base64 Encoding Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <base64-test-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=utf-8`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
base64Content,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/octet-stream`,
|
|
||||||
`Content-Disposition: attachment; filename="binary.dat"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
'VGhpcyBpcyBiaW5hcnkgZGF0YSBmb3IgdGVzdGluZw==',
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Base64 encoded email accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('MIME Handling - Content-Disposition headers', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const boundary = 'disposition-test-boundary';
|
|
||||||
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: Content-Disposition Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <disposition-test-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain`,
|
|
||||||
`Content-Disposition: inline`,
|
|
||||||
'',
|
|
||||||
'This is inline text content.',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: image/jpeg`,
|
|
||||||
`Content-Disposition: attachment; filename="photo.jpg"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAEBAQ==',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/pdf`,
|
|
||||||
`Content-Disposition: attachment; filename="report.pdf"; size=1234`,
|
|
||||||
`Content-Description: Monthly Report`,
|
|
||||||
'',
|
|
||||||
'PDF content here',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/html`,
|
|
||||||
`Content-Disposition: inline; filename="content.html"`,
|
|
||||||
'',
|
|
||||||
'<html><body>Inline HTML content</body></html>',
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with various Content-Disposition headers accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('MIME Handling - International character sets', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const boundary = 'intl-charset-boundary';
|
|
||||||
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: International Character Sets`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <intl-charset-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=utf-8`,
|
|
||||||
'',
|
|
||||||
'UTF-8: Français, Español, Deutsch, 中文, 日本語, 한국어, العربية',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=iso-8859-1`,
|
|
||||||
'',
|
|
||||||
'ISO-8859-1: Français, Español, Português',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=windows-1252`,
|
|
||||||
'',
|
|
||||||
'Windows-1252: Special chars: €‚ƒ„…†‡',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=shift_jis`,
|
|
||||||
'',
|
|
||||||
'Shift-JIS: Japanese text',
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with international character sets accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop test server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,629 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts'
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
const SAMPLE_FILES_DIR = path.join(process.cwd(), '.nogit', 'sample-files');
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
// Helper function to read and encode files
|
|
||||||
function readFileAsBase64(filePath: string): string {
|
|
||||||
try {
|
|
||||||
const fileContent = fs.readFileSync(filePath);
|
|
||||||
return fileContent.toString('base64');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to read file ${filePath}:`, err);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tap.test('setup - start test server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Attachment Handling - Multiple file types', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (completed) return;
|
|
||||||
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const boundary = 'attachment-test-boundary-12345';
|
|
||||||
|
|
||||||
// Create various attachments
|
|
||||||
const textAttachment = 'This is a text attachment content.\nIt has multiple lines.\nAnd special chars: åäö';
|
|
||||||
const jsonAttachment = JSON.stringify({
|
|
||||||
name: 'test',
|
|
||||||
data: [1, 2, 3],
|
|
||||||
unicode: 'ñoño',
|
|
||||||
special: '∑∆≈'
|
|
||||||
}, null, 2);
|
|
||||||
|
|
||||||
// Read real files from sample directory
|
|
||||||
const sampleImage = readFileAsBase64(path.join(SAMPLE_FILES_DIR, '003-pdflatex-image/image.jpg'));
|
|
||||||
const minimalPdf = readFileAsBase64(path.join(SAMPLE_FILES_DIR, '001-trivial/minimal-document.pdf'));
|
|
||||||
const multiPagePdf = readFileAsBase64(path.join(SAMPLE_FILES_DIR, '004-pdflatex-4-pages/pdflatex-4-pages.pdf'));
|
|
||||||
const pdfWithAttachment = readFileAsBase64(path.join(SAMPLE_FILES_DIR, '025-attachment/with-attachment.pdf'));
|
|
||||||
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: Attachment Handling Test - Multiple Types`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <attachment-test-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
'This is a multi-part message with various attachments.',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=utf-8`,
|
|
||||||
'',
|
|
||||||
'This email tests attachment handling capabilities.',
|
|
||||||
'The server should properly process all attached files.',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=utf-8`,
|
|
||||||
`Content-Disposition: attachment; filename="document.txt"`,
|
|
||||||
`Content-Transfer-Encoding: 7bit`,
|
|
||||||
'',
|
|
||||||
textAttachment,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/json; charset=utf-8`,
|
|
||||||
`Content-Disposition: attachment; filename="data.json"`,
|
|
||||||
'',
|
|
||||||
jsonAttachment,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: image/jpeg`,
|
|
||||||
`Content-Disposition: attachment; filename="sample-image.jpg"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
sampleImage,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/octet-stream`,
|
|
||||||
`Content-Disposition: attachment; filename="binary.bin"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
Buffer.from('Binary file content with null bytes\0\0\0').toString('base64'),
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/csv`,
|
|
||||||
`Content-Disposition: attachment; filename="spreadsheet.csv"`,
|
|
||||||
'',
|
|
||||||
'Name,Age,Country',
|
|
||||||
'Alice,25,Sweden',
|
|
||||||
'Bob,30,Norway',
|
|
||||||
'Charlie,35,Denmark',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/xml; charset=utf-8`,
|
|
||||||
`Content-Disposition: attachment; filename="config.xml"`,
|
|
||||||
'',
|
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
||||||
'<config>',
|
|
||||||
' <setting name="test">value</setting>',
|
|
||||||
' <unicode>ñoño ∑∆≈</unicode>',
|
|
||||||
'</config>',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/pdf`,
|
|
||||||
`Content-Disposition: attachment; filename="minimal-document.pdf"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
minimalPdf,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/pdf`,
|
|
||||||
`Content-Disposition: attachment; filename="multi-page-document.pdf"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
multiPagePdf,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/pdf`,
|
|
||||||
`Content-Disposition: attachment; filename="pdf-with-embedded-attachment.pdf"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
pdfWithAttachment,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/html; charset=utf-8`,
|
|
||||||
`Content-Disposition: attachment; filename="webpage.html"`,
|
|
||||||
'',
|
|
||||||
'<!DOCTYPE html>',
|
|
||||||
'<html><head><title>Test</title></head>',
|
|
||||||
'<body><h1>HTML Attachment</h1><p>Content with <em>markup</em></p></body>',
|
|
||||||
'</html>',
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
console.log('Sending email with 10 different attachment types including real PDFs');
|
|
||||||
socket.write(email);
|
|
||||||
dataBuffer = '';
|
|
||||||
step = 'sent';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with multiple attachments accepted successfully');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Attachment Handling - Large attachment', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (completed) return;
|
|
||||||
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const boundary = 'large-attachment-boundary';
|
|
||||||
|
|
||||||
// Use a real large PDF file
|
|
||||||
const largePdf = readFileAsBase64(path.join(SAMPLE_FILES_DIR, '009-pdflatex-geotopo/GeoTopo.pdf'));
|
|
||||||
const largePdfSize = Buffer.from(largePdf, 'base64').length;
|
|
||||||
console.log(`Large PDF size: ${(largePdfSize / 1024).toFixed(2)}KB`);
|
|
||||||
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: Large Attachment Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <large-attach-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain`,
|
|
||||||
'',
|
|
||||||
'This email contains a large attachment.',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/pdf`,
|
|
||||||
`Content-Disposition: attachment; filename="large-geotopo.pdf"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
largePdf,
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
console.log(`Sending email with large PDF attachment (${(largePdfSize / 1024).toFixed(2)}KB)`);
|
|
||||||
socket.write(email);
|
|
||||||
dataBuffer = '';
|
|
||||||
step = 'sent';
|
|
||||||
} else if (step === 'sent' && (dataBuffer.includes('250 ') || dataBuffer.includes('552 '))) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
const accepted = dataBuffer.includes('250');
|
|
||||||
const rejected = dataBuffer.includes('552'); // Size exceeded
|
|
||||||
|
|
||||||
console.log(`Large attachment: ${accepted ? 'accepted' : 'rejected (size limit)'}`);
|
|
||||||
expect(accepted || rejected).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Attachment Handling - Inline vs attachment disposition', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (completed) return;
|
|
||||||
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const boundary = 'inline-attachment-boundary';
|
|
||||||
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: Inline vs Attachment Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <inline-test-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: multipart/related; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/html`,
|
|
||||||
'',
|
|
||||||
'<html><body>',
|
|
||||||
'<p>This email has inline images:</p>',
|
|
||||||
'<img src="cid:image1">',
|
|
||||||
'<img src="cid:image2">',
|
|
||||||
'</body></html>',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: image/png`,
|
|
||||||
`Content-ID: <image1>`,
|
|
||||||
`Content-Disposition: inline; filename="inline1.png"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
readFileAsBase64(path.join(SAMPLE_FILES_DIR, '008-reportlab-inline-image/smile.png')) || 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: image/png`,
|
|
||||||
`Content-ID: <image2>`,
|
|
||||||
`Content-Disposition: inline; filename="inline2.png"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
readFileAsBase64(path.join(SAMPLE_FILES_DIR, '019-grayscale-image/page-0-X0.png')) || 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/pdf`,
|
|
||||||
`Content-Disposition: attachment; filename="document.pdf"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
readFileAsBase64(path.join(SAMPLE_FILES_DIR, '013-reportlab-overlay/reportlab-overlay.pdf')) || 'JVBERi0xLjQKJcOkw7zDtsOVDQo=',
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
dataBuffer = '';
|
|
||||||
step = 'sent';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with inline and attachment dispositions accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Attachment Handling - Filename encoding', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (completed) return;
|
|
||||||
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const boundary = 'filename-encoding-boundary';
|
|
||||||
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: Filename Encoding Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <filename-test-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain`,
|
|
||||||
'',
|
|
||||||
'Testing various filename encodings.',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain`,
|
|
||||||
`Content-Disposition: attachment; filename="simple.txt"`,
|
|
||||||
'',
|
|
||||||
'Simple ASCII filename',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain`,
|
|
||||||
`Content-Disposition: attachment; filename="åäö-nordic.txt"`,
|
|
||||||
'',
|
|
||||||
'Nordic characters in filename',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain`,
|
|
||||||
`Content-Disposition: attachment; filename*=UTF-8''%C3%A5%C3%A4%C3%B6-encoded.txt`,
|
|
||||||
'',
|
|
||||||
'RFC 2231 encoded filename',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain`,
|
|
||||||
`Content-Disposition: attachment; filename="=?UTF-8?B?8J+YgC1lbW9qaS50eHQ=?="`,
|
|
||||||
'',
|
|
||||||
'MIME encoded filename with emoji',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain`,
|
|
||||||
`Content-Disposition: attachment; filename="very long filename that exceeds normal limits and should be handled properly by the server.txt"`,
|
|
||||||
'',
|
|
||||||
'Very long filename',
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
dataBuffer = '';
|
|
||||||
step = 'sent';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with various filename encodings accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Attachment Handling - Empty and malformed attachments', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (completed) return;
|
|
||||||
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const boundary = 'malformed-boundary';
|
|
||||||
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: Empty and Malformed Attachments`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <malformed-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain`,
|
|
||||||
'',
|
|
||||||
'Testing empty and malformed attachments.',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/octet-stream`,
|
|
||||||
`Content-Disposition: attachment; filename="empty.dat"`,
|
|
||||||
'',
|
|
||||||
'', // Empty attachment
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain`,
|
|
||||||
`Content-Disposition: attachment`, // Missing filename
|
|
||||||
'',
|
|
||||||
'Attachment without filename',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: application/pdf`,
|
|
||||||
`Content-Disposition: attachment; filename="broken.pdf"`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
'NOT-VALID-BASE64-@#$%', // Invalid base64
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Disposition: attachment; filename="no-content-type.txt"`, // Missing Content-Type
|
|
||||||
'',
|
|
||||||
'Attachment without Content-Type header',
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
dataBuffer = '';
|
|
||||||
step = 'sent';
|
|
||||||
} else if (step === 'sent' && (dataBuffer.includes('250 ') || dataBuffer.includes('550 '))) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
const result = dataBuffer.includes('250') ? 'accepted' : 'rejected';
|
|
||||||
console.log(`Email with malformed attachments ${result}`);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop test server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,462 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 30050;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start test server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT, hostname: 'localhost' });
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Special Character Handling - Comprehensive Unicode test', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: Special Character Test - Unicode & Symbols ñáéíóú`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <special-chars-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: text/plain; charset=utf-8`,
|
|
||||||
`Content-Transfer-Encoding: 8bit`,
|
|
||||||
'',
|
|
||||||
'This email tests special character handling:',
|
|
||||||
'',
|
|
||||||
'=== UNICODE CHARACTERS ===',
|
|
||||||
'Accented letters: àáâãäåæçèéêëìíîïñòóôõöøùúûüý',
|
|
||||||
'German umlauts: äöüÄÖÜß',
|
|
||||||
'Scandinavian: åäöÅÄÖ',
|
|
||||||
'French: àâéèêëïîôœùûüÿç',
|
|
||||||
'Spanish: ñáéíóúü¿¡',
|
|
||||||
'Polish: ąćęłńóśźż',
|
|
||||||
'Russian: абвгдеёжзийклмнопрстуфхцчшщъыьэюя',
|
|
||||||
'Greek: αβγδεζηθικλμνξοπρστυφχψω',
|
|
||||||
'Arabic: العربية',
|
|
||||||
'Hebrew: עברית',
|
|
||||||
'Chinese: 中文测试',
|
|
||||||
'Japanese: 日本語テスト',
|
|
||||||
'Korean: 한국어 테스트',
|
|
||||||
'Thai: ภาษาไทย',
|
|
||||||
'',
|
|
||||||
'=== MATHEMATICAL SYMBOLS ===',
|
|
||||||
'Math: ∑∏∫∆∇∂∞±×÷≠≤≥≈∝∪∩⊂⊃∈∀∃',
|
|
||||||
'Greek letters: αβγδεζηθικλμνξοπρστυφχψω',
|
|
||||||
'Arrows: ←→↑↓↔↕⇐⇒⇑⇓⇔⇕',
|
|
||||||
'',
|
|
||||||
'=== CURRENCY & SYMBOLS ===',
|
|
||||||
'Currency: $€£¥¢₹₽₩₪₫₨₦₡₵₴₸₼₲₱',
|
|
||||||
'Symbols: ©®™§¶†‡•…‰‱°℃℉№',
|
|
||||||
`Punctuation: «»""''‚„‹›–—―‖‗''""‚„…‰′″‴‵‶‷‸‹›※‼‽⁇⁈⁉⁏⁐⁑⁒⁓⁔⁕⁖⁗⁘⁙⁚⁛⁜⁝⁞`,
|
|
||||||
'',
|
|
||||||
'=== EMOJI & SYMBOLS ===',
|
|
||||||
'Common: ☀☁☂☃☄★☆☇☈☉☊☋☌☍☎☏☐☑☒☓☔☕☖☗☘☙☚☛☜☝☞☟☠☡☢☣☤☥☦☧☨☩☪☫☬☭☮☯☰☱☲☳☴☵☶☷',
|
|
||||||
'Smileys: ☺☻☹☿♀♁♂♃♄♅♆♇',
|
|
||||||
'Hearts: ♡♢♣♤♥♦♧♨♩♪♫♬♭♮♯',
|
|
||||||
'',
|
|
||||||
'=== SPECIAL FORMATTING ===',
|
|
||||||
'Zero-width chars: ',
|
|
||||||
'Combining: e̊åa̋o̧ç',
|
|
||||||
'Ligatures: fffiflffifflſtst',
|
|
||||||
'Fractions: ½⅓⅔¼¾⅛⅜⅝⅞',
|
|
||||||
'Superscript: ⁰¹²³⁴⁵⁶⁷⁸⁹',
|
|
||||||
'Subscript: ₀₁₂₃₄₅₆₇₈₉',
|
|
||||||
'',
|
|
||||||
'End of special character test.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
console.log('Sending email with comprehensive Unicode characters');
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with special characters accepted successfully');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Special Character Handling - Control characters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: Control Character Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <control-chars-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: text/plain; charset=utf-8`,
|
|
||||||
'',
|
|
||||||
'=== CONTROL CHARACTERS TEST ===',
|
|
||||||
'Tab character: (between words)',
|
|
||||||
'Non-breaking space: word word',
|
|
||||||
'Soft hyphen: supercalifragilisticexpialidocious',
|
|
||||||
'Vertical tab: word\x0Bword',
|
|
||||||
'Form feed: word\x0Cword',
|
|
||||||
'Backspace: word\x08word',
|
|
||||||
'',
|
|
||||||
'=== LINE ENDING TESTS ===',
|
|
||||||
'Unix LF: Line1\nLine2',
|
|
||||||
'Windows CRLF: Line3\r\nLine4',
|
|
||||||
'Mac CR: Line5\rLine6',
|
|
||||||
'',
|
|
||||||
'=== BOUNDARY CHARACTERS ===',
|
|
||||||
'SMTP boundary test: . (dot at start)',
|
|
||||||
'Double dots: .. (escaped in SMTP)',
|
|
||||||
'CRLF.CRLF sequence test',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with control characters accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Special Character Handling - Subject header encoding', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: =?UTF-8?B?8J+YgCBFbW9qaSBpbiBTdWJqZWN0IOKcqCDwn4yI?=`,
|
|
||||||
`Subject: =?UTF-8?Q?Quoted=2DPrintable=20Subject=20=C3=A1=C3=A9=C3=AD=C3=B3=C3=BA?=`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <encoded-subject-${Date.now()}@example.com>`,
|
|
||||||
'',
|
|
||||||
'Testing encoded subject headers with special characters.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with encoded subject headers accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Special Character Handling - Address headers with special chars', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: "José García" <jose@example.com>`,
|
|
||||||
`To: "François Müller" <francois@example.com>, "北京用户" <beijing@example.com>`,
|
|
||||||
`Cc: =?UTF-8?B?IkFubmEgw4XDpMO2Ig==?= <anna@example.com>`,
|
|
||||||
`Reply-To: "Søren Ñoño" <soren@example.com>`,
|
|
||||||
`Subject: Special names in address headers`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <special-addrs-${Date.now()}@example.com>`,
|
|
||||||
'',
|
|
||||||
'Testing special characters in email addresses and display names.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with special characters in addresses accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Special Character Handling - Mixed encodings', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const boundary = 'mixed-encoding-boundary';
|
|
||||||
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: Mixed Encoding Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <mixed-enc-${Date.now()}@example.com>`,
|
|
||||||
`MIME-Version: 1.0`,
|
|
||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=utf-8`,
|
|
||||||
`Content-Transfer-Encoding: 8bit`,
|
|
||||||
'',
|
|
||||||
'UTF-8 part: ñáéíóú 中文 日本語',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=iso-8859-1`,
|
|
||||||
`Content-Transfer-Encoding: quoted-printable`,
|
|
||||||
'',
|
|
||||||
'ISO-8859-1 part: =F1=E1=E9=ED=F3=FA',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=windows-1252`,
|
|
||||||
'',
|
|
||||||
'Windows-1252 part: €‚ƒ„…†‡',
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
`Content-Type: text/plain; charset=utf-16`,
|
|
||||||
`Content-Transfer-Encoding: base64`,
|
|
||||||
'',
|
|
||||||
Buffer.from('UTF-16 text: ñoño', 'utf16le').toString('base64'),
|
|
||||||
'',
|
|
||||||
`--${boundary}--`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with mixed character encodings accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop test server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts'
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start test server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Email Routing - Local domain routing', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (completed) return;
|
|
||||||
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO localhost\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
// Local sender
|
|
||||||
socket.write('MAIL FROM:<test@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
// Local recipient
|
|
||||||
socket.write('RCPT TO:<local@localhost>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt') {
|
|
||||||
const accepted = dataBuffer.includes('250');
|
|
||||||
console.log(`Local domain routing: ${accepted ? 'accepted' : 'rejected'}`);
|
|
||||||
|
|
||||||
if (accepted) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: test@example.com`,
|
|
||||||
`To: local@localhost`,
|
|
||||||
`Subject: Local Domain Routing Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <local-routing-${Date.now()}@localhost>`,
|
|
||||||
'',
|
|
||||||
'This email tests local domain routing.',
|
|
||||||
'The server should route this email locally.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
dataBuffer = '';
|
|
||||||
step = 'sent';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Local domain email routed successfully');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Email Routing - External domain routing', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (completed) return;
|
|
||||||
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO localhost\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
// External recipient
|
|
||||||
socket.write('RCPT TO:<recipient@external.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt') {
|
|
||||||
const accepted = dataBuffer.includes('250');
|
|
||||||
console.log(`External domain routing: ${accepted ? 'accepted' : 'rejected'}`);
|
|
||||||
|
|
||||||
if (accepted) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@external.com`,
|
|
||||||
`Subject: External Domain Routing Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <external-routing-${Date.now()}@example.com>`,
|
|
||||||
'',
|
|
||||||
'This email tests external domain routing.',
|
|
||||||
'The server should accept this for relay.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
dataBuffer = '';
|
|
||||||
step = 'sent';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('External domain email accepted for relay');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Email Routing - Multiple recipients', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let recipientCount = 0;
|
|
||||||
const totalRecipients = 5;
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (completed) return;
|
|
||||||
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO localhost\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
recipientCount++;
|
|
||||||
socket.write(`RCPT TO:<recipient${recipientCount}@example.com>\r\n`);
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt' && dataBuffer.includes('250')) {
|
|
||||||
if (recipientCount < totalRecipients) {
|
|
||||||
recipientCount++;
|
|
||||||
socket.write(`RCPT TO:<recipient${recipientCount}@example.com>\r\n`);
|
|
||||||
dataBuffer = '';
|
|
||||||
} else {
|
|
||||||
console.log(`All ${totalRecipients} recipients accepted`);
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
}
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const recipients = Array.from({length: totalRecipients}, (_, i) => `recipient${i+1}@example.com`);
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: ${recipients.join(', ')}`,
|
|
||||||
`Subject: Multiple Recipients Routing Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <multi-recipient-${Date.now()}@example.com>`,
|
|
||||||
'',
|
|
||||||
'This email tests routing to multiple recipients.',
|
|
||||||
`Total recipients: ${totalRecipients}`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
dataBuffer = '';
|
|
||||||
step = 'sent';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with multiple recipients routed successfully');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Email Routing - Invalid domain handling', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let testType = 'invalid-tld';
|
|
||||||
const testCases = [
|
|
||||||
{ email: 'user@invalid-tld', type: 'invalid-tld' },
|
|
||||||
{ email: 'user@.com', type: 'missing-domain' },
|
|
||||||
{ email: 'user@domain..com', type: 'double-dot' },
|
|
||||||
{ email: 'user@-domain.com', type: 'leading-dash' },
|
|
||||||
{ email: 'user@domain-.com', type: 'trailing-dash' }
|
|
||||||
];
|
|
||||||
let currentTest = 0;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO localhost\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
testType = testCases[currentTest].type;
|
|
||||||
socket.write(`RCPT TO:<${testCases[currentTest].email}>\r\n`);
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt') {
|
|
||||||
const rejected = dataBuffer.includes('550') || dataBuffer.includes('553') || dataBuffer.includes('501');
|
|
||||||
console.log(`Invalid domain test (${testType}): ${rejected ? 'properly rejected' : 'unexpectedly accepted'}`);
|
|
||||||
|
|
||||||
currentTest++;
|
|
||||||
if (currentTest < testCases.length) {
|
|
||||||
// Reset for next test
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
step = 'rset';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
} else if (step === 'rset' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Email Routing - Mixed local and external recipients', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
const recipients = [
|
|
||||||
'local@localhost',
|
|
||||||
'external@example.com',
|
|
||||||
'another@localhost',
|
|
||||||
'remote@external.com'
|
|
||||||
];
|
|
||||||
let currentRecipient = 0;
|
|
||||||
let acceptedRecipients: string[] = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (completed) return;
|
|
||||||
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO localhost\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write(`RCPT TO:<${recipients[currentRecipient]}>\r\n`);
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt') {
|
|
||||||
if (dataBuffer.includes('250')) {
|
|
||||||
acceptedRecipients.push(recipients[currentRecipient]);
|
|
||||||
console.log(`Recipient ${recipients[currentRecipient]} accepted`);
|
|
||||||
} else {
|
|
||||||
console.log(`Recipient ${recipients[currentRecipient]} rejected`);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentRecipient++;
|
|
||||||
if (currentRecipient < recipients.length) {
|
|
||||||
socket.write(`RCPT TO:<${recipients[currentRecipient]}>\r\n`);
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (acceptedRecipients.length > 0) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: ${acceptedRecipients.join(', ')}`,
|
|
||||||
`Subject: Mixed Recipients Routing Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <mixed-routing-${Date.now()}@example.com>`,
|
|
||||||
'',
|
|
||||||
'This email tests routing to mixed local and external recipients.',
|
|
||||||
`Accepted recipients: ${acceptedRecipients.length}`,
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
dataBuffer = '';
|
|
||||||
step = 'sent';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with mixed recipients routed successfully');
|
|
||||||
expect(acceptedRecipients.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Email Routing - Subdomain routing', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
const subdomainTests = [
|
|
||||||
'user@mail.example.com',
|
|
||||||
'user@smtp.corp.example.com',
|
|
||||||
'user@deep.sub.domain.example.com'
|
|
||||||
];
|
|
||||||
let currentTest = 0;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
if (completed) return;
|
|
||||||
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO localhost\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
socket.write(`RCPT TO:<${subdomainTests[currentTest]}>\r\n`);
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt') {
|
|
||||||
const accepted = dataBuffer.includes('250');
|
|
||||||
console.log(`Subdomain routing test (${subdomainTests[currentTest]}): ${accepted ? 'accepted' : 'rejected'}`);
|
|
||||||
|
|
||||||
currentTest++;
|
|
||||||
if (currentTest < subdomainTests.length) {
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
step = 'rset';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
}
|
|
||||||
} else if (step === 'rset' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: ${subdomainTests[subdomainTests.length - 1]}`,
|
|
||||||
`Subject: Subdomain Routing Test`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <subdomain-routing-${Date.now()}@example.com>`,
|
|
||||||
'',
|
|
||||||
'This email tests subdomain routing.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
dataBuffer = '';
|
|
||||||
step = 'sent';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Subdomain routing test completed');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop test server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts'
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start test server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DSN - Extension advertised in EHLO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (dataBuffer.includes('220 ') && !dataBuffer.includes('EHLO')) {
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (dataBuffer.includes('250')) {
|
|
||||||
// Check if DSN extension is advertised
|
|
||||||
const dsnSupported = dataBuffer.toLowerCase().includes('dsn');
|
|
||||||
console.log('DSN extension advertised:', dsnSupported);
|
|
||||||
|
|
||||||
// Parse extensions
|
|
||||||
const lines = dataBuffer.split('\r\n');
|
|
||||||
const extensions = lines
|
|
||||||
.filter(line => line.startsWith('250-') || (line.startsWith('250 ') && lines.indexOf(line) > 0))
|
|
||||||
.map(line => line.substring(4).split(' ')[0].toUpperCase());
|
|
||||||
|
|
||||||
console.log('Server extensions:', extensions);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DSN - Success notification request', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
// MAIL FROM with DSN parameters
|
|
||||||
const envId = `dsn-success-${Date.now()}`;
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> RET=FULL ENVID=${envId}\r\n`);
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail') {
|
|
||||||
const accepted = dataBuffer.includes('250');
|
|
||||||
const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555');
|
|
||||||
|
|
||||||
console.log(`MAIL FROM with DSN: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`);
|
|
||||||
|
|
||||||
if (accepted || notSupported) {
|
|
||||||
step = 'rcpt';
|
|
||||||
// Plain MAIL FROM if DSN not supported
|
|
||||||
if (notSupported) {
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else {
|
|
||||||
// RCPT TO with NOTIFY parameter
|
|
||||||
socket.write('RCPT TO:<recipient@example.com> NOTIFY=SUCCESS\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (step === 'rcpt') {
|
|
||||||
const accepted = dataBuffer.includes('250');
|
|
||||||
const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555');
|
|
||||||
|
|
||||||
if (notSupported) {
|
|
||||||
// DSN not supported, try plain RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
step = 'rcpt_plain';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (accepted) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
}
|
|
||||||
} else if (step === 'rcpt_plain' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: DSN Test - Success Notification`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <dsn-success-${Date.now()}@example.com>`,
|
|
||||||
'',
|
|
||||||
'This email tests DSN success notification.',
|
|
||||||
'The server should send a success DSN if supported.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with DSN success request accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DSN - Multiple notification types', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
// Request multiple notification types
|
|
||||||
socket.write('RCPT TO:<recipient@example.com> NOTIFY=SUCCESS,FAILURE,DELAY\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt') {
|
|
||||||
const accepted = dataBuffer.includes('250');
|
|
||||||
const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555');
|
|
||||||
|
|
||||||
console.log(`Multiple NOTIFY types: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`);
|
|
||||||
|
|
||||||
if (notSupported) {
|
|
||||||
// Try plain RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
step = 'rcpt_plain';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (accepted) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
}
|
|
||||||
} else if (step === 'rcpt_plain' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: DSN Test - Multiple Notifications`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <dsn-multi-${Date.now()}@example.com>`,
|
|
||||||
'',
|
|
||||||
'Testing multiple DSN notification types.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with multiple DSN types accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DSN - Never notify', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
// Request no notifications
|
|
||||||
socket.write('RCPT TO:<recipient@example.com> NOTIFY=NEVER\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt') {
|
|
||||||
const accepted = dataBuffer.includes('250');
|
|
||||||
const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555');
|
|
||||||
|
|
||||||
console.log(`NOTIFY=NEVER: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`);
|
|
||||||
expect(accepted || notSupported).toEqual(true);
|
|
||||||
|
|
||||||
if (notSupported) {
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
step = 'rcpt_plain';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (accepted) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
}
|
|
||||||
} else if (step === 'rcpt_plain' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: DSN Test - Never Notify`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <dsn-never-${Date.now()}@example.com>`,
|
|
||||||
'',
|
|
||||||
'This email should not generate any DSN.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with NOTIFY=NEVER accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DSN - Original recipient tracking', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail' && dataBuffer.includes('250')) {
|
|
||||||
step = 'rcpt';
|
|
||||||
// Include original recipient for tracking
|
|
||||||
socket.write('RCPT TO:<recipient@example.com> NOTIFY=FAILURE ORCPT=rfc822;original@example.com\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'rcpt') {
|
|
||||||
const accepted = dataBuffer.includes('250');
|
|
||||||
const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555');
|
|
||||||
|
|
||||||
console.log(`ORCPT parameter: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`);
|
|
||||||
|
|
||||||
if (notSupported) {
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
step = 'rcpt_plain';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (accepted) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
}
|
|
||||||
} else if (step === 'rcpt_plain' && dataBuffer.includes('250')) {
|
|
||||||
step = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'data' && dataBuffer.includes('354')) {
|
|
||||||
const email = [
|
|
||||||
`From: sender@example.com`,
|
|
||||||
`To: recipient@example.com`,
|
|
||||||
`Subject: DSN Test - Original Recipient`,
|
|
||||||
`Date: ${new Date().toUTCString()}`,
|
|
||||||
`Message-ID: <dsn-orcpt-${Date.now()}@example.com>`,
|
|
||||||
'',
|
|
||||||
'This email tests ORCPT parameter for tracking.',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(email);
|
|
||||||
step = 'sent';
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) {
|
|
||||||
if (!completed) {
|
|
||||||
completed = true;
|
|
||||||
console.log('Email with ORCPT tracking accepted');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DSN - Return parameter handling', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataBuffer = '';
|
|
||||||
let step = 'greeting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
dataBuffer += data.toString();
|
|
||||||
console.log('Server response:', data.toString());
|
|
||||||
|
|
||||||
if (step === 'greeting' && dataBuffer.includes('220 ')) {
|
|
||||||
step = 'ehlo';
|
|
||||||
socket.write('EHLO testclient\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'ehlo' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail_hdrs';
|
|
||||||
// Test RET=HDRS
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> RET=HDRS\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail_hdrs') {
|
|
||||||
const accepted = dataBuffer.includes('250');
|
|
||||||
const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555');
|
|
||||||
|
|
||||||
console.log(`RET=HDRS: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`);
|
|
||||||
|
|
||||||
if (accepted || notSupported) {
|
|
||||||
// Reset and test RET=FULL
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
step = 'reset';
|
|
||||||
dataBuffer = '';
|
|
||||||
}
|
|
||||||
} else if (step === 'reset' && dataBuffer.includes('250')) {
|
|
||||||
step = 'mail_full';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> RET=FULL\r\n');
|
|
||||||
dataBuffer = '';
|
|
||||||
} else if (step === 'mail_full') {
|
|
||||||
const accepted = dataBuffer.includes('250');
|
|
||||||
const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555');
|
|
||||||
|
|
||||||
console.log(`RET=FULL: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`);
|
|
||||||
expect(accepted || notSupported).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error('Socket error:', err);
|
|
||||||
done.reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop test server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,475 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('setup - start SMTP server', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: TEST_PORT,
|
|
||||||
tlsEnabled: false,
|
|
||||||
hostname: 'localhost'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
expect(testServer.port).toEqual(TEST_PORT);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Invalid command
|
|
||||||
tap.test('Syntax Errors - should reject invalid command', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'invalid_command';
|
|
||||||
socket.write('INVALID_COMMAND\r\n');
|
|
||||||
} else if (currentStep === 'invalid_command' && receivedData.match(/[45]\d{2}/)) {
|
|
||||||
// Extract response code immediately after receiving error response
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
// Find the last line that starts with 4xx or 5xx
|
|
||||||
let errorCode = '';
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
||||||
if (match) {
|
|
||||||
errorCode = match[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Expect 500 (syntax error) or 502 (command not implemented)
|
|
||||||
expect(errorCode).toMatch(/^(500|502)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: MAIL FROM without brackets
|
|
||||||
tap.test('Syntax Errors - should reject MAIL FROM without brackets', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_no_brackets';
|
|
||||||
socket.write('MAIL FROM:test@example.com\r\n'); // Missing angle brackets
|
|
||||||
} else if (currentStep === 'mail_from_no_brackets' && receivedData.match(/[45]\d{2}/)) {
|
|
||||||
// Extract the most recent error response code
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
let responseCode = '';
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
||||||
if (match) {
|
|
||||||
responseCode = match[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Expect 501 (syntax error in parameters)
|
|
||||||
expect(responseCode).toEqual('501');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RCPT TO without brackets
|
|
||||||
tap.test('Syntax Errors - should reject RCPT TO without brackets', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to_no_brackets';
|
|
||||||
socket.write('RCPT TO:recipient@example.com\r\n'); // Missing angle brackets
|
|
||||||
} else if (currentStep === 'rcpt_to_no_brackets' && receivedData.match(/[45]\d{2}/)) {
|
|
||||||
// Extract the most recent error response code
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
let responseCode = '';
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
||||||
if (match) {
|
|
||||||
responseCode = match[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Expect 501 (syntax error in parameters)
|
|
||||||
expect(responseCode).toEqual('501');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EHLO without hostname
|
|
||||||
tap.test('Syntax Errors - should reject EHLO without hostname', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo_no_hostname';
|
|
||||||
socket.write('EHLO\r\n'); // Missing hostname
|
|
||||||
} else if (currentStep === 'ehlo_no_hostname' && receivedData.match(/[45]\d{2}/)) {
|
|
||||||
// Extract the most recent error response code
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
let responseCode = '';
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
||||||
if (match) {
|
|
||||||
responseCode = match[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Expect 501 (syntax error in parameters)
|
|
||||||
expect(responseCode).toEqual('501');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Command with extra parameters
|
|
||||||
tap.test('Syntax Errors - should handle commands with extra parameters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'quit_extra';
|
|
||||||
socket.write('QUIT extra parameters\r\n'); // QUIT doesn't take parameters
|
|
||||||
} else if (currentStep === 'quit_extra') {
|
|
||||||
// Extract the most recent response code (could be 221 or error)
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
let responseCode = '';
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const match = lines[i].match(/^([2-5]\d{2})\s/);
|
|
||||||
if (match) {
|
|
||||||
responseCode = match[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socket.destroy();
|
|
||||||
// Some servers might accept it (221) or reject it (501)
|
|
||||||
expect(responseCode).toMatch(/^(221|501)$/);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Malformed addresses
|
|
||||||
tap.test('Syntax Errors - should reject malformed email addresses', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_malformed';
|
|
||||||
socket.write('MAIL FROM:<not an email>\r\n'); // Malformed address
|
|
||||||
} else if (currentStep === 'mail_from_malformed' && receivedData.match(/[45]\d{2}/)) {
|
|
||||||
// Extract the most recent error response code
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
let responseCode = '';
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
||||||
if (match) {
|
|
||||||
responseCode = match[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Expect 501 or 553 (bad address)
|
|
||||||
expect(responseCode).toMatch(/^(501|553)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Commands in wrong order
|
|
||||||
tap.test('Syntax Errors - should reject commands in wrong sequence', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'data_without_rcpt';
|
|
||||||
socket.write('DATA\r\n'); // DATA without MAIL FROM/RCPT TO
|
|
||||||
} else if (currentStep === 'data_without_rcpt' && receivedData.match(/[45]\d{2}/)) {
|
|
||||||
// Extract the most recent error response code
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
let responseCode = '';
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
||||||
if (match) {
|
|
||||||
responseCode = match[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Expect 503 (bad sequence of commands)
|
|
||||||
expect(responseCode).toEqual('503');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Long commands
|
|
||||||
tap.test('Syntax Errors - should handle excessively long commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const longString = 'A'.repeat(1000); // Very long string
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'long_command';
|
|
||||||
socket.write(`EHLO ${longString}\r\n`); // Excessively long hostname
|
|
||||||
} else if (currentStep === 'long_command') {
|
|
||||||
// Wait for complete response (including all continuation lines)
|
|
||||||
if (receivedData.includes('250 ') || receivedData.match(/[45]\d{2}\s/)) {
|
|
||||||
currentStep = 'done';
|
|
||||||
|
|
||||||
// The server accepted the long EHLO command with 250
|
|
||||||
// Some servers might reject with 500/501
|
|
||||||
// Since we see 250 in the logs, the server accepts it
|
|
||||||
const hasError = receivedData.match(/([45]\d{2})\s/);
|
|
||||||
const hasSuccess = receivedData.includes('250 ');
|
|
||||||
|
|
||||||
// Determine the response code
|
|
||||||
let responseCode = '';
|
|
||||||
if (hasError) {
|
|
||||||
responseCode = hasError[1];
|
|
||||||
} else if (hasSuccess) {
|
|
||||||
responseCode = '250';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some servers accept long hostnames, others reject them
|
|
||||||
// Accept either 250 (ok), 500 (syntax error), or 501 (line too long)
|
|
||||||
expect(responseCode).toMatch(/^(250|500|501)$/);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('teardown - stop SMTP server', async () => {
|
|
||||||
if (testServer) {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 30051;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('setup - start SMTP server', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: TEST_PORT,
|
|
||||||
tlsEnabled: false,
|
|
||||||
hostname: 'localhost'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
expect(testServer.port).toEqual(TEST_PORT);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: MAIL FROM before EHLO/HELO
|
|
||||||
tap.test('Invalid Sequence - should reject MAIL FROM before EHLO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'mail_from_without_ehlo';
|
|
||||||
socket.write('MAIL FROM:<test@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_without_ehlo' && receivedData.includes('503')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('503'); // Bad sequence of commands
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RCPT TO before MAIL FROM
|
|
||||||
tap.test('Invalid Sequence - should reject RCPT TO before MAIL FROM', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_without_mail';
|
|
||||||
socket.write('RCPT TO:<test@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_without_mail' && receivedData.includes('503')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('503');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: DATA before RCPT TO
|
|
||||||
tap.test('Invalid Sequence - should reject DATA before RCPT TO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<test@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_without_rcpt';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_without_rcpt') {
|
|
||||||
if (receivedData.includes('503')) {
|
|
||||||
// Expected: bad sequence error
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('503');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
} else if (receivedData.includes('354')) {
|
|
||||||
// Some servers accept DATA without recipients
|
|
||||||
// Send empty data to trigger error
|
|
||||||
socket.write('.\r\n');
|
|
||||||
currentStep = 'data_sent';
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'data_sent' && receivedData.match(/[45]\d{2}/)) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Should get an error when trying to send without recipients
|
|
||||||
expect(receivedData).toMatch(/[45]\d{2}/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Multiple EHLO commands (should be allowed)
|
|
||||||
tap.test('Invalid Sequence - should allow multiple EHLO commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let commandsSent = false;
|
|
||||||
|
|
||||||
socket.on('data', async (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
// Wait for server greeting and only send commands once
|
|
||||||
if (!commandsSent && receivedData.includes('220 localhost ESMTP')) {
|
|
||||||
commandsSent = true;
|
|
||||||
|
|
||||||
// Send all 3 EHLO commands sequentially
|
|
||||||
socket.write('EHLO test1.example.com\r\n');
|
|
||||||
|
|
||||||
// Wait for response before sending next
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
socket.write('EHLO test2.example.com\r\n');
|
|
||||||
|
|
||||||
// Wait for response before sending next
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
socket.write('EHLO test3.example.com\r\n');
|
|
||||||
|
|
||||||
// Wait for all responses
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
|
||||||
|
|
||||||
// Check that we got 3 successful EHLO responses
|
|
||||||
const ehloResponses = (receivedData.match(/250-localhost greets test\d+\.example\.com/g) || []).length;
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(ehloResponses).toEqual(3);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error('Connection timeout'));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Multiple MAIL FROM without RSET
|
|
||||||
tap.test('Invalid Sequence - should reject second MAIL FROM without RSET', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'first_mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender1@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'first_mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'second_mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender2@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'second_mail_from') {
|
|
||||||
// Check if we get either 503 (expected) or 250 (current behavior)
|
|
||||||
if (receivedData.includes('503') || receivedData.includes('250 OK')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Accept either behavior for now
|
|
||||||
expect(receivedData).toMatch(/503|250 OK/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: DATA without MAIL FROM
|
|
||||||
tap.test('Invalid Sequence - should reject DATA without MAIL FROM', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_without_mail';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_without_mail' && receivedData.includes('503')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('503');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Commands after QUIT
|
|
||||||
tap.test('Invalid Sequence - should reject commands after QUIT', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let quitResponseReceived = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
quitResponseReceived = true;
|
|
||||||
// Try to send command after QUIT
|
|
||||||
try {
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
// If write succeeds, wait to see if we get a response
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve(); // No response expected after QUIT
|
|
||||||
}, 1000);
|
|
||||||
} catch (err) {
|
|
||||||
// Write failed - connection already closed
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
if (quitResponseReceived) {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
if (quitResponseReceived && error.message.includes('EPIPE')) {
|
|
||||||
done.resolve();
|
|
||||||
} else {
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RCPT TO without proper email brackets
|
|
||||||
tap.test('Invalid Sequence - should handle commands with wrong syntax in sequence', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'bad_rcpt';
|
|
||||||
// RCPT TO with wrong syntax
|
|
||||||
socket.write('RCPT TO:recipient@example.com\r\n'); // Missing brackets
|
|
||||||
} else if (currentStep === 'bad_rcpt' && receivedData.includes('501')) {
|
|
||||||
// After syntax error, try valid command
|
|
||||||
currentStep = 'valid_rcpt';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'valid_rcpt' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('501'); // Syntax error
|
|
||||||
expect(receivedData).toInclude('250'); // Valid command worked
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('teardown - stop SMTP server', async () => {
|
|
||||||
if (testServer) {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
}
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('setup - start SMTP server', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: TEST_PORT,
|
|
||||||
tlsEnabled: false,
|
|
||||||
hostname: 'localhost'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
expect(testServer.port).toEqual(TEST_PORT);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Temporary failure response codes
|
|
||||||
tap.test('Temporary Failures - should handle 4xx response codes properly', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
// Use a special address that might trigger temporary failure
|
|
||||||
socket.write('MAIL FROM:<temporary-failure@test.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.match(/[245]\d{2}/)) {
|
|
||||||
// Extract the most recent response code
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
let responseCode = '';
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const match = lines[i].match(/^([245]\d{2})\s/);
|
|
||||||
if (match) {
|
|
||||||
responseCode = match[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (responseCode?.startsWith('4')) {
|
|
||||||
// Temporary failure - expected for special addresses
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(responseCode).toMatch(/^4\d{2}$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
} else if (responseCode === '250') {
|
|
||||||
// Server accepts the address - this is also valid behavior
|
|
||||||
// Continue with the flow to test normal operation
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rcpt_to') {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Test passed - server handled the flow
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Retry after temporary failure
|
|
||||||
tap.test('Temporary Failures - should allow retry after temporary failure', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const attemptConnection = async (attemptNumber: number): Promise<{ success: boolean; responseCode?: string }> => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
// Include attempt number to potentially vary server response
|
|
||||||
socket.write(`MAIL FROM:<retry-test-${attemptNumber}@example.com>\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.match(/[245]\d{2}/)) {
|
|
||||||
// Extract the most recent response code
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
let responseCode = '';
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const match = lines[i].match(/^([245]\d{2})\s/);
|
|
||||||
if (match) {
|
|
||||||
responseCode = match[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve({ success: responseCode === '250' || responseCode?.startsWith('4'), responseCode });
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', () => {
|
|
||||||
resolve({ success: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve({ success: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try multiple attempts
|
|
||||||
const attempt1 = await attemptConnection(1);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait before retry
|
|
||||||
const attempt2 = await attemptConnection(2);
|
|
||||||
|
|
||||||
// At least one attempt should work
|
|
||||||
expect(attempt1.success || attempt2.success).toEqual(true);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Temporary failure during DATA
|
|
||||||
tap.test('Temporary Failures - should handle temporary failure during DATA phase', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<temp-fail-data@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'message';
|
|
||||||
// Send a message that might trigger temporary failure
|
|
||||||
const message = 'Subject: Temporary Failure Test\r\n' +
|
|
||||||
'X-Test-Header: temporary-failure\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
'This message tests temporary failure handling.\r\n' +
|
|
||||||
'.\r\n';
|
|
||||||
socket.write(message);
|
|
||||||
} else if (currentStep === 'message' && receivedData.match(/[245]\d{2}/)) {
|
|
||||||
currentStep = 'done'; // Prevent further processing
|
|
||||||
|
|
||||||
// Extract the most recent response code - handle both plain and log format
|
|
||||||
const lines = receivedData.split('\n');
|
|
||||||
let responseCode = '';
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
// Try to match response codes in different formats
|
|
||||||
const plainMatch = lines[i].match(/^([245]\d{2})\s/);
|
|
||||||
const logMatch = lines[i].match(/→\s*([245]\d{2})\s/);
|
|
||||||
const embeddedMatch = lines[i].match(/\b([245]\d{2})\s+OK/);
|
|
||||||
|
|
||||||
if (plainMatch) {
|
|
||||||
responseCode = plainMatch[1];
|
|
||||||
break;
|
|
||||||
} else if (logMatch) {
|
|
||||||
responseCode = logMatch[1];
|
|
||||||
break;
|
|
||||||
} else if (embeddedMatch) {
|
|
||||||
responseCode = embeddedMatch[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Either accepted (250) or temporary failure (4xx)
|
|
||||||
if (responseCode) {
|
|
||||||
console.log(`Response code found: '${responseCode}'`);
|
|
||||||
// Ensure the response code is trimmed and valid
|
|
||||||
const trimmedCode = responseCode.trim();
|
|
||||||
if (trimmedCode === '250' || trimmedCode.match(/^4\d{2}$/)) {
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
} else {
|
|
||||||
console.error(`Unexpected response code: '${trimmedCode}'`);
|
|
||||||
expect(true).toEqual(true); // Pass anyway to avoid blocking
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no response code found, just pass the test
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
}
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Common temporary failure codes
|
|
||||||
tap.test('Temporary Failures - verify proper temporary failure codes', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Common temporary failure codes and their meanings
|
|
||||||
const temporaryFailureCodes = {
|
|
||||||
'421': 'Service not available, closing transmission channel',
|
|
||||||
'450': 'Requested mail action not taken: mailbox unavailable',
|
|
||||||
'451': 'Requested action aborted: local error in processing',
|
|
||||||
'452': 'Requested action not taken: insufficient system storage'
|
|
||||||
};
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let foundTemporaryCode = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
// Check for any temporary failure codes
|
|
||||||
for (const code of Object.keys(temporaryFailureCodes)) {
|
|
||||||
if (receivedData.includes(code)) {
|
|
||||||
foundTemporaryCode = true;
|
|
||||||
console.log(`Found temporary failure code: ${code} - ${temporaryFailureCodes[code as keyof typeof temporaryFailureCodes]}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'testing';
|
|
||||||
// Try various commands that might trigger temporary failures
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'testing') {
|
|
||||||
// Continue with normal flow
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Test passes whether we found temporary codes or not
|
|
||||||
// (server may not expose them in normal operation)
|
|
||||||
done.resolve();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Server overload simulation
|
|
||||||
tap.test('Temporary Failures - should handle server overload gracefully', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const connections: net.Socket[] = [];
|
|
||||||
const results: Array<{ connected: boolean; responseCode?: string }> = [];
|
|
||||||
|
|
||||||
// Create multiple rapid connections to simulate load
|
|
||||||
const connectionPromises = [];
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
connectionPromises.push(
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 2000
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
connections.push(socket);
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
const response = data.toString();
|
|
||||||
const responseCode = response.match(/(\d{3})/)?.[1];
|
|
||||||
|
|
||||||
if (responseCode?.startsWith('4')) {
|
|
||||||
// Temporary failure due to load
|
|
||||||
results.push({ connected: true, responseCode });
|
|
||||||
} else if (responseCode === '220') {
|
|
||||||
// Normal greeting
|
|
||||||
results.push({ connected: true, responseCode });
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve();
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', () => {
|
|
||||||
results.push({ connected: false });
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
results.push({ connected: false });
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(connectionPromises);
|
|
||||||
|
|
||||||
// Clean up any remaining connections
|
|
||||||
for (const socket of connections) {
|
|
||||||
if (socket && !socket.destroyed) {
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should handle connections (either accept or temporary failure)
|
|
||||||
const handled = results.filter(r => r.connected).length;
|
|
||||||
expect(handled).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Temporary failure with retry header
|
|
||||||
tap.test('Temporary Failures - should provide retry information if available', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
// Try to trigger a temporary failure
|
|
||||||
socket.write('MAIL FROM:<test-retry@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from') {
|
|
||||||
const response = receivedData;
|
|
||||||
|
|
||||||
// Check if response includes retry information
|
|
||||||
if (response.includes('try again') || response.includes('retry') || response.includes('later')) {
|
|
||||||
console.log('Server provided retry guidance in temporary failure');
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('teardown - stop SMTP server', async () => {
|
|
||||||
if (testServer) {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 30028;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server for permanent failure tests', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: TEST_PORT,
|
|
||||||
hostname: 'localhost'
|
|
||||||
});
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Permanent Failures - should return 5xx for invalid recipient syntax', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
|
|
||||||
const mailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mailResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send RCPT TO with invalid syntax (double @)
|
|
||||||
socket.write('RCPT TO:<invalid@@permanent-failure.com>\r\n');
|
|
||||||
|
|
||||||
const rcptResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to invalid recipient:', rcptResponse);
|
|
||||||
|
|
||||||
// Should get a permanent failure (5xx)
|
|
||||||
const permanentFailureCodes = ['550', '551', '552', '553', '554', '501'];
|
|
||||||
const isPermanentFailure = permanentFailureCodes.some(code => rcptResponse.includes(code));
|
|
||||||
|
|
||||||
expect(isPermanentFailure).toEqual(true);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Permanent Failures - should handle non-existent domain', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
|
|
||||||
const mailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mailResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Send RCPT TO with non-existent domain
|
|
||||||
socket.write('RCPT TO:<user@this-domain-absolutely-does-not-exist-12345.com>\r\n');
|
|
||||||
|
|
||||||
const rcptResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to non-existent domain:', rcptResponse);
|
|
||||||
|
|
||||||
// Server might:
|
|
||||||
// 1. Accept it (250) and handle bounces later
|
|
||||||
// 2. Reject with permanent failure (5xx)
|
|
||||||
// Both are valid approaches
|
|
||||||
const acceptedOrRejected = rcptResponse.includes('250') || /^5\d{2}/.test(rcptResponse);
|
|
||||||
expect(acceptedOrRejected).toEqual(true);
|
|
||||||
|
|
||||||
if (rcptResponse.includes('250')) {
|
|
||||||
console.log('Server accepts unknown domains (will handle bounces later)');
|
|
||||||
} else {
|
|
||||||
console.log('Server rejects unknown domains immediately');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Permanent Failures - should reject oversized messages', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if SIZE is advertised
|
|
||||||
const sizeMatch = ehloResponse.match(/250[- ]SIZE\s+(\d+)/);
|
|
||||||
const maxSize = sizeMatch ? parseInt(sizeMatch[1]) : null;
|
|
||||||
|
|
||||||
console.log('Server max size:', maxSize || 'not advertised');
|
|
||||||
|
|
||||||
// Send MAIL FROM with SIZE parameter exceeding limit
|
|
||||||
const oversizeAmount = maxSize ? maxSize + 1000000 : 100000000; // 100MB if no limit advertised
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${oversizeAmount}\r\n`);
|
|
||||||
|
|
||||||
const mailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response to oversize MAIL FROM:', mailResponse);
|
|
||||||
|
|
||||||
if (maxSize && oversizeAmount > maxSize) {
|
|
||||||
// Server should reject with 552 but currently accepts - this is a bug
|
|
||||||
// TODO: Fix server to properly enforce SIZE limits
|
|
||||||
// For now, accept both behaviors
|
|
||||||
if (mailResponse.match(/^5\d{2}/)) {
|
|
||||||
// Correct behavior - server rejects oversized message
|
|
||||||
expect(mailResponse.toLowerCase()).toMatch(/size|too.*large|exceed/);
|
|
||||||
} else {
|
|
||||||
// Current behavior - server incorrectly accepts oversized message
|
|
||||||
expect(mailResponse).toMatch(/^250/);
|
|
||||||
console.log('WARNING: Server not enforcing SIZE limit - accepting oversized message');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No size limit advertised, server might accept
|
|
||||||
expect(mailResponse).toMatch(/^[2-5]\d{2}/);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Permanent Failures - should persist after RSET', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// First attempt with invalid syntax
|
|
||||||
socket.write('MAIL FROM:<invalid@@syntax.com>\r\n');
|
|
||||||
|
|
||||||
const firstMailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('First MAIL FROM response:', firstMailResponse);
|
|
||||||
const firstWasRejected = /^5\d{2}/.test(firstMailResponse);
|
|
||||||
|
|
||||||
if (firstWasRejected) {
|
|
||||||
// Try RSET
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
|
|
||||||
const rsetResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(rsetResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Try same invalid syntax again
|
|
||||||
socket.write('MAIL FROM:<invalid@@syntax.com>\r\n');
|
|
||||||
|
|
||||||
const secondMailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Second MAIL FROM response after RSET:', secondMailResponse);
|
|
||||||
|
|
||||||
// Should still get permanent failure
|
|
||||||
expect(secondMailResponse).toMatch(/^5\d{2}/);
|
|
||||||
console.log('Permanent failures persist correctly after RSET');
|
|
||||||
} else {
|
|
||||||
console.log('Server accepts invalid syntax in MAIL FROM (lenient parsing)');
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 30052;
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT, hostname: 'localhost' });
|
|
||||||
expect(testServer).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ERR-05: Resource exhaustion handling - Connection limit', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
const connections: net.Socket[] = [];
|
|
||||||
const maxAttempts = 50; // Reduced from 150 to speed up test
|
|
||||||
let exhaustionDetected = false;
|
|
||||||
let connectionsEstablished = 0;
|
|
||||||
let lastError: string | null = null;
|
|
||||||
|
|
||||||
// Set a timeout for the entire test
|
|
||||||
const testTimeout = setTimeout(() => {
|
|
||||||
console.log('Test timeout reached, cleaning up...');
|
|
||||||
exhaustionDetected = true; // Consider timeout as resource protection
|
|
||||||
}, 20000); // 20 second timeout
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => {
|
|
||||||
connections.push(socket);
|
|
||||||
connectionsEstablished++;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
socket.once('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try EHLO on each connection
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n')) {
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handleData = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('250 ') && data.includes('\r\n')) {
|
|
||||||
socket.removeListener('data', handleData);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handleData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for resource exhaustion indicators
|
|
||||||
if (ehloResponse.includes('421') ||
|
|
||||||
ehloResponse.includes('too many') ||
|
|
||||||
ehloResponse.includes('limit') ||
|
|
||||||
ehloResponse.includes('resource')) {
|
|
||||||
exhaustionDetected = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't keep all connections open - close older ones to prevent timeout
|
|
||||||
if (connections.length > 10) {
|
|
||||||
const oldSocket = connections.shift();
|
|
||||||
if (oldSocket && !oldSocket.destroyed) {
|
|
||||||
oldSocket.write('QUIT\r\n');
|
|
||||||
oldSocket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay every 10 connections to avoid overwhelming
|
|
||||||
if (i % 10 === 0 && i > 0) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error;
|
|
||||||
lastError = error.message;
|
|
||||||
|
|
||||||
// Connection refused or resource errors indicate exhaustion handling
|
|
||||||
if (error.message.includes('ECONNREFUSED') ||
|
|
||||||
error.message.includes('EMFILE') ||
|
|
||||||
error.message.includes('ENFILE') ||
|
|
||||||
error.message.includes('too many') ||
|
|
||||||
error.message.includes('resource')) {
|
|
||||||
exhaustionDetected = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other errors, continue trying
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up connections
|
|
||||||
for (const socket of connections) {
|
|
||||||
try {
|
|
||||||
if (!socket.destroyed) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for connections to close
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
// Test passes if we either:
|
|
||||||
// 1. Detected resource exhaustion (server properly limits connections)
|
|
||||||
// 2. Established fewer connections than attempted (server has limits)
|
|
||||||
// 3. Server handled all connections gracefully (no crashes)
|
|
||||||
const hasResourceProtection = exhaustionDetected || connectionsEstablished < maxAttempts;
|
|
||||||
const handledGracefully = connectionsEstablished === maxAttempts && !lastError;
|
|
||||||
|
|
||||||
console.log(`Connections established: ${connectionsEstablished}/${maxAttempts}`);
|
|
||||||
console.log(`Exhaustion detected: ${exhaustionDetected}`);
|
|
||||||
if (lastError) console.log(`Last error: ${lastError}`);
|
|
||||||
|
|
||||||
clearTimeout(testTimeout); // Clear the timeout
|
|
||||||
|
|
||||||
// Pass if server either has protection OR handles many connections gracefully
|
|
||||||
expect(hasResourceProtection || handledGracefully).toEqual(true);
|
|
||||||
|
|
||||||
if (handledGracefully) {
|
|
||||||
console.log('Server handled all connections gracefully without resource limits');
|
|
||||||
}
|
|
||||||
done.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Test error:', error);
|
|
||||||
clearTimeout(testTimeout); // Clear the timeout
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ERR-05: Resource exhaustion handling - Memory limits', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
// Set a timeout for this test
|
|
||||||
const testTimeout = setTimeout(() => {
|
|
||||||
console.log('Memory test timeout reached');
|
|
||||||
done.resolve(); // Just pass the test on timeout
|
|
||||||
}, 15000); // 15 second timeout
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 10000 // Reduced from 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', async () => {
|
|
||||||
try {
|
|
||||||
// Read greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handleData = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('250 ') && data.includes('\r\n')) {
|
|
||||||
socket.removeListener('data', handleData);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handleData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to send a very large email that might exhaust memory
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
const response = chunk.toString();
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
const response = chunk.toString();
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Try to send extremely large headers to test memory limits
|
|
||||||
const largeHeader = 'X-Test-Header: ' + 'A'.repeat(1024 * 100) + '\r\n';
|
|
||||||
let resourceError = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Send multiple large headers
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
socket.write(largeHeader);
|
|
||||||
|
|
||||||
// Check if socket is still writable
|
|
||||||
if (!socket.writable) {
|
|
||||||
resourceError = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('\r\n.\r\n');
|
|
||||||
|
|
||||||
const endResponse = await new Promise<string>((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
reject(new Error('Timeout waiting for response'));
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.once('error', (err) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
// Connection errors during large data handling indicate resource protection
|
|
||||||
resourceError = true;
|
|
||||||
resolve('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for resource protection responses
|
|
||||||
if (endResponse.includes('552') || // Message too large
|
|
||||||
endResponse.includes('451') || // Temporary failure
|
|
||||||
endResponse.includes('421') || // Service unavailable
|
|
||||||
endResponse.includes('resource') ||
|
|
||||||
endResponse.includes('memory') ||
|
|
||||||
endResponse.includes('limit')) {
|
|
||||||
resourceError = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resource protection is working if we got an error or protective response
|
|
||||||
expect(resourceError || endResponse.includes('552') || endResponse.includes('451')).toEqual(true);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
// Errors during large data transmission indicate resource protection
|
|
||||||
console.log('Expected resource protection error:', err);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
clearTimeout(testTimeout);
|
|
||||||
done.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
socket.end();
|
|
||||||
clearTimeout(testTimeout);
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
clearTimeout(testTimeout);
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
import * as plugins from '@git.zone/tstest/tapbundle';
|
|
||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ERR-06: Malformed MIME handling - Invalid boundary', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', async () => {
|
|
||||||
try {
|
|
||||||
// Read greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handleData = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('250 ') && data.includes('\r\n')) {
|
|
||||||
socket.removeListener('data', handleData);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handleData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
const response = chunk.toString();
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
const response = chunk.toString();
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send DATA
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Send malformed MIME with invalid boundary
|
|
||||||
const malformedMime = [
|
|
||||||
'From: sender@example.com',
|
|
||||||
'To: recipient@example.com',
|
|
||||||
'Subject: Malformed MIME Test',
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
'Content-Type: multipart/mixed; boundary=invalid-boundary',
|
|
||||||
'',
|
|
||||||
'--invalid-boundary',
|
|
||||||
'Content-Type: text/plain',
|
|
||||||
'Content-Transfer-Encoding: invalid-encoding',
|
|
||||||
'',
|
|
||||||
'This is malformed MIME content.',
|
|
||||||
'--invalid-boundary',
|
|
||||||
'Content-Type: application/octet-stream',
|
|
||||||
'Content-Disposition: attachment; filename="malformed.txt', // Missing closing quote
|
|
||||||
'',
|
|
||||||
'Malformed attachment content without proper boundary.',
|
|
||||||
'--invalid-boundary--missing-final-boundary', // Malformed closing boundary
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(malformedMime);
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Server should either:
|
|
||||||
// 1. Accept the message (250) - tolerant handling
|
|
||||||
// 2. Reject with error (550/552) - strict MIME validation
|
|
||||||
// 3. Return temporary failure (4xx) - processing error
|
|
||||||
const validResponse = response.includes('250') ||
|
|
||||||
response.includes('550') ||
|
|
||||||
response.includes('552') ||
|
|
||||||
response.includes('451') ||
|
|
||||||
response.includes('mime') ||
|
|
||||||
response.includes('malformed');
|
|
||||||
|
|
||||||
console.log('Malformed MIME response:', response.substring(0, 100));
|
|
||||||
expect(validResponse).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
socket.end();
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ERR-06: Malformed MIME handling - Missing headers', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', async () => {
|
|
||||||
try {
|
|
||||||
// Read greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handleData = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('250 ') && data.includes('\r\n')) {
|
|
||||||
socket.removeListener('data', handleData);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handleData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
const response = chunk.toString();
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
const response = chunk.toString();
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send DATA
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Send MIME with missing required headers
|
|
||||||
const malformedMime = [
|
|
||||||
'Subject: Missing MIME headers',
|
|
||||||
'Content-Type: multipart/mixed', // Missing boundary parameter
|
|
||||||
'',
|
|
||||||
'--boundary',
|
|
||||||
// Missing Content-Type for part
|
|
||||||
'',
|
|
||||||
'This part has no Content-Type header.',
|
|
||||||
'--boundary',
|
|
||||||
'Content-Type: text/plain',
|
|
||||||
// Missing blank line between headers and body
|
|
||||||
'This part has no separator line.',
|
|
||||||
'--boundary--',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(malformedMime);
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Server should handle this gracefully
|
|
||||||
const validResponse = response.includes('250') ||
|
|
||||||
response.includes('550') ||
|
|
||||||
response.includes('552') ||
|
|
||||||
response.includes('451');
|
|
||||||
|
|
||||||
console.log('Missing headers response:', response.substring(0, 100));
|
|
||||||
expect(validResponse).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
socket.end();
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ERR-06: Malformed MIME handling - Nested multipart errors', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', async () => {
|
|
||||||
try {
|
|
||||||
// Read greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handleData = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('250 ') && data.includes('\r\n')) {
|
|
||||||
socket.removeListener('data', handleData);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handleData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
const response = chunk.toString();
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send RCPT TO
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
const response = chunk.toString();
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send DATA
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Send deeply nested multipart with errors
|
|
||||||
const malformedMime = [
|
|
||||||
'From: sender@example.com',
|
|
||||||
'To: recipient@example.com',
|
|
||||||
'Subject: Nested multipart errors',
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
'Content-Type: multipart/mixed; boundary="outer"',
|
|
||||||
'',
|
|
||||||
'--outer',
|
|
||||||
'Content-Type: multipart/alternative; boundary="inner"',
|
|
||||||
'',
|
|
||||||
'--inner',
|
|
||||||
'Content-Type: multipart/related; boundary="nested"', // Too deeply nested
|
|
||||||
'',
|
|
||||||
'--nested',
|
|
||||||
'Content-Type: text/plain',
|
|
||||||
'Content-Transfer-Encoding: base64',
|
|
||||||
'',
|
|
||||||
'NOT-VALID-BASE64-CONTENT!!!', // Invalid base64
|
|
||||||
'--nested', // Missing closing --
|
|
||||||
'--inner--', // Improper nesting
|
|
||||||
'--outer', // Missing part content
|
|
||||||
'--outer--',
|
|
||||||
'.',
|
|
||||||
''
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.write(malformedMime);
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Server should handle complex MIME errors gracefully
|
|
||||||
const validResponse = response.includes('250') ||
|
|
||||||
response.includes('550') ||
|
|
||||||
response.includes('552') ||
|
|
||||||
response.includes('451');
|
|
||||||
|
|
||||||
console.log('Nested multipart response:', response.substring(0, 100));
|
|
||||||
expect(validResponse).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
socket.end();
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
import * as plugins from '@git.zone/tstest/tapbundle';
|
|
||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const activeSockets = new Set<net.Socket>();
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ERR-07: Exception handling - Invalid commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
activeSockets.add(socket);
|
|
||||||
socket.on('close', () => activeSockets.delete(socket));
|
|
||||||
|
|
||||||
socket.on('connect', async () => {
|
|
||||||
try {
|
|
||||||
// Read greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handleData = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('250 ') && data.includes('\r\n')) {
|
|
||||||
socket.removeListener('data', handleData);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handleData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test various exception-triggering commands
|
|
||||||
const invalidCommands = [
|
|
||||||
'INVALID_COMMAND_THAT_SHOULD_TRIGGER_EXCEPTION',
|
|
||||||
'MAIL FROM:<>', // Empty address
|
|
||||||
'RCPT TO:<>', // Empty address
|
|
||||||
'\x00\x01\x02INVALID_BYTES', // Binary data
|
|
||||||
'VERY_LONG_COMMAND_' + 'X'.repeat(1000), // Excessively long command
|
|
||||||
'MAIL FROM', // Missing parameter
|
|
||||||
'RCPT TO', // Missing parameter
|
|
||||||
'DATA DATA DATA' // Invalid syntax
|
|
||||||
];
|
|
||||||
|
|
||||||
let exceptionHandled = false;
|
|
||||||
let serverStillResponding = true;
|
|
||||||
|
|
||||||
for (const command of invalidCommands) {
|
|
||||||
try {
|
|
||||||
socket.write(command + '\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
reject(new Error('Timeout waiting for response'));
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Command: "${command.substring(0, 50)}..." -> Response: ${response.substring(0, 50)}`);
|
|
||||||
|
|
||||||
// Check if server handled the exception properly
|
|
||||||
if (response.includes('500') || // Command not recognized
|
|
||||||
response.includes('501') || // Syntax error
|
|
||||||
response.includes('502') || // Command not implemented
|
|
||||||
response.includes('503') || // Bad sequence
|
|
||||||
response.includes('error') ||
|
|
||||||
response.includes('invalid')) {
|
|
||||||
exceptionHandled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay between commands
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Error with command:', command, err);
|
|
||||||
// Connection might be closed by server - that's ok for some commands
|
|
||||||
serverStillResponding = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If still connected, verify server is still responsive
|
|
||||||
if (serverStillResponding) {
|
|
||||||
try {
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
const noopResponse = await new Promise<string>((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
reject(new Error('Timeout on NOOP'));
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (noopResponse.includes('250')) {
|
|
||||||
serverStillResponding = true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
serverStillResponding = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Exception handled:', exceptionHandled);
|
|
||||||
console.log('Server still responding:', serverStillResponding);
|
|
||||||
|
|
||||||
// Test passes if exceptions were handled OR server is still responding
|
|
||||||
expect(exceptionHandled || serverStillResponding).toEqual(true);
|
|
||||||
|
|
||||||
if (socket.writable) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
}
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
socket.end();
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ERR-07: Exception handling - Malformed protocol', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
activeSockets.add(socket);
|
|
||||||
socket.on('close', () => activeSockets.delete(socket));
|
|
||||||
|
|
||||||
socket.on('connect', async () => {
|
|
||||||
try {
|
|
||||||
// Read greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send commands with protocol violations
|
|
||||||
const protocolViolations = [
|
|
||||||
'EHLO', // No hostname
|
|
||||||
'MAIL FROM:<test@example.com> SIZE=', // Incomplete SIZE
|
|
||||||
'RCPT TO:<test@example.com> NOTIFY=', // Incomplete NOTIFY
|
|
||||||
'AUTH PLAIN', // No credentials
|
|
||||||
'STARTTLS EXTRA', // Extra parameters
|
|
||||||
'MAIL FROM:<test@example.com>\r\nRCPT TO:<test@example.com>', // Multiple commands in one line
|
|
||||||
];
|
|
||||||
|
|
||||||
let violationsHandled = 0;
|
|
||||||
|
|
||||||
for (const violation of protocolViolations) {
|
|
||||||
try {
|
|
||||||
socket.write(violation + '\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
resolve('TIMEOUT');
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response !== 'TIMEOUT' &&
|
|
||||||
(response.includes('500') ||
|
|
||||||
response.includes('501') ||
|
|
||||||
response.includes('503'))) {
|
|
||||||
violationsHandled++;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
// Error is ok - server might close connection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Protocol violations handled: ${violationsHandled}/${protocolViolations.length}`);
|
|
||||||
|
|
||||||
// Server should handle at least some violations properly
|
|
||||||
expect(violationsHandled).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
if (socket.writable) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
}
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
socket.end();
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ERR-07: Exception handling - Recovery after errors', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
activeSockets.add(socket);
|
|
||||||
socket.on('close', () => activeSockets.delete(socket));
|
|
||||||
|
|
||||||
socket.on('connect', async () => {
|
|
||||||
try {
|
|
||||||
// Read greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handleData = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('250 ') && data.includes('\r\n')) {
|
|
||||||
socket.removeListener('data', handleData);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handleData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger an error
|
|
||||||
socket.write('INVALID_COMMAND\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
const response = chunk.toString();
|
|
||||||
expect(response).toMatch(/50[0-3]/);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now try a valid command sequence to ensure recovery
|
|
||||||
socket.write('MAIL FROM:<test@example.com>\r\n');
|
|
||||||
|
|
||||||
const mailResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mailResponse).toInclude('250');
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
|
|
||||||
const rcptResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(rcptResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Server recovered successfully after exception
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
|
|
||||||
const rsetResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(rsetResponse).toInclude('250');
|
|
||||||
|
|
||||||
console.log('Server recovered successfully after exception');
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
socket.end();
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
// Close any remaining sockets
|
|
||||||
for (const socket of activeSockets) {
|
|
||||||
if (!socket.destroyed) {
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all sockets to be fully closed
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
import * as plugins from '@git.zone/tstest/tapbundle';
|
|
||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ERR-08: Error logging - Command errors', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', async () => {
|
|
||||||
try {
|
|
||||||
// Read greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handleData = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('250 ') && data.includes('\r\n')) {
|
|
||||||
socket.removeListener('data', handleData);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handleData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test various error conditions that should be logged
|
|
||||||
const errorTests = [
|
|
||||||
{ command: 'INVALID_COMMAND', expectedCode: '500', description: 'Invalid command' },
|
|
||||||
{ command: 'MAIL FROM:<invalid@@email>', expectedCode: '501', description: 'Invalid email syntax' },
|
|
||||||
{ command: 'RCPT TO:<invalid@@recipient>', expectedCode: '501', description: 'Invalid recipient syntax' },
|
|
||||||
{ command: 'VRFY nonexistent@domain.com', expectedCode: '550', description: 'User verification failed' },
|
|
||||||
{ command: 'EXPN invalidlist', expectedCode: '550', description: 'List expansion failed' }
|
|
||||||
];
|
|
||||||
|
|
||||||
let errorsDetected = 0;
|
|
||||||
let totalTests = errorTests.length;
|
|
||||||
|
|
||||||
for (const test of errorTests) {
|
|
||||||
try {
|
|
||||||
socket.write(test.command + '\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
resolve('TIMEOUT');
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`${test.description}: ${test.command} -> ${response.substring(0, 50)}`);
|
|
||||||
|
|
||||||
// Check if appropriate error code was returned
|
|
||||||
if (response.includes(test.expectedCode) ||
|
|
||||||
response.includes('500') || // General error
|
|
||||||
response.includes('501') || // Syntax error
|
|
||||||
response.includes('502') || // Not implemented
|
|
||||||
response.includes('550')) { // Action not taken
|
|
||||||
errorsDetected++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay between commands
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Error during test:', test.description, err);
|
|
||||||
// Connection errors also count as detected errors
|
|
||||||
errorsDetected++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const detectionRate = errorsDetected / totalTests;
|
|
||||||
console.log(`Error detection rate: ${errorsDetected}/${totalTests} (${Math.round(detectionRate * 100)}%)`);
|
|
||||||
|
|
||||||
// Expect at least 80% of errors to be properly detected and responded to
|
|
||||||
expect(detectionRate).toBeGreaterThanOrEqual(0.8);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
socket.end();
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ERR-08: Error logging - Protocol violations', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', async () => {
|
|
||||||
try {
|
|
||||||
// Read greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test protocol violations that should trigger error logging
|
|
||||||
const violations = [
|
|
||||||
{
|
|
||||||
sequence: ['RCPT TO:<test@example.com>'], // RCPT before MAIL
|
|
||||||
description: 'RCPT before MAIL FROM'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sequence: ['MAIL FROM:<sender@example.com>', 'DATA'], // DATA before RCPT
|
|
||||||
description: 'DATA before RCPT TO'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sequence: ['EHLO testhost', 'EHLO testhost', 'MAIL FROM:<test@example.com>', 'MAIL FROM:<test2@example.com>'], // Double MAIL FROM
|
|
||||||
description: 'Multiple MAIL FROM commands'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
let violationsDetected = 0;
|
|
||||||
|
|
||||||
for (const violation of violations) {
|
|
||||||
// Reset connection state
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Testing: ${violation.description}`);
|
|
||||||
|
|
||||||
for (const cmd of violation.sequence) {
|
|
||||||
socket.write(cmd + '\r\n');
|
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
resolve('TIMEOUT');
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for error responses
|
|
||||||
if (response.includes('503') || // Bad sequence
|
|
||||||
response.includes('501') || // Syntax error
|
|
||||||
response.includes('500')) { // Error
|
|
||||||
violationsDetected++;
|
|
||||||
console.log(` Violation detected: ${response.substring(0, 50)}`);
|
|
||||||
break; // Move to next violation test
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Protocol violations detected: ${violationsDetected}/${violations.length}`);
|
|
||||||
|
|
||||||
// Expect all protocol violations to be detected
|
|
||||||
expect(violationsDetected).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
socket.end();
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ERR-08: Error logging - Data transmission errors', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', async () => {
|
|
||||||
try {
|
|
||||||
// Read greeting
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handleData = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('250 ') && data.includes('\r\n')) {
|
|
||||||
socket.removeListener('data', handleData);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handleData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up valid email transaction
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
const response = chunk.toString();
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
const response = chunk.toString();
|
|
||||||
expect(response).toInclude('250');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
|
|
||||||
const dataResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dataResponse).toInclude('354');
|
|
||||||
|
|
||||||
// Test various data transmission errors
|
|
||||||
const dataErrors = [
|
|
||||||
{
|
|
||||||
data: 'From: sender@example.com\r\n.\r\n', // Premature termination
|
|
||||||
description: 'Premature dot termination'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
data: 'Subject: Test\r\n\r\n' + '\x00\x01\x02\x03', // Binary data
|
|
||||||
description: 'Binary data in message'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
data: 'X-Long-Line: ' + 'A'.repeat(2000) + '\r\n', // Excessively long line
|
|
||||||
description: 'Excessively long header line'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const errorData of dataErrors) {
|
|
||||||
console.log(`Testing: ${errorData.description}`);
|
|
||||||
socket.write(errorData.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Terminate the data
|
|
||||||
socket.write('\r\n.\r\n');
|
|
||||||
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
resolve('TIMEOUT');
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
socket.once('data', (chunk) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(chunk.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Data transmission response:', finalResponse.substring(0, 100));
|
|
||||||
|
|
||||||
// Server should either accept (250) or reject (5xx) but must respond
|
|
||||||
const hasResponse = finalResponse !== 'TIMEOUT' &&
|
|
||||||
(finalResponse.includes('250') ||
|
|
||||||
finalResponse.includes('5'));
|
|
||||||
|
|
||||||
expect(hasResponse).toEqual(true);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
done.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
socket.end();
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
// import { createTestSmtpClient, sendConcurrentEmails, measureClientThroughput } from '../../helpers/smtp.client.js';
|
|
||||||
import { connectToSmtp, sendSmtpCommand, waitForGreeting, createMimeMessage, closeSmtpConnection } from '../../helpers/utils.js';
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server for performance testing', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: 2531,
|
|
||||||
hostname: 'localhost',
|
|
||||||
maxConnections: 1000,
|
|
||||||
size: 50 * 1024 * 1024 // 50MB for performance testing
|
|
||||||
});
|
|
||||||
expect(testServer).toBeInstanceOf(Object);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Enable these tests when the helper functions are implemented
|
|
||||||
/*
|
|
||||||
tap.test('PERF-01: Throughput Testing - measure emails per second', async () => {
|
|
||||||
const client = createTestSmtpClient({
|
|
||||||
host: testServer.hostname,
|
|
||||||
port: testServer.port,
|
|
||||||
maxConnections: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Warm up the connection pool
|
|
||||||
console.log('🔥 Warming up connection pool...');
|
|
||||||
await sendConcurrentEmails(client, 5);
|
|
||||||
|
|
||||||
// Measure throughput for 10 seconds
|
|
||||||
console.log('📊 Measuring throughput for 10 seconds...');
|
|
||||||
const startTime = Date.now();
|
|
||||||
const testDuration = 10000; // 10 seconds
|
|
||||||
|
|
||||||
const result = await measureClientThroughput(client, testDuration, {
|
|
||||||
from: 'perf-test@example.com',
|
|
||||||
to: 'recipient@example.com',
|
|
||||||
subject: 'Performance Test Email',
|
|
||||||
text: 'This is a performance test email to measure throughput.'
|
|
||||||
});
|
|
||||||
|
|
||||||
const actualDuration = (Date.now() - startTime) / 1000;
|
|
||||||
|
|
||||||
console.log('📈 Throughput Test Results:');
|
|
||||||
console.log(` Total emails sent: ${result.totalSent}`);
|
|
||||||
console.log(` Successful: ${result.successCount}`);
|
|
||||||
console.log(` Failed: ${result.errorCount}`);
|
|
||||||
console.log(` Duration: ${actualDuration.toFixed(2)}s`);
|
|
||||||
console.log(` Throughput: ${result.throughput.toFixed(2)} emails/second`);
|
|
||||||
|
|
||||||
// Performance expectations
|
|
||||||
expect(result.throughput).toBeGreaterThan(10); // At least 10 emails/second
|
|
||||||
expect(result.errorCount).toBeLessThan(result.totalSent * 0.05); // Less than 5% errors
|
|
||||||
|
|
||||||
console.log('✅ Throughput test passed');
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
if (client.close) {
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('PERF-01: Burst throughput - handle sudden load spikes', async () => {
|
|
||||||
const client = createTestSmtpClient({
|
|
||||||
host: testServer.hostname,
|
|
||||||
port: testServer.port,
|
|
||||||
maxConnections: 20
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Send burst of emails
|
|
||||||
const burstSize = 100;
|
|
||||||
console.log(`💥 Sending burst of ${burstSize} emails...`);
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
const results = await sendConcurrentEmails(client, burstSize, {
|
|
||||||
from: 'burst-test@example.com',
|
|
||||||
to: 'recipient@example.com',
|
|
||||||
subject: 'Burst Test Email',
|
|
||||||
text: 'Testing burst performance.'
|
|
||||||
});
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
const successCount = results.filter(r => r && !r.rejected).length;
|
|
||||||
const throughput = (successCount / duration) * 1000;
|
|
||||||
|
|
||||||
console.log(`✅ Burst completed in ${duration}ms`);
|
|
||||||
console.log(` Success rate: ${successCount}/${burstSize} (${(successCount/burstSize*100).toFixed(1)}%)`);
|
|
||||||
console.log(` Burst throughput: ${throughput.toFixed(2)} emails/second`);
|
|
||||||
|
|
||||||
expect(successCount).toBeGreaterThan(burstSize * 0.95); // 95% success rate
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
if (client.close) {
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
|
|
||||||
tap.test('PERF-01: Large message throughput - measure with varying sizes', async () => {
|
|
||||||
const messageSizes = [
|
|
||||||
{ size: 1024, label: '1KB' },
|
|
||||||
{ size: 100 * 1024, label: '100KB' },
|
|
||||||
{ size: 1024 * 1024, label: '1MB' },
|
|
||||||
{ size: 5 * 1024 * 1024, label: '5MB' }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { size, label } of messageSizes) {
|
|
||||||
console.log(`\n📧 Testing throughput with ${label} messages...`);
|
|
||||||
|
|
||||||
const socket = await connectToSmtp(testServer.hostname, testServer.port);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForGreeting(socket);
|
|
||||||
await sendSmtpCommand(socket, 'EHLO test.example.com', '250');
|
|
||||||
|
|
||||||
// Send a few messages of this size
|
|
||||||
const messageCount = 5;
|
|
||||||
const timings: number[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < messageCount; i++) {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
await sendSmtpCommand(socket, 'MAIL FROM:<size-test@example.com>', '250');
|
|
||||||
await sendSmtpCommand(socket, 'RCPT TO:<recipient@example.com>', '250');
|
|
||||||
await sendSmtpCommand(socket, 'DATA', '354');
|
|
||||||
|
|
||||||
// Create message with padding to reach target size
|
|
||||||
const padding = 'X'.repeat(Math.max(0, size - 200)); // Account for headers
|
|
||||||
const emailContent = createMimeMessage({
|
|
||||||
from: 'size-test@example.com',
|
|
||||||
to: 'recipient@example.com',
|
|
||||||
subject: `${label} Performance Test`,
|
|
||||||
text: padding
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write(emailContent);
|
|
||||||
socket.write('\r\n.\r\n');
|
|
||||||
|
|
||||||
// Wait for acceptance
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => reject(new Error('Timeout')), 30000);
|
|
||||||
const onData = (data: Buffer) => {
|
|
||||||
if (data.toString().includes('250')) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
socket.removeListener('data', onData);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', onData);
|
|
||||||
});
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
timings.push(duration);
|
|
||||||
|
|
||||||
// Reset for next message
|
|
||||||
await sendSmtpCommand(socket, 'RSET', '250');
|
|
||||||
}
|
|
||||||
|
|
||||||
const avgTime = timings.reduce((a, b) => a + b, 0) / timings.length;
|
|
||||||
const throughputMBps = (size / 1024 / 1024) / (avgTime / 1000);
|
|
||||||
|
|
||||||
console.log(` Average time: ${avgTime.toFixed(0)}ms`);
|
|
||||||
console.log(` Throughput: ${throughputMBps.toFixed(2)} MB/s`);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await closeSmtpConnection(socket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✅ Large message throughput test completed');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
console.log('✅ Test server stopped');
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user