4 Commits

Author SHA1 Message Date
f601859f8b v2.3.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:26:20 +00:00
eb2643de93 feat(mailer-smtp): add in-process security pipeline for SMTP delivery (DKIM/SPF/DMARC, content scanning, IP reputation) 2026-02-10 22:26:20 +00:00
595634fb0f v2.2.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:04:56 +00:00
cee8a51081 fix(readme): Clarify Rust-powered architecture and mandatory Rust bridge; expand README with Rust workspace details and project structure updates 2026-02-10 22:04:56 +00:00
153 changed files with 683 additions and 47657 deletions

View File

@@ -1,5 +1,24 @@
# 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)
Clarify Rust-powered architecture and mandatory Rust bridge; expand README with Rust workspace details and project structure updates
- Emphasizes that the SMTP server is Rust-powered (high-performance) and not a nodemailer-based TS server.
- Documents that the Rust binary (mailer-bin) is required — if unavailable UnifiedEmailServer.start() will throw an error.
- Adds installation/build note: run `pnpm build` to compile the Rust binary.
- Adds a new Rust Acceleration Layer section listing workspace crates and responsibilities (mailer-core, mailer-security, mailer-smtp, mailer-bin, mailer-napi).
- Updates project structure: marks legacy TS SMTP server as fallback/legacy, adds dist_rust output, and clarifies which operations run in Rust vs TypeScript.
## 2026-02-10 - 2.2.0 - feat(mailer-smtp) ## 2026-02-10 - 2.2.0 - feat(mailer-smtp)
implement in-process SMTP server and management IPC integration implement in-process SMTP server and management IPC integration

View File

@@ -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=

View File

@@ -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 };

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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=

View File

@@ -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;

View File

@@ -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==

View File

@@ -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

View File

@@ -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';

View File

@@ -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==

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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=

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartmta", "name": "@push.rocks/smartmta",
"version": "2.2.0", "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",

299
readme.md
View File

@@ -1,6 +1,6 @@
# @push.rocks/smartmta # @push.rocks/smartmta
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration — no nodemailer, no shortcuts. A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. 🚀
## Issue Reporting and Security ## Issue Reporting and Security
@@ -14,68 +14,83 @@ pnpm install @push.rocks/smartmta
npm install @push.rocks/smartmta npm install @push.rocks/smartmta
``` ```
After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`). The Rust binary is **required**`smartmta` will not start without it.
## Overview ## Overview
`@push.rocks/smartmta` is a **complete mail server solution** — SMTP server, SMTP client, email security, content scanning, and delivery management — all built with a custom SMTP implementation. No wrappers around nodemailer. No half-measures. `@push.rocks/smartmta` is a **complete mail server solution** — SMTP server, SMTP client, email security, content scanning, and delivery management — all built with a custom SMTP implementation. The SMTP server itself runs as a Rust binary for maximum performance, communicating with the TypeScript orchestration layer via IPC.
### What's Inside ### What's Inside
| Module | What It Does | | Module | What It Does |
|---|---| |---|---|
| **SMTP Server** | RFC 5321-compliant server with TLS/STARTTLS, authentication, pipelining | | **Rust SMTP Server** | High-performance SMTP engine written in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
| **SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation | | **SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation |
| **DKIM** | Key generation, signing, and verification — per domain | | **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
| **SPF** | Full SPF record validation | | **SPF** | Full SPF record validation via Rust |
| **DMARC** | Policy enforcement and verification | | **DMARC** | Policy enforcement and verification |
| **Email Router** | Pattern-based routing with priority, forward/deliver/reject/process actions | | **Email Router** | Pattern-based routing with priority, forward/deliver/reject/process actions |
| **Bounce Manager** | Automatic bounce detection, classification (hard/soft), and tracking | | **Bounce Manager** | Automatic bounce detection via Rust, classification (hard/soft), and suppression tracking |
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection | | **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection — powered by Rust |
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring | | **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring via Rust |
| **Rate Limiter** | Hierarchical rate limiting (global, per-domain, per-IP) | | **Rate Limiter** | Hierarchical rate limiting (global, per-domain, per-IP) |
| **Delivery Queue** | Persistent queue with exponential backoff retry | | **Delivery Queue** | Persistent queue with exponential backoff retry |
| **Template Engine** | Email templates with variable substitution | | **Template Engine** | Email templates with variable substitution |
| **Domain Registry** | Multi-domain management with per-domain configuration | | **Domain Registry** | Multi-domain management with per-domain configuration |
| **DNS Manager** | Automatic DNS record management with Cloudflare API integration | | **DNS Manager** | Automatic DNS record management with Cloudflare API integration |
| **Rust Accelerator** | Performance-critical operations (DKIM, MIME, validation) in Rust via IPC | | **Rust Security Bridge** | All security ops (DKIM+SPF+DMARC+DNSBL+content scanning) run in Rust via IPC |
| **Rust Security Bridge** | Compound email security verification (DKIM+SPF+DMARC) via Rust binary |
### Architecture ### 🏗️ Architecture
``` ```
┌─────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────
│ UnifiedEmailServer │ │ UnifiedEmailServer │
│ (orchestrates all components, emits events) │ │ (orchestrates all components, emits events) │
├────────────────────┬──────────────────────────────────┤ ├───────────┬───────────┬──────────────┬───────────────────────┤
SMTP │ Email │ Security Delivery │ │ Email │ Security Delivery │ Configuration
Server │ Router │ Stack System │ │ Router │ Stack System
│ ┌─────┐ │ ┌─────┐ │ ┌───────┐ │ ┌────────────────┐ │ │ ┌─────┐ │ ┌───────┐ │ ┌─────────┐ │ ┌────────────────┐
│ │ TLS │ │ │Match│ │ │ DKIM │ │ Queue │ │ │ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry
│ │ Auth│ │ │Route│ │ │ SPF │ │ Rate Limit │ │ │ │Route │ │ │ SPF │ │ Rate Lim │ │ │ DnsManager
│ │ Cmd │ │ │ Act │ │ │ DMARC │ │ SMTP Client │ │ │ │ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator
│ Data│ │ │ │ IPRep │ │ Retry Logic │ │ └──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates
└─────┘ │ └─────┘ │ │ Scan │ │ └────────────────┘ │ │ │ Scan │ │ └──────────┘ │ └────────────────┘
│ └───────┘ │ │ │ │ └───────┘
├────────────────────┴──────────────────────────────────┤ ├───────────┴───────────┴──────────────┴───────────────────────┤
│ Rust Security Bridge │ Rust Security Bridge (smartrust IPC)
│ (RustSecurityBridge singleton via smartrust IPC) │ ├──────────────────────────────────────────────────────────────┤
├─────────────────────────────────────────────────────────┤
│ Rust Acceleration Layer │ │ Rust Acceleration Layer │
(mailer-core, mailer-security, mailer-bin) ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐
└─────────────────────────────────────────────────────────┘ │ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
│ │ TLS/AUTH │ │IP Rep/Content │ │ MIME/Bounce │ │
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────┘
``` ```
**Data flow for inbound mail:**
1. Rust SMTP server accepts the connection and handles the SMTP protocol
2. On `DATA` completion, Rust emits an `emailReceived` event via IPC
3. TypeScript processes the email (routing, scanning, delivery decisions)
4. TypeScript sends the processing result back to Rust via IPC
5. Rust sends the final SMTP response to the client
## Usage ## Usage
### Setting Up the Email Server ### 🚀 Setting Up the Email Server
The central entry point is `UnifiedEmailServer`, which orchestrates SMTP, routing, security, and delivery: The central entry point is `UnifiedEmailServer`, which orchestrates the Rust SMTP server, routing, security, and delivery:
```typescript ```typescript
import { UnifiedEmailServer } from '@push.rocks/smartmta'; import { UnifiedEmailServer } from '@push.rocks/smartmta';
const emailServer = new UnifiedEmailServer(dcRouterRef, { const emailServer = new UnifiedEmailServer(dcRouterRef, {
// Ports to listen on (465 = implicit TLS, 25/587 = STARTTLS)
ports: [25, 587, 465], ports: [25, 587, 465],
hostname: 'mail.example.com', hostname: 'mail.example.com',
// Multi-domain configuration
domains: [ domains: [
{ {
domain: 'example.com', domain: 'example.com',
@@ -87,11 +102,13 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
rotationInterval: 90, rotationInterval: 90,
}, },
rateLimits: { rateLimits: {
maxMessagesPerMinute: 100, outbound: { messagesPerMinute: 100 },
maxRecipientsPerMessage: 50, inbound: { messagesPerMinute: 200, connectionsPerIp: 20 },
}, },
}, },
], ],
// Routing rules (evaluated by priority, highest first)
routes: [ routes: [
{ {
name: 'catch-all-forward', name: 'catch-all-forward',
@@ -122,31 +139,39 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
}, },
}, },
], ],
// Authentication settings for the SMTP server
auth: { auth: {
required: false, required: false,
methods: ['PLAIN', 'LOGIN'], methods: ['PLAIN', 'LOGIN'],
users: [{ username: 'outbound', password: 'secret' }], users: [{ username: 'outbound', password: 'secret' }],
}, },
// TLS certificates
tls: { tls: {
certPath: '/etc/ssl/mail.crt', certPath: '/etc/ssl/mail.crt',
keyPath: '/etc/ssl/mail.key', keyPath: '/etc/ssl/mail.key',
}, },
maxMessageSize: 25 * 1024 * 1024, // 25 MB maxMessageSize: 25 * 1024 * 1024, // 25 MB
maxClients: 500, maxClients: 500,
}); });
// start() boots the Rust SMTP server, security bridge, DNS records, and delivery queue
await emailServer.start(); await emailServer.start();
``` ```
### Sending Emails with the SMTP Client > 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
### 📧 Sending Emails with the SMTP Client
Create and send emails using the built-in SMTP client with connection pooling: Create and send emails using the built-in SMTP client with connection pooling:
```typescript ```typescript
import { Email, createSmtpClient } from '@push.rocks/smartmta'; import { Email, Delivery } from '@push.rocks/smartmta';
// Create a client with connection pooling // Create a client with connection pooling
const client = createSmtpClient({ const client = Delivery.smtpClientMod.createSmtpClient({
host: 'smtp.example.com', host: 'smtp.example.com',
port: 587, port: 587,
secure: false, // will upgrade via STARTTLS secure: false, // will upgrade via STARTTLS
@@ -181,9 +206,22 @@ const result = await client.sendMail(email);
console.log(`Message sent: ${result.messageId}`); console.log(`Message sent: ${result.messageId}`);
``` ```
### DKIM Signing Additional client factories are available:
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by `UnifiedEmailServer` during outbound delivery — there is no standalone `signEmail()` call: ```typescript
// Pooled client for high-throughput scenarios
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
// Optimized for bulk sending
const bulk = Delivery.smtpClientMod.createBulkSmtpClient({ /* ... */ });
// Optimized for transactional emails
const transactional = Delivery.smtpClientMod.createTransactionalSmtpClient({ /* ... */ });
```
### 🔑 DKIM Signing
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by `UnifiedEmailServer` during outbound delivery:
```typescript ```typescript
import { DKIMCreator } from '@push.rocks/smartmta'; import { DKIMCreator } from '@push.rocks/smartmta';
@@ -206,11 +244,11 @@ if (needsRotation) {
} }
``` ```
When `UnifiedEmailServer.start()` is called, DKIM signing is applied to all outbound mail automatically using the keys managed by `DKIMCreator`. The `RustSecurityBridge` can also perform DKIM signing via its `signDkim()` method for high-performance scenarios. When `UnifiedEmailServer.start()` is called, DKIM signing is applied to all outbound mail automatically using the Rust security bridge's `signDkim()` method for maximum performance.
### Email Authentication (SPF, DKIM, DMARC) ### 🛡️ Email Authentication (SPF, DKIM, DMARC)
Verify incoming emails against all three authentication standards. Note that the first argument to `SpfVerifier.verify()` and `DmarcVerifier.verify()` is an `Email` object: Verify incoming emails against all three authentication standards. All verification is powered by the Rust binary:
```typescript ```typescript
import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta'; import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
@@ -221,7 +259,7 @@ const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror', // -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror',
// domain: string, ip: string } // domain: string, ip: string }
// DKIM verification // DKIM verification — takes raw email content
const dkimVerifier = new DKIMVerifier(); const dkimVerifier = new DKIMVerifier();
const dkimResult = await dkimVerifier.verify(rawEmailContent); const dkimResult = await dkimVerifier.verify(rawEmailContent);
@@ -232,9 +270,9 @@ const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
// spfDomainAligned: boolean, dkimDomainAligned: boolean, ... } // spfDomainAligned: boolean, dkimDomainAligned: boolean, ... }
``` ```
### Email Routing ### 🔀 Email Routing
Pattern-based routing engine with priority ordering and flexible match criteria. Routes are evaluated by priority (highest first) using `evaluateRoutes()`: Pattern-based routing engine with priority ordering and flexible match criteria. Routes are evaluated by priority (highest first):
```typescript ```typescript
import { EmailRouter } from '@push.rocks/smartmta'; import { EmailRouter } from '@push.rocks/smartmta';
@@ -288,9 +326,22 @@ const router = new EmailRouter([
const matchedRoute = await router.evaluateRoutes(emailContext); const matchedRoute = await router.evaluateRoutes(emailContext);
``` ```
### Content Scanning **Match criteria available:**
Built-in content scanner for detecting spam, phishing, malware, and other threats. Use the `scanEmail()` method: | Criterion | Description |
|---|---|
| `recipients` | Glob patterns for recipient addresses (`*@example.com`) |
| `senders` | Glob patterns for sender addresses |
| `clientIp` | IP addresses or CIDR ranges |
| `authenticated` | Require authentication status |
| `headers` | Match specific headers (string or RegExp) |
| `sizeRange` | Message size constraints (`{ min?, max? }`) |
| `subject` | Subject line pattern (string or RegExp) |
| `hasAttachments` | Filter by attachment presence |
### 🔍 Content Scanning
Built-in content scanner for detecting spam, phishing, malware, and other threats. Text pattern scanning runs in Rust for performance; binary attachment scanning (PE headers, VBA macros) runs in TypeScript:
```typescript ```typescript
import { ContentScanner } from '@push.rocks/smartmta'; import { ContentScanner } from '@push.rocks/smartmta';
@@ -317,14 +368,14 @@ const result = await scanner.scanEmail(email);
// -> { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] } // -> { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] }
``` ```
### IP Reputation Checking ### 🌐 IP Reputation Checking
Check sender IP addresses against DNSBL blacklists and classify IP types: Check sender IP addresses against DNSBL blacklists and classify IP types. DNSBL lookups run in Rust:
```typescript ```typescript
import { IPReputationChecker } from '@push.rocks/smartmta'; import { IPReputationChecker } from '@push.rocks/smartmta';
const ipChecker = new IPReputationChecker({ const ipChecker = IPReputationChecker.getInstance({
enableDNSBL: true, enableDNSBL: true,
dnsblServers: ['zen.spamhaus.org', 'bl.spamcop.net'], dnsblServers: ['zen.spamhaus.org', 'bl.spamcop.net'],
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
@@ -334,14 +385,13 @@ const reputation = await ipChecker.checkReputation('192.168.1.1');
// -> { score: 85, isSpam: false, isProxy: false, isTor: false, blacklists: [] } // -> { score: 85, isSpam: false, isProxy: false, isTor: false, blacklists: [] }
``` ```
When the `RustSecurityBridge` is running, `IPReputationChecker` automatically delegates DNSBL lookups to the Rust binary for improved performance. ### ⏱️ Rate Limiting
### Rate Limiting Hierarchical rate limiting to protect your server and maintain deliverability:
Hierarchical rate limiting to protect your server and maintain deliverability. Configuration uses `maxMessagesPerMinute` and organizes domain-level limits under the `domains` key:
```typescript ```typescript
import { UnifiedRateLimiter } from '@push.rocks/smartmta'; import { Delivery } from '@push.rocks/smartmta';
const { UnifiedRateLimiter } = Delivery;
const rateLimiter = new UnifiedRateLimiter({ const rateLimiter = new UnifiedRateLimiter({
global: { global: {
@@ -374,12 +424,13 @@ if (!allowed.allowed) {
} }
``` ```
### Bounce Management ### 📬 Bounce Management
Automatic bounce detection, classification, and suppression tracking. Use `isEmailSuppressed()` to check if an address should be suppressed: Automatic bounce detection (via Rust), classification, and suppression tracking:
```typescript ```typescript
import { BounceManager } from '@push.rocks/smartmta'; import { Core } from '@push.rocks/smartmta';
const { BounceManager } = Core;
const bounceManager = new BounceManager(); const bounceManager = new BounceManager();
@@ -399,16 +450,17 @@ bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
bounceManager.removeFromSuppressionList('recovered@example.com'); bounceManager.removeFromSuppressionList('recovered@example.com');
``` ```
### Email Templates ### 📝 Email Templates
Template engine with variable substitution for transactional and notification emails. Use `createEmail()` to produce a ready-to-send `Email` from a registered template: Template engine with variable substitution for transactional and notification emails:
```typescript ```typescript
import { TemplateManager } from '@push.rocks/smartmta'; import { Core } from '@push.rocks/smartmta';
const { TemplateManager } = Core;
const templates = new TemplateManager({ const templates = new TemplateManager({
from: 'noreply@example.com', from: 'noreply@example.com',
footerHtml: '<p>2026 Example Corp</p>', footerHtml: '<p>&copy; 2026 Example Corp</p>',
}); });
// Register a template // Register a template
@@ -430,19 +482,11 @@ const email = await templates.createEmail('welcome', {
}); });
``` ```
### DNS Management ### 🌍 DNS Management
DNS record management for email authentication is handled internally by `UnifiedEmailServer`. The `DnsManager` is not instantiated directly — it receives its configuration from the `dcRouter` reference and automatically ensures MX, SPF, DKIM, and DMARC records are in place for all configured domains: DNS record management for email authentication is handled automatically by `UnifiedEmailServer`. When the server starts, it ensures MX, SPF, DKIM, and DMARC records are in place for all configured domains via the Cloudflare API:
```typescript ```typescript
// DNS management is automatic when using UnifiedEmailServer.
// When the server starts, it calls ensureDnsRecords() internally
// for all configured domains, setting up:
// - MX records pointing to your mail server
// - SPF TXT records authorizing your server IP
// - DKIM TXT records with public keys from DKIMCreator
// - DMARC TXT records with your policy
const emailServer = new UnifiedEmailServer(dcRouterRef, { const emailServer = new UnifiedEmailServer(dcRouterRef, {
hostname: 'mail.example.com', hostname: 'mail.example.com',
domains: [ domains: [
@@ -454,47 +498,17 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
// ... other config // ... other config
}); });
// DNS records are set up automatically on start // DNS records are set up automatically on start:
// - MX records pointing to your mail server
// - SPF TXT records authorizing your server IP
// - DKIM TXT records with public keys from DKIMCreator
// - DMARC TXT records with your policy
await emailServer.start(); await emailServer.start();
``` ```
For DNS lookups and record verification outside of the server lifecycle, the `DNSManager` class (note the capital N) can be used directly: ### 🦀 RustSecurityBridge
```typescript The `RustSecurityBridge` is the singleton that manages the Rust binary process. It handles security verification, content scanning, bounce detection, and the SMTP server lifecycle — all via `@push.rocks/smartrust` IPC:
import { DNSManager, DKIMCreator } from '@push.rocks/smartmta';
const dkimCreator = new DKIMCreator('/path/to/keys');
const dnsManager = new DNSManager(dkimCreator);
// Verify all email authentication records for a domain
const results = await dnsManager.verifyEmailAuthRecords('example.com', 'default');
console.log(results.spf); // { valid: boolean, record: string, ... }
console.log(results.dkim); // { valid: boolean, record: string, ... }
console.log(results.dmarc); // { valid: boolean, record: string, ... }
// Generate recommended DNS records
const records = await dnsManager.generateAllRecommendedRecords('example.com');
```
## Rust Acceleration
Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC).
### Rust Crates
The Rust workspace is at `rust/` with five crates:
| Crate | Status | Purpose |
|---|---|---|
| `mailer-core` | Complete (26 tests) | Email types, validation, MIME building, bounce detection |
| `mailer-security` | Complete (12 tests) | DKIM signing/verification, SPF checks, DMARC policy, IP reputation/DNSBL |
| `mailer-bin` | Complete | CLI + smartrust IPC bridge (handles `verifyEmail` compound method) |
| `mailer-smtp` | Planned (Phase 3) | SMTP protocol in Rust |
| `mailer-napi` | Planned (Phase 3) | Native Node.js addon |
### RustSecurityBridge
The `RustSecurityBridge` is a singleton that manages the Rust binary process and provides high-performance security verification. It is automatically started and stopped with `UnifiedEmailServer`:
```typescript ```typescript
import { RustSecurityBridge } from '@push.rocks/smartmta'; import { RustSecurityBridge } from '@push.rocks/smartmta';
@@ -511,7 +525,7 @@ const securityResult = await bridge.verifyEmail({
}); });
// -> { dkim: [...], spf: { result, explanation }, dmarc: { result, policy } } // -> { dkim: [...], spf: { result, explanation }, dmarc: { result, policy } }
// Individual operations // Individual security operations
const dkimResults = await bridge.verifyDkim(rawEmailString); const dkimResults = await bridge.verifyDkim(rawEmailString);
const spfResult = await bridge.checkSpf({ const spfResult = await bridge.checkSpf({
ip: '203.0.113.10', ip: '203.0.113.10',
@@ -520,10 +534,61 @@ const spfResult = await bridge.checkSpf({
}); });
const reputationResult = await bridge.checkIpReputation('203.0.113.10'); const reputationResult = await bridge.checkIpReputation('203.0.113.10');
// DKIM signing
const signed = await bridge.signDkim({
email: rawEmailString,
domain: 'example.com',
selector: 'default',
privateKeyPem: privateKey,
});
// Content scanning
const scanResult = await bridge.scanContent({
subject: 'Win a free iPhone!!!',
body: '<a href="http://phishing.example.com">Click here</a>',
from: 'scammer@evil.com',
});
// Bounce detection
const bounceResult = await bridge.detectBounce({
subject: 'Delivery Status Notification (Failure)',
body: '550 5.1.1 User unknown',
from: 'mailer-daemon@example.com',
});
await bridge.stop(); await bridge.stop();
``` ```
When the bridge is running, the TypeScript security components (`SpfVerifier`, `DKIMVerifier`, `IPReputationChecker`) automatically delegate to the Rust binary. If the binary is unavailable, the system falls back gracefully to TypeScript-only verification. > ⚠️ **Important:** The Rust bridge is **mandatory**. There are no TypeScript fallbacks. If the Rust binary is unavailable, `UnifiedEmailServer.start()` will throw an error.
## 🦀 Rust Acceleration Layer
Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC). The Rust workspace lives at `rust/` with five crates:
| Crate | Status | Purpose |
|---|---|---|
| `mailer-core` | ✅ Complete (26 tests) | Email types, validation, MIME building, bounce detection |
| `mailer-security` | ✅ Complete (22 tests) | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
| `mailer-smtp` | ✅ Complete (72 tests) | Full SMTP protocol engine — TCP/TLS server, STARTTLS, AUTH, pipelining, rate limiting |
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — security, content scanning, SMTP server lifecycle |
| `mailer-napi` | 🔜 Planned | Native Node.js addon (N-API) |
### What Runs in Rust
| Operation | Runs In | Why |
|---|---|---|
| SMTP server (port listening, protocol, TLS) | Rust | Performance, memory safety, zero-copy parsing |
| DKIM signing & verification | Rust | Crypto-heavy, benefits from native speed |
| SPF validation | Rust | DNS lookups with async resolver |
| DMARC policy checking | Rust | Integrates with SPF/DKIM results |
| IP reputation / DNSBL | Rust | Parallel DNS queries |
| Content scanning (text patterns) | Rust | Regex engine performance |
| Bounce detection (pattern matching) | Rust | Regex engine performance |
| Email validation & MIME building | Rust | Parsing performance |
| Binary attachment scanning | TypeScript | Buffer data too large for IPC |
| Email routing & orchestration | TypeScript | Business logic, flexibility |
| Delivery queue & retry | TypeScript | State management, persistence |
| Template rendering | TypeScript | String interpolation |
## Project Structure ## Project Structure
@@ -534,14 +599,20 @@ smartmta/
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager │ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter │ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
│ │ │ ├── smtpclient/ # SMTP client with connection pooling │ │ │ ├── smtpclient/ # SMTP client with connection pooling
│ │ │ └── smtpserver/ # SMTP server with TLS, auth, pipelining │ │ │ └── smtpserver/ # Legacy TS SMTP server (socket-handler fallback)
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager │ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier │ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge │ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
├── rust/ # Rust workspace ├── rust/ # Rust workspace
│ └── crates/ # mailer-core, mailer-security, mailer-bin, mailer-smtp, mailer-napi │ └── crates/
├── test/ # Comprehensive test suite │ ├── mailer-core/ # Email types, validation, MIME, bounce detection
└── dist_ts/ # Compiled output │ ├── mailer-security/ # DKIM, SPF, DMARC, IP reputation, content scanning
│ ├── mailer-smtp/ # Full SMTP server (TCP/TLS, state machine, rate limiting)
│ ├── mailer-bin/ # CLI + smartrust IPC bridge
│ └── mailer-napi/ # N-API addon (planned)
├── test/ # Test suite
├── dist_ts/ # Compiled TypeScript output
└── dist_rust/ # Compiled Rust binaries
``` ```
## License and Legal Information ## License and Legal Information

1
rust/Cargo.lock generated
View File

@@ -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",

View File

@@ -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,

View File

@@ -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

View File

@@ -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()));
}
} }

View File

@@ -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;

View File

@@ -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
*/ */

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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: super­cali­fragi­listic­expi­ali­docious',
'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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

Some files were not shown because too many files have changed in this diff Show More