9 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
f1c5546186 v2.2.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:00:44 +00:00
5220ee0857 feat(mailer-smtp): implement in-process SMTP server and management IPC integration 2026-02-10 22:00:44 +00:00
fc2e6d44f4 v2.1.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 21:19:13 +00:00
15a45089aa feat(security): migrate content scanning and bounce detection to Rust security bridge; add scanContent IPC command and Rust content scanner with tests; update TS RustSecurityBridge and callers, and adjust CI package references 2026-02-10 21:19:13 +00:00
b82468ab1e BREAKING CHANGE(rust-bridge): make Rust the primary security backend, remove all TS fallbacks
Some checks failed
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Type Check & Lint (push) Failing after 6s
CI / Build All Platforms (push) Failing after 4s
Phase 3 of the Rust migration: the Rust security bridge is now mandatory
and all TypeScript security fallback implementations have been removed.

- UnifiedEmailServer.start() throws if Rust bridge fails to start
- SpfVerifier gutted to thin wrapper (parseSpfRecord stays in TS)
- DKIMVerifier gutted to thin wrapper delegating to bridge.verifyDkim()
- IPReputationChecker delegates to bridge.checkIpReputation(), keeps LRU cache
- DmarcVerifier keeps alignment logic (works with pre-computed results)
- DKIM signing via bridge.signDkim() in all 4 locations
- Removed mailauth and ip packages from plugins.ts (~1,200 lines deleted)
2026-02-10 20:30:43 +00:00
200 changed files with 6313 additions and 52095 deletions

View File

@@ -84,7 +84,7 @@ jobs:
mailer --version || echo "Note: Binary execution may fail in CI environment"
echo ""
echo "Checking installed files:"
npm ls -g @serve.zone/mailer || true
npm ls -g @push.rocks/smartmta || true
- name: Publish to npm
env:
@@ -93,10 +93,10 @@ jobs:
echo "Publishing to npm registry..."
npm publish --access public
echo ""
echo "✅ Successfully published @serve.zone/mailer to npm!"
echo "✅ Successfully published @push.rocks/smartmta to npm!"
echo ""
echo "Package info:"
npm view @serve.zone/mailer
npm view @push.rocks/smartmta
- name: Verify npm package
run: |
@@ -104,10 +104,10 @@ jobs:
sleep 30
echo ""
echo "Verifying published package..."
npm view @serve.zone/mailer
npm view @push.rocks/smartmta
echo ""
echo "Testing installation from npm:"
npm install -g @serve.zone/mailer
npm install -g @push.rocks/smartmta
echo ""
echo "Package installed successfully!"
which mailer || echo "Binary location check skipped"
@@ -118,12 +118,12 @@ jobs:
echo " npm Publish Complete!"
echo "================================================"
echo ""
echo "✅ Package: @serve.zone/mailer"
echo "✅ Package: @push.rocks/smartmta"
echo "✅ Version: ${{ steps.version.outputs.version }}"
echo ""
echo "Installation:"
echo " npm install -g @serve.zone/mailer"
echo " npm install -g @push.rocks/smartmta"
echo ""
echo "Registry:"
echo " https://www.npmjs.com/package/@serve.zone/mailer"
echo " https://www.npmjs.com/package/@push.rocks/smartmta"
echo ""

View File

@@ -1,5 +1,46 @@
# 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)
implement in-process SMTP server and management IPC integration
- Add full SMTP protocol engine crate (mailer-smtp) with modules: command, config, connection, data, response, session, state, validation, rate_limiter and server
- Introduce SmtpServerConfig, DataAccumulator (DATA phase handling, dot-unstuffing, size limits) and SmtpResponse builder with EHLO capability construction
- Add in-process RateLimiter using DashMap and runtime-configurable RateLimitConfig
- Add TCP/TLS server start/stop API (start_server) with TlsAcceptor building from PEM and SmtpServerHandle for shutdown and status
- Integrate callback registry and oneshot-based correlation callbacks in mailer-bin management mode for email processing/auth results and JSON IPC parsing for SmtpServerConfig
- TypeScript bridge and routing updates: new IPC commands/types (startSmtpServer, stopSmtpServer, emailProcessingResult, authResult, configureRateLimits) and event handlers (emailReceived, authRequest)
- Update Cargo manifests and lockfile to add dependencies (dashmap, regex, rustls, rustls-pemfile, rustls-pki-types, uuid, serde_json, base64, etc.)
- Add comprehensive unit tests for new modules (config, data, response, session, state, rate_limiter, validation)
## 2026-02-10 - 2.1.0 - feat(security)
migrate content scanning and bounce detection to Rust security bridge; add scanContent IPC command and Rust content scanner with tests; update TS RustSecurityBridge and callers, and adjust CI package references
- Add Rust content scanner implementation (rust/crates/mailer-security/src/content_scanner.rs) with pattern-based detection and unit tests (~515 lines)
- Expose new IPC command 'scanContent' in mailer-bin and marshal results via JSON for the RustSecurityBridge
- Update TypeScript RustSecurityBridge with scanContent typing and method, and replace local JS detection logic (bounce/content) to call Rust bridge
- Update tests to start/stop the RustSecurityBridge and rely on Rust-based detection (test updates in test.bouncemanager.ts and test.contentscanner.ts)
- Update CI workflow messages and package references from @serve.zone/mailer to @push.rocks/smartmta
- Add regex dependency to rust mailer-security workspace (Cargo.toml / Cargo.lock updated)
## 2026-02-10 - 2.0.1 - fix(docs/readme)
update README: clarify APIs, document RustSecurityBridge, update examples and architecture diagram

View File

@@ -3,7 +3,7 @@
*/
export const commitinfo = {
name: '@push.rocks/smartmta',
version: '2.0.0',
version: '2.2.1',
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
};
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=

View File

@@ -165,21 +165,6 @@ export declare class BounceManager {
type: BounceType;
category: BounceCategory;
} | null;
/**
* Analyze SMTP response and diagnostic codes to determine bounce type
* @param smtpResponse SMTP response string
* @param diagnosticCode Diagnostic code from bounce
* @param statusCode Status code from bounce
* @returns Detected bounce type and category
*/
private detectBounceType;
/**
* Check if text matches any pattern for a bounce type
* @param text Text to check against patterns
* @param bounceType Bounce type to get patterns for
* @returns Whether the text matches any pattern
*/
private matchesPattern;
/**
* Get all known hard bounced addresses
* @returns Array of hard bounced email addresses

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,5 @@
import * as plugins from '../../plugins.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
export class EmailSignJob {
emailServerRef;
jobOptions;
@@ -12,25 +13,14 @@ export class EmailSignJob {
}
async getSignatureHeader(emailMessage) {
const privateKey = await this.loadPrivateKey();
const signResult = await plugins.dkimSign(emailMessage, {
signingDomain: this.jobOptions.domain,
const bridge = RustSecurityBridge.getInstance();
const signResult = await bridge.signDkim({
rawMessage: emailMessage,
domain: this.jobOptions.domain,
selector: this.jobOptions.selector,
privateKey,
canonicalization: 'relaxed/relaxed',
algorithm: 'rsa-sha256',
signTime: new Date(),
signatureData: [
{
signingDomain: this.jobOptions.domain,
selector: this.jobOptions.selector,
privateKey,
algorithm: 'rsa-sha256',
canonicalization: 'relaxed/relaxed',
},
],
});
const signature = signResult.signatures;
return signature;
return signResult.header;
}
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5lbWFpbHNpZ25qb2IuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2NsYXNzZXMuZW1haWxzaWduam9iLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFjNUMsTUFBTSxPQUFPLFlBQVk7SUFDdkIsY0FBYyxDQUFxQjtJQUNuQyxVQUFVLENBQXVCO0lBRWpDLFlBQVksY0FBa0MsRUFBRSxPQUE2QjtRQUMzRSxJQUFJLENBQUMsY0FBYyxHQUFHLGNBQWMsQ0FBQztRQUNyQyxJQUFJLENBQUMsVUFBVSxHQUFHLE9BQU8sQ0FBQztJQUM1QixDQUFDO0lBRUQsS0FBSyxDQUFDLGNBQWM7UUFDbEIsTUFBTSxPQUFPLEdBQUcsTUFBTSxJQUFJLENBQUMsY0FBYyxDQUFDLFdBQVcsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUMzRixPQUFPLE9BQU8sQ0FBQyxVQUFVLENBQUM7SUFDNUIsQ0FBQztJQUVNLEtBQUssQ0FBQyxrQkFBa0IsQ0FBQyxZQUFvQjtRQUNsRCxNQUFNLFVBQVUsR0FBRyxNQUFNLElBQUksQ0FBQyxjQUFjLEVBQUUsQ0FBQztRQUMvQyxNQUFNLFVBQVUsR0FBRyxNQUFNLE9BQU8sQ0FBQyxRQUFRLENBQUMsWUFBWSxFQUFFO1lBQ3RELGFBQWEsRUFBRSxJQUFJLENBQUMsVUFBVSxDQUFDLE1BQU07WUFDckMsUUFBUSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsUUFBUTtZQUNsQyxVQUFVO1lBQ1YsZ0JBQWdCLEVBQUUsaUJBQWlCO1lBQ25DLFNBQVMsRUFBRSxZQUFZO1lBQ3ZCLFFBQVEsRUFBRSxJQUFJLElBQUksRUFBRTtZQUNwQixhQUFhLEVBQUU7Z0JBQ2I7b0JBQ0UsYUFBYSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsTUFBTTtvQkFDckMsUUFBUSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsUUFBUTtvQkFDbEMsVUFBVTtvQkFDVixTQUFTLEVBQUUsWUFBWTtvQkFDdkIsZ0JBQWdCLEVBQUUsaUJBQWlCO2lCQUNwQzthQUNGO1NBQ0YsQ0FBQyxDQUFDO1FBQ0gsTUFBTSxTQUFTLEdBQUcsVUFBVSxDQUFDLFVBQVUsQ0FBQztRQUN4QyxPQUFPLFNBQVMsQ0FBQztJQUNuQixDQUFDO0NBQ0YifQ==
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5lbWFpbHNpZ25qb2IuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2NsYXNzZXMuZW1haWxzaWduam9iLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sOENBQThDLENBQUM7QUFhbEYsTUFBTSxPQUFPLFlBQVk7SUFDdkIsY0FBYyxDQUFxQjtJQUNuQyxVQUFVLENBQXVCO0lBRWpDLFlBQVksY0FBa0MsRUFBRSxPQUE2QjtRQUMzRSxJQUFJLENBQUMsY0FBYyxHQUFHLGNBQWMsQ0FBQztRQUNyQyxJQUFJLENBQUMsVUFBVSxHQUFHLE9BQU8sQ0FBQztJQUM1QixDQUFDO0lBRUQsS0FBSyxDQUFDLGNBQWM7UUFDbEIsTUFBTSxPQUFPLEdBQUcsTUFBTSxJQUFJLENBQUMsY0FBYyxDQUFDLFdBQVcsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUMzRixPQUFPLE9BQU8sQ0FBQyxVQUFVLENBQUM7SUFDNUIsQ0FBQztJQUVNLEtBQUssQ0FBQyxrQkFBa0IsQ0FBQyxZQUFvQjtRQUNsRCxNQUFNLFVBQVUsR0FBRyxNQUFNLElBQUksQ0FBQyxjQUFjLEVBQUUsQ0FBQztRQUMvQyxNQUFNLE1BQU0sR0FBRyxrQkFBa0IsQ0FBQyxXQUFXLEVBQUUsQ0FBQztRQUNoRCxNQUFNLFVBQVUsR0FBRyxNQUFNLE1BQU0sQ0FBQyxRQUFRLENBQUM7WUFDdkMsVUFBVSxFQUFFLFlBQVk7WUFDeEIsTUFBTSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsTUFBTTtZQUM5QixRQUFRLEVBQUUsSUFBSSxDQUFDLFVBQVUsQ0FBQyxRQUFRO1lBQ2xDLFVBQVU7U0FDWCxDQUFDLENBQUM7UUFDSCxPQUFPLFVBQVUsQ0FBQyxNQUFNLENBQUM7SUFDM0IsQ0FBQztDQUNGIn0=

File diff suppressed because one or more lines are too long

View File

@@ -8,5 +8,4 @@ export type { IRateLimitConfig } from './classes.ratelimiter.js';
export * from './classes.unified.rate.limiter.js';
export * from './classes.mta.config.js';
import * as smtpClientMod from './smtpclient/index.js';
import * as smtpServerMod from './smtpserver/index.js';
export { smtpClientMod, smtpServerMod };
export { smtpClientMod };

View File

@@ -13,6 +13,5 @@ export * from './classes.unified.rate.limiter.js';
export * from './classes.mta.config.js';
// Import and export SMTP modules as namespaces to avoid conflicts
import * as smtpClientMod from './smtpclient/index.js';
import * as smtpServerMod from './smtpserver/index.js';
export { smtpClientMod, smtpServerMod };
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDRCQUE0QjtBQUM1QixjQUFjLDJCQUEyQixDQUFDO0FBQzFDLGNBQWMsNkJBQTZCLENBQUM7QUFDNUMsY0FBYyw4QkFBOEIsQ0FBQztBQUU3Qyx1Q0FBdUM7QUFDdkMsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBQ3pELE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSw4QkFBOEIsQ0FBQztBQUU5RCw2Q0FBNkM7QUFDN0MsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBR3ZELHVCQUF1QjtBQUN2QixjQUFjLG1DQUFtQyxDQUFDO0FBRWxELGdDQUFnQztBQUNoQyxjQUFjLHlCQUF5QixDQUFDO0FBRXhDLGtFQUFrRTtBQUNsRSxPQUFPLEtBQUssYUFBYSxNQUFNLHVCQUF1QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxhQUFhLE1BQU0sdUJBQXVCLENBQUM7QUFFdkQsT0FBTyxFQUFFLGFBQWEsRUFBRSxhQUFhLEVBQUUsQ0FBQyJ9
export { smtpClientMod };
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDRCQUE0QjtBQUM1QixjQUFjLDJCQUEyQixDQUFDO0FBQzFDLGNBQWMsNkJBQTZCLENBQUM7QUFDNUMsY0FBYyw4QkFBOEIsQ0FBQztBQUU3Qyx1Q0FBdUM7QUFDdkMsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBQ3pELE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSw4QkFBOEIsQ0FBQztBQUU5RCw2Q0FBNkM7QUFDN0MsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBR3ZELHVCQUF1QjtBQUN2QixjQUFjLG1DQUFtQyxDQUFDO0FBRWxELGdDQUFnQztBQUNoQyxjQUFjLHlCQUF5QixDQUFDO0FBRXhDLGtFQUFrRTtBQUNsRSxPQUFPLEtBQUssYUFBYSxNQUFNLHVCQUF1QixDQUFDO0FBRXZELE9BQU8sRUFBRSxhQUFhLEVBQUUsQ0FBQyJ9

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 { DKIMCreator } from '../security/classes.dkimcreator.js';
interface IIPWarmupConfig {
@@ -154,19 +153,24 @@ export declare class UnifiedEmailServer extends EventEmitter {
* Start the unified email server
*/
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(): Promise<void>;
/**
* Verify inbound email security (DKIM/SPF/DMARC) using the Rust bridge.
* Falls back gracefully if the bridge is not running.
* Handle an emailReceived event from the Rust SMTP server.
* Decodes the email data, processes it through the routing system,
* and sends back the result via the correlation-ID callback.
*/
private handleRustEmailReceived;
/**
* Handle an authRequest event from the Rust SMTP server.
* Validates credentials and sends back the result.
*/
private handleRustAuthRequest;
/**
* Verify inbound email security (DKIM/SPF/DMARC) using pre-computed Rust results
* or falling back to IPC call if no pre-computed results are available.
*/
private verifyInboundSecurity;
/**

File diff suppressed because one or more lines are too long

View File

@@ -11,36 +11,19 @@ export interface IDkimVerificationResult {
signatureFields?: Record<string, string>;
}
/**
* Enhanced DKIM verifier using smartmail capabilities
* DKIM verifier — delegates to the Rust security bridge.
*/
export declare class DKIMVerifier {
private verificationCache;
private cacheTtl;
constructor();
/**
* Verify DKIM signature for an email
* @param emailData The raw email data
* @param options Verification options
* @returns Verification result
* Verify DKIM signature for an email via Rust bridge
*/
verify(emailData: string, options?: {
useCache?: boolean;
returnDetails?: boolean;
}): Promise<IDkimVerificationResult>;
/**
* Fetch DKIM public key from DNS
* @param domain The domain
* @param selector The DKIM selector
* @returns The DKIM public key or null if not found
*/
private fetchDkimKey;
/**
* Clear the verification cache
*/
/** No-op — Rust bridge handles its own caching */
clearCache(): void;
/**
* Get the size of the verification cache
* @returns Number of cached items
*/
/** Always 0 — cache is managed by the Rust side */
getCacheSize(): number;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -50,54 +50,22 @@ export interface SpfResult {
error?: string;
}
/**
* Class for verifying SPF records
* Class for verifying SPF records.
* Delegates actual SPF evaluation to the Rust security bridge.
* Retains parseSpfRecord() for lightweight local parsing.
*/
export declare class SpfVerifier {
private dnsManager?;
private lookupCount;
constructor(dnsManager?: any);
constructor(_dnsManager?: any);
/**
* Parse SPF record from TXT record
* @param record SPF TXT record
* @returns Parsed SPF record or null if invalid
* Parse SPF record from TXT record (pure string parsing, no DNS)
*/
parseSpfRecord(record: string): SpfRecord | null;
/**
* Check if IP is in CIDR range
* @param ip IP address to check
* @param cidr CIDR range
* @returns Whether the IP is in the CIDR range
*/
private isIpInCidr;
/**
* Check if a domain has the specified IP in its A or AAAA records
* @param domain Domain to check
* @param ip IP address to check
* @returns Whether the domain resolves to the IP
*/
private isDomainResolvingToIp;
/**
* Verify SPF for a given email with IP and helo domain
* @param email Email to verify
* @param ip Sender IP address
* @param heloDomain HELO/EHLO domain used by sender
* @returns SPF verification result
* Verify SPF for a given email — delegates to Rust bridge
*/
verify(email: Email, ip: string, heloDomain: string): Promise<SpfResult>;
/**
* Check SPF record against IP address
* @param spfRecord Parsed SPF record
* @param domain Domain being checked
* @param ip IP address to check
* @returns SPF result
*/
private checkSpfRecord;
/**
* Check if email passes SPF verification
* @param email Email to verify
* @param ip Sender IP address
* @param heloDomain HELO/EHLO domain used by sender
* @returns Whether email passes SPF
* Check if email passes SPF verification and apply headers
*/
verifyAndApply(email: Email, ip: string, heloDomain: string): Promise<boolean>;
}

File diff suppressed because one or more lines are too long

View File

@@ -42,9 +42,6 @@ import * as cloudflare from '@apiclient.xyz/cloudflare';
export { cloudflare, };
import * as tsclass from '@tsclass/tsclass';
export { tsclass, };
import * as mailauth from 'mailauth';
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
import mailparser from 'mailparser';
import * as uuid from 'uuid';
import * as ip from 'ip';
export { mailauth, dkimSign, mailparser, uuid, ip, };
export { mailparser, uuid, };

View File

@@ -48,10 +48,7 @@ export { cloudflare, };
import * as tsclass from '@tsclass/tsclass';
export { tsclass, };
// third party
import * as mailauth from 'mailauth';
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
import mailparser from 'mailparser';
import * as uuid from 'uuid';
import * as ip from 'ip';
export { mailauth, dkimSign, mailparser, uuid, ip, };
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsRUFBRSxFQUNGLE1BQU0sRUFDTixJQUFJLEVBQ0osR0FBRyxFQUNILEVBQUUsRUFDRixJQUFJLEVBQ0osR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxtQkFBbUIsTUFBTSx3QkFBd0IsQ0FBQztBQUU5RCxPQUFPLEVBQ0wsbUJBQW1CLEVBQ3BCLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssV0FBVyxNQUFNLHlCQUF5QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFFdkQsT0FBTyxFQUNMLFlBQVksRUFDWixXQUFXLEVBQ1gsV0FBVyxHQUNaLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxFQUFFLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBQ25FLE9BQU8sS0FBSyxVQUFVLE1BQU0sd0JBQXdCLENBQUM7QUFDckQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFVBQVUsTUFBTSx3QkFBd0IsQ0FBQztBQUNyRCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBQ25ELE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFDL0MsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUV2RCxNQUFNLENBQUMsTUFBTSxPQUFPLEdBQUcsSUFBSSxPQUFPLENBQUMsSUFBSSxtQkFBbUIsRUFBRSxDQUFDLENBQUM7QUFFOUQsT0FBTyxFQUFFLFdBQVcsRUFBRSxJQUFJLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLE9BQU8sRUFBRSxVQUFVLEVBQUUsUUFBUSxFQUFFLFFBQVEsRUFBRSxTQUFTLEVBQUUsWUFBWSxFQUFFLFlBQVksRUFBRSxTQUFTLEVBQUUsVUFBVSxFQUFFLFlBQVksRUFBRSxZQUFZLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsV0FBVyxFQUFFLENBQUM7QUFLdlAsc0JBQXNCO0FBQ3RCLE9BQU8sS0FBSyxVQUFVLE1BQU0sMkJBQTJCLENBQUM7QUFFeEQsT0FBTyxFQUNMLFVBQVUsR0FDWCxDQUFBO0FBRUQsZ0JBQWdCO0FBQ2hCLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUNMLE9BQU8sR0FDUixDQUFBO0FBRUQsY0FBYztBQUNkLE9BQU8sS0FBSyxRQUFRLE1BQU0sVUFBVSxDQUFDO0FBQ3JDLE9BQU8sRUFBRSxRQUFRLEVBQUUsTUFBTSwyQkFBMkIsQ0FBQztBQUNyRCxPQUFPLFVBQVUsTUFBTSxZQUFZLENBQUM7QUFDcEMsT0FBTyxLQUFLLElBQUksTUFBTSxNQUFNLENBQUM7QUFDN0IsT0FBTyxLQUFLLEVBQUUsTUFBTSxJQUFJLENBQUM7QUFFekIsT0FBTyxFQUNMLFFBQVEsRUFDUixRQUFRLEVBQ1IsVUFBVSxFQUNWLElBQUksRUFDSixFQUFFLEdBQ0gsQ0FBQSJ9
export { mailparser, uuid, };
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsRUFBRSxFQUNGLE1BQU0sRUFDTixJQUFJLEVBQ0osR0FBRyxFQUNILEVBQUUsRUFDRixJQUFJLEVBQ0osR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxtQkFBbUIsTUFBTSx3QkFBd0IsQ0FBQztBQUU5RCxPQUFPLEVBQ0wsbUJBQW1CLEVBQ3BCLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssV0FBVyxNQUFNLHlCQUF5QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFFdkQsT0FBTyxFQUNMLFlBQVksRUFDWixXQUFXLEVBQ1gsV0FBVyxHQUNaLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxFQUFFLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBQ25FLE9BQU8sS0FBSyxVQUFVLE1BQU0sd0JBQXdCLENBQUM7QUFDckQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFVBQVUsTUFBTSx3QkFBd0IsQ0FBQztBQUNyRCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBQ25ELE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFDL0MsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUV2RCxNQUFNLENBQUMsTUFBTSxPQUFPLEdBQUcsSUFBSSxPQUFPLENBQUMsSUFBSSxtQkFBbUIsRUFBRSxDQUFDLENBQUM7QUFFOUQsT0FBTyxFQUFFLFdBQVcsRUFBRSxJQUFJLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLE9BQU8sRUFBRSxVQUFVLEVBQUUsUUFBUSxFQUFFLFFBQVEsRUFBRSxTQUFTLEVBQUUsWUFBWSxFQUFFLFlBQVksRUFBRSxTQUFTLEVBQUUsVUFBVSxFQUFFLFlBQVksRUFBRSxZQUFZLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsV0FBVyxFQUFFLENBQUM7QUFLdlAsc0JBQXNCO0FBQ3RCLE9BQU8sS0FBSyxVQUFVLE1BQU0sMkJBQTJCLENBQUM7QUFFeEQsT0FBTyxFQUNMLFVBQVUsR0FDWCxDQUFBO0FBRUQsZ0JBQWdCO0FBQ2hCLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUNMLE9BQU8sR0FDUixDQUFBO0FBRUQsY0FBYztBQUNkLE9BQU8sVUFBVSxNQUFNLFlBQVksQ0FBQztBQUNwQyxPQUFPLEtBQUssSUFBSSxNQUFNLE1BQU0sQ0FBQztBQUU3QixPQUFPLEVBQ0wsVUFBVSxFQUNWLElBQUksR0FDTCxDQUFBIn0=

View File

@@ -54,9 +54,6 @@ export declare class ContentScanner {
private static instance;
private scanCache;
private options;
private static readonly MALICIOUS_PATTERNS;
private static readonly EXECUTABLE_EXTENSIONS;
private static readonly MACRO_DOCUMENT_EXTENSIONS;
/**
* Default options for the content scanner
*/
@@ -73,7 +70,9 @@ export declare class ContentScanner {
*/
static getInstance(options?: IContentScannerOptions): ContentScanner;
/**
* Scan an email for malicious content
* Scan an email for malicious content.
* Delegates text/subject/html/filename pattern scanning to Rust.
* Binary attachment scanning (PE headers, VBA macros) stays in TS.
* @param email The email to scan
* @returns Scan result
*/
@@ -85,41 +84,19 @@ export declare class ContentScanner {
*/
private generateCacheKey;
/**
* Scan email subject for threats
* @param subject The subject to scan
* @param result The scan result to update
*/
private scanSubject;
/**
* Scan plain text content for threats
* @param text The text content to scan
* @param result The scan result to update
*/
private scanTextContent;
/**
* Scan HTML content for threats
* @param html The HTML content to scan
* @param result The scan result to update
*/
private scanHtmlContent;
/**
* Scan an attachment for threats
* Scan attachment binary content for PE headers and VBA macros.
* This stays in TS because it accesses raw Buffer data (too large for IPC).
* @param attachment The attachment to scan
* @param result The scan result to update
*/
private scanAttachment;
private scanAttachmentBinary;
/**
* Extract links from HTML content
* @param html HTML content
* @returns Array of extracted links
* Apply custom rules (runtime-configured patterns) to the email.
* These stay in TS because they are configured at runtime.
* @param email The email to check
* @param result The scan result to update
*/
private extractLinksFromHtml;
/**
* Extract plain text from HTML
* @param html HTML content
* @returns Extracted text
*/
private extractTextFromHtml;
private applyCustomRules;
/**
* Extract text from a binary buffer for scanning
* @param buffer Binary content
@@ -128,17 +105,10 @@ export declare class ContentScanner {
private extractTextFromBuffer;
/**
* Check if an Office document likely contains macros
* This is a simplified check - real implementation would use specialized libraries
* @param attachment The attachment to check
* @returns Whether the file likely contains macros
*/
private likelyContainsMacros;
/**
* Map a pattern category to a threat type
* @param category The pattern category
* @returns The corresponding threat type
*/
private mapCategoryToThreatType;
/**
* Log a high threat finding to the security logger
* @param email The email containing the threat

File diff suppressed because one or more lines are too long

View File

@@ -48,103 +48,26 @@ export interface IIPReputationOptions {
enableIPInfo?: boolean;
}
/**
* Class for checking IP reputation of inbound email senders
* IP reputation checker — delegates DNSBL lookups to the Rust security bridge.
* Retains LRU caching and disk persistence in TypeScript.
*/
export declare class IPReputationChecker {
private static instance;
private reputationCache;
private options;
private storageManager?;
private static readonly DEFAULT_DNSBL_SERVERS;
private static readonly DEFAULT_OPTIONS;
/**
* Constructor for IPReputationChecker
* @param options Configuration options
* @param storageManager Optional StorageManager instance for persistence
*/
constructor(options?: IIPReputationOptions, storageManager?: any);
/**
* Get the singleton instance of the checker
* @param options Configuration options
* @param storageManager Optional StorageManager instance for persistence
* @returns Singleton instance
*/
static getInstance(options?: IIPReputationOptions, storageManager?: any): IPReputationChecker;
/**
* Check an IP address's reputation
* @param ip IP address to check
* @returns Reputation check result
* Check an IP address's reputation via the Rust bridge
*/
checkReputation(ip: string): Promise<IReputationResult>;
/**
* Check an IP against DNS blacklists
* @param ip IP address to check
* @returns DNSBL check results
*/
private checkDNSBL;
/**
* Get information about an IP address
* @param ip IP address to check
* @returns IP information
*/
private getIPInfo;
/**
* Simplified method to determine country from IP
* In a real implementation, this would use a geolocation database or service
* @param ip IP address
* @returns Country code
*/
private determineCountry;
/**
* Simplified method to determine organization from IP
* In a real implementation, this would use an IP-to-org database or service
* @param ip IP address
* @returns Organization name
*/
private determineOrg;
/**
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
* @param ip IP address to reverse
* @returns Reversed IP for DNSBL queries
*/
private reverseIP;
/**
* Create an error result for when reputation check fails
* @param ip IP address
* @param errorMessage Error message
* @returns Error result
*/
private createErrorResult;
/**
* Validate IP address format
* @param ip IP address to validate
* @returns Whether the IP is valid
*/
private isValidIPAddress;
/**
* Log reputation check to security logger
* @param ip IP address
* @param result Reputation result
*/
private logReputationCheck;
/**
* Save cache to disk or storage manager
*/
private saveCache;
/**
* Load cache from disk or storage manager
*/
private loadCache;
/**
* Get the risk level for a reputation score
* @param score Reputation score (0-100)
* @returns Risk level description
*/
static getRiskLevel(score: number): 'high' | 'medium' | 'low' | 'trusted';
/**
* Update the storage manager after instantiation
* This is useful when the storage manager is not available at construction time
* @param storageManager The StorageManager instance to use
*/
updateStorageManager(storageManager: any): void;
}

File diff suppressed because one or more lines are too long

View File

@@ -48,12 +48,65 @@ interface IReputationResult {
listed_count: number;
total_checked: number;
}
interface IContentScanResult {
threatScore: number;
threatType: string | null;
threatDetails: string | null;
scannedElements: string[];
}
interface IVersionInfo {
bin: string;
core: string;
security: string;
smtp: string;
}
interface ISmtpServerConfig {
hostname: string;
ports: number[];
securePort?: number;
tlsCertPem?: string;
tlsKeyPem?: string;
maxMessageSize?: number;
maxConnections?: number;
maxRecipients?: number;
connectionTimeoutSecs?: number;
dataTimeoutSecs?: number;
authEnabled?: boolean;
maxAuthFailures?: number;
socketTimeoutSecs?: number;
processingTimeoutSecs?: number;
rateLimits?: IRateLimitConfig;
}
interface IRateLimitConfig {
maxConnectionsPerIp?: number;
maxMessagesPerSender?: number;
maxAuthFailuresPerIp?: number;
windowSecs?: number;
}
interface IEmailData {
type: 'inline' | 'file';
base64?: string;
path?: string;
}
interface IEmailReceivedEvent {
correlationId: string;
sessionId: string;
mailFrom: string;
rcptTo: string[];
data: IEmailData;
remoteAddr: string;
clientHostname: string | null;
secure: boolean;
authenticatedUser: string | null;
securityResults: any | null;
}
interface IAuthRequestEvent {
correlationId: string;
sessionId: string;
username: string;
password: string;
remoteAddr: string;
}
/**
* Bridge between TypeScript and the Rust `mailer-bin` binary.
*
@@ -88,6 +141,13 @@ export declare class RustSecurityBridge {
diagnosticCode?: string;
statusCode?: string;
}): Promise<IBounceDetection>;
/** Scan email content for threats (phishing, spam, malware, etc.). */
scanContent(opts: {
subject?: string;
textBody?: string;
htmlBody?: string;
attachmentNames?: string[];
}): Promise<IContentScanResult>;
/** Check IP reputation via DNSBL. */
checkIpReputation(ip: string): Promise<IReputationResult>;
/** Verify DKIM signatures on a raw email message. */
@@ -122,5 +182,48 @@ export declare class RustSecurityBridge {
hostname?: string;
mailFrom: string;
}): Promise<IEmailSecurityResult>;
/**
* Start the Rust SMTP server.
* The server will listen on the configured ports and emit events for
* emailReceived and authRequest that must be handled by the caller.
*/
startSmtpServer(config: ISmtpServerConfig): Promise<boolean>;
/** Stop the Rust SMTP server. */
stopSmtpServer(): Promise<void>;
/**
* Send the result of email processing back to the Rust SMTP server.
* This resolves a pending correlation-ID callback, allowing the Rust
* server to send the SMTP response to the client.
*/
sendEmailProcessingResult(opts: {
correlationId: string;
accepted: boolean;
smtpCode?: number;
smtpMessage?: string;
}): Promise<void>;
/**
* Send the result of authentication validation back to the Rust SMTP server.
*/
sendAuthResult(opts: {
correlationId: string;
success: boolean;
message?: string;
}): Promise<void>;
/** Update rate limit configuration at runtime. */
configureRateLimits(config: IRateLimitConfig): Promise<void>;
/**
* Register a handler for emailReceived events from the Rust SMTP server.
* These events fire when a complete email has been received and needs processing.
*/
onEmailReceived(handler: (data: IEmailReceivedEvent) => void): void;
/**
* Register a handler for authRequest events from the Rust SMTP server.
* The handler must call sendAuthResult() with the correlationId.
*/
onAuthRequest(handler: (data: IAuthRequestEvent) => void): void;
/** Remove an emailReceived event handler. */
offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void;
/** Remove an authRequest event handler. */
offAuthRequest(handler: (data: IAuthRequestEvent) => void): void;
}
export type { IDkimVerificationResult, ISpfResult, IDmarcResult, IEmailSecurityResult, IValidationResult, IBounceDetection, IReputationResult as IRustReputationResult, IVersionInfo, };
export type { IDkimVerificationResult, ISpfResult, IDmarcResult, IEmailSecurityResult, IValidationResult, IBounceDetection, IContentScanResult, IReputationResult as IRustReputationResult, IVersionInfo, ISmtpServerConfig, IRateLimitConfig, IEmailData, IEmailReceivedEvent, IAuthRequestEvent, };

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,16 @@
{
"@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",
"version": "2.0.1",
"version": "2.3.0",
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
"keywords": [
"mta",

299
readme.md
View File

@@ -1,6 +1,6 @@
# @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
@@ -14,68 +14,83 @@ pnpm 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
`@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 |
|---|---|
| **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 |
| **DKIM** | Key generation, signing, and verification — per domain |
| **SPF** | Full SPF record validation |
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
| **SPF** | Full SPF record validation via Rust |
| **DMARC** | Policy enforcement and verification |
| **Email Router** | Pattern-based routing with priority, forward/deliver/reject/process actions |
| **Bounce Manager** | Automatic bounce detection, classification (hard/soft), and tracking |
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection |
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring |
| **Bounce Manager** | Automatic bounce detection via Rust, classification (hard/soft), and suppression tracking |
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection — powered by Rust |
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring via Rust |
| **Rate Limiter** | Hierarchical rate limiting (global, per-domain, per-IP) |
| **Delivery Queue** | Persistent queue with exponential backoff retry |
| **Template Engine** | Email templates with variable substitution |
| **Domain Registry** | Multi-domain management with per-domain configuration |
| **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** | Compound email security verification (DKIM+SPF+DMARC) via Rust binary |
| **Rust Security Bridge** | All security ops (DKIM+SPF+DMARC+DNSBL+content scanning) run in Rust via IPC |
### Architecture
### 🏗️ Architecture
```
┌─────────────────────────────────────────────────────────┐
┌──────────────────────────────────────────────────────────────
│ UnifiedEmailServer │
│ (orchestrates all components, emits events) │
├────────────────────┬──────────────────────────────────┤
SMTP │ Email │ Security Delivery │
Server │ Router │ Stack System │
│ ┌─────┐ │ ┌─────┐ │ ┌───────┐ │ ┌────────────────┐ │
│ │ TLS │ │ │Match│ │ │ DKIM │ │ Queue │ │
│ │ Auth│ │ │Route│ │ │ SPF │ │ Rate Limit │ │
│ │ Cmd │ │ │ Act │ │ │ DMARC │ │ SMTP Client │ │
│ Data│ │ │ │ IPRep │ │ Retry Logic │ │
└─────┘ │ └─────┘ │ │ Scan │ │ └────────────────┘ │
│ └───────┘ │ │
├────────────────────┴──────────────────────────────────┤
│ Rust Security Bridge
│ (RustSecurityBridge singleton via smartrust IPC) │
├─────────────────────────────────────────────────────────┤
├───────────┬───────────┬──────────────┬───────────────────────┤
│ Email │ Security Delivery │ Configuration
│ Router │ Stack System
│ ┌─────┐ │ ┌───────┐ │ ┌─────────┐ │ ┌────────────────┐
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry
│ │Route │ │ │ SPF │ │ Rate Lim │ │ │ DnsManager
│ │ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator
└──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates
│ │ Scan │ │ └──────────┘ │ └────────────────┘
│ │ └───────┘
├───────────┴───────────┴──────────────┴───────────────────────┤
│ Rust Security Bridge (smartrust IPC)
├──────────────────────────────────────────────────────────────┤
│ 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
### 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
import { UnifiedEmailServer } from '@push.rocks/smartmta';
const emailServer = new UnifiedEmailServer(dcRouterRef, {
// Ports to listen on (465 = implicit TLS, 25/587 = STARTTLS)
ports: [25, 587, 465],
hostname: 'mail.example.com',
// Multi-domain configuration
domains: [
{
domain: 'example.com',
@@ -87,11 +102,13 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
rotationInterval: 90,
},
rateLimits: {
maxMessagesPerMinute: 100,
maxRecipientsPerMessage: 50,
outbound: { messagesPerMinute: 100 },
inbound: { messagesPerMinute: 200, connectionsPerIp: 20 },
},
},
],
// Routing rules (evaluated by priority, highest first)
routes: [
{
name: 'catch-all-forward',
@@ -122,31 +139,39 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
},
},
],
// Authentication settings for the SMTP server
auth: {
required: false,
methods: ['PLAIN', 'LOGIN'],
users: [{ username: 'outbound', password: 'secret' }],
},
// TLS certificates
tls: {
certPath: '/etc/ssl/mail.crt',
keyPath: '/etc/ssl/mail.key',
},
maxMessageSize: 25 * 1024 * 1024, // 25 MB
maxClients: 500,
});
// start() boots the Rust SMTP server, security bridge, DNS records, and delivery queue
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:
```typescript
import { Email, createSmtpClient } from '@push.rocks/smartmta';
import { Email, Delivery } from '@push.rocks/smartmta';
// Create a client with connection pooling
const client = createSmtpClient({
const client = Delivery.smtpClientMod.createSmtpClient({
host: 'smtp.example.com',
port: 587,
secure: false, // will upgrade via STARTTLS
@@ -181,9 +206,22 @@ const result = await client.sendMail(email);
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
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
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',
// domain: string, ip: string }
// DKIM verification
// DKIM verification — takes raw email content
const dkimVerifier = new DKIMVerifier();
const dkimResult = await dkimVerifier.verify(rawEmailContent);
@@ -232,9 +270,9 @@ const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
// 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
import { EmailRouter } from '@push.rocks/smartmta';
@@ -288,9 +326,22 @@ const router = new EmailRouter([
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
import { ContentScanner } from '@push.rocks/smartmta';
@@ -317,14 +368,14 @@ const result = await scanner.scanEmail(email);
// -> { 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
import { IPReputationChecker } from '@push.rocks/smartmta';
const ipChecker = new IPReputationChecker({
const ipChecker = IPReputationChecker.getInstance({
enableDNSBL: true,
dnsblServers: ['zen.spamhaus.org', 'bl.spamcop.net'],
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: [] }
```
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. Configuration uses `maxMessagesPerMinute` and organizes domain-level limits under the `domains` key:
Hierarchical rate limiting to protect your server and maintain deliverability:
```typescript
import { UnifiedRateLimiter } from '@push.rocks/smartmta';
import { Delivery } from '@push.rocks/smartmta';
const { UnifiedRateLimiter } = Delivery;
const rateLimiter = new UnifiedRateLimiter({
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
import { BounceManager } from '@push.rocks/smartmta';
import { Core } from '@push.rocks/smartmta';
const { BounceManager } = Core;
const bounceManager = new BounceManager();
@@ -399,16 +450,17 @@ bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
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
import { TemplateManager } from '@push.rocks/smartmta';
import { Core } from '@push.rocks/smartmta';
const { TemplateManager } = Core;
const templates = new TemplateManager({
from: 'noreply@example.com',
footerHtml: '<p>2026 Example Corp</p>',
footerHtml: '<p>&copy; 2026 Example Corp</p>',
});
// 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
// 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, {
hostname: 'mail.example.com',
domains: [
@@ -454,47 +498,17 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
// ... 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();
```
For DNS lookups and record verification outside of the server lifecycle, the `DNSManager` class (note the capital N) can be used directly:
### 🦀 RustSecurityBridge
```typescript
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`:
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:
```typescript
import { RustSecurityBridge } from '@push.rocks/smartmta';
@@ -511,7 +525,7 @@ const securityResult = await bridge.verifyEmail({
});
// -> { dkim: [...], spf: { result, explanation }, dmarc: { result, policy } }
// Individual operations
// Individual security operations
const dkimResults = await bridge.verifyDkim(rawEmailString);
const spfResult = await bridge.checkSpf({
ip: '203.0.113.10',
@@ -520,10 +534,61 @@ const spfResult = await bridge.checkSpf({
});
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();
```
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
@@ -534,14 +599,20 @@ smartmta/
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
│ │ │ ├── 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
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
├── rust/ # Rust workspace
│ └── crates/ # mailer-core, mailer-security, mailer-bin, mailer-smtp, mailer-napi
├── test/ # Comprehensive test suite
└── dist_ts/ # Compiled output
│ └── crates/
│ ├── mailer-core/ # Email types, validation, MIME, bounce detection
│ ├── 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

View File

@@ -1,198 +1,24 @@
# Mailer Implementation Plan & Progress
# Rust Migration Plan
## Project Goals
## Completed Phases
Build a Deno-based mail server package (`@serve.zone/mailer`) with:
1. CLI interface similar to nupst/spark
2. SMTP server and client (ported from dcrouter)
3. HTTP REST API (Mailgun-compatible)
4. Automatic DNS management via Cloudflare
5. Systemd daemon service
6. Binary distribution via npm
### Phase 3: Rust Primary Backend (DKIM/SPF/DMARC/IP Reputation)
- Rust is the mandatory security backend — no TS fallbacks
- All DKIM signing/verification, SPF, DMARC, IP reputation through Rust bridge
## Completed Work
### Phase 5: BounceManager + ContentScanner
- BounceManager bounce detection delegated to Rust `detectBounce` IPC command
- ContentScanner pattern matching delegated to new Rust `scanContent` IPC command
- New module: `rust/crates/mailer-security/src/content_scanner.rs` (10 Rust tests)
- ~215 lines removed from BounceManager, ~350 lines removed from ContentScanner
- Binary attachment scanning (PE headers, VBA macros) stays in TS
- Custom rules (runtime-configured) stay in TS
- Net change: ~-560 TS lines, +265 Rust lines
### ✅ Phase 1: Project Structure
- [x] Created Deno-based project structure (deno.json, package.json)
- [x] Set up bin/ wrappers for npm binary distribution
- [x] Created compilation scripts (compile-all.sh)
- [x] Set up install scripts (install-binary.js)
- [x] Created TypeScript source directory structure
## Deferred
### ✅ Phase 2: Mail Implementation (Ported from dcrouter)
- [x] Copied and adapted mail/core/ (Email, EmailValidator, BounceManager, TemplateManager)
- [x] Copied and adapted mail/delivery/ (SMTP client, SMTP server, queues, rate limiting)
- [x] Copied and adapted mail/routing/ (EmailRouter, DomainRegistry, DnsManager)
- [x] Copied and adapted mail/security/ (DKIM, SPF, DMARC)
- [x] Fixed all imports from .js to .ts extensions
- [x] Created stub modules for dcrouter dependencies (storage, security, deliverability, errors)
### ✅ Phase 3: Supporting Modules
- [x] Created logger module (simple console logging)
- [x] Created paths module (project paths)
- [x] Created plugins.ts (Deno dependencies + Node.js compatibility)
- [x] Added required npm dependencies (lru-cache, mailaddress-validator, cloudflare)
### ✅ Phase 4: DNS Management
- [x] Created DnsManager class with DNS record generation
- [x] Created CloudflareClient for automatic DNS setup
- [x] Added DNS validation functionality
### ✅ Phase 5: HTTP API
- [x] Created ApiServer class with basic routing
- [x] Implemented Mailgun-compatible endpoint structure
- [x] Added authentication and rate limiting stubs
### ✅ Phase 6: Configuration Management
- [x] Created ConfigManager for JSON-based config storage
- [x] Added domain configuration support
- [x] Implemented config load/save functionality
### ✅ Phase 7: Daemon Service
- [x] Created DaemonManager to coordinate SMTP server and API server
- [x] Added start/stop functionality
- [x] Integrated with ConfigManager
### ✅ Phase 8: CLI Interface
- [x] Created MailerCli class with command routing
- [x] Implemented service commands (start/stop/restart/status/enable/disable)
- [x] Implemented domain commands (add/remove/list)
- [x] Implemented DNS commands (setup/validate/show)
- [x] Implemented send command
- [x] Implemented config commands (show/set)
- [x] Added help and version commands
### ✅ Phase 9: Documentation
- [x] Created comprehensive README.md
- [x] Documented all CLI commands
- [x] Documented HTTP API endpoints
- [x] Provided configuration examples
- [x] Documented DNS requirements
- [x] Created changelog
## Next Steps (Remaining Work)
### Testing & Debugging
1. Fix remaining import/dependency issues
2. Test compilation with `deno compile`
3. Test CLI commands end-to-end
4. Test SMTP sending/receiving
5. Test HTTP API endpoints
6. Write unit tests
### Systemd Integration
1. Create systemd service file
2. Implement service enable/disable
3. Add service status checking
4. Test daemon auto-restart
### Cloudflare Integration
1. Test actual Cloudflare API calls
2. Handle Cloudflare errors gracefully
3. Add zone detection
4. Verify DNS record creation
### Production Readiness
1. Add proper error handling throughout
2. Implement logging to files
3. Add rate limiting implementation
4. Implement API key authentication
5. Add TLS certificate management
6. Implement email queue persistence
### Advanced Features
1. Webhook support for incoming emails
2. Email template system
3. Analytics and reporting
4. SMTP credential management
5. Email event tracking
6. Bounce handling
## Known Issues
1. Some npm dependencies may need version adjustments
2. Deno crypto APIs may need adaptation for DKIM signing
3. Buffer vs Uint8Array conversions may be needed
4. Some dcrouter-specific code may need further adaptation
## File Structure Overview
```
mailer/
├── README.md ✅ Complete
├── license ✅ Complete
├── changelog.md ✅ Complete
├── deno.json ✅ Complete
├── package.json ✅ Complete
├── mod.ts ✅ Complete
├── bin/
│ └── mailer-wrapper.js ✅ Complete
├── scripts/
│ ├── compile-all.sh ✅ Complete
│ └── install-binary.js ✅ Complete
└── ts/
├── 00_commitinfo_data.ts ✅ Complete
├── index.ts ✅ Complete
├── cli.ts ✅ Complete
├── plugins.ts ✅ Complete
├── logger.ts ✅ Complete
├── paths.ts ✅ Complete
├── classes.mailer.ts ✅ Complete
├── cli/
│ ├── index.ts ✅ Complete
│ └── mailer-cli.ts ✅ Complete
├── api/
│ ├── index.ts ✅ Complete
│ ├── api-server.ts ✅ Complete
│ └── routes/ ✅ Structure ready
├── dns/
│ ├── index.ts ✅ Complete
│ ├── dns-manager.ts ✅ Complete
│ └── cloudflare-client.ts ✅ Complete
├── daemon/
│ ├── index.ts ✅ Complete
│ └── daemon-manager.ts ✅ Complete
├── config/
│ ├── index.ts ✅ Complete
│ └── config-manager.ts ✅ Complete
├── storage/
│ └── index.ts ✅ Stub complete
├── security/
│ └── index.ts ✅ Stub complete
├── deliverability/
│ └── index.ts ✅ Stub complete
├── errors/
│ └── index.ts ✅ Stub complete
└── mail/ ✅ Ported from dcrouter
├── core/ ✅ Complete
├── delivery/ ✅ Complete
├── routing/ ✅ Complete
└── security/ ✅ Complete
```
## Summary
The mailer package structure is **95% complete**. All major components have been implemented:
- Project structure and build system ✅
- Mail implementation ported from dcrouter ✅
- CLI interface ✅
- DNS management ✅
- HTTP API ✅
- Configuration system ✅
- Daemon management ✅
- Documentation ✅
**Remaining work**: Testing, debugging dependency issues, systemd integration, and production hardening.
| Component | Rationale |
|-----------|-----------|
| EmailValidator | Already thin; uses smartmail; minimal gain |
| DNS record generation | Pure string building; zero benefit from Rust |
| MIME building (`toRFC822String`) | Sync in TS, async via IPC; too much blast radius |

20
rust/Cargo.lock generated
View File

@@ -1005,6 +1005,7 @@ name = "mailer-bin"
version = "0.1.0"
dependencies = [
"clap",
"dashmap",
"hickory-resolver 0.25.2",
"mailer-core",
"mailer-security",
@@ -1054,6 +1055,7 @@ dependencies = [
"mail-auth",
"mailer-core",
"psl",
"regex",
"ring",
"rustls-pki-types",
"serde",
@@ -1067,15 +1069,24 @@ dependencies = [
name = "mailer-smtp"
version = "0.1.0"
dependencies = [
"base64",
"bytes",
"dashmap",
"hickory-resolver 0.25.2",
"mailer-core",
"mailer-security",
"mailparse",
"regex",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-rustls",
"tracing",
"uuid",
]
[[package]]
@@ -1490,6 +1501,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"

View File

@@ -18,3 +18,4 @@ serde.workspace = true
serde_json.workspace = true
clap.workspace = true
hickory-resolver.workspace = true
dashmap.workspace = true

View File

@@ -1,4 +1,4 @@
//! mailer-bin: CLI and IPC binary for the @serve.zone/mailer Rust crates.
//! mailer-bin: CLI and IPC binary for the @push.rocks/smartmta Rust crates.
//!
//! Supports two modes:
//! 1. **CLI mode** — traditional subcommands for testing and standalone use
@@ -6,9 +6,16 @@
//! integration with `@push.rocks/smartrust` from TypeScript
use clap::{Parser, Subcommand};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::io::{self, BufRead, Write};
use std::net::IpAddr;
use std::sync::Arc;
use tokio::sync::oneshot;
use mailer_smtp::connection::{
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult,
};
/// mailer-bin: Rust-powered email security tools
#[derive(Parser)]
@@ -105,6 +112,43 @@ struct IpcEvent {
data: serde_json::Value,
}
// --- Pending callbacks for correlation-ID based reverse calls ---
/// Stores oneshot senders for pending email processing and auth callbacks.
struct PendingCallbacks {
email: DashMap<String, oneshot::Sender<EmailProcessingResult>>,
auth: DashMap<String, oneshot::Sender<AuthResult>>,
}
impl PendingCallbacks {
fn new() -> Self {
Self {
email: DashMap::new(),
auth: DashMap::new(),
}
}
}
impl CallbackRegistry for PendingCallbacks {
fn register_email_callback(
&self,
correlation_id: &str,
) -> oneshot::Receiver<EmailProcessingResult> {
let (tx, rx) = oneshot::channel();
self.email.insert(correlation_id.to_string(), tx);
rx
}
fn register_auth_callback(
&self,
correlation_id: &str,
) -> oneshot::Receiver<AuthResult> {
let (tx, rx) = oneshot::channel();
self.auth.insert(correlation_id.to_string(), tx);
rx
}
}
fn main() {
let cli = Cli::parse();
@@ -278,7 +322,17 @@ fn main() {
use std::io::Read;
/// Shared state for the management mode.
struct ManagementState {
callbacks: Arc<PendingCallbacks>,
smtp_handle: Option<mailer_smtp::server::SmtpServerHandle>,
smtp_event_rx: Option<tokio::sync::mpsc::Receiver<ConnectionEvent>>,
}
/// Run in management/IPC mode for smartrust bridge.
///
/// This mode supports both request/response IPC (existing commands) and
/// long-running SMTP server with event-based callbacks.
fn run_management_mode() {
// Signal readiness
let ready_event = IpcEvent {
@@ -294,17 +348,40 @@ fn run_management_mode() {
let rt = tokio::runtime::Runtime::new().unwrap();
let stdin = io::stdin();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
let callbacks = Arc::new(PendingCallbacks::new());
let mut state = ManagementState {
callbacks: callbacks.clone(),
smtp_handle: None,
smtp_event_rx: None,
};
if line.trim().is_empty() {
continue;
}
// We need to read stdin in a separate thread (blocking I/O)
// and process commands + SMTP events in the tokio runtime.
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel::<String>(256);
// Spawn stdin reader thread
std::thread::spawn(move || {
let stdin = io::stdin();
for line in stdin.lock().lines() {
match line {
Ok(l) if !l.trim().is_empty() => {
if cmd_tx.blocking_send(l).is_err() {
break;
}
}
Ok(_) => continue,
Err(_) => break,
}
}
});
rt.block_on(async {
loop {
// Select between stdin commands and SMTP server events
tokio::select! {
cmd = cmd_rx.recv() => {
match cmd {
Some(line) => {
let req: IpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
@@ -314,19 +391,110 @@ fn run_management_mode() {
result: None,
error: Some(format!("Invalid request: {}", e)),
};
println!("{}", serde_json::to_string(&resp).unwrap());
io::stdout().flush().unwrap();
emit_line(&serde_json::to_string(&resp).unwrap());
continue;
}
};
let response = rt.block_on(handle_ipc_request(&req));
println!("{}", serde_json::to_string(&response).unwrap());
io::stdout().flush().unwrap();
let response = handle_ipc_request(&req, &mut state).await;
emit_line(&serde_json::to_string(&response).unwrap());
}
None => {
// stdin closed — shut down
if let Some(handle) = state.smtp_handle.take() {
handle.shutdown().await;
}
break;
}
}
}
event = async {
if let Some(rx) = &mut state.smtp_event_rx {
rx.recv().await
} else {
// No SMTP server running — wait forever (yields to other branch)
std::future::pending::<Option<ConnectionEvent>>().await
}
} => {
if let Some(event) = event {
handle_smtp_event(event);
}
}
}
}
});
}
/// Emit a line to stdout and flush.
fn emit_line(line: &str) {
let stdout = io::stdout();
let mut handle = stdout.lock();
let _ = writeln!(handle, "{}", line);
let _ = handle.flush();
}
/// Emit an IPC event to stdout.
fn emit_event(event_name: &str, data: serde_json::Value) {
let event = IpcEvent {
event: event_name.to_string(),
data,
};
emit_line(&serde_json::to_string(&event).unwrap());
}
/// Handle a connection event from the SMTP server.
fn handle_smtp_event(event: ConnectionEvent) {
match event {
ConnectionEvent::EmailReceived {
correlation_id,
session_id,
mail_from,
rcpt_to,
data,
remote_addr,
client_hostname,
secure,
authenticated_user,
security_results,
} => {
emit_event(
"emailReceived",
serde_json::json!({
"correlationId": correlation_id,
"sessionId": session_id,
"mailFrom": mail_from,
"rcptTo": rcpt_to,
"data": data,
"remoteAddr": remote_addr,
"clientHostname": client_hostname,
"secure": secure,
"authenticatedUser": authenticated_user,
"securityResults": security_results,
}),
);
}
ConnectionEvent::AuthRequest {
correlation_id,
session_id,
username,
password,
remote_addr,
} => {
emit_event(
"authRequest",
serde_json::json!({
"correlationId": correlation_id,
"sessionId": session_id,
"username": username,
"password": password,
"remoteAddr": remote_addr,
}),
);
}
}
}
async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> IpcResponse {
match req.method.as_str() {
"ping" => IpcResponse {
id: req.id.clone(),
@@ -560,6 +728,25 @@ async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
}
}
"scanContent" => {
let subject = req.params.get("subject").and_then(|v| v.as_str());
let text_body = req.params.get("textBody").and_then(|v| v.as_str());
let html_body = req.params.get("htmlBody").and_then(|v| v.as_str());
let attachment_names: Vec<String> = req.params.get("attachmentNames")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let result = mailer_security::content_scanner::scan_content(
subject, text_body, html_body, &attachment_names
);
IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::to_value(&result).unwrap()),
error: None,
}
}
"checkSpf" => {
let ip_str = req.params.get("ip").and_then(|v| v.as_str()).unwrap_or("");
let helo = req
@@ -617,6 +804,35 @@ async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
}
}
// --- SMTP Server lifecycle commands ---
"startSmtpServer" => {
handle_start_smtp_server(req, state).await
}
"stopSmtpServer" => {
handle_stop_smtp_server(req, state).await
}
"emailProcessingResult" => {
handle_email_processing_result(req, state)
}
"authResult" => {
handle_auth_result(req, state)
}
"configureRateLimits" => {
// Rate limit configuration is set at startSmtpServer time.
// This command allows runtime updates, but for now we acknowledge it.
IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::json!({"configured": true})),
error: None,
}
}
_ => IpcResponse {
id: req.id.clone(),
success: false,
@@ -625,3 +841,214 @@ async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
},
}
}
/// Handle startSmtpServer IPC command.
async fn handle_start_smtp_server(req: &IpcRequest, state: &mut ManagementState) -> IpcResponse {
// Stop existing server if running
if let Some(handle) = state.smtp_handle.take() {
handle.shutdown().await;
}
// Parse config from params
let config = match parse_smtp_config(&req.params) {
Ok(c) => c,
Err(e) => {
return IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(format!("Invalid config: {}", e)),
};
}
};
// Parse optional rate limit config
let rate_config = req.params.get("rateLimits").and_then(|v| {
serde_json::from_value::<mailer_smtp::rate_limiter::RateLimitConfig>(v.clone()).ok()
});
match mailer_smtp::server::start_server(config, state.callbacks.clone(), rate_config).await {
Ok((handle, event_rx)) => {
state.smtp_handle = Some(handle);
state.smtp_event_rx = Some(event_rx);
IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::json!({"started": true})),
error: None,
}
}
Err(e) => IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(format!("Failed to start SMTP server: {}", e)),
},
}
}
/// Handle stopSmtpServer IPC command.
async fn handle_stop_smtp_server(req: &IpcRequest, state: &mut ManagementState) -> IpcResponse {
if let Some(handle) = state.smtp_handle.take() {
handle.shutdown().await;
state.smtp_event_rx = None;
IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::json!({"stopped": true})),
error: None,
}
} else {
IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::json!({"stopped": true, "wasRunning": false})),
error: None,
}
}
}
/// Handle emailProcessingResult IPC command — resolves a pending email callback.
fn handle_email_processing_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
let correlation_id = req
.params
.get("correlationId")
.and_then(|v| v.as_str())
.unwrap_or("");
let result = EmailProcessingResult {
accepted: req.params.get("accepted").and_then(|v| v.as_bool()).unwrap_or(false),
smtp_code: req.params.get("smtpCode").and_then(|v| v.as_u64()).map(|v| v as u16),
smtp_message: req
.params
.get("smtpMessage")
.and_then(|v| v.as_str())
.map(String::from),
};
if let Some((_, tx)) = state.callbacks.email.remove(correlation_id) {
let _ = tx.send(result);
IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::json!({"resolved": true})),
error: None,
}
} else {
IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(format!(
"No pending callback for correlationId: {}",
correlation_id
)),
}
}
}
/// Handle authResult IPC command — resolves a pending auth callback.
fn handle_auth_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
let correlation_id = req
.params
.get("correlationId")
.and_then(|v| v.as_str())
.unwrap_or("");
let result = AuthResult {
success: req.params.get("success").and_then(|v| v.as_bool()).unwrap_or(false),
message: req
.params
.get("message")
.and_then(|v| v.as_str())
.map(String::from),
};
if let Some((_, tx)) = state.callbacks.auth.remove(correlation_id) {
let _ = tx.send(result);
IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::json!({"resolved": true})),
error: None,
}
} else {
IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(format!(
"No pending auth callback for correlationId: {}",
correlation_id
)),
}
}
}
/// Parse SmtpServerConfig from IPC params JSON.
fn parse_smtp_config(
params: &serde_json::Value,
) -> Result<mailer_smtp::config::SmtpServerConfig, String> {
let mut config = mailer_smtp::config::SmtpServerConfig::default();
if let Some(hostname) = params.get("hostname").and_then(|v| v.as_str()) {
config.hostname = hostname.to_string();
}
if let Some(ports) = params.get("ports").and_then(|v| v.as_array()) {
config.ports = ports
.iter()
.filter_map(|v| v.as_u64().map(|p| p as u16))
.collect();
}
if let Some(secure_port) = params.get("securePort").and_then(|v| v.as_u64()) {
config.secure_port = Some(secure_port as u16);
}
if let Some(cert) = params.get("tlsCertPem").and_then(|v| v.as_str()) {
config.tls_cert_pem = Some(cert.to_string());
}
if let Some(key) = params.get("tlsKeyPem").and_then(|v| v.as_str()) {
config.tls_key_pem = Some(key.to_string());
}
if let Some(size) = params.get("maxMessageSize").and_then(|v| v.as_u64()) {
config.max_message_size = size;
}
if let Some(conns) = params.get("maxConnections").and_then(|v| v.as_u64()) {
config.max_connections = conns as u32;
}
if let Some(rcpts) = params.get("maxRecipients").and_then(|v| v.as_u64()) {
config.max_recipients = rcpts as u32;
}
if let Some(timeout) = params.get("connectionTimeoutSecs").and_then(|v| v.as_u64()) {
config.connection_timeout_secs = timeout;
}
if let Some(timeout) = params.get("dataTimeoutSecs").and_then(|v| v.as_u64()) {
config.data_timeout_secs = timeout;
}
if let Some(auth) = params.get("authEnabled").and_then(|v| v.as_bool()) {
config.auth_enabled = auth;
}
if let Some(failures) = params.get("maxAuthFailures").and_then(|v| v.as_u64()) {
config.max_auth_failures = failures as u32;
}
if let Some(timeout) = params.get("socketTimeoutSecs").and_then(|v| v.as_u64()) {
config.socket_timeout_secs = timeout;
}
if let Some(timeout) = params.get("processingTimeoutSecs").and_then(|v| v.as_u64()) {
config.processing_timeout_secs = timeout;
}
Ok(config)
}

View File

@@ -17,3 +17,4 @@ hickory-resolver.workspace = true
ipnet.workspace = true
rustls-pki-types.workspace = true
psl.workspace = true
regex.workspace = true

View File

@@ -0,0 +1,515 @@
//! Content scanning for email threat detection.
//!
//! Provides pattern-based scanning of email subjects, text bodies, HTML bodies,
//! and attachment filenames for phishing, spam, malware, suspicious links,
//! script injection, and sensitive data patterns.
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
// ---------------------------------------------------------------------------
// Result types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentScanResult {
pub threat_score: u32,
pub threat_type: Option<String>,
pub threat_details: Option<String>,
pub scanned_elements: Vec<String>,
}
// ---------------------------------------------------------------------------
// Pattern definitions (compiled once via LazyLock)
// ---------------------------------------------------------------------------
static PHISHING_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?i)(?:verify|confirm|update|login).*(?:account|password|details)").unwrap(),
Regex::new(r"(?i)urgent.*(?:action|attention|required)").unwrap(),
Regex::new(r"(?i)(?:paypal|apple|microsoft|amazon|google|bank).*(?:verify|confirm|suspend)").unwrap(),
Regex::new(r"(?i)your.*(?:account).*(?:suspended|compromised|locked)").unwrap(),
Regex::new(r"(?i)\b(?:password reset|security alert|security notice)\b").unwrap(),
]
});
static SPAM_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?i)\b(?:viagra|cialis|enlargement|diet pill|lose weight fast|cheap meds)\b").unwrap(),
Regex::new(r"(?i)\b(?:million dollars|lottery winner|prize claim|inheritance|rich widow)\b").unwrap(),
Regex::new(r"(?i)\b(?:earn from home|make money fast|earn \$\d{3,}/day)\b").unwrap(),
Regex::new(r"(?i)\b(?:limited time offer|act now|exclusive deal|only \d+ left)\b").unwrap(),
Regex::new(r"(?i)\b(?:forex|stock tip|investment opportunity|cryptocurrency|bitcoin)\b").unwrap(),
]
});
static MALWARE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?i)(?:attached file|see attachment).*(?:invoice|receipt|statement|document)").unwrap(),
Regex::new(r"(?i)open.*(?:the attached|this attachment)").unwrap(),
Regex::new(r"(?i)(?:enable|allow).*(?:macros|content|editing)").unwrap(),
Regex::new(r"(?i)download.*(?:attachment|file|document)").unwrap(),
Regex::new(r"(?i)\b(?:ransomware protection|virus alert|malware detected)\b").unwrap(),
]
});
static SUSPICIOUS_LINK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?i)https?://bit\.ly/").unwrap(),
Regex::new(r"(?i)https?://goo\.gl/").unwrap(),
Regex::new(r"(?i)https?://t\.co/").unwrap(),
Regex::new(r"(?i)https?://tinyurl\.com/").unwrap(),
Regex::new(r"(?i)https?://(?:\d{1,3}\.){3}\d{1,3}").unwrap(),
Regex::new(r"(?i)https?://.*\.(?:xyz|top|club|gq|cf)/").unwrap(),
Regex::new(r"(?i)(?:login|account|signin|auth).*\.(?:xyz|top|club|gq|cf|tk|ml|ga|pw|ws|buzz)\b").unwrap(),
]
});
static SCRIPT_INJECTION_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?is)<script.*>.*</script>").unwrap(),
Regex::new(r"(?i)javascript:").unwrap(),
Regex::new(r#"(?i)on(?:click|load|mouse|error|focus|blur)=".*""#).unwrap(),
Regex::new(r"(?i)document\.(?:cookie|write|location)").unwrap(),
Regex::new(r"(?i)eval\s*\(").unwrap(),
]
});
static SENSITIVE_DATA_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"\b(?:\d{3}-\d{2}-\d{4}|\d{9})\b").unwrap(),
Regex::new(r"\b\d{13,16}\b").unwrap(),
Regex::new(r"\b(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})\b").unwrap(),
]
});
/// Link extraction from HTML href attributes.
static HREF_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)href=["'](https?://[^"']+)["']"#).unwrap()
});
/// Executable file extensions that are considered dangerous.
static EXECUTABLE_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
".exe", ".dll", ".bat", ".cmd", ".msi", ".vbs", ".ps1",
".sh", ".jar", ".py", ".com", ".scr", ".pif", ".hta", ".cpl",
".reg", ".vba", ".lnk", ".wsf", ".msp", ".mst",
]
});
/// Document extensions that may contain macros.
static MACRO_DOCUMENT_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
".doc", ".docm", ".xls", ".xlsm", ".ppt", ".pptm",
".dotm", ".xlsb", ".ppam", ".potm",
]
});
// ---------------------------------------------------------------------------
// HTML helpers
// ---------------------------------------------------------------------------
/// Strip HTML tags and decode common entities to produce plain text.
fn extract_text_from_html(html: &str) -> String {
// Remove style and script blocks first
let no_style = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
let no_script = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
let no_tags = Regex::new(r"<[^>]+>").unwrap();
let text = no_style.replace_all(html, " ");
let text = no_script.replace_all(&text, " ");
let text = no_tags.replace_all(&text, " ");
text.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
/// Extract all href links from HTML.
fn extract_links_from_html(html: &str) -> Vec<String> {
HREF_PATTERN
.captures_iter(html)
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
.collect()
}
// ---------------------------------------------------------------------------
// Scoring helpers
// ---------------------------------------------------------------------------
fn matches_any(text: &str, patterns: &[Regex]) -> bool {
patterns.iter().any(|p| p.is_match(text))
}
// ---------------------------------------------------------------------------
// Main scan entry point
// ---------------------------------------------------------------------------
/// Scan email content for threats.
///
/// This mirrors the TypeScript ContentScanner logic — scanning the subject,
/// text body, HTML body, and attachment filenames against predefined patterns.
/// Returns an aggregate threat score and the highest-severity threat type.
pub fn scan_content(
subject: Option<&str>,
text_body: Option<&str>,
html_body: Option<&str>,
attachment_names: &[String],
) -> ContentScanResult {
let mut score: u32 = 0;
let mut threat_type: Option<String> = None;
let mut threat_details: Option<String> = None;
let mut scanned: Vec<String> = Vec::new();
// Helper: upgrade threat info only if the new finding is more severe.
macro_rules! record {
($new_score:expr, $ttype:expr, $details:expr) => {
score += $new_score;
// Always adopt the threat type from the highest-scoring match.
threat_type = Some($ttype.to_string());
threat_details = Some($details.to_string());
};
}
// ── Subject scanning ──────────────────────────────────────────────
if let Some(subj) = subject {
scanned.push("subject".into());
if matches_any(subj, &PHISHING_PATTERNS) {
record!(25, "phishing", format!("Subject contains potential phishing indicators: {}", subj));
} else if matches_any(subj, &SPAM_PATTERNS) {
record!(15, "spam", format!("Subject contains potential spam indicators: {}", subj));
}
}
// ── Text body scanning ────────────────────────────────────────────
if let Some(text) = text_body {
scanned.push("text".into());
// Check each category and accumulate score (same order as TS)
for pat in SUSPICIOUS_LINK_PATTERNS.iter() {
if pat.is_match(text) {
score += 20;
if threat_type.as_deref() != Some("suspicious_link") {
threat_type = Some("suspicious_link".into());
threat_details = Some("Text contains suspicious links".into());
}
}
}
for pat in PHISHING_PATTERNS.iter() {
if pat.is_match(text) {
score += 25;
threat_type = Some("phishing".into());
threat_details = Some("Text contains potential phishing indicators".into());
}
}
for pat in SPAM_PATTERNS.iter() {
if pat.is_match(text) {
score += 15;
if threat_type.is_none() {
threat_type = Some("spam".into());
threat_details = Some("Text contains potential spam indicators".into());
}
}
}
for pat in MALWARE_PATTERNS.iter() {
if pat.is_match(text) {
score += 30;
threat_type = Some("malware".into());
threat_details = Some("Text contains potential malware indicators".into());
}
}
for pat in SENSITIVE_DATA_PATTERNS.iter() {
if pat.is_match(text) {
score += 25;
if threat_type.is_none() {
threat_type = Some("sensitive_data".into());
threat_details = Some("Text contains potentially sensitive data patterns".into());
}
}
}
}
// ── HTML body scanning ────────────────────────────────────────────
if let Some(html) = html_body {
scanned.push("html".into());
// Script injection check
for pat in SCRIPT_INJECTION_PATTERNS.iter() {
if pat.is_match(html) {
score += 40;
if threat_type.as_deref() != Some("xss") {
threat_type = Some("xss".into());
threat_details = Some("HTML contains potentially malicious script content".into());
}
}
}
// Extract text from HTML and scan (half score to avoid double counting)
let text_content = extract_text_from_html(html);
if !text_content.is_empty() {
let mut html_text_score: u32 = 0;
let mut html_text_type: Option<String> = None;
let mut html_text_details: Option<String> = None;
// Re-run text patterns on extracted HTML text
for pat in SUSPICIOUS_LINK_PATTERNS.iter() {
if pat.is_match(&text_content) {
html_text_score += 20;
html_text_type = Some("suspicious_link".into());
html_text_details = Some("Text contains suspicious links".into());
}
}
for pat in PHISHING_PATTERNS.iter() {
if pat.is_match(&text_content) {
html_text_score += 25;
html_text_type = Some("phishing".into());
html_text_details = Some("Text contains potential phishing indicators".into());
}
}
for pat in SPAM_PATTERNS.iter() {
if pat.is_match(&text_content) {
html_text_score += 15;
if html_text_type.is_none() {
html_text_type = Some("spam".into());
html_text_details = Some("Text contains potential spam indicators".into());
}
}
}
for pat in MALWARE_PATTERNS.iter() {
if pat.is_match(&text_content) {
html_text_score += 30;
html_text_type = Some("malware".into());
html_text_details = Some("Text contains potential malware indicators".into());
}
}
for pat in SENSITIVE_DATA_PATTERNS.iter() {
if pat.is_match(&text_content) {
html_text_score += 25;
if html_text_type.is_none() {
html_text_type = Some("sensitive_data".into());
html_text_details = Some("Text contains potentially sensitive data patterns".into());
}
}
}
if html_text_score > 0 {
// Add half of the text content score to avoid double counting
score += html_text_score / 2;
if let Some(t) = html_text_type {
if threat_type.is_none() || html_text_score > score {
threat_type = Some(t);
threat_details = html_text_details;
}
}
}
}
// Extract and check links from HTML
let links = extract_links_from_html(html);
if !links.is_empty() {
let mut suspicious_count = 0u32;
for link in &links {
if matches_any(link, &SUSPICIOUS_LINK_PATTERNS) {
suspicious_count += 1;
}
}
if suspicious_count > 0 {
let pct = (suspicious_count as f64 / links.len() as f64) * 100.0;
let additional = std::cmp::min(40, (pct / 2.5) as u32);
score += additional;
if additional > 20 || threat_type.is_none() {
threat_type = Some("suspicious_link".into());
threat_details = Some(format!(
"HTML contains {} suspicious links out of {} total links",
suspicious_count,
links.len()
));
}
}
}
}
// ── Attachment filename scanning ──────────────────────────────────
for name in attachment_names {
let lower = name.to_lowercase();
scanned.push(format!("attachment:{}", lower));
// Check executable extensions
for ext in EXECUTABLE_EXTENSIONS.iter() {
if lower.ends_with(ext) {
score += 70;
threat_type = Some("executable".into());
threat_details = Some(format!(
"Attachment has a potentially dangerous extension: {}",
name
));
break;
}
}
// Check macro document extensions
for ext in MACRO_DOCUMENT_EXTENSIONS.iter() {
if lower.ends_with(ext) {
// Flag macro-capable documents (lower score than executables)
score += 20;
if threat_type.is_none() {
threat_type = Some("malicious_macro".into());
threat_details = Some(format!(
"Attachment is a macro-capable document: {}",
name
));
}
break;
}
}
}
ContentScanResult {
threat_score: score,
threat_type,
threat_details,
scanned_elements: scanned,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_content() {
let result = scan_content(
Some("Project Update"),
Some("The project is on track."),
None,
&[],
);
assert_eq!(result.threat_score, 0);
assert!(result.threat_type.is_none());
}
#[test]
fn test_phishing_subject() {
let result = scan_content(
Some("URGENT: Verify your bank account details immediately"),
None,
None,
&[],
);
assert!(result.threat_score >= 25);
assert_eq!(result.threat_type.as_deref(), Some("phishing"));
}
#[test]
fn test_spam_body() {
let result = scan_content(
None,
Some("Win a million dollars in the lottery winner contest!"),
None,
&[],
);
assert!(result.threat_score >= 15);
assert_eq!(result.threat_type.as_deref(), Some("spam"));
}
#[test]
fn test_suspicious_links() {
let result = scan_content(
None,
Some("Check out https://bit.ly/2x3F5 for more info"),
None,
&[],
);
assert!(result.threat_score >= 20);
assert_eq!(result.threat_type.as_deref(), Some("suspicious_link"));
}
#[test]
fn test_script_injection() {
let result = scan_content(
None,
None,
Some("<p>Hello</p><script>document.cookie='steal';</script>"),
&[],
);
assert!(result.threat_score >= 40);
assert_eq!(result.threat_type.as_deref(), Some("xss"));
}
#[test]
fn test_executable_attachment() {
let result = scan_content(
None,
None,
None,
&["update.exe".into()],
);
assert!(result.threat_score >= 70);
assert_eq!(result.threat_type.as_deref(), Some("executable"));
}
#[test]
fn test_macro_document() {
let result = scan_content(
None,
None,
None,
&["report.docm".into()],
);
assert!(result.threat_score >= 20);
assert_eq!(result.threat_type.as_deref(), Some("malicious_macro"));
}
#[test]
fn test_malware_indicators() {
let result = scan_content(
None,
Some("Please enable macros to view this document properly."),
None,
&[],
);
assert!(result.threat_score >= 30);
assert_eq!(result.threat_type.as_deref(), Some("malware"));
}
#[test]
fn test_html_link_extraction() {
let result = scan_content(
None,
None,
Some(r#"<a href="https://bit.ly/abc">click</a> and <a href="https://t.co/xyz">here</a>"#),
&[],
);
assert!(result.threat_score > 0);
}
#[test]
fn test_compound_threats() {
let result = scan_content(
Some("URGENT: Verify your account details immediately"),
Some("Your account will be suspended unless you verify at https://bit.ly/2x3F5"),
Some(r#"<a href="https://bit.ly/2x3F5">verify</a>"#),
&["verification.exe".into()],
);
assert!(result.threat_score > 70);
}
}

View File

@@ -1,5 +1,6 @@
//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking.
pub mod content_scanner;
pub mod dkim;
pub mod dmarc;
pub mod error;

View File

@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
mailer-core = { path = "../mailer-core" }
mailer-security = { path = "../mailer-security" }
tokio.workspace = true
tokio-rustls.workspace = true
hickory-resolver.workspace = true
@@ -14,3 +15,11 @@ thiserror.workspace = true
tracing.workspace = true
bytes.workspace = true
serde.workspace = true
serde_json = "1"
regex = "1"
uuid = { version = "1", features = ["v4"] }
base64.workspace = true
rustls-pki-types.workspace = true
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
rustls-pemfile = "2"
mailparse.workspace = true

View File

@@ -0,0 +1,421 @@
//! SMTP command parser.
//!
//! Parses raw SMTP command lines into structured `SmtpCommand` variants.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A parsed SMTP command.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SmtpCommand {
/// EHLO with client hostname/IP
Ehlo(String),
/// HELO with client hostname/IP
Helo(String),
/// MAIL FROM with sender address and optional parameters (e.g. SIZE=12345)
MailFrom {
address: String,
params: HashMap<String, Option<String>>,
},
/// RCPT TO with recipient address and optional parameters
RcptTo {
address: String,
params: HashMap<String, Option<String>>,
},
/// DATA command — begin message body
Data,
/// RSET — reset current transaction
Rset,
/// NOOP — no operation
Noop,
/// QUIT — close connection
Quit,
/// STARTTLS — upgrade to TLS
StartTls,
/// AUTH with mechanism and optional initial response
Auth {
mechanism: AuthMechanism,
initial_response: Option<String>,
},
/// HELP with optional topic
Help(Option<String>),
/// VRFY with address or username
Vrfy(String),
/// EXPN with mailing list name
Expn(String),
}
/// Supported AUTH mechanisms.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AuthMechanism {
Plain,
Login,
}
/// Errors that can occur during command parsing.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum ParseError {
#[error("empty command line")]
Empty,
#[error("unrecognized command: {0}")]
UnrecognizedCommand(String),
#[error("syntax error in parameters: {0}")]
SyntaxError(String),
#[error("missing required argument for {0}")]
MissingArgument(String),
}
/// Parse a raw SMTP command line (without trailing CRLF) into a `SmtpCommand`.
pub fn parse_command(line: &str) -> Result<SmtpCommand, ParseError> {
let line = line.trim_end_matches('\r').trim_end_matches('\n');
if line.is_empty() {
return Err(ParseError::Empty);
}
// Split into verb and the rest
let (verb, rest) = split_first_word(line);
let verb_upper = verb.to_ascii_uppercase();
match verb_upper.as_str() {
"EHLO" => {
let hostname = rest.trim();
if hostname.is_empty() {
return Err(ParseError::MissingArgument("EHLO".into()));
}
Ok(SmtpCommand::Ehlo(hostname.to_string()))
}
"HELO" => {
let hostname = rest.trim();
if hostname.is_empty() {
return Err(ParseError::MissingArgument("HELO".into()));
}
Ok(SmtpCommand::Helo(hostname.to_string()))
}
"MAIL" => parse_mail_from(rest),
"RCPT" => parse_rcpt_to(rest),
"DATA" => Ok(SmtpCommand::Data),
"RSET" => Ok(SmtpCommand::Rset),
"NOOP" => Ok(SmtpCommand::Noop),
"QUIT" => Ok(SmtpCommand::Quit),
"STARTTLS" => Ok(SmtpCommand::StartTls),
"AUTH" => parse_auth(rest),
"HELP" => {
let topic = rest.trim();
if topic.is_empty() {
Ok(SmtpCommand::Help(None))
} else {
Ok(SmtpCommand::Help(Some(topic.to_string())))
}
}
"VRFY" => {
let arg = rest.trim();
if arg.is_empty() {
return Err(ParseError::MissingArgument("VRFY".into()));
}
Ok(SmtpCommand::Vrfy(arg.to_string()))
}
"EXPN" => {
let arg = rest.trim();
if arg.is_empty() {
return Err(ParseError::MissingArgument("EXPN".into()));
}
Ok(SmtpCommand::Expn(arg.to_string()))
}
_ => Err(ParseError::UnrecognizedCommand(verb_upper)),
}
}
/// Parse `FROM:<addr> [PARAM=VALUE ...]` after "MAIL".
fn parse_mail_from(rest: &str) -> Result<SmtpCommand, ParseError> {
// Expect "FROM:" prefix (case-insensitive, whitespace-flexible)
let rest = rest.trim_start();
let rest_upper = rest.to_ascii_uppercase();
if !rest_upper.starts_with("FROM") {
return Err(ParseError::SyntaxError(
"expected FROM after MAIL".into(),
));
}
let rest = &rest[4..]; // skip "FROM"
let rest = rest.trim_start();
if !rest.starts_with(':') {
return Err(ParseError::SyntaxError(
"expected colon after MAIL FROM".into(),
));
}
let rest = &rest[1..]; // skip ':'
let rest = rest.trim_start();
parse_address_and_params(rest, "MAIL FROM").map(|(address, params)| SmtpCommand::MailFrom {
address,
params,
})
}
/// Parse `TO:<addr> [PARAM=VALUE ...]` after "RCPT".
fn parse_rcpt_to(rest: &str) -> Result<SmtpCommand, ParseError> {
let rest = rest.trim_start();
let rest_upper = rest.to_ascii_uppercase();
if !rest_upper.starts_with("TO") {
return Err(ParseError::SyntaxError("expected TO after RCPT".into()));
}
let rest = &rest[2..]; // skip "TO"
let rest = rest.trim_start();
if !rest.starts_with(':') {
return Err(ParseError::SyntaxError(
"expected colon after RCPT TO".into(),
));
}
let rest = &rest[1..]; // skip ':'
let rest = rest.trim_start();
parse_address_and_params(rest, "RCPT TO").map(|(address, params)| SmtpCommand::RcptTo {
address,
params,
})
}
/// Parse `<address> [PARAM=VALUE ...]` from the rest of a MAIL FROM or RCPT TO line.
fn parse_address_and_params(
input: &str,
context: &str,
) -> Result<(String, HashMap<String, Option<String>>), ParseError> {
if !input.starts_with('<') {
return Err(ParseError::SyntaxError(format!(
"expected '<' in {context}"
)));
}
let close_bracket = input.find('>').ok_or_else(|| {
ParseError::SyntaxError(format!("missing '>' in {context}"))
})?;
let address = input[1..close_bracket].to_string();
let remainder = &input[close_bracket + 1..];
let params = parse_params(remainder)?;
Ok((address, params))
}
/// Parse SMTP extension parameters like `SIZE=12345 BODY=8BITMIME`.
fn parse_params(input: &str) -> Result<HashMap<String, Option<String>>, ParseError> {
let mut params = HashMap::new();
for token in input.split_whitespace() {
if let Some(eq_pos) = token.find('=') {
let key = token[..eq_pos].to_ascii_uppercase();
let value = token[eq_pos + 1..].to_string();
params.insert(key, Some(value));
} else {
params.insert(token.to_ascii_uppercase(), None);
}
}
Ok(params)
}
/// Parse AUTH command: `AUTH <mechanism> [initial-response]`.
fn parse_auth(rest: &str) -> Result<SmtpCommand, ParseError> {
let rest = rest.trim();
if rest.is_empty() {
return Err(ParseError::MissingArgument("AUTH".into()));
}
let (mech_str, initial) = split_first_word(rest);
let mechanism = match mech_str.to_ascii_uppercase().as_str() {
"PLAIN" => AuthMechanism::Plain,
"LOGIN" => AuthMechanism::Login,
other => {
return Err(ParseError::SyntaxError(format!(
"unsupported AUTH mechanism: {other}"
)));
}
};
let initial_response = {
let s = initial.trim();
if s.is_empty() { None } else { Some(s.to_string()) }
};
Ok(SmtpCommand::Auth {
mechanism,
initial_response,
})
}
/// Split a string into the first whitespace-delimited word and the remainder.
fn split_first_word(s: &str) -> (&str, &str) {
match s.find(char::is_whitespace) {
Some(pos) => (&s[..pos], &s[pos + 1..]),
None => (s, ""),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ehlo() {
let cmd = parse_command("EHLO mail.example.com").unwrap();
assert_eq!(cmd, SmtpCommand::Ehlo("mail.example.com".into()));
}
#[test]
fn test_ehlo_case_insensitive() {
let cmd = parse_command("ehlo MAIL.EXAMPLE.COM").unwrap();
assert_eq!(cmd, SmtpCommand::Ehlo("MAIL.EXAMPLE.COM".into()));
}
#[test]
fn test_helo() {
let cmd = parse_command("HELO example.com").unwrap();
assert_eq!(cmd, SmtpCommand::Helo("example.com".into()));
}
#[test]
fn test_ehlo_missing_arg() {
let err = parse_command("EHLO").unwrap_err();
assert!(matches!(err, ParseError::MissingArgument(_)));
}
#[test]
fn test_mail_from() {
let cmd = parse_command("MAIL FROM:<sender@example.com>").unwrap();
assert_eq!(
cmd,
SmtpCommand::MailFrom {
address: "sender@example.com".into(),
params: HashMap::new(),
}
);
}
#[test]
fn test_mail_from_with_params() {
let cmd = parse_command("MAIL FROM:<sender@example.com> SIZE=12345 BODY=8BITMIME").unwrap();
if let SmtpCommand::MailFrom { address, params } = cmd {
assert_eq!(address, "sender@example.com");
assert_eq!(params.get("SIZE"), Some(&Some("12345".into())));
assert_eq!(params.get("BODY"), Some(&Some("8BITMIME".into())));
} else {
panic!("expected MailFrom");
}
}
#[test]
fn test_mail_from_empty_address() {
let cmd = parse_command("MAIL FROM:<>").unwrap();
assert_eq!(
cmd,
SmtpCommand::MailFrom {
address: "".into(),
params: HashMap::new(),
}
);
}
#[test]
fn test_mail_from_flexible_spacing() {
let cmd = parse_command("MAIL FROM: <user@example.com>").unwrap();
if let SmtpCommand::MailFrom { address, .. } = cmd {
assert_eq!(address, "user@example.com");
} else {
panic!("expected MailFrom");
}
}
#[test]
fn test_rcpt_to() {
let cmd = parse_command("RCPT TO:<recipient@example.com>").unwrap();
assert_eq!(
cmd,
SmtpCommand::RcptTo {
address: "recipient@example.com".into(),
params: HashMap::new(),
}
);
}
#[test]
fn test_data() {
assert_eq!(parse_command("DATA").unwrap(), SmtpCommand::Data);
}
#[test]
fn test_rset() {
assert_eq!(parse_command("RSET").unwrap(), SmtpCommand::Rset);
}
#[test]
fn test_noop() {
assert_eq!(parse_command("NOOP").unwrap(), SmtpCommand::Noop);
}
#[test]
fn test_quit() {
assert_eq!(parse_command("QUIT").unwrap(), SmtpCommand::Quit);
}
#[test]
fn test_starttls() {
assert_eq!(parse_command("STARTTLS").unwrap(), SmtpCommand::StartTls);
}
#[test]
fn test_auth_plain() {
let cmd = parse_command("AUTH PLAIN dGVzdAB0ZXN0AHBhc3N3b3Jk").unwrap();
assert_eq!(
cmd,
SmtpCommand::Auth {
mechanism: AuthMechanism::Plain,
initial_response: Some("dGVzdAB0ZXN0AHBhc3N3b3Jk".into()),
}
);
}
#[test]
fn test_auth_login_no_initial() {
let cmd = parse_command("AUTH LOGIN").unwrap();
assert_eq!(
cmd,
SmtpCommand::Auth {
mechanism: AuthMechanism::Login,
initial_response: None,
}
);
}
#[test]
fn test_help() {
assert_eq!(parse_command("HELP").unwrap(), SmtpCommand::Help(None));
assert_eq!(
parse_command("HELP MAIL").unwrap(),
SmtpCommand::Help(Some("MAIL".into()))
);
}
#[test]
fn test_vrfy() {
assert_eq!(
parse_command("VRFY user@example.com").unwrap(),
SmtpCommand::Vrfy("user@example.com".into())
);
}
#[test]
fn test_expn() {
assert_eq!(
parse_command("EXPN staff").unwrap(),
SmtpCommand::Expn("staff".into())
);
}
#[test]
fn test_empty() {
assert!(matches!(parse_command(""), Err(ParseError::Empty)));
}
#[test]
fn test_unrecognized() {
let err = parse_command("FOOBAR test").unwrap_err();
assert!(matches!(err, ParseError::UnrecognizedCommand(_)));
}
#[test]
fn test_crlf_stripped() {
let cmd = parse_command("QUIT\r\n").unwrap();
assert_eq!(cmd, SmtpCommand::Quit);
}
}

View File

@@ -0,0 +1,86 @@
//! SMTP server configuration.
use serde::{Deserialize, Serialize};
/// Configuration for an SMTP server instance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmtpServerConfig {
/// Server hostname for greeting and EHLO responses.
pub hostname: String,
/// Ports to listen on (e.g. [25, 587]).
pub ports: Vec<u16>,
/// Port for implicit TLS (e.g. 465). None = no implicit TLS port.
pub secure_port: Option<u16>,
/// TLS certificate chain in PEM format.
pub tls_cert_pem: Option<String>,
/// TLS private key in PEM format.
pub tls_key_pem: Option<String>,
/// Maximum message size in bytes.
pub max_message_size: u64,
/// Maximum number of concurrent connections.
pub max_connections: u32,
/// Maximum recipients per message.
pub max_recipients: u32,
/// Connection timeout in seconds.
pub connection_timeout_secs: u64,
/// Data phase timeout in seconds.
pub data_timeout_secs: u64,
/// Whether authentication is available.
pub auth_enabled: bool,
/// Maximum authentication failures before disconnect.
pub max_auth_failures: u32,
/// Socket timeout in seconds (idle timeout for the entire connection).
pub socket_timeout_secs: u64,
/// Timeout in seconds waiting for TS to respond to email processing.
pub processing_timeout_secs: u64,
}
impl Default for SmtpServerConfig {
fn default() -> Self {
Self {
hostname: "mail.example.com".to_string(),
ports: vec![25],
secure_port: None,
tls_cert_pem: None,
tls_key_pem: None,
max_message_size: 10 * 1024 * 1024, // 10 MB
max_connections: 100,
max_recipients: 100,
connection_timeout_secs: 30,
data_timeout_secs: 60,
auth_enabled: false,
max_auth_failures: 3,
socket_timeout_secs: 300,
processing_timeout_secs: 30,
}
}
}
impl SmtpServerConfig {
/// Check if TLS is configured.
pub fn has_tls(&self) -> bool {
self.tls_cert_pem.is_some() && self.tls_key_pem.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_defaults() {
let cfg = SmtpServerConfig::default();
assert_eq!(cfg.max_message_size, 10 * 1024 * 1024);
assert_eq!(cfg.max_connections, 100);
assert!(!cfg.has_tls());
}
#[test]
fn test_has_tls() {
let mut cfg = SmtpServerConfig::default();
cfg.tls_cert_pem = Some("cert".into());
assert!(!cfg.has_tls()); // need both
cfg.tls_key_pem = Some("key".into());
assert!(cfg.has_tls());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,289 @@
//! Email DATA phase processor.
//!
//! Handles dot-unstuffing, end-of-data detection, size enforcement,
//! and streaming accumulation of email data.
/// Result of processing a chunk of DATA input.
#[derive(Debug, Clone, PartialEq)]
pub enum DataAction {
/// More data needed — continue accumulating.
Continue,
/// End-of-data detected. The complete message body is ready.
Complete,
/// Message size limit exceeded.
SizeExceeded,
}
/// Streaming email data accumulator.
///
/// Processes incoming bytes from the DATA phase, handling:
/// - CRLF line ending normalization
/// - Dot-unstuffing (RFC 5321 §4.5.2)
/// - End-of-data marker detection (`<CRLF>.<CRLF>`)
/// - Size enforcement
pub struct DataAccumulator {
/// Accumulated message bytes.
buffer: Vec<u8>,
/// Maximum allowed size in bytes. 0 = unlimited.
max_size: u64,
/// Whether we've detected end-of-data.
complete: bool,
/// Whether the current position is at the start of a line.
at_line_start: bool,
/// Partial state for cross-chunk boundary handling.
partial: PartialState,
}
/// Tracks partial sequences that span chunk boundaries.
#[derive(Debug, Clone, Copy, PartialEq)]
enum PartialState {
/// No partial sequence.
None,
/// Saw `\r`, waiting for `\n`.
Cr,
/// At line start, saw `.`, waiting to determine dot-stuffing vs end-of-data.
Dot,
/// At line start, saw `.\r`, waiting for `\n` (end-of-data) or other.
DotCr,
}
impl DataAccumulator {
/// Create a new accumulator with the given size limit.
pub fn new(max_size: u64) -> Self {
Self {
buffer: Vec::with_capacity(8192),
max_size,
complete: false,
at_line_start: true, // First byte is at start of first line
partial: PartialState::None,
}
}
/// Process a chunk of incoming data.
///
/// Returns the action to take: continue, complete, or size exceeded.
pub fn process_chunk(&mut self, chunk: &[u8]) -> DataAction {
if self.complete {
return DataAction::Complete;
}
for &byte in chunk {
match self.partial {
PartialState::None => {
if self.at_line_start && byte == b'.' {
self.partial = PartialState::Dot;
} else if byte == b'\r' {
self.partial = PartialState::Cr;
} else {
self.buffer.push(byte);
self.at_line_start = false;
}
}
PartialState::Cr => {
if byte == b'\n' {
self.buffer.extend_from_slice(b"\r\n");
self.at_line_start = true;
self.partial = PartialState::None;
} else {
// Bare CR — emit it and process current byte
self.buffer.push(b'\r');
self.at_line_start = false;
self.partial = PartialState::None;
// Re-process current byte
if byte == b'\r' {
self.partial = PartialState::Cr;
} else {
self.buffer.push(byte);
}
}
}
PartialState::Dot => {
if byte == b'\r' {
self.partial = PartialState::DotCr;
} else if byte == b'.' {
// Dot-unstuffing: \r\n.. → \r\n.
// Emit one dot, consume the other
self.buffer.push(b'.');
self.at_line_start = false;
self.partial = PartialState::None;
} else {
// Dot at line start but not stuffing or end-of-data
self.buffer.push(b'.');
self.buffer.push(byte);
self.at_line_start = false;
self.partial = PartialState::None;
}
}
PartialState::DotCr => {
if byte == b'\n' {
// End-of-data: <CRLF>.<CRLF>
// Remove the trailing \r\n from the buffer
// (it was part of the terminator, not the message)
if self.buffer.ends_with(b"\r\n") {
let new_len = self.buffer.len() - 2;
self.buffer.truncate(new_len);
}
self.complete = true;
return DataAction::Complete;
} else {
// Not end-of-data — emit .\r and process current byte
self.buffer.push(b'.');
self.buffer.push(b'\r');
self.at_line_start = false;
self.partial = PartialState::None;
// Re-process current byte
if byte == b'\r' {
self.partial = PartialState::Cr;
} else {
self.buffer.push(byte);
}
}
}
}
// Check size limit
if self.max_size > 0 && self.buffer.len() as u64 > self.max_size {
return DataAction::SizeExceeded;
}
}
DataAction::Continue
}
/// Consume the accumulator and return the complete message data.
///
/// Returns `None` if end-of-data has not been detected.
pub fn into_message(self) -> Option<Vec<u8>> {
if !self.complete {
return None;
}
Some(self.buffer)
}
/// Get a reference to the accumulated data so far.
pub fn data(&self) -> &[u8] {
&self.buffer
}
/// Get the current accumulated size.
pub fn size(&self) -> usize {
self.buffer.len()
}
/// Whether end-of-data has been detected.
pub fn is_complete(&self) -> bool {
self.complete
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_message() {
let mut acc = DataAccumulator::new(0);
let data = b"Subject: Test\r\n\r\nHello world\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Subject: Test\r\n\r\nHello world");
}
#[test]
fn test_dot_unstuffing() {
let mut acc = DataAccumulator::new(0);
// A line starting with ".." should become "."
let data = b"Line 1\r\n..dot-stuffed\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Line 1\r\n.dot-stuffed");
}
#[test]
fn test_multiple_chunks() {
let mut acc = DataAccumulator::new(0);
assert_eq!(acc.process_chunk(b"Subject: Test\r\n"), DataAction::Continue);
assert_eq!(acc.process_chunk(b"\r\nBody line 1\r\n"), DataAction::Continue);
assert_eq!(acc.process_chunk(b"Body line 2\r\n.\r\n"), DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Subject: Test\r\n\r\nBody line 1\r\nBody line 2");
}
#[test]
fn test_end_of_data_spanning_chunks() {
let mut acc = DataAccumulator::new(0);
assert_eq!(acc.process_chunk(b"Body\r\n"), DataAction::Continue);
assert_eq!(acc.process_chunk(b".\r"), DataAction::Continue);
assert_eq!(acc.process_chunk(b"\n"), DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Body");
}
#[test]
fn test_size_limit() {
let mut acc = DataAccumulator::new(10);
let data = b"This is definitely more than 10 bytes\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::SizeExceeded);
}
#[test]
fn test_not_complete() {
let mut acc = DataAccumulator::new(0);
acc.process_chunk(b"partial data");
assert!(!acc.is_complete());
assert!(acc.into_message().is_none());
}
#[test]
fn test_empty_message() {
let mut acc = DataAccumulator::new(0);
let action = acc.process_chunk(b".\r\n");
assert_eq!(action, DataAction::Complete);
let msg = acc.into_message().unwrap();
assert!(msg.is_empty());
}
#[test]
fn test_dot_not_at_line_start() {
let mut acc = DataAccumulator::new(0);
let data = b"Hello.World\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Hello.World");
}
#[test]
fn test_multiple_dots_in_line() {
let mut acc = DataAccumulator::new(0);
let data = b"...\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::Complete);
// First dot at line start is dot-unstuffed, leaving ".."
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"..");
}
#[test]
fn test_crlf_dot_spanning_three_chunks() {
let mut acc = DataAccumulator::new(0);
assert_eq!(acc.process_chunk(b"Body\r"), DataAction::Continue);
assert_eq!(acc.process_chunk(b"\n."), DataAction::Continue);
assert_eq!(acc.process_chunk(b"\r\n"), DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Body");
}
#[test]
fn test_bare_cr() {
let mut acc = DataAccumulator::new(0);
let data = b"Hello\rWorld\r\n.\r\n";
let action = acc.process_chunk(data);
assert_eq!(action, DataAction::Complete);
let msg = acc.into_message().unwrap();
assert_eq!(msg, b"Hello\rWorld");
}
}

View File

@@ -1,18 +1,39 @@
//! mailer-smtp: SMTP protocol engine (server + client).
//!
//! This crate provides the SMTP protocol implementation including:
//! - Command parsing (`command`)
//! - State machine (`state`)
//! - Response building (`response`)
//! - Email data accumulation (`data`)
//! - Per-connection session state (`session`)
//! - Address/input validation (`validation`)
//! - Server configuration (`config`)
//! - Rate limiting (`rate_limiter`)
//! - TCP/TLS server (`server`)
//! - Connection handling (`connection`)
pub mod command;
pub mod config;
pub mod connection;
pub mod data;
pub mod rate_limiter;
pub mod response;
pub mod server;
pub mod session;
pub mod state;
pub mod validation;
pub use mailer_core;
/// Placeholder for the SMTP server and client implementation.
// Re-export key types for convenience.
pub use command::{AuthMechanism, SmtpCommand};
pub use config::SmtpServerConfig;
pub use data::{DataAccumulator, DataAction};
pub use response::SmtpResponse;
pub use session::SmtpSession;
pub use state::SmtpState;
/// Crate version.
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version() {
assert!(!version().is_empty());
}
}

View File

@@ -0,0 +1,198 @@
//! In-process SMTP rate limiter.
//!
//! Uses DashMap for lock-free concurrent access to rate counters.
//! Tracks connections per IP, messages per sender, and auth failures.
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
/// Rate limiter configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
/// Maximum connections per IP per window.
pub max_connections_per_ip: u32,
/// Maximum messages per sender per window.
pub max_messages_per_sender: u32,
/// Maximum auth failures per IP per window.
pub max_auth_failures_per_ip: u32,
/// Window duration in seconds.
pub window_secs: u64,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
max_connections_per_ip: 50,
max_messages_per_sender: 100,
max_auth_failures_per_ip: 5,
window_secs: 60,
}
}
}
/// A timestamped counter entry.
struct CounterEntry {
count: u32,
window_start: Instant,
}
/// In-process rate limiter using DashMap.
pub struct RateLimiter {
config: RateLimitConfig,
window: Duration,
connections: DashMap<String, CounterEntry>,
messages: DashMap<String, CounterEntry>,
auth_failures: DashMap<String, CounterEntry>,
}
impl RateLimiter {
/// Create a new rate limiter with the given configuration.
pub fn new(config: RateLimitConfig) -> Self {
let window = Duration::from_secs(config.window_secs);
Self {
config,
window,
connections: DashMap::new(),
messages: DashMap::new(),
auth_failures: DashMap::new(),
}
}
/// Update the configuration at runtime.
pub fn update_config(&mut self, config: RateLimitConfig) {
self.window = Duration::from_secs(config.window_secs);
self.config = config;
}
/// Check and record a new connection from an IP.
/// Returns `true` if the connection should be allowed.
pub fn check_connection(&self, ip: &str) -> bool {
self.increment_and_check(
&self.connections,
ip,
self.config.max_connections_per_ip,
)
}
/// Check and record a message from a sender.
/// Returns `true` if the message should be allowed.
pub fn check_message(&self, sender: &str) -> bool {
self.increment_and_check(
&self.messages,
sender,
self.config.max_messages_per_sender,
)
}
/// Check and record an auth failure from an IP.
/// Returns `true` if more attempts should be allowed.
pub fn check_auth_failure(&self, ip: &str) -> bool {
self.increment_and_check(
&self.auth_failures,
ip,
self.config.max_auth_failures_per_ip,
)
}
/// Increment a counter and check against the limit.
/// Returns `true` if within limits.
fn increment_and_check(
&self,
map: &DashMap<String, CounterEntry>,
key: &str,
limit: u32,
) -> bool {
let now = Instant::now();
let mut entry = map
.entry(key.to_string())
.or_insert_with(|| CounterEntry {
count: 0,
window_start: now,
});
// Reset window if expired
if now.duration_since(entry.window_start) > self.window {
entry.count = 0;
entry.window_start = now;
}
entry.count += 1;
entry.count <= limit
}
/// Clean up expired entries. Call periodically.
pub fn cleanup(&self) {
let now = Instant::now();
let window = self.window;
self.connections
.retain(|_, v| now.duration_since(v.window_start) <= window);
self.messages
.retain(|_, v| now.duration_since(v.window_start) <= window);
self.auth_failures
.retain(|_, v| now.duration_since(v.window_start) <= window);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connection_limit() {
let limiter = RateLimiter::new(RateLimitConfig {
max_connections_per_ip: 3,
window_secs: 60,
..Default::default()
});
assert!(limiter.check_connection("1.2.3.4"));
assert!(limiter.check_connection("1.2.3.4"));
assert!(limiter.check_connection("1.2.3.4"));
assert!(!limiter.check_connection("1.2.3.4")); // 4th = over limit
// Different IP is independent
assert!(limiter.check_connection("5.6.7.8"));
}
#[test]
fn test_message_limit() {
let limiter = RateLimiter::new(RateLimitConfig {
max_messages_per_sender: 2,
window_secs: 60,
..Default::default()
});
assert!(limiter.check_message("sender@example.com"));
assert!(limiter.check_message("sender@example.com"));
assert!(!limiter.check_message("sender@example.com"));
}
#[test]
fn test_auth_failure_limit() {
let limiter = RateLimiter::new(RateLimitConfig {
max_auth_failures_per_ip: 2,
window_secs: 60,
..Default::default()
});
assert!(limiter.check_auth_failure("1.2.3.4"));
assert!(limiter.check_auth_failure("1.2.3.4"));
assert!(!limiter.check_auth_failure("1.2.3.4"));
}
#[test]
fn test_cleanup() {
let limiter = RateLimiter::new(RateLimitConfig {
max_connections_per_ip: 1,
window_secs: 60,
..Default::default()
});
limiter.check_connection("1.2.3.4");
assert_eq!(limiter.connections.len(), 1);
limiter.cleanup(); // entries not expired
assert_eq!(limiter.connections.len(), 1);
}
}

View File

@@ -0,0 +1,284 @@
//! SMTP response builder.
//!
//! Constructs properly formatted SMTP response lines with status codes,
//! multiline support, and EHLO capability advertisement.
use serde::{Deserialize, Serialize};
/// An SMTP response to send to the client.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SmtpResponse {
/// 3-digit SMTP status code.
pub code: u16,
/// Response lines (without the status code prefix).
pub lines: Vec<String>,
}
impl SmtpResponse {
/// Create a single-line response.
pub fn new(code: u16, message: impl Into<String>) -> Self {
Self {
code,
lines: vec![message.into()],
}
}
/// Create a multiline response.
pub fn multiline(code: u16, lines: Vec<String>) -> Self {
Self { code, lines }
}
/// Format the response as bytes ready to write to the socket.
///
/// Multiline responses use `code-text` for intermediate lines
/// and `code text` for the final line (RFC 5321 §4.2).
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::new();
if self.lines.is_empty() {
buf.extend_from_slice(format!("{} \r\n", self.code).as_bytes());
} else if self.lines.len() == 1 {
buf.extend_from_slice(
format!("{} {}\r\n", self.code, self.lines[0]).as_bytes(),
);
} else {
for (i, line) in self.lines.iter().enumerate() {
if i < self.lines.len() - 1 {
buf.extend_from_slice(
format!("{}-{}\r\n", self.code, line).as_bytes(),
);
} else {
buf.extend_from_slice(
format!("{} {}\r\n", self.code, line).as_bytes(),
);
}
}
}
buf
}
// --- Common response constructors ---
/// 220 Service ready greeting.
pub fn greeting(hostname: &str) -> Self {
Self::new(220, format!("{hostname} ESMTP Service Ready"))
}
/// 221 Service closing.
pub fn closing(hostname: &str) -> Self {
Self::new(221, format!("{hostname} Service closing transmission channel"))
}
/// 250 OK.
pub fn ok(message: impl Into<String>) -> Self {
Self::new(250, message)
}
/// EHLO response with capabilities.
pub fn ehlo_response(hostname: &str, capabilities: &[String]) -> Self {
let mut lines = Vec::with_capacity(capabilities.len() + 1);
lines.push(format!("{hostname} greets you"));
for cap in capabilities {
lines.push(cap.clone());
}
Self::multiline(250, lines)
}
/// 235 Authentication successful.
pub fn auth_success() -> Self {
Self::new(235, "2.7.0 Authentication successful")
}
/// 334 Auth challenge (base64-encoded prompt).
pub fn auth_challenge(prompt: &str) -> Self {
Self::new(334, prompt)
}
/// 354 Start mail input.
pub fn start_data() -> Self {
Self::new(354, "Start mail input; end with <CRLF>.<CRLF>")
}
/// 421 Service not available.
pub fn service_unavailable(hostname: &str, reason: &str) -> Self {
Self::new(421, format!("{hostname} {reason}"))
}
/// 450 Temporary failure.
pub fn temp_failure(message: impl Into<String>) -> Self {
Self::new(450, message)
}
/// 451 Local error.
pub fn local_error(message: impl Into<String>) -> Self {
Self::new(451, message)
}
/// 500 Syntax error.
pub fn syntax_error() -> Self {
Self::new(500, "Syntax error, command unrecognized")
}
/// 501 Syntax error in parameters.
pub fn param_error(message: impl Into<String>) -> Self {
Self::new(501, message)
}
/// 502 Command not implemented.
pub fn not_implemented() -> Self {
Self::new(502, "Command not implemented")
}
/// 503 Bad sequence.
pub fn bad_sequence(message: impl Into<String>) -> Self {
Self::new(503, message)
}
/// 530 Authentication required.
pub fn auth_required() -> Self {
Self::new(530, "5.7.0 Authentication required")
}
/// 535 Authentication failed.
pub fn auth_failed() -> Self {
Self::new(535, "5.7.8 Authentication credentials invalid")
}
/// 550 Mailbox unavailable.
pub fn mailbox_unavailable(message: impl Into<String>) -> Self {
Self::new(550, message)
}
/// 552 Message size exceeded.
pub fn size_exceeded(max_size: u64) -> Self {
Self::new(
552,
format!("5.3.4 Message size exceeds maximum of {max_size} bytes"),
)
}
/// 554 Transaction failed.
pub fn transaction_failed(message: impl Into<String>) -> Self {
Self::new(554, message)
}
/// Check if this is a success response (2xx).
pub fn is_success(&self) -> bool {
self.code >= 200 && self.code < 300
}
/// Check if this is a temporary error (4xx).
pub fn is_temp_error(&self) -> bool {
self.code >= 400 && self.code < 500
}
/// Check if this is a permanent error (5xx).
pub fn is_perm_error(&self) -> bool {
self.code >= 500 && self.code < 600
}
}
/// Build the list of EHLO capabilities for the server.
pub fn build_capabilities(
max_size: u64,
tls_available: bool,
already_secure: bool,
auth_available: bool,
) -> Vec<String> {
let mut caps = vec![
format!("SIZE {max_size}"),
"8BITMIME".to_string(),
"PIPELINING".to_string(),
"ENHANCEDSTATUSCODES".to_string(),
"HELP".to_string(),
];
// Only advertise STARTTLS if TLS is available and not already using TLS
if tls_available && !already_secure {
caps.push("STARTTLS".to_string());
}
if auth_available {
caps.push("AUTH PLAIN LOGIN".to_string());
}
caps
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_single_line() {
let resp = SmtpResponse::new(250, "OK");
assert_eq!(resp.to_bytes(), b"250 OK\r\n");
}
#[test]
fn test_multiline() {
let resp = SmtpResponse::multiline(
250,
vec![
"mail.example.com greets you".into(),
"SIZE 10485760".into(),
"STARTTLS".into(),
],
);
let expected = b"250-mail.example.com greets you\r\n250-SIZE 10485760\r\n250 STARTTLS\r\n";
assert_eq!(resp.to_bytes(), expected.to_vec());
}
#[test]
fn test_greeting() {
let resp = SmtpResponse::greeting("mail.example.com");
assert_eq!(resp.code, 220);
assert!(resp.lines[0].contains("mail.example.com"));
}
#[test]
fn test_ehlo_response() {
let caps = vec!["SIZE 10485760".into(), "STARTTLS".into()];
let resp = SmtpResponse::ehlo_response("mail.example.com", &caps);
assert_eq!(resp.code, 250);
assert_eq!(resp.lines.len(), 3); // hostname + 2 caps
}
#[test]
fn test_status_checks() {
assert!(SmtpResponse::new(250, "OK").is_success());
assert!(SmtpResponse::new(450, "Try later").is_temp_error());
assert!(SmtpResponse::new(550, "No such user").is_perm_error());
assert!(!SmtpResponse::new(250, "OK").is_temp_error());
}
#[test]
fn test_build_capabilities() {
let caps = build_capabilities(10485760, true, false, true);
assert!(caps.contains(&"SIZE 10485760".to_string()));
assert!(caps.contains(&"STARTTLS".to_string()));
assert!(caps.contains(&"AUTH PLAIN LOGIN".to_string()));
assert!(caps.contains(&"PIPELINING".to_string()));
}
#[test]
fn test_build_capabilities_secure() {
// When already secure, STARTTLS should NOT be advertised
let caps = build_capabilities(10485760, true, true, false);
assert!(!caps.contains(&"STARTTLS".to_string()));
assert!(!caps.contains(&"AUTH PLAIN LOGIN".to_string()));
}
#[test]
fn test_empty_response() {
let resp = SmtpResponse::multiline(250, vec![]);
assert_eq!(resp.to_bytes(), b"250 \r\n");
}
#[test]
fn test_common_responses() {
assert_eq!(SmtpResponse::start_data().code, 354);
assert_eq!(SmtpResponse::syntax_error().code, 500);
assert_eq!(SmtpResponse::not_implemented().code, 502);
assert_eq!(SmtpResponse::bad_sequence("test").code, 503);
assert_eq!(SmtpResponse::auth_required().code, 530);
assert_eq!(SmtpResponse::auth_failed().code, 535);
assert_eq!(SmtpResponse::auth_success().code, 235);
}
}

View File

@@ -0,0 +1,331 @@
//! SMTP TCP/TLS server.
//!
//! Listens on configured ports, accepts connections, and dispatches
//! them to per-connection handlers.
use crate::config::SmtpServerConfig;
use crate::connection::{
self, CallbackRegistry, ConnectionEvent, SmtpStream,
};
use crate::rate_limiter::{RateLimitConfig, RateLimiter};
use hickory_resolver::TokioResolver;
use mailer_security::MessageAuthenticator;
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use std::io::BufReader;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use tokio::io::BufReader as TokioBufReader;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tracing::{error, info, warn};
/// Handle for a running SMTP server.
pub struct SmtpServerHandle {
/// Shutdown signal.
shutdown: Arc<AtomicBool>,
/// Join handles for the listener tasks.
handles: Vec<tokio::task::JoinHandle<()>>,
/// Active connection count.
pub active_connections: Arc<AtomicU32>,
}
impl SmtpServerHandle {
/// Signal shutdown and wait for all listeners to stop.
pub async fn shutdown(self) {
self.shutdown.store(true, Ordering::SeqCst);
for handle in self.handles {
let _ = handle.await;
}
info!("SMTP server shut down");
}
/// Check if the server is running.
pub fn is_running(&self) -> bool {
!self.shutdown.load(Ordering::SeqCst)
}
}
/// Start the SMTP server with the given configuration.
///
/// Returns a handle that can be used to shut down the server,
/// and an event receiver for connection events (emailReceived, authRequest).
pub async fn start_server(
config: SmtpServerConfig,
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
rate_limit_config: Option<RateLimitConfig>,
) -> Result<(SmtpServerHandle, mpsc::Receiver<ConnectionEvent>), Box<dyn std::error::Error + Send + Sync>>
{
let config = Arc::new(config);
let shutdown = Arc::new(AtomicBool::new(false));
let active_connections = Arc::new(AtomicU32::new(0));
let rate_limiter = Arc::new(RateLimiter::new(
rate_limit_config.unwrap_or_default(),
));
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
let tls_acceptor = if config.has_tls() {
Some(Arc::new(build_tls_acceptor(&config)?))
} else {
None
};
let mut handles = Vec::new();
// Start listeners on each port
for &port in &config.ports {
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
info!(port = port, "SMTP server listening (STARTTLS)");
let handle = tokio::spawn(accept_loop(
listener,
config.clone(),
shutdown.clone(),
active_connections.clone(),
rate_limiter.clone(),
event_tx.clone(),
callback_registry.clone(),
tls_acceptor.clone(),
false, // not implicit TLS
authenticator.clone(),
resolver.clone(),
));
handles.push(handle);
}
// Start implicit TLS listener if configured
if let Some(secure_port) = config.secure_port {
if tls_acceptor.is_some() {
let listener =
TcpListener::bind(format!("0.0.0.0:{secure_port}")).await?;
info!(port = secure_port, "SMTP server listening (implicit TLS)");
let handle = tokio::spawn(accept_loop(
listener,
config.clone(),
shutdown.clone(),
active_connections.clone(),
rate_limiter.clone(),
event_tx.clone(),
callback_registry.clone(),
tls_acceptor.clone(),
true, // implicit TLS
authenticator.clone(),
resolver.clone(),
));
handles.push(handle);
} else {
warn!("Secure port configured but TLS certificates not provided");
}
}
// Spawn periodic rate limiter cleanup
{
let rate_limiter = rate_limiter.clone();
let shutdown = shutdown.clone();
tokio::spawn(async move {
let mut interval =
tokio::time::interval(tokio::time::Duration::from_secs(60));
loop {
interval.tick().await;
if shutdown.load(Ordering::SeqCst) {
break;
}
rate_limiter.cleanup();
}
});
}
Ok((
SmtpServerHandle {
shutdown,
handles,
active_connections,
},
event_rx,
))
}
/// Accept loop for a single listener.
async fn accept_loop(
listener: TcpListener,
config: Arc<SmtpServerConfig>,
shutdown: Arc<AtomicBool>,
active_connections: Arc<AtomicU32>,
rate_limiter: Arc<RateLimiter>,
event_tx: mpsc::Sender<ConnectionEvent>,
callback_registry: Arc<dyn CallbackRegistry + Send + Sync>,
tls_acceptor: Option<Arc<tokio_rustls::TlsAcceptor>>,
implicit_tls: bool,
authenticator: Arc<MessageAuthenticator>,
resolver: Arc<TokioResolver>,
) {
loop {
if shutdown.load(Ordering::SeqCst) {
break;
}
// Use a short timeout to check shutdown periodically
let accept_result = tokio::time::timeout(
tokio::time::Duration::from_secs(1),
listener.accept(),
)
.await;
let (tcp_stream, peer_addr) = match accept_result {
Ok(Ok((stream, addr))) => (stream, addr),
Ok(Err(e)) => {
error!(error = %e, "Accept error");
continue;
}
Err(_) => continue, // timeout, check shutdown
};
// Check max connections
let current = active_connections.load(Ordering::SeqCst);
if current >= config.max_connections {
warn!(
current = current,
max = config.max_connections,
"Max connections reached, rejecting"
);
drop(tcp_stream);
continue;
}
let remote_addr = peer_addr.ip().to_string();
let config = config.clone();
let rate_limiter = rate_limiter.clone();
let event_tx = event_tx.clone();
let callback_registry = callback_registry.clone();
let tls_acceptor = tls_acceptor.clone();
let active_connections = active_connections.clone();
let authenticator = authenticator.clone();
let resolver = resolver.clone();
active_connections.fetch_add(1, Ordering::SeqCst);
tokio::spawn(async move {
let stream = if implicit_tls {
// Implicit TLS: wrap immediately
if let Some(acceptor) = &tls_acceptor {
match acceptor.accept(tcp_stream).await {
Ok(tls_stream) => {
SmtpStream::Tls(TokioBufReader::new(tls_stream))
}
Err(e) => {
warn!(
remote_addr = %remote_addr,
error = %e,
"Implicit TLS handshake failed"
);
active_connections.fetch_sub(1, Ordering::SeqCst);
return;
}
}
} else {
active_connections.fetch_sub(1, Ordering::SeqCst);
return;
}
} else {
SmtpStream::Plain(TokioBufReader::new(tcp_stream))
};
connection::handle_connection(
stream,
config,
rate_limiter,
event_tx,
callback_registry,
tls_acceptor,
remote_addr,
implicit_tls,
authenticator,
resolver,
)
.await;
active_connections.fetch_sub(1, Ordering::SeqCst);
});
}
}
/// Build a TLS acceptor from PEM cert/key strings.
fn build_tls_acceptor(
config: &SmtpServerConfig,
) -> Result<tokio_rustls::TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> {
let cert_pem = config
.tls_cert_pem
.as_ref()
.ok_or("TLS cert not configured")?;
let key_pem = config
.tls_key_pem
.as_ref()
.ok_or("TLS key not configured")?;
// Parse certificates
let certs: Vec<CertificateDer<'static>> = {
let mut reader = BufReader::new(cert_pem.as_bytes());
rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()?
};
if certs.is_empty() {
return Err("No certificates found in PEM".into());
}
// Parse private key
let key: PrivateKeyDer<'static> = {
let mut reader = BufReader::new(key_pem.as_bytes());
// Try PKCS8 first, then RSA, then EC
let mut keys = Vec::new();
for item in rustls_pemfile::read_all(&mut reader) {
match item? {
rustls_pemfile::Item::Pkcs8Key(key) => {
keys.push(PrivateKeyDer::Pkcs8(key));
}
rustls_pemfile::Item::Pkcs1Key(key) => {
keys.push(PrivateKeyDer::Pkcs1(key));
}
rustls_pemfile::Item::Sec1Key(key) => {
keys.push(PrivateKeyDer::Sec1(key));
}
_ => {}
}
}
keys.into_iter()
.next()
.ok_or("No private key found in PEM")?
};
let tls_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_server_config_defaults() {
let config = SmtpServerConfig::default();
assert!(!config.has_tls());
assert_eq!(config.ports, vec![25]);
}
}

View File

@@ -0,0 +1,206 @@
//! Per-connection SMTP session state.
//!
//! Tracks the envelope, authentication, TLS status, and counters
//! for a single SMTP connection.
use crate::state::SmtpState;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Envelope accumulator for the current mail transaction.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Envelope {
/// Sender address from MAIL FROM.
pub mail_from: String,
/// Recipient addresses from RCPT TO.
pub rcpt_to: Vec<String>,
/// Declared message size from MAIL FROM SIZE= param (if any).
pub declared_size: Option<u64>,
/// BODY parameter (e.g. "8BITMIME").
pub body_type: Option<String>,
}
/// Authentication state for the session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuthState {
/// Not authenticated and not in progress.
None,
/// Waiting for AUTH credentials (LOGIN flow step).
WaitingForUsername,
/// Have username, waiting for password.
WaitingForPassword { username: String },
/// Successfully authenticated.
Authenticated { username: String },
}
impl Default for AuthState {
fn default() -> Self {
AuthState::None
}
}
/// Per-connection session state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmtpSession {
/// Unique session identifier.
pub id: String,
/// Current protocol state.
pub state: SmtpState,
/// Client's EHLO/HELO hostname.
pub client_hostname: Option<String>,
/// Whether the client used EHLO (vs HELO).
pub esmtp: bool,
/// Whether the connection is using TLS.
pub secure: bool,
/// Authentication state.
pub auth_state: AuthState,
/// Current transaction envelope.
pub envelope: Envelope,
/// Remote IP address.
pub remote_addr: String,
/// Number of messages sent in this session.
pub message_count: u32,
/// Number of failed auth attempts.
pub auth_failures: u32,
/// Number of invalid commands.
pub invalid_commands: u32,
/// Maximum allowed invalid commands before disconnect.
pub max_invalid_commands: u32,
}
impl SmtpSession {
/// Create a new session for a connection.
pub fn new(remote_addr: String, secure: bool) -> Self {
Self {
id: Uuid::new_v4().to_string(),
state: SmtpState::Connected,
client_hostname: None,
esmtp: false,
secure,
auth_state: AuthState::None,
envelope: Envelope::default(),
remote_addr,
message_count: 0,
auth_failures: 0,
invalid_commands: 0,
max_invalid_commands: 20,
}
}
/// Reset the current transaction (RSET), preserving connection state.
pub fn reset_transaction(&mut self) {
self.envelope = Envelope::default();
if self.state != SmtpState::Connected {
self.state = SmtpState::Greeted;
}
}
/// Reset session for a new EHLO (preserves counters and TLS).
pub fn reset_for_ehlo(&mut self, hostname: String, esmtp: bool) {
self.client_hostname = Some(hostname);
self.esmtp = esmtp;
self.envelope = Envelope::default();
self.state = SmtpState::Greeted;
// Auth state is reset on new EHLO per RFC
self.auth_state = AuthState::None;
}
/// Check if the client is authenticated.
pub fn is_authenticated(&self) -> bool {
matches!(self.auth_state, AuthState::Authenticated { .. })
}
/// Get the authenticated username, if any.
pub fn authenticated_user(&self) -> Option<&str> {
match &self.auth_state {
AuthState::Authenticated { username } => Some(username),
_ => None,
}
}
/// Record a completed message delivery.
pub fn record_message(&mut self) {
self.message_count += 1;
}
/// Record a failed auth attempt. Returns true if limit exceeded.
pub fn record_auth_failure(&mut self, max_failures: u32) -> bool {
self.auth_failures += 1;
self.auth_failures >= max_failures
}
/// Record an invalid command. Returns true if limit exceeded.
pub fn record_invalid_command(&mut self) -> bool {
self.invalid_commands += 1;
self.invalid_commands >= self.max_invalid_commands
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_session() {
let session = SmtpSession::new("127.0.0.1".into(), false);
assert_eq!(session.state, SmtpState::Connected);
assert!(!session.secure);
assert!(!session.is_authenticated());
assert!(session.client_hostname.is_none());
}
#[test]
fn test_reset_transaction() {
let mut session = SmtpSession::new("127.0.0.1".into(), false);
session.state = SmtpState::RcptTo;
session.envelope.mail_from = "sender@example.com".into();
session.envelope.rcpt_to.push("rcpt@example.com".into());
session.reset_transaction();
assert_eq!(session.state, SmtpState::Greeted);
assert!(session.envelope.mail_from.is_empty());
assert!(session.envelope.rcpt_to.is_empty());
}
#[test]
fn test_reset_for_ehlo() {
let mut session = SmtpSession::new("127.0.0.1".into(), true);
session.auth_state = AuthState::Authenticated {
username: "user".into(),
};
session.reset_for_ehlo("mail.example.com".into(), true);
assert_eq!(session.state, SmtpState::Greeted);
assert_eq!(session.client_hostname.as_deref(), Some("mail.example.com"));
assert!(session.esmtp);
assert!(!session.is_authenticated()); // Auth reset after EHLO
}
#[test]
fn test_auth_failures() {
let mut session = SmtpSession::new("127.0.0.1".into(), false);
assert!(!session.record_auth_failure(3));
assert!(!session.record_auth_failure(3));
assert!(session.record_auth_failure(3)); // 3rd failure -> limit
}
#[test]
fn test_invalid_commands() {
let mut session = SmtpSession::new("127.0.0.1".into(), false);
session.max_invalid_commands = 3;
assert!(!session.record_invalid_command());
assert!(!session.record_invalid_command());
assert!(session.record_invalid_command()); // 3rd -> limit
}
#[test]
fn test_message_count() {
let mut session = SmtpSession::new("127.0.0.1".into(), false);
assert_eq!(session.message_count, 0);
session.record_message();
session.record_message();
assert_eq!(session.message_count, 2);
}
}

View File

@@ -0,0 +1,219 @@
//! SMTP protocol state machine.
//!
//! Defines valid states and transitions for an SMTP session.
use serde::{Deserialize, Serialize};
/// SMTP session states following RFC 5321.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SmtpState {
/// Initial state — waiting for server greeting.
Connected,
/// After successful EHLO/HELO.
Greeted,
/// After MAIL FROM accepted.
MailFrom,
/// After at least one RCPT TO accepted.
RcptTo,
/// In DATA mode — accumulating message body.
Data,
/// Transaction completed — can start a new one or QUIT.
Finished,
}
/// State transition errors.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum TransitionError {
#[error("cannot {action} in state {state:?}")]
InvalidTransition {
state: SmtpState,
action: &'static str,
},
}
impl SmtpState {
/// Check whether EHLO/HELO is valid in the current state.
/// EHLO/HELO can be issued at any time to reset the session.
pub fn can_ehlo(&self) -> bool {
true
}
/// Check whether MAIL FROM is valid in the current state.
pub fn can_mail_from(&self) -> bool {
matches!(self, SmtpState::Greeted | SmtpState::Finished)
}
/// Check whether RCPT TO is valid in the current state.
pub fn can_rcpt_to(&self) -> bool {
matches!(self, SmtpState::MailFrom | SmtpState::RcptTo)
}
/// Check whether DATA is valid in the current state.
pub fn can_data(&self) -> bool {
matches!(self, SmtpState::RcptTo)
}
/// Check whether STARTTLS is valid in the current state.
/// Only before a transaction starts.
pub fn can_starttls(&self) -> bool {
matches!(self, SmtpState::Connected | SmtpState::Greeted | SmtpState::Finished)
}
/// Check whether AUTH is valid in the current state.
/// Only after EHLO and before a transaction starts.
pub fn can_auth(&self) -> bool {
matches!(self, SmtpState::Greeted | SmtpState::Finished)
}
/// Transition to Greeted state (after EHLO/HELO).
pub fn transition_ehlo(&self) -> Result<SmtpState, TransitionError> {
// EHLO is always valid — it resets the session.
Ok(SmtpState::Greeted)
}
/// Transition to MailFrom state (after MAIL FROM accepted).
pub fn transition_mail_from(&self) -> Result<SmtpState, TransitionError> {
if self.can_mail_from() {
Ok(SmtpState::MailFrom)
} else {
Err(TransitionError::InvalidTransition {
state: *self,
action: "MAIL FROM",
})
}
}
/// Transition to RcptTo state (after RCPT TO accepted).
pub fn transition_rcpt_to(&self) -> Result<SmtpState, TransitionError> {
if self.can_rcpt_to() {
Ok(SmtpState::RcptTo)
} else {
Err(TransitionError::InvalidTransition {
state: *self,
action: "RCPT TO",
})
}
}
/// Transition to Data state (after DATA command accepted).
pub fn transition_data(&self) -> Result<SmtpState, TransitionError> {
if self.can_data() {
Ok(SmtpState::Data)
} else {
Err(TransitionError::InvalidTransition {
state: *self,
action: "DATA",
})
}
}
/// Transition to Finished state (after end-of-data).
pub fn transition_finished(&self) -> Result<SmtpState, TransitionError> {
if *self == SmtpState::Data {
Ok(SmtpState::Finished)
} else {
Err(TransitionError::InvalidTransition {
state: *self,
action: "finish DATA",
})
}
}
/// Reset to Greeted state (after RSET command).
pub fn transition_rset(&self) -> Result<SmtpState, TransitionError> {
match self {
SmtpState::Connected => Err(TransitionError::InvalidTransition {
state: *self,
action: "RSET",
}),
_ => Ok(SmtpState::Greeted),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initial_state() {
let state = SmtpState::Connected;
assert!(!state.can_mail_from());
assert!(!state.can_rcpt_to());
assert!(!state.can_data());
assert!(state.can_starttls());
assert!(state.can_ehlo());
}
#[test]
fn test_ehlo_always_valid() {
for state in [
SmtpState::Connected,
SmtpState::Greeted,
SmtpState::MailFrom,
SmtpState::RcptTo,
SmtpState::Data,
SmtpState::Finished,
] {
assert!(state.can_ehlo());
assert!(state.transition_ehlo().is_ok());
}
}
#[test]
fn test_normal_flow() {
let state = SmtpState::Connected;
let state = state.transition_ehlo().unwrap();
assert_eq!(state, SmtpState::Greeted);
let state = state.transition_mail_from().unwrap();
assert_eq!(state, SmtpState::MailFrom);
let state = state.transition_rcpt_to().unwrap();
assert_eq!(state, SmtpState::RcptTo);
// Multiple RCPT TO
let state = state.transition_rcpt_to().unwrap();
assert_eq!(state, SmtpState::RcptTo);
let state = state.transition_data().unwrap();
assert_eq!(state, SmtpState::Data);
let state = state.transition_finished().unwrap();
assert_eq!(state, SmtpState::Finished);
// New transaction
let state = state.transition_mail_from().unwrap();
assert_eq!(state, SmtpState::MailFrom);
}
#[test]
fn test_invalid_transitions() {
assert!(SmtpState::Connected.transition_mail_from().is_err());
assert!(SmtpState::Connected.transition_rcpt_to().is_err());
assert!(SmtpState::Connected.transition_data().is_err());
assert!(SmtpState::Greeted.transition_rcpt_to().is_err());
assert!(SmtpState::Greeted.transition_data().is_err());
assert!(SmtpState::MailFrom.transition_data().is_err());
}
#[test]
fn test_rset() {
let state = SmtpState::RcptTo;
let state = state.transition_rset().unwrap();
assert_eq!(state, SmtpState::Greeted);
// RSET from Connected is invalid (no EHLO yet)
assert!(SmtpState::Connected.transition_rset().is_err());
}
#[test]
fn test_starttls_validity() {
assert!(SmtpState::Connected.can_starttls());
assert!(SmtpState::Greeted.can_starttls());
assert!(!SmtpState::MailFrom.can_starttls());
assert!(!SmtpState::RcptTo.can_starttls());
assert!(!SmtpState::Data.can_starttls());
assert!(SmtpState::Finished.can_starttls());
}
}

View File

@@ -0,0 +1,169 @@
//! SMTP-level validation utilities.
//!
//! Address parsing, EHLO hostname validation, and header injection detection.
use regex::Regex;
use std::sync::LazyLock;
/// Regex for basic email address format validation.
static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap()
});
/// Regex for valid EHLO hostname (domain name or IPv4/IPv6 literal).
/// Currently unused in favor of a more permissive check, but available
/// for strict validation if needed.
#[allow(dead_code)]
static EHLO_RE: LazyLock<Regex> = LazyLock::new(|| {
// Permissive: domain names, IP literals [1.2.3.4], [IPv6:...], or bare words
Regex::new(r"^(?:\[(?:IPv6:)?[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9\-\.]*[a-zA-Z0-9])?)$").unwrap()
});
/// Validate an email address for basic SMTP format.
///
/// Returns `true` if the address has a valid-looking format.
/// Empty addresses (for bounce messages, MAIL FROM:<>) return `true`.
pub fn is_valid_smtp_address(address: &str) -> bool {
// Empty address is valid for MAIL FROM (bounce)
if address.is_empty() {
return true;
}
EMAIL_RE.is_match(address)
}
/// Validate an EHLO/HELO hostname.
///
/// Returns `true` if the hostname looks syntactically valid.
/// We are permissive because real-world SMTP clients send all kinds of values.
pub fn is_valid_ehlo_hostname(hostname: &str) -> bool {
if hostname.is_empty() {
return false;
}
// Be permissive — most SMTP servers accept anything non-empty.
// Only reject obviously malicious patterns.
if hostname.len() > 255 {
return false;
}
if contains_header_injection(hostname) {
return false;
}
// Must not contain null bytes
if hostname.contains('\0') {
return false;
}
true
}
/// Check for SMTP header injection attempts.
///
/// Returns `true` if the input contains characters that could be used
/// for header injection (bare CR/LF).
pub fn contains_header_injection(input: &str) -> bool {
input.contains('\r') || input.contains('\n')
}
/// Validate the size parameter from MAIL FROM.
///
/// Returns the parsed size if valid and within the max, or an error message.
pub fn validate_size_param(value: &str, max_size: u64) -> Result<u64, String> {
let size: u64 = value
.parse()
.map_err(|_| format!("invalid SIZE value: {value}"))?;
if size > max_size {
return Err(format!(
"message size {size} exceeds maximum {max_size}"
));
}
Ok(size)
}
/// Extract the domain part from an email address.
pub fn extract_domain(address: &str) -> Option<&str> {
if address.is_empty() {
return None;
}
address.rsplit_once('@').map(|(_, domain)| domain)
}
/// Normalize an email address by lowercasing the domain part.
pub fn normalize_address(address: &str) -> String {
if address.is_empty() {
return String::new();
}
match address.rsplit_once('@') {
Some((local, domain)) => format!("{local}@{}", domain.to_ascii_lowercase()),
None => address.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_email() {
assert!(is_valid_smtp_address("user@example.com"));
assert!(is_valid_smtp_address("user+tag@sub.example.com"));
assert!(is_valid_smtp_address("a@b.c"));
}
#[test]
fn test_empty_address_valid() {
assert!(is_valid_smtp_address(""));
}
#[test]
fn test_invalid_email() {
assert!(!is_valid_smtp_address("no-at-sign"));
assert!(!is_valid_smtp_address("@no-local.com"));
assert!(!is_valid_smtp_address("user@"));
assert!(!is_valid_smtp_address("user@nodot"));
assert!(!is_valid_smtp_address("has space@example.com"));
}
#[test]
fn test_valid_ehlo() {
assert!(is_valid_ehlo_hostname("mail.example.com"));
assert!(is_valid_ehlo_hostname("localhost"));
assert!(is_valid_ehlo_hostname("[127.0.0.1]"));
assert!(is_valid_ehlo_hostname("[IPv6:::1]"));
}
#[test]
fn test_invalid_ehlo() {
assert!(!is_valid_ehlo_hostname(""));
assert!(!is_valid_ehlo_hostname("host\r\nname"));
assert!(!is_valid_ehlo_hostname(&"a".repeat(256)));
}
#[test]
fn test_header_injection() {
assert!(contains_header_injection("test\r\nBcc: evil@evil.com"));
assert!(contains_header_injection("test\ninjection"));
assert!(contains_header_injection("test\rinjection"));
assert!(!contains_header_injection("normal text"));
}
#[test]
fn test_size_param() {
assert_eq!(validate_size_param("12345", 1_000_000), Ok(12345));
assert!(validate_size_param("99999999", 1_000).is_err());
assert!(validate_size_param("notanumber", 1_000).is_err());
}
#[test]
fn test_extract_domain() {
assert_eq!(extract_domain("user@example.com"), Some("example.com"));
assert_eq!(extract_domain(""), None);
assert_eq!(extract_domain("nodomain"), None);
}
#[test]
fn test_normalize_address() {
assert_eq!(
normalize_address("User@EXAMPLE.COM"),
"User@example.com"
);
assert_eq!(normalize_address(""), "");
}
}

View File

@@ -1,8 +1,4 @@
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 {
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> {
// Find a free port if one wasn't specified
// Using smartnetwork to find an available port in the range 10000-60000
let port = config.port;
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()
};
export async function startTestServer(_config: ITestServerConfig): Promise<ITestServer> {
throw new Error(
'startTestServer is no longer available — the TS SMTP server was removed in Phase 7B. ' +
'Use the Rust SMTP server (via UnifiedEmailServer) for integration testing.'
);
}
/**
@@ -193,77 +42,29 @@ export async function startTestServer(config: ITestServerConfig): Promise<ITestS
*/
export async function stopTestServer(testServer: ITestServer): Promise<void> {
if (!testServer || !testServer.smtpServer) {
console.warn('⚠️ No test server to stop');
return;
}
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') {
await testServer.smtpServer.close();
}
// Wait for port to be free
await waitForPortFree(testServer.port);
console.log(`✅ Test SMTP server stopped`);
} catch (error) {
console.error('Error stopping test server:', error);
console.error('Error stopping test server:', 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> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
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));
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(`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`);
throw new Error(`No available ports found starting from ${startPort}`);
}
/**
@@ -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
*/

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

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