Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f601859f8b | |||
| eb2643de93 | |||
| 595634fb0f | |||
| cee8a51081 | |||
| f1c5546186 | |||
| 5220ee0857 | |||
| fc2e6d44f4 | |||
| 15a45089aa | |||
| b82468ab1e | |||
| ffe294643c | |||
| f1071faf3d | |||
| 6b082cee8f |
@@ -84,7 +84,7 @@ jobs:
|
|||||||
mailer --version || echo "Note: Binary execution may fail in CI environment"
|
mailer --version || echo "Note: Binary execution may fail in CI environment"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Checking installed files:"
|
echo "Checking installed files:"
|
||||||
npm ls -g @serve.zone/mailer || true
|
npm ls -g @push.rocks/smartmta || true
|
||||||
|
|
||||||
- name: Publish to npm
|
- name: Publish to npm
|
||||||
env:
|
env:
|
||||||
@@ -93,10 +93,10 @@ jobs:
|
|||||||
echo "Publishing to npm registry..."
|
echo "Publishing to npm registry..."
|
||||||
npm publish --access public
|
npm publish --access public
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Successfully published @serve.zone/mailer to npm!"
|
echo "✅ Successfully published @push.rocks/smartmta to npm!"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Package info:"
|
echo "Package info:"
|
||||||
npm view @serve.zone/mailer
|
npm view @push.rocks/smartmta
|
||||||
|
|
||||||
- name: Verify npm package
|
- name: Verify npm package
|
||||||
run: |
|
run: |
|
||||||
@@ -104,10 +104,10 @@ jobs:
|
|||||||
sleep 30
|
sleep 30
|
||||||
echo ""
|
echo ""
|
||||||
echo "Verifying published package..."
|
echo "Verifying published package..."
|
||||||
npm view @serve.zone/mailer
|
npm view @push.rocks/smartmta
|
||||||
echo ""
|
echo ""
|
||||||
echo "Testing installation from npm:"
|
echo "Testing installation from npm:"
|
||||||
npm install -g @serve.zone/mailer
|
npm install -g @push.rocks/smartmta
|
||||||
echo ""
|
echo ""
|
||||||
echo "Package installed successfully!"
|
echo "Package installed successfully!"
|
||||||
which mailer || echo "Binary location check skipped"
|
which mailer || echo "Binary location check skipped"
|
||||||
@@ -118,12 +118,12 @@ jobs:
|
|||||||
echo " npm Publish Complete!"
|
echo " npm Publish Complete!"
|
||||||
echo "================================================"
|
echo "================================================"
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Package: @serve.zone/mailer"
|
echo "✅ Package: @push.rocks/smartmta"
|
||||||
echo "✅ Version: ${{ steps.version.outputs.version }}"
|
echo "✅ Version: ${{ steps.version.outputs.version }}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Installation:"
|
echo "Installation:"
|
||||||
echo " npm install -g @serve.zone/mailer"
|
echo " npm install -g @push.rocks/smartmta"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Registry:"
|
echo "Registry:"
|
||||||
echo " https://www.npmjs.com/package/@serve.zone/mailer"
|
echo " https://www.npmjs.com/package/@push.rocks/smartmta"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
52
changelog.md
52
changelog.md
@@ -1,5 +1,57 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.3.0 - feat(mailer-smtp)
|
||||||
|
add in-process security pipeline for SMTP delivery (DKIM/SPF/DMARC, content scanning, IP reputation)
|
||||||
|
|
||||||
|
- Integrate mailer_security verification (DKIM/SPF/DMARC) and IP reputation checks into the Rust SMTP server; run concurrently and wrapped with a 30s timeout.
|
||||||
|
- Add MIME parsing using mailparse and an extract_mime_parts helper to extract subject, text/html bodies and attachment filenames for content scanning.
|
||||||
|
- Wire MessageAuthenticator and TokioResolver into server and connection startup; pass them into the delivery pipeline and connection handlers.
|
||||||
|
- Run content scanning (mailer_security::content_scanner), combine results (dkim/spf/dmarc, contentScan, ipReputation) into a JSON object and attach as security_results on EmailReceived events.
|
||||||
|
- Update Rust crates (Cargo.toml/Cargo.lock) to include mailparse and resolver usage and add serde::Deserialize where required; add unit tests for MIME extraction.
|
||||||
|
- Remove the TypeScript SMTP server implementation and many TS tests; replace test helper (server.loader.ts) with a stub that points tests to use the Rust SMTP server and provide small utilities (getAvailablePort/isPortFree).
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.2.1 - fix(readme)
|
||||||
|
Clarify Rust-powered architecture and mandatory Rust bridge; expand README with Rust workspace details and project structure updates
|
||||||
|
|
||||||
|
- Emphasizes that the SMTP server is Rust-powered (high-performance) and not a nodemailer-based TS server.
|
||||||
|
- Documents that the Rust binary (mailer-bin) is required — if unavailable UnifiedEmailServer.start() will throw an error.
|
||||||
|
- Adds installation/build note: run `pnpm build` to compile the Rust binary.
|
||||||
|
- Adds a new Rust Acceleration Layer section listing workspace crates and responsibilities (mailer-core, mailer-security, mailer-smtp, mailer-bin, mailer-napi).
|
||||||
|
- Updates project structure: marks legacy TS SMTP server as fallback/legacy, adds dist_rust output, and clarifies which operations run in Rust vs TypeScript.
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.2.0 - feat(mailer-smtp)
|
||||||
|
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
|
||||||
|
|
||||||
|
- Documented RustSecurityBridge: startup/shutdown, automatic delegation, compound verifyEmail API, and individual operations
|
||||||
|
- Clarified verification APIs: SpfVerifier.verify() and DmarcVerifier.verify() examples now take an Email object as the first argument
|
||||||
|
- Updated example method names/usages: scanEmail, createEmail, evaluateRoutes, checkMessageLimit, isEmailSuppressed, DKIMCreator rotation and output formatting
|
||||||
|
- Reformatted architecture diagram and added Rust Security Bridge and expanded Rust Acceleration details
|
||||||
|
- Rate limiter example updated: renamed/standardized config keys (maxMessagesPerMinute, domains) and added additional limits (maxRecipientsPerMessage, maxConnectionsPerIP, etc.)
|
||||||
|
- DNS management documentation reorganized: UnifiedEmailServer now handles DNS record setup automatically; DNSManager usage clarified for standalone checks
|
||||||
|
- Minor wording/formatting tweaks throughout README (arrow styles, headings, test counts)
|
||||||
|
|
||||||
## 2026-02-10 - 2.0.0 - BREAKING CHANGE(smartmta)
|
## 2026-02-10 - 2.0.0 - BREAKING CHANGE(smartmta)
|
||||||
Rebrand package to @push.rocks/smartmta, add consolidated email security verification and IPC handler
|
Rebrand package to @push.rocks/smartmta, add consolidated email security verification and IPC handler
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartmta',
|
name: '@push.rocks/smartmta',
|
||||||
version: '1.3.1',
|
version: '2.2.1',
|
||||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxvQkFBb0I7SUFDMUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHNHQUFzRztDQUNwSCxDQUFBIn0=
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=
|
||||||
15
dist_ts/mail/core/classes.bouncemanager.d.ts
vendored
15
dist_ts/mail/core/classes.bouncemanager.d.ts
vendored
@@ -165,21 +165,6 @@ export declare class BounceManager {
|
|||||||
type: BounceType;
|
type: BounceType;
|
||||||
category: BounceCategory;
|
category: BounceCategory;
|
||||||
} | null;
|
} | 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
|
* Get all known hard bounced addresses
|
||||||
* @returns Array of hard bounced email 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
@@ -1,4 +1,5 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
export class EmailSignJob {
|
export class EmailSignJob {
|
||||||
emailServerRef;
|
emailServerRef;
|
||||||
jobOptions;
|
jobOptions;
|
||||||
@@ -12,25 +13,14 @@ export class EmailSignJob {
|
|||||||
}
|
}
|
||||||
async getSignatureHeader(emailMessage) {
|
async getSignatureHeader(emailMessage) {
|
||||||
const privateKey = await this.loadPrivateKey();
|
const privateKey = await this.loadPrivateKey();
|
||||||
const signResult = await plugins.dkimSign(emailMessage, {
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
signingDomain: this.jobOptions.domain,
|
const signResult = await bridge.signDkim({
|
||||||
|
rawMessage: emailMessage,
|
||||||
|
domain: this.jobOptions.domain,
|
||||||
selector: this.jobOptions.selector,
|
selector: this.jobOptions.selector,
|
||||||
privateKey,
|
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 signResult.header;
|
||||||
return signature;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5lbWFpbHNpZ25qb2IuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2NsYXNzZXMuZW1haWxzaWduam9iLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFjNUMsTUFBTSxPQUFPLFlBQVk7SUFDdkIsY0FBYyxDQUFxQjtJQUNuQyxVQUFVLENBQXVCO0lBRWpDLFlBQVksY0FBa0MsRUFBRSxPQUE2QjtRQUMzRSxJQUFJLENBQUMsY0FBYyxHQUFHLGNBQWMsQ0FBQztRQUNyQyxJQUFJLENBQUMsVUFBVSxHQUFHLE9BQU8sQ0FBQztJQUM1QixDQUFDO0lBRUQsS0FBSyxDQUFDLGNBQWM7UUFDbEIsTUFBTSxPQUFPLEdBQUcsTUFBTSxJQUFJLENBQUMsY0FBYyxDQUFDLFdBQVcsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUMzRixPQUFPLE9BQU8sQ0FBQyxVQUFVLENBQUM7SUFDNUIsQ0FBQztJQUVNLEtBQUssQ0FBQyxrQkFBa0IsQ0FBQyxZQUFvQjtRQUNsRCxNQUFNLFVBQVUsR0FBRyxNQUFNLElBQUksQ0FBQyxjQUFjLEVBQUUsQ0FBQztRQUMvQyxNQUFNLFVBQVUsR0FBRyxNQUFNLE9BQU8sQ0FBQyxRQUFRLENBQUMsWUFBWSxFQUFFO1lBQ3RELGFBQWEsRUFBRSxJQUFJLENBQUMsVUFBVSxDQUFDLE1BQU07WUFDckMsUUFBUSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsUUFBUTtZQUNsQyxVQUFVO1lBQ1YsZ0JBQWdCLEVBQUUsaUJBQWlCO1lBQ25DLFNBQVMsRUFBRSxZQUFZO1lBQ3ZCLFFBQVEsRUFBRSxJQUFJLElBQUksRUFBRTtZQUNwQixhQUFhLEVBQUU7Z0JBQ2I7b0JBQ0UsYUFBYSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsTUFBTTtvQkFDckMsUUFBUSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsUUFBUTtvQkFDbEMsVUFBVTtvQkFDVixTQUFTLEVBQUUsWUFBWTtvQkFDdkIsZ0JBQWdCLEVBQUUsaUJBQWlCO2lCQUNwQzthQUNGO1NBQ0YsQ0FBQyxDQUFDO1FBQ0gsTUFBTSxTQUFTLEdBQUcsVUFBVSxDQUFDLFVBQVUsQ0FBQztRQUN4QyxPQUFPLFNBQVMsQ0FBQztJQUNuQixDQUFDO0NBQ0YifQ==
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5lbWFpbHNpZ25qb2IuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2NsYXNzZXMuZW1haWxzaWduam9iLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sOENBQThDLENBQUM7QUFhbEYsTUFBTSxPQUFPLFlBQVk7SUFDdkIsY0FBYyxDQUFxQjtJQUNuQyxVQUFVLENBQXVCO0lBRWpDLFlBQVksY0FBa0MsRUFBRSxPQUE2QjtRQUMzRSxJQUFJLENBQUMsY0FBYyxHQUFHLGNBQWMsQ0FBQztRQUNyQyxJQUFJLENBQUMsVUFBVSxHQUFHLE9BQU8sQ0FBQztJQUM1QixDQUFDO0lBRUQsS0FBSyxDQUFDLGNBQWM7UUFDbEIsTUFBTSxPQUFPLEdBQUcsTUFBTSxJQUFJLENBQUMsY0FBYyxDQUFDLFdBQVcsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUMzRixPQUFPLE9BQU8sQ0FBQyxVQUFVLENBQUM7SUFDNUIsQ0FBQztJQUVNLEtBQUssQ0FBQyxrQkFBa0IsQ0FBQyxZQUFvQjtRQUNsRCxNQUFNLFVBQVUsR0FBRyxNQUFNLElBQUksQ0FBQyxjQUFjLEVBQUUsQ0FBQztRQUMvQyxNQUFNLE1BQU0sR0FBRyxrQkFBa0IsQ0FBQyxXQUFXLEVBQUUsQ0FBQztRQUNoRCxNQUFNLFVBQVUsR0FBRyxNQUFNLE1BQU0sQ0FBQyxRQUFRLENBQUM7WUFDdkMsVUFBVSxFQUFFLFlBQVk7WUFDeEIsTUFBTSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsTUFBTTtZQUM5QixRQUFRLEVBQUUsSUFBSSxDQUFDLFVBQVUsQ0FBQyxRQUFRO1lBQ2xDLFVBQVU7U0FDWCxDQUFDLENBQUM7UUFDSCxPQUFPLFVBQVUsQ0FBQyxNQUFNLENBQUM7SUFDM0IsQ0FBQztDQUNGIn0=
|
||||||
File diff suppressed because one or more lines are too long
3
dist_ts/mail/delivery/index.d.ts
vendored
3
dist_ts/mail/delivery/index.d.ts
vendored
@@ -8,5 +8,4 @@ export type { IRateLimitConfig } from './classes.ratelimiter.js';
|
|||||||
export * from './classes.unified.rate.limiter.js';
|
export * from './classes.unified.rate.limiter.js';
|
||||||
export * from './classes.mta.config.js';
|
export * from './classes.mta.config.js';
|
||||||
import * as smtpClientMod from './smtpclient/index.js';
|
import * as smtpClientMod from './smtpclient/index.js';
|
||||||
import * as smtpServerMod from './smtpserver/index.js';
|
export { smtpClientMod };
|
||||||
export { smtpClientMod, smtpServerMod };
|
|
||||||
|
|||||||
@@ -13,6 +13,5 @@ export * from './classes.unified.rate.limiter.js';
|
|||||||
export * from './classes.mta.config.js';
|
export * from './classes.mta.config.js';
|
||||||
// Import and export SMTP modules as namespaces to avoid conflicts
|
// Import and export SMTP modules as namespaces to avoid conflicts
|
||||||
import * as smtpClientMod from './smtpclient/index.js';
|
import * as smtpClientMod from './smtpclient/index.js';
|
||||||
import * as smtpServerMod from './smtpserver/index.js';
|
export { smtpClientMod };
|
||||||
export { smtpClientMod, smtpServerMod };
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDRCQUE0QjtBQUM1QixjQUFjLDJCQUEyQixDQUFDO0FBQzFDLGNBQWMsNkJBQTZCLENBQUM7QUFDNUMsY0FBYyw4QkFBOEIsQ0FBQztBQUU3Qyx1Q0FBdUM7QUFDdkMsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBQ3pELE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSw4QkFBOEIsQ0FBQztBQUU5RCw2Q0FBNkM7QUFDN0MsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBR3ZELHVCQUF1QjtBQUN2QixjQUFjLG1DQUFtQyxDQUFDO0FBRWxELGdDQUFnQztBQUNoQyxjQUFjLHlCQUF5QixDQUFDO0FBRXhDLGtFQUFrRTtBQUNsRSxPQUFPLEtBQUssYUFBYSxNQUFNLHVCQUF1QixDQUFDO0FBRXZELE9BQU8sRUFBRSxhQUFhLEVBQUUsQ0FBQyJ9
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDRCQUE0QjtBQUM1QixjQUFjLDJCQUEyQixDQUFDO0FBQzFDLGNBQWMsNkJBQTZCLENBQUM7QUFDNUMsY0FBYyw4QkFBOEIsQ0FBQztBQUU3Qyx1Q0FBdUM7QUFDdkMsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBQ3pELE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSw4QkFBOEIsQ0FBQztBQUU5RCw2Q0FBNkM7QUFDN0MsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBR3ZELHVCQUF1QjtBQUN2QixjQUFjLG1DQUFtQyxDQUFDO0FBRWxELGdDQUFnQztBQUNoQyxjQUFjLHlCQUF5QixDQUFDO0FBRXhDLGtFQUFrRTtBQUNsRSxPQUFPLEtBQUssYUFBYSxNQUFNLHVCQUF1QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxhQUFhLE1BQU0sdUJBQXVCLENBQUM7QUFFdkQsT0FBTyxFQUFFLGFBQWEsRUFBRSxhQUFhLEVBQUUsQ0FBQyJ9
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Certificate Utilities for SMTP Server
|
|
||||||
* Provides utilities for managing TLS certificates
|
|
||||||
*/
|
|
||||||
import * as tls from 'tls';
|
|
||||||
/**
|
|
||||||
* Certificate data
|
|
||||||
*/
|
|
||||||
export interface ICertificateData {
|
|
||||||
key: Buffer;
|
|
||||||
cert: Buffer;
|
|
||||||
ca?: Buffer;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Load certificates from PEM format strings
|
|
||||||
* @param options - Certificate options
|
|
||||||
* @returns Certificate data with Buffer format
|
|
||||||
*/
|
|
||||||
export declare function loadCertificatesFromString(options: {
|
|
||||||
key: string | Buffer;
|
|
||||||
cert: string | Buffer;
|
|
||||||
ca?: string | Buffer;
|
|
||||||
}): ICertificateData;
|
|
||||||
/**
|
|
||||||
* Load certificates from files
|
|
||||||
* @param options - Certificate file paths
|
|
||||||
* @returns Certificate data with Buffer format
|
|
||||||
*/
|
|
||||||
export declare function loadCertificatesFromFiles(options: {
|
|
||||||
keyPath: string;
|
|
||||||
certPath: string;
|
|
||||||
caPath?: string;
|
|
||||||
}): ICertificateData;
|
|
||||||
/**
|
|
||||||
* Generate self-signed certificates for testing
|
|
||||||
* @returns Certificate data with Buffer format
|
|
||||||
*/
|
|
||||||
export declare function generateSelfSignedCertificates(): ICertificateData;
|
|
||||||
/**
|
|
||||||
* Create TLS options for secure server or STARTTLS
|
|
||||||
* @param certificates - Certificate data
|
|
||||||
* @param isServer - Whether this is for server (true) or client (false)
|
|
||||||
* @returns TLS options
|
|
||||||
*/
|
|
||||||
export declare function createTlsOptions(certificates: ICertificateData, isServer?: boolean): tls.TlsOptions;
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,156 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Command Handler
|
|
||||||
* Responsible for parsing and handling SMTP commands
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { ISmtpSession } from './interfaces.js';
|
|
||||||
import type { ICommandHandler, ISmtpServer } from './interfaces.js';
|
|
||||||
import { SmtpCommand } from './constants.js';
|
|
||||||
/**
|
|
||||||
* Handles SMTP commands and responses
|
|
||||||
*/
|
|
||||||
export declare class CommandHandler implements ICommandHandler {
|
|
||||||
/**
|
|
||||||
* Reference to the SMTP server instance
|
|
||||||
*/
|
|
||||||
private smtpServer;
|
|
||||||
/**
|
|
||||||
* Creates a new command handler
|
|
||||||
* @param smtpServer - SMTP server instance
|
|
||||||
*/
|
|
||||||
constructor(smtpServer: ISmtpServer);
|
|
||||||
/**
|
|
||||||
* Process a command from the client
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param commandLine - Command line from client
|
|
||||||
*/
|
|
||||||
processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Send a response to the client
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param response - Response to send
|
|
||||||
*/
|
|
||||||
sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void;
|
|
||||||
/**
|
|
||||||
* Check if a socket error is potentially recoverable
|
|
||||||
* @param error - The error that occurred
|
|
||||||
* @returns Whether the error is potentially recoverable
|
|
||||||
*/
|
|
||||||
private isRecoverableSocketError;
|
|
||||||
/**
|
|
||||||
* Handle recoverable socket errors with retry logic
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param error - The error that occurred
|
|
||||||
* @param response - The response that failed to send
|
|
||||||
*/
|
|
||||||
private handleSocketError;
|
|
||||||
/**
|
|
||||||
* Handle EHLO command
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param clientHostname - Client hostname from EHLO command
|
|
||||||
*/
|
|
||||||
handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void;
|
|
||||||
/**
|
|
||||||
* Handle MAIL FROM command
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments
|
|
||||||
*/
|
|
||||||
handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void;
|
|
||||||
/**
|
|
||||||
* Handle RCPT TO command
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments
|
|
||||||
*/
|
|
||||||
handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void;
|
|
||||||
/**
|
|
||||||
* Handle DATA command
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Handle RSET command
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Handle NOOP command
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleNoop(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Handle QUIT command
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket, args?: string): void;
|
|
||||||
/**
|
|
||||||
* Handle AUTH command
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments
|
|
||||||
*/
|
|
||||||
private handleAuth;
|
|
||||||
/**
|
|
||||||
* Handle AUTH PLAIN authentication
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param session - Session
|
|
||||||
* @param initialResponse - Optional initial response
|
|
||||||
*/
|
|
||||||
private handleAuthPlain;
|
|
||||||
/**
|
|
||||||
* Handle AUTH LOGIN authentication
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param session - Session
|
|
||||||
* @param initialResponse - Optional initial response
|
|
||||||
*/
|
|
||||||
private handleAuthLogin;
|
|
||||||
/**
|
|
||||||
* Handle AUTH LOGIN response
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param session - Session
|
|
||||||
* @param response - Response from client
|
|
||||||
*/
|
|
||||||
private handleAuthLoginResponse;
|
|
||||||
/**
|
|
||||||
* Handle HELP command
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments
|
|
||||||
*/
|
|
||||||
private handleHelp;
|
|
||||||
/**
|
|
||||||
* Handle VRFY command (Verify user/mailbox)
|
|
||||||
* RFC 5321 Section 3.5.1: Server MAY respond with 252 to avoid disclosing sensitive information
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments (username to verify)
|
|
||||||
*/
|
|
||||||
private handleVrfy;
|
|
||||||
/**
|
|
||||||
* Handle EXPN command (Expand mailing list)
|
|
||||||
* RFC 5321 Section 3.5.2: Server MAY disable this for security
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param args - Command arguments (mailing list to expand)
|
|
||||||
*/
|
|
||||||
private handleExpn;
|
|
||||||
/**
|
|
||||||
* Reset session to after-EHLO state
|
|
||||||
* @param session - SMTP session to reset
|
|
||||||
*/
|
|
||||||
private resetSession;
|
|
||||||
/**
|
|
||||||
* Validate command sequence based on current state
|
|
||||||
* @param command - Command to validate
|
|
||||||
* @param session - Current session
|
|
||||||
* @returns Whether the command is valid in the current state
|
|
||||||
*/
|
|
||||||
private validateCommandSequence;
|
|
||||||
/**
|
|
||||||
* Handle an SMTP command (interface requirement)
|
|
||||||
*/
|
|
||||||
handleCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, command: SmtpCommand, args: string, session: ISmtpSession): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Get supported commands for current session state (interface requirement)
|
|
||||||
*/
|
|
||||||
getSupportedCommands(session: ISmtpSession): SmtpCommand[];
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,159 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Connection Manager
|
|
||||||
* Responsible for managing socket connections to the SMTP server
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { IConnectionManager, ISmtpServer } from './interfaces.js';
|
|
||||||
/**
|
|
||||||
* Manager for SMTP connections
|
|
||||||
* Handles connection setup, event listeners, and lifecycle management
|
|
||||||
* Provides resource management, connection tracking, and monitoring
|
|
||||||
*/
|
|
||||||
export declare class ConnectionManager implements IConnectionManager {
|
|
||||||
/**
|
|
||||||
* Reference to the SMTP server instance
|
|
||||||
*/
|
|
||||||
private smtpServer;
|
|
||||||
/**
|
|
||||||
* Set of active socket connections
|
|
||||||
*/
|
|
||||||
private activeConnections;
|
|
||||||
/**
|
|
||||||
* Connection tracking for resource management
|
|
||||||
*/
|
|
||||||
private connectionStats;
|
|
||||||
/**
|
|
||||||
* Per-IP connection tracking for rate limiting
|
|
||||||
*/
|
|
||||||
private ipConnections;
|
|
||||||
/**
|
|
||||||
* Resource monitoring interval
|
|
||||||
*/
|
|
||||||
private resourceCheckInterval;
|
|
||||||
/**
|
|
||||||
* Track cleanup timers so we can clear them
|
|
||||||
*/
|
|
||||||
private cleanupTimers;
|
|
||||||
/**
|
|
||||||
* SMTP server options with enhanced resource controls
|
|
||||||
*/
|
|
||||||
private options;
|
|
||||||
/**
|
|
||||||
* Creates a new connection manager with enhanced resource management
|
|
||||||
* @param smtpServer - SMTP server instance
|
|
||||||
*/
|
|
||||||
constructor(smtpServer: ISmtpServer);
|
|
||||||
/**
|
|
||||||
* Start resource monitoring interval to check resource usage
|
|
||||||
*/
|
|
||||||
private startResourceMonitoring;
|
|
||||||
/**
|
|
||||||
* Monitor resource usage and log statistics
|
|
||||||
*/
|
|
||||||
private monitorResourceUsage;
|
|
||||||
/**
|
|
||||||
* Clean up expired IP rate limits and perform additional resource monitoring
|
|
||||||
*/
|
|
||||||
private cleanupIpRateLimits;
|
|
||||||
/**
|
|
||||||
* Validate and repair resource tracking to prevent leaks
|
|
||||||
*/
|
|
||||||
private validateResourceTracking;
|
|
||||||
/**
|
|
||||||
* Handle a new connection with resource management
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleNewConnection(socket: plugins.net.Socket): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Check if an IP has exceeded the rate limit
|
|
||||||
* @param ip - Client IP address
|
|
||||||
* @returns True if rate limited
|
|
||||||
*/
|
|
||||||
private isIPRateLimited;
|
|
||||||
/**
|
|
||||||
* Track a new connection from an IP
|
|
||||||
* @param ip - Client IP address
|
|
||||||
*/
|
|
||||||
private trackIPConnection;
|
|
||||||
/**
|
|
||||||
* Check if an IP has reached its connection limit
|
|
||||||
* @param ip - Client IP address
|
|
||||||
* @returns True if limit reached
|
|
||||||
*/
|
|
||||||
private hasReachedIPConnectionLimit;
|
|
||||||
/**
|
|
||||||
* Handle a new secure TLS connection with resource management
|
|
||||||
* @param socket - Client TLS socket
|
|
||||||
*/
|
|
||||||
handleNewSecureConnection(socket: plugins.tls.TLSSocket): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Set up event handlers for a socket with enhanced resource management
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Get the current connection count
|
|
||||||
* @returns Number of active connections
|
|
||||||
*/
|
|
||||||
getConnectionCount(): number;
|
|
||||||
/**
|
|
||||||
* Check if the server has reached the maximum number of connections
|
|
||||||
* @returns True if max connections reached
|
|
||||||
*/
|
|
||||||
hasReachedMaxConnections(): boolean;
|
|
||||||
/**
|
|
||||||
* Close all active connections
|
|
||||||
*/
|
|
||||||
closeAllConnections(): void;
|
|
||||||
/**
|
|
||||||
* Handle socket close event
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param hadError - Whether the socket was closed due to error
|
|
||||||
*/
|
|
||||||
private handleSocketClose;
|
|
||||||
/**
|
|
||||||
* Handle socket error event
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param error - Error object
|
|
||||||
*/
|
|
||||||
private handleSocketError;
|
|
||||||
/**
|
|
||||||
* Handle socket timeout event
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
private handleSocketTimeout;
|
|
||||||
/**
|
|
||||||
* Reject a connection
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param reason - Reason for rejection
|
|
||||||
*/
|
|
||||||
private rejectConnection;
|
|
||||||
/**
|
|
||||||
* Send greeting message
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
private sendGreeting;
|
|
||||||
/**
|
|
||||||
* Send service closing notification
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
private sendServiceClosing;
|
|
||||||
/**
|
|
||||||
* Send response to client
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param response - Response to send
|
|
||||||
*/
|
|
||||||
private sendResponse;
|
|
||||||
/**
|
|
||||||
* Handle a new connection (interface requirement)
|
|
||||||
*/
|
|
||||||
handleConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Check if accepting new connections (interface requirement)
|
|
||||||
*/
|
|
||||||
canAcceptConnection(): boolean;
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
130
dist_ts/mail/delivery/smtpserver/constants.d.ts
vendored
130
dist_ts/mail/delivery/smtpserver/constants.d.ts
vendored
@@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Constants
|
|
||||||
* This file contains all constants and enums used by the SMTP server
|
|
||||||
*/
|
|
||||||
import { SmtpState } from '../interfaces.js';
|
|
||||||
export { SmtpState };
|
|
||||||
/**
|
|
||||||
* SMTP Response Codes
|
|
||||||
* Based on RFC 5321 and common SMTP practice
|
|
||||||
*/
|
|
||||||
export declare enum SmtpResponseCode {
|
|
||||||
SUCCESS = 250,// Requested mail action okay, completed
|
|
||||||
SYSTEM_STATUS = 211,// System status, or system help reply
|
|
||||||
HELP_MESSAGE = 214,// Help message
|
|
||||||
SERVICE_READY = 220,// <domain> Service ready
|
|
||||||
SERVICE_CLOSING = 221,// <domain> Service closing transmission channel
|
|
||||||
AUTHENTICATION_SUCCESSFUL = 235,// Authentication successful
|
|
||||||
OK = 250,// Requested mail action okay, completed
|
|
||||||
FORWARD = 251,// User not local; will forward to <forward-path>
|
|
||||||
CANNOT_VRFY = 252,// Cannot VRFY user, but will accept message and attempt delivery
|
|
||||||
MORE_INFO_NEEDED = 334,// Server challenge for authentication
|
|
||||||
START_MAIL_INPUT = 354,// Start mail input; end with <CRLF>.<CRLF>
|
|
||||||
SERVICE_NOT_AVAILABLE = 421,// <domain> Service not available, closing transmission channel
|
|
||||||
MAILBOX_TEMPORARILY_UNAVAILABLE = 450,// Requested mail action not taken: mailbox unavailable
|
|
||||||
LOCAL_ERROR = 451,// Requested action aborted: local error in processing
|
|
||||||
INSUFFICIENT_STORAGE = 452,// Requested action not taken: insufficient system storage
|
|
||||||
TLS_UNAVAILABLE_TEMP = 454,// TLS not available due to temporary reason
|
|
||||||
SYNTAX_ERROR = 500,// Syntax error, command unrecognized
|
|
||||||
SYNTAX_ERROR_PARAMETERS = 501,// Syntax error in parameters or arguments
|
|
||||||
COMMAND_NOT_IMPLEMENTED = 502,// Command not implemented
|
|
||||||
BAD_SEQUENCE = 503,// Bad sequence of commands
|
|
||||||
COMMAND_PARAMETER_NOT_IMPLEMENTED = 504,// Command parameter not implemented
|
|
||||||
AUTH_REQUIRED = 530,// Authentication required
|
|
||||||
AUTH_FAILED = 535,// Authentication credentials invalid
|
|
||||||
MAILBOX_UNAVAILABLE = 550,// Requested action not taken: mailbox unavailable
|
|
||||||
USER_NOT_LOCAL = 551,// User not local; please try <forward-path>
|
|
||||||
EXCEEDED_STORAGE = 552,// Requested mail action aborted: exceeded storage allocation
|
|
||||||
MAILBOX_NAME_INVALID = 553,// Requested action not taken: mailbox name not allowed
|
|
||||||
TRANSACTION_FAILED = 554,// Transaction failed
|
|
||||||
MAIL_RCPT_PARAMETERS_INVALID = 555
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP Command Types
|
|
||||||
*/
|
|
||||||
export declare enum SmtpCommand {
|
|
||||||
HELO = "HELO",
|
|
||||||
EHLO = "EHLO",
|
|
||||||
MAIL_FROM = "MAIL",
|
|
||||||
RCPT_TO = "RCPT",
|
|
||||||
DATA = "DATA",
|
|
||||||
RSET = "RSET",
|
|
||||||
NOOP = "NOOP",
|
|
||||||
QUIT = "QUIT",
|
|
||||||
STARTTLS = "STARTTLS",
|
|
||||||
AUTH = "AUTH",
|
|
||||||
HELP = "HELP",
|
|
||||||
VRFY = "VRFY",
|
|
||||||
EXPN = "EXPN"
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Security log event types
|
|
||||||
*/
|
|
||||||
export declare enum SecurityEventType {
|
|
||||||
CONNECTION = "connection",
|
|
||||||
AUTHENTICATION = "authentication",
|
|
||||||
COMMAND = "command",
|
|
||||||
DATA = "data",
|
|
||||||
IP_REPUTATION = "ip_reputation",
|
|
||||||
TLS_NEGOTIATION = "tls_negotiation",
|
|
||||||
DKIM = "dkim",
|
|
||||||
SPF = "spf",
|
|
||||||
DMARC = "dmarc",
|
|
||||||
EMAIL_VALIDATION = "email_validation",
|
|
||||||
SPAM = "spam",
|
|
||||||
ACCESS_CONTROL = "access_control"
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Security log levels
|
|
||||||
*/
|
|
||||||
export declare enum SecurityLogLevel {
|
|
||||||
DEBUG = "debug",
|
|
||||||
INFO = "info",
|
|
||||||
WARN = "warn",
|
|
||||||
ERROR = "error"
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP Server Defaults
|
|
||||||
*/
|
|
||||||
export declare const SMTP_DEFAULTS: {
|
|
||||||
CONNECTION_TIMEOUT: number;
|
|
||||||
SOCKET_TIMEOUT: number;
|
|
||||||
DATA_TIMEOUT: number;
|
|
||||||
CLEANUP_INTERVAL: number;
|
|
||||||
MAX_CONNECTIONS: number;
|
|
||||||
MAX_RECIPIENTS: number;
|
|
||||||
MAX_MESSAGE_SIZE: number;
|
|
||||||
SMTP_PORT: number;
|
|
||||||
SUBMISSION_PORT: number;
|
|
||||||
SECURE_PORT: number;
|
|
||||||
HOSTNAME: string;
|
|
||||||
CRLF: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* SMTP Command Patterns
|
|
||||||
* Regular expressions for parsing SMTP commands
|
|
||||||
*/
|
|
||||||
export declare const SMTP_PATTERNS: {
|
|
||||||
EHLO: RegExp;
|
|
||||||
MAIL_FROM: RegExp;
|
|
||||||
RCPT_TO: RegExp;
|
|
||||||
PARAM: RegExp;
|
|
||||||
EMAIL: RegExp;
|
|
||||||
END_DATA: RegExp;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* SMTP Extension List
|
|
||||||
* These extensions are advertised in the EHLO response
|
|
||||||
*/
|
|
||||||
export declare const SMTP_EXTENSIONS: {
|
|
||||||
PIPELINING: string;
|
|
||||||
SIZE: string;
|
|
||||||
EIGHTBITMIME: string;
|
|
||||||
STARTTLS: string;
|
|
||||||
AUTH: string;
|
|
||||||
ENHANCEDSTATUSCODES: string;
|
|
||||||
HELP: string;
|
|
||||||
CHUNKING: string;
|
|
||||||
DSN: string;
|
|
||||||
formatExtension(name: string, parameter?: string | number): string;
|
|
||||||
};
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Constants
|
|
||||||
* This file contains all constants and enums used by the SMTP server
|
|
||||||
*/
|
|
||||||
import { SmtpState } from '../interfaces.js';
|
|
||||||
// Re-export SmtpState enum from the main interfaces file
|
|
||||||
export { SmtpState };
|
|
||||||
/**
|
|
||||||
* SMTP Response Codes
|
|
||||||
* Based on RFC 5321 and common SMTP practice
|
|
||||||
*/
|
|
||||||
export var SmtpResponseCode;
|
|
||||||
(function (SmtpResponseCode) {
|
|
||||||
// Success codes (2xx)
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SUCCESS"] = 250] = "SUCCESS";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SYSTEM_STATUS"] = 211] = "SYSTEM_STATUS";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["HELP_MESSAGE"] = 214] = "HELP_MESSAGE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SERVICE_READY"] = 220] = "SERVICE_READY";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SERVICE_CLOSING"] = 221] = "SERVICE_CLOSING";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["AUTHENTICATION_SUCCESSFUL"] = 235] = "AUTHENTICATION_SUCCESSFUL";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["OK"] = 250] = "OK";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["FORWARD"] = 251] = "FORWARD";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["CANNOT_VRFY"] = 252] = "CANNOT_VRFY";
|
|
||||||
// Intermediate codes (3xx)
|
|
||||||
SmtpResponseCode[SmtpResponseCode["MORE_INFO_NEEDED"] = 334] = "MORE_INFO_NEEDED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["START_MAIL_INPUT"] = 354] = "START_MAIL_INPUT";
|
|
||||||
// Temporary error codes (4xx)
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SERVICE_NOT_AVAILABLE"] = 421] = "SERVICE_NOT_AVAILABLE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["MAILBOX_TEMPORARILY_UNAVAILABLE"] = 450] = "MAILBOX_TEMPORARILY_UNAVAILABLE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["LOCAL_ERROR"] = 451] = "LOCAL_ERROR";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["INSUFFICIENT_STORAGE"] = 452] = "INSUFFICIENT_STORAGE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["TLS_UNAVAILABLE_TEMP"] = 454] = "TLS_UNAVAILABLE_TEMP";
|
|
||||||
// Permanent error codes (5xx)
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SYNTAX_ERROR"] = 500] = "SYNTAX_ERROR";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["SYNTAX_ERROR_PARAMETERS"] = 501] = "SYNTAX_ERROR_PARAMETERS";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["COMMAND_NOT_IMPLEMENTED"] = 502] = "COMMAND_NOT_IMPLEMENTED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["BAD_SEQUENCE"] = 503] = "BAD_SEQUENCE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["COMMAND_PARAMETER_NOT_IMPLEMENTED"] = 504] = "COMMAND_PARAMETER_NOT_IMPLEMENTED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["AUTH_REQUIRED"] = 530] = "AUTH_REQUIRED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["AUTH_FAILED"] = 535] = "AUTH_FAILED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["MAILBOX_UNAVAILABLE"] = 550] = "MAILBOX_UNAVAILABLE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["USER_NOT_LOCAL"] = 551] = "USER_NOT_LOCAL";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["EXCEEDED_STORAGE"] = 552] = "EXCEEDED_STORAGE";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["MAILBOX_NAME_INVALID"] = 553] = "MAILBOX_NAME_INVALID";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["TRANSACTION_FAILED"] = 554] = "TRANSACTION_FAILED";
|
|
||||||
SmtpResponseCode[SmtpResponseCode["MAIL_RCPT_PARAMETERS_INVALID"] = 555] = "MAIL_RCPT_PARAMETERS_INVALID";
|
|
||||||
})(SmtpResponseCode || (SmtpResponseCode = {}));
|
|
||||||
/**
|
|
||||||
* SMTP Command Types
|
|
||||||
*/
|
|
||||||
export var SmtpCommand;
|
|
||||||
(function (SmtpCommand) {
|
|
||||||
SmtpCommand["HELO"] = "HELO";
|
|
||||||
SmtpCommand["EHLO"] = "EHLO";
|
|
||||||
SmtpCommand["MAIL_FROM"] = "MAIL";
|
|
||||||
SmtpCommand["RCPT_TO"] = "RCPT";
|
|
||||||
SmtpCommand["DATA"] = "DATA";
|
|
||||||
SmtpCommand["RSET"] = "RSET";
|
|
||||||
SmtpCommand["NOOP"] = "NOOP";
|
|
||||||
SmtpCommand["QUIT"] = "QUIT";
|
|
||||||
SmtpCommand["STARTTLS"] = "STARTTLS";
|
|
||||||
SmtpCommand["AUTH"] = "AUTH";
|
|
||||||
SmtpCommand["HELP"] = "HELP";
|
|
||||||
SmtpCommand["VRFY"] = "VRFY";
|
|
||||||
SmtpCommand["EXPN"] = "EXPN";
|
|
||||||
})(SmtpCommand || (SmtpCommand = {}));
|
|
||||||
/**
|
|
||||||
* Security log event types
|
|
||||||
*/
|
|
||||||
export var SecurityEventType;
|
|
||||||
(function (SecurityEventType) {
|
|
||||||
SecurityEventType["CONNECTION"] = "connection";
|
|
||||||
SecurityEventType["AUTHENTICATION"] = "authentication";
|
|
||||||
SecurityEventType["COMMAND"] = "command";
|
|
||||||
SecurityEventType["DATA"] = "data";
|
|
||||||
SecurityEventType["IP_REPUTATION"] = "ip_reputation";
|
|
||||||
SecurityEventType["TLS_NEGOTIATION"] = "tls_negotiation";
|
|
||||||
SecurityEventType["DKIM"] = "dkim";
|
|
||||||
SecurityEventType["SPF"] = "spf";
|
|
||||||
SecurityEventType["DMARC"] = "dmarc";
|
|
||||||
SecurityEventType["EMAIL_VALIDATION"] = "email_validation";
|
|
||||||
SecurityEventType["SPAM"] = "spam";
|
|
||||||
SecurityEventType["ACCESS_CONTROL"] = "access_control";
|
|
||||||
})(SecurityEventType || (SecurityEventType = {}));
|
|
||||||
/**
|
|
||||||
* Security log levels
|
|
||||||
*/
|
|
||||||
export var SecurityLogLevel;
|
|
||||||
(function (SecurityLogLevel) {
|
|
||||||
SecurityLogLevel["DEBUG"] = "debug";
|
|
||||||
SecurityLogLevel["INFO"] = "info";
|
|
||||||
SecurityLogLevel["WARN"] = "warn";
|
|
||||||
SecurityLogLevel["ERROR"] = "error";
|
|
||||||
})(SecurityLogLevel || (SecurityLogLevel = {}));
|
|
||||||
/**
|
|
||||||
* SMTP Server Defaults
|
|
||||||
*/
|
|
||||||
export const SMTP_DEFAULTS = {
|
|
||||||
// Default timeouts in milliseconds
|
|
||||||
CONNECTION_TIMEOUT: 30000, // 30 seconds
|
|
||||||
SOCKET_TIMEOUT: 300000, // 5 minutes
|
|
||||||
DATA_TIMEOUT: 60000, // 1 minute
|
|
||||||
CLEANUP_INTERVAL: 5000, // 5 seconds
|
|
||||||
// Default limits
|
|
||||||
MAX_CONNECTIONS: 100,
|
|
||||||
MAX_RECIPIENTS: 100,
|
|
||||||
MAX_MESSAGE_SIZE: 10485760, // 10MB
|
|
||||||
// Default ports
|
|
||||||
SMTP_PORT: 25,
|
|
||||||
SUBMISSION_PORT: 587,
|
|
||||||
SECURE_PORT: 465,
|
|
||||||
// Default hostname
|
|
||||||
HOSTNAME: 'mail.lossless.one',
|
|
||||||
// CRLF line ending required by SMTP protocol
|
|
||||||
CRLF: '\r\n',
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* SMTP Command Patterns
|
|
||||||
* Regular expressions for parsing SMTP commands
|
|
||||||
*/
|
|
||||||
export const SMTP_PATTERNS = {
|
|
||||||
// Match EHLO/HELO command: "EHLO example.com"
|
|
||||||
// Made very permissive to handle various client implementations
|
|
||||||
EHLO: /^(?:EHLO|HELO)\s+(.+)$/i,
|
|
||||||
// Match MAIL FROM command: "MAIL FROM:<user@example.com> [PARAM=VALUE]"
|
|
||||||
// Made more permissive with whitespace and parameter formats
|
|
||||||
MAIL_FROM: /^MAIL\s+FROM\s*:\s*<([^>]*)>((?:\s+[a-zA-Z0-9][a-zA-Z0-9\-]*(?:=[^\s]+)?)*)$/i,
|
|
||||||
// Match RCPT TO command: "RCPT TO:<user@example.com> [PARAM=VALUE]"
|
|
||||||
// Made more permissive with whitespace and parameter formats
|
|
||||||
RCPT_TO: /^RCPT\s+TO\s*:\s*<([^>]*)>((?:\s+[a-zA-Z0-9][a-zA-Z0-9\-]*(?:=[^\s]+)?)*)$/i,
|
|
||||||
// Match parameter format: "PARAM=VALUE"
|
|
||||||
PARAM: /\s+([A-Za-z0-9][A-Za-z0-9\-]*)(?:=([^\s]+))?/g,
|
|
||||||
// Match email address format - basic validation
|
|
||||||
// This pattern rejects common invalid formats while being permissive for edge cases
|
|
||||||
// Checks: no spaces, has @, has domain with dot, no double dots, proper domain format
|
|
||||||
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
||||||
// Match end of DATA marker: \r\n.\r\n or just .\r\n at the start of a line (to handle various client implementations)
|
|
||||||
END_DATA: /(\r\n\.\r\n$)|(\n\.\r\n$)|(\r\n\.\n$)|(\n\.\n$)|^\.(\r\n|\n)$/,
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* SMTP Extension List
|
|
||||||
* These extensions are advertised in the EHLO response
|
|
||||||
*/
|
|
||||||
export const SMTP_EXTENSIONS = {
|
|
||||||
// Basic extensions (RFC 1869)
|
|
||||||
PIPELINING: 'PIPELINING',
|
|
||||||
SIZE: 'SIZE',
|
|
||||||
EIGHTBITMIME: '8BITMIME',
|
|
||||||
// Security extensions
|
|
||||||
STARTTLS: 'STARTTLS',
|
|
||||||
AUTH: 'AUTH',
|
|
||||||
// Additional extensions
|
|
||||||
ENHANCEDSTATUSCODES: 'ENHANCEDSTATUSCODES',
|
|
||||||
HELP: 'HELP',
|
|
||||||
CHUNKING: 'CHUNKING',
|
|
||||||
DSN: 'DSN',
|
|
||||||
// Format an extension with a parameter
|
|
||||||
formatExtension(name, parameter) {
|
|
||||||
return parameter !== undefined ? `${name} ${parameter}` : name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29uc3RhbnRzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vdHMvbWFpbC9kZWxpdmVyeS9zbXRwc2VydmVyL2NvbnN0YW50cy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7O0dBR0c7QUFFSCxPQUFPLEVBQUUsU0FBUyxFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFFN0MseURBQXlEO0FBQ3pELE9BQU8sRUFBRSxTQUFTLEVBQUUsQ0FBQztBQUVyQjs7O0dBR0c7QUFDSCxNQUFNLENBQU4sSUFBWSxnQkFxQ1g7QUFyQ0QsV0FBWSxnQkFBZ0I7SUFDMUIsc0JBQXNCO0lBQ3RCLCtEQUFhLENBQUE7SUFDYiwyRUFBbUIsQ0FBQTtJQUNuQix5RUFBa0IsQ0FBQTtJQUNsQiwyRUFBbUIsQ0FBQTtJQUNuQiwrRUFBcUIsQ0FBQTtJQUNyQixtR0FBK0IsQ0FBQTtJQUMvQixxREFBUSxDQUFBO0lBQ1IsK0RBQWEsQ0FBQTtJQUNiLHVFQUFpQixDQUFBO0lBRWpCLDJCQUEyQjtJQUMzQixpRkFBc0IsQ0FBQTtJQUN0QixpRkFBc0IsQ0FBQTtJQUV0Qiw4QkFBOEI7SUFDOUIsMkZBQTJCLENBQUE7SUFDM0IsK0dBQXFDLENBQUE7SUFDckMsdUVBQWlCLENBQUE7SUFDakIseUZBQTBCLENBQUE7SUFDMUIseUZBQTBCLENBQUE7SUFFMUIsOEJBQThCO0lBQzlCLHlFQUFrQixDQUFBO0lBQ2xCLCtGQUE2QixDQUFBO0lBQzdCLCtGQUE2QixDQUFBO0lBQzdCLHlFQUFrQixDQUFBO0lBQ2xCLG1IQUF1QyxDQUFBO0lBQ3ZDLDJFQUFtQixDQUFBO0lBQ25CLHVFQUFpQixDQUFBO0lBQ2pCLHVGQUF5QixDQUFBO0lBQ3pCLDZFQUFvQixDQUFBO0lBQ3BCLGlGQUFzQixDQUFBO0lBQ3RCLHlGQUEwQixDQUFBO0lBQzFCLHFGQUF3QixDQUFBO0lBQ3hCLHlHQUFrQyxDQUFBO0FBQ3BDLENBQUMsRUFyQ1csZ0JBQWdCLEtBQWhCLGdCQUFnQixRQXFDM0I7QUFFRDs7R0FFRztBQUNILE1BQU0sQ0FBTixJQUFZLFdBY1g7QUFkRCxXQUFZLFdBQVc7SUFDckIsNEJBQWEsQ0FBQTtJQUNiLDRCQUFhLENBQUE7SUFDYixpQ0FBa0IsQ0FBQTtJQUNsQiwrQkFBZ0IsQ0FBQTtJQUNoQiw0QkFBYSxDQUFBO0lBQ2IsNEJBQWEsQ0FBQTtJQUNiLDRCQUFhLENBQUE7SUFDYiw0QkFBYSxDQUFBO0lBQ2Isb0NBQXFCLENBQUE7SUFDckIsNEJBQWEsQ0FBQTtJQUNiLDRCQUFhLENBQUE7SUFDYiw0QkFBYSxDQUFBO0lBQ2IsNEJBQWEsQ0FBQTtBQUNmLENBQUMsRUFkVyxXQUFXLEtBQVgsV0FBVyxRQWN0QjtBQUVEOztHQUVHO0FBQ0gsTUFBTSxDQUFOLElBQVksaUJBYVg7QUFiRCxXQUFZLGlCQUFpQjtJQUMzQiw4Q0FBeUIsQ0FBQTtJQUN6QixzREFBaUMsQ0FBQTtJQUNqQyx3Q0FBbUIsQ0FBQTtJQUNuQixrQ0FBYSxDQUFBO0lBQ2Isb0RBQStCLENBQUE7SUFDL0Isd0RBQW1DLENBQUE7SUFDbkMsa0NBQWEsQ0FBQTtJQUNiLGdDQUFXLENBQUE7SUFDWCxvQ0FBZSxDQUFBO0lBQ2YsMERBQXFDLENBQUE7SUFDckMsa0NBQWEsQ0FBQTtJQUNiLHNEQUFpQyxDQUFBO0FBQ25DLENBQUMsRUFiVyxpQkFBaUIsS0FBakIsaUJBQWlCLFFBYTVCO0FBRUQ7O0dBRUc7QUFDSCxNQUFNLENBQU4sSUFBWSxnQkFLWDtBQUxELFdBQVksZ0JBQWdCO0lBQzFCLG1DQUFlLENBQUE7SUFDZixpQ0FBYSxDQUFBO0lBQ2IsaUNBQWEsQ0FBQTtJQUNiLG1DQUFlLENBQUE7QUFDakIsQ0FBQyxFQUxXLGdCQUFnQixLQUFoQixnQkFBZ0IsUUFLM0I7QUFFRDs7R0FFRztBQUNILE1BQU0sQ0FBQyxNQUFNLGFBQWEsR0FBRztJQUMzQixtQ0FBbUM7SUFDbkMsa0JBQWtCLEVBQUUsS0FBSyxFQUFRLGFBQWE7SUFDOUMsY0FBYyxFQUFFLE1BQU0sRUFBVyxZQUFZO0lBQzdDLFlBQVksRUFBRSxLQUFLLEVBQWMsV0FBVztJQUM1QyxnQkFBZ0IsRUFBRSxJQUFJLEVBQVcsWUFBWTtJQUU3QyxpQkFBaUI7SUFDakIsZUFBZSxFQUFFLEdBQUc7SUFDcEIsY0FBYyxFQUFFLEdBQUc7SUFDbkIsZ0JBQWdCLEVBQUUsUUFBUSxFQUFPLE9BQU87SUFFeEMsZ0JBQWdCO0lBQ2hCLFNBQVMsRUFBRSxFQUFFO0lBQ2IsZUFBZSxFQUFFLEdBQUc7SUFDcEIsV0FBVyxFQUFFLEdBQUc7SUFFaEIsbUJBQW1CO0lBQ25CLFFBQVEsRUFBRSxtQkFBbUI7SUFFN0IsNkNBQTZDO0lBQzdDLElBQUksRUFBRSxNQUFNO0NBQ2IsQ0FBQztBQUVGOzs7R0FHRztBQUNILE1BQU0sQ0FBQyxNQUFNLGFBQWEsR0FBRztJQUMzQiw4Q0FBOEM7SUFDOUMsZ0VBQWdFO0lBQ2hFLElBQUksRUFBRSx5QkFBeUI7SUFFL0Isd0VBQXdFO0lBQ3hFLDZEQUE2RDtJQUM3RCxTQUFTLEVBQUUsK0VBQStFO0lBRTFGLG9FQUFvRTtJQUNwRSw2REFBNkQ7SUFDN0QsT0FBTyxFQUFFLDZFQUE2RTtJQUV0Rix3Q0FBd0M7SUFDeEMsS0FBSyxFQUFFLCtDQUErQztJQUV0RCxnREFBZ0Q7SUFDaEQsb0ZBQW9GO0lBQ3BGLHNGQUFzRjtJQUN0RixLQUFLLEVBQUUsNEJBQTRCO0lBRW5DLHNIQUFzSDtJQUN0SCxRQUFRLEVBQUUsK0RBQStEO0NBQzFFLENBQUM7QUFFRjs7O0dBR0c7QUFDSCxNQUFNLENBQUMsTUFBTSxlQUFlLEdBQUc7SUFDN0IsOEJBQThCO0lBQzlCLFVBQVUsRUFBRSxZQUFZO0lBQ3hCLElBQUksRUFBRSxNQUFNO0lBQ1osWUFBWSxFQUFFLFVBQVU7SUFFeEIsc0JBQXNCO0lBQ3RCLFFBQVEsRUFBRSxVQUFVO0lBQ3BCLElBQUksRUFBRSxNQUFNO0lBRVosd0JBQXdCO0lBQ3hCLG1CQUFtQixFQUFFLHFCQUFxQjtJQUMxQyxJQUFJLEVBQUUsTUFBTTtJQUNaLFFBQVEsRUFBRSxVQUFVO0lBQ3BCLEdBQUcsRUFBRSxLQUFLO0lBRVYsdUNBQXVDO0lBQ3ZDLGVBQWUsQ0FBQyxJQUFZLEVBQUUsU0FBMkI7UUFDdkQsT0FBTyxTQUFTLEtBQUssU0FBUyxDQUFDLENBQUMsQ0FBQyxHQUFHLElBQUksSUFBSSxTQUFTLEVBQUUsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDO0lBQ2pFLENBQUM7Q0FDRixDQUFDIn0=
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Creation Factory
|
|
||||||
* Provides a simple way to create a complete SMTP server
|
|
||||||
*/
|
|
||||||
import { SmtpServer } from './smtp-server.js';
|
|
||||||
import type { ISmtpServerOptions } from './interfaces.js';
|
|
||||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
|
||||||
/**
|
|
||||||
* Create a complete SMTP server with all components
|
|
||||||
* @param emailServer - Email server reference
|
|
||||||
* @param options - SMTP server options
|
|
||||||
* @returns Configured SMTP server instance
|
|
||||||
*/
|
|
||||||
export declare function createSmtpServer(emailServer: UnifiedEmailServer, options: ISmtpServerOptions): SmtpServer;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Creation Factory
|
|
||||||
* Provides a simple way to create a complete SMTP server
|
|
||||||
*/
|
|
||||||
import { SmtpServer } from './smtp-server.js';
|
|
||||||
import { SessionManager } from './session-manager.js';
|
|
||||||
import { ConnectionManager } from './connection-manager.js';
|
|
||||||
import { CommandHandler } from './command-handler.js';
|
|
||||||
import { DataHandler } from './data-handler.js';
|
|
||||||
import { TlsHandler } from './tls-handler.js';
|
|
||||||
import { SecurityHandler } from './security-handler.js';
|
|
||||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
|
||||||
/**
|
|
||||||
* Create a complete SMTP server with all components
|
|
||||||
* @param emailServer - Email server reference
|
|
||||||
* @param options - SMTP server options
|
|
||||||
* @returns Configured SMTP server instance
|
|
||||||
*/
|
|
||||||
export function createSmtpServer(emailServer, options) {
|
|
||||||
// First create the SMTP server instance
|
|
||||||
const smtpServer = new SmtpServer({
|
|
||||||
emailServer,
|
|
||||||
options
|
|
||||||
});
|
|
||||||
// Return the configured server
|
|
||||||
return smtpServer;
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY3JlYXRlLXNlcnZlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3RzL21haWwvZGVsaXZlcnkvc210cHNlcnZlci9jcmVhdGUtc2VydmVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7R0FHRztBQUVILE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUM5QyxPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDdEQsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFDNUQsT0FBTyxFQUFFLGNBQWMsRUFBRSxNQUFNLHNCQUFzQixDQUFDO0FBQ3RELE9BQU8sRUFBRSxXQUFXLEVBQUUsTUFBTSxtQkFBbUIsQ0FBQztBQUNoRCxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDOUMsT0FBTyxFQUFFLGVBQWUsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBRXhELE9BQU8sRUFBRSxrQkFBa0IsRUFBRSxNQUFNLCtDQUErQyxDQUFDO0FBRW5GOzs7OztHQUtHO0FBQ0gsTUFBTSxVQUFVLGdCQUFnQixDQUFDLFdBQStCLEVBQUUsT0FBMkI7SUFDM0Ysd0NBQXdDO0lBQ3hDLE1BQU0sVUFBVSxHQUFHLElBQUksVUFBVSxDQUFDO1FBQ2hDLFdBQVc7UUFDWCxPQUFPO0tBQ1IsQ0FBQyxDQUFDO0lBRUgsK0JBQStCO0lBQy9CLE9BQU8sVUFBVSxDQUFDO0FBQ3BCLENBQUMifQ==
|
|
||||||
123
dist_ts/mail/delivery/smtpserver/data-handler.d.ts
vendored
123
dist_ts/mail/delivery/smtpserver/data-handler.d.ts
vendored
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Data Handler
|
|
||||||
* Responsible for processing email data during and after DATA command
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { ISmtpSession, ISmtpTransactionResult } from './interfaces.js';
|
|
||||||
import type { IDataHandler, ISmtpServer } from './interfaces.js';
|
|
||||||
import { Email } from '../../core/classes.email.js';
|
|
||||||
/**
|
|
||||||
* Handles SMTP DATA command and email data processing
|
|
||||||
*/
|
|
||||||
export declare class DataHandler implements IDataHandler {
|
|
||||||
/**
|
|
||||||
* Reference to the SMTP server instance
|
|
||||||
*/
|
|
||||||
private smtpServer;
|
|
||||||
/**
|
|
||||||
* Creates a new data handler
|
|
||||||
* @param smtpServer - SMTP server instance
|
|
||||||
*/
|
|
||||||
constructor(smtpServer: ISmtpServer);
|
|
||||||
/**
|
|
||||||
* Process incoming email data
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param data - Data chunk
|
|
||||||
* @returns Promise that resolves when the data is processed
|
|
||||||
*/
|
|
||||||
processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Handle raw data chunks during DATA mode (optimized for large messages)
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param data - Raw data chunk
|
|
||||||
*/
|
|
||||||
handleDataReceived(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Process email data chunks efficiently for large messages
|
|
||||||
* @param chunks - Array of email data chunks
|
|
||||||
* @returns Processed email data string
|
|
||||||
*/
|
|
||||||
private processEmailDataStreaming;
|
|
||||||
/**
|
|
||||||
* Process a complete email
|
|
||||||
* @param rawData - Raw email data
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @returns Promise that resolves with the Email object
|
|
||||||
*/
|
|
||||||
processEmail(rawData: string, session: ISmtpSession): Promise<Email>;
|
|
||||||
/**
|
|
||||||
* Parse email from raw data
|
|
||||||
* @param rawData - Raw email data
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @returns Email object
|
|
||||||
*/
|
|
||||||
private parseEmailFromData;
|
|
||||||
/**
|
|
||||||
* Process a complete email (legacy method)
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @returns Promise that resolves with the result of the transaction
|
|
||||||
*/
|
|
||||||
processEmailLegacy(session: ISmtpSession): Promise<ISmtpTransactionResult>;
|
|
||||||
/**
|
|
||||||
* Save an email to disk
|
|
||||||
* @param session - SMTP session
|
|
||||||
*/
|
|
||||||
saveEmail(session: ISmtpSession): void;
|
|
||||||
/**
|
|
||||||
* Parse an email into an Email object
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @returns Promise that resolves with the parsed Email object
|
|
||||||
*/
|
|
||||||
parseEmail(session: ISmtpSession): Promise<Email>;
|
|
||||||
/**
|
|
||||||
* Basic fallback method for parsing emails
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @returns The parsed Email object
|
|
||||||
*/
|
|
||||||
private parseEmailBasic;
|
|
||||||
/**
|
|
||||||
* Handle multipart content parsing
|
|
||||||
* @param email - Email object to update
|
|
||||||
* @param bodyText - Body text to parse
|
|
||||||
* @param boundary - MIME boundary
|
|
||||||
*/
|
|
||||||
private handleMultipartContent;
|
|
||||||
/**
|
|
||||||
* Handle end of data marker received
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param session - SMTP session
|
|
||||||
*/
|
|
||||||
private handleEndOfData;
|
|
||||||
/**
|
|
||||||
* Reset session after email processing
|
|
||||||
* @param session - SMTP session
|
|
||||||
*/
|
|
||||||
private resetSession;
|
|
||||||
/**
|
|
||||||
* Send a response to the client
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param response - Response message
|
|
||||||
*/
|
|
||||||
private sendResponse;
|
|
||||||
/**
|
|
||||||
* Check if a socket error is potentially recoverable
|
|
||||||
* @param error - The error that occurred
|
|
||||||
* @returns Whether the error is potentially recoverable
|
|
||||||
*/
|
|
||||||
private isRecoverableSocketError;
|
|
||||||
/**
|
|
||||||
* Handle recoverable socket errors with retry logic
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param error - The error that occurred
|
|
||||||
* @param response - The response that failed to send
|
|
||||||
*/
|
|
||||||
private handleSocketError;
|
|
||||||
/**
|
|
||||||
* Handle email data (interface requirement)
|
|
||||||
*/
|
|
||||||
handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string, session: ISmtpSession): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
20
dist_ts/mail/delivery/smtpserver/index.d.ts
vendored
20
dist_ts/mail/delivery/smtpserver/index.d.ts
vendored
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Module Exports
|
|
||||||
* This file exports all components of the refactored SMTP server
|
|
||||||
*/
|
|
||||||
export * from './interfaces.js';
|
|
||||||
export { SmtpServer } from './smtp-server.js';
|
|
||||||
export { SessionManager } from './session-manager.js';
|
|
||||||
export { ConnectionManager } from './connection-manager.js';
|
|
||||||
export { CommandHandler } from './command-handler.js';
|
|
||||||
export { DataHandler } from './data-handler.js';
|
|
||||||
export { TlsHandler } from './tls-handler.js';
|
|
||||||
export { SecurityHandler } from './security-handler.js';
|
|
||||||
export * from './constants.js';
|
|
||||||
export { SmtpLogger } from './utils/logging.js';
|
|
||||||
export * from './utils/validation.js';
|
|
||||||
export * from './utils/helpers.js';
|
|
||||||
export * from './certificate-utils.js';
|
|
||||||
export * from './secure-server.js';
|
|
||||||
export * from './starttls-handler.js';
|
|
||||||
export { createSmtpServer } from './create-server.js';
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Module Exports
|
|
||||||
* This file exports all components of the refactored SMTP server
|
|
||||||
*/
|
|
||||||
// Export interfaces
|
|
||||||
export * from './interfaces.js';
|
|
||||||
// Export server classes
|
|
||||||
export { SmtpServer } from './smtp-server.js';
|
|
||||||
export { SessionManager } from './session-manager.js';
|
|
||||||
export { ConnectionManager } from './connection-manager.js';
|
|
||||||
export { CommandHandler } from './command-handler.js';
|
|
||||||
export { DataHandler } from './data-handler.js';
|
|
||||||
export { TlsHandler } from './tls-handler.js';
|
|
||||||
export { SecurityHandler } from './security-handler.js';
|
|
||||||
// Export constants
|
|
||||||
export * from './constants.js';
|
|
||||||
// Export utilities
|
|
||||||
export { SmtpLogger } from './utils/logging.js';
|
|
||||||
export * from './utils/validation.js';
|
|
||||||
export * from './utils/helpers.js';
|
|
||||||
// Export TLS and certificate utilities
|
|
||||||
export * from './certificate-utils.js';
|
|
||||||
export * from './secure-server.js';
|
|
||||||
export * from './starttls-handler.js';
|
|
||||||
// Factory function to create a complete SMTP server with default components
|
|
||||||
export { createSmtpServer } from './create-server.js';
|
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L3NtdHBzZXJ2ZXIvaW5kZXgudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7OztHQUdHO0FBRUgsb0JBQW9CO0FBQ3BCLGNBQWMsaUJBQWlCLENBQUM7QUFFaEMsd0JBQXdCO0FBQ3hCLE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUM5QyxPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDdEQsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFDNUQsT0FBTyxFQUFFLGNBQWMsRUFBRSxNQUFNLHNCQUFzQixDQUFDO0FBQ3RELE9BQU8sRUFBRSxXQUFXLEVBQUUsTUFBTSxtQkFBbUIsQ0FBQztBQUNoRCxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDOUMsT0FBTyxFQUFFLGVBQWUsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBRXhELG1CQUFtQjtBQUNuQixjQUFjLGdCQUFnQixDQUFDO0FBRS9CLG1CQUFtQjtBQUNuQixPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sb0JBQW9CLENBQUM7QUFDaEQsY0FBYyx1QkFBdUIsQ0FBQztBQUN0QyxjQUFjLG9CQUFvQixDQUFDO0FBRW5DLHVDQUF1QztBQUN2QyxjQUFjLHdCQUF3QixDQUFDO0FBQ3ZDLGNBQWMsb0JBQW9CLENBQUM7QUFDbkMsY0FBYyx1QkFBdUIsQ0FBQztBQUV0Qyw0RUFBNEU7QUFDNUUsT0FBTyxFQUFFLGdCQUFnQixFQUFFLE1BQU0sb0JBQW9CLENBQUMifQ==
|
|
||||||
530
dist_ts/mail/delivery/smtpserver/interfaces.d.ts
vendored
530
dist_ts/mail/delivery/smtpserver/interfaces.d.ts
vendored
@@ -1,530 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Interfaces
|
|
||||||
* Defines all the interfaces used by the SMTP server implementation
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { Email } from '../../core/classes.email.js';
|
|
||||||
import type { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
|
||||||
import { SmtpState } from '../interfaces.js';
|
|
||||||
import { SmtpCommand } from './constants.js';
|
|
||||||
export { SmtpState, SmtpCommand };
|
|
||||||
export type { IEnvelopeRecipient } from '../interfaces.js';
|
|
||||||
/**
|
|
||||||
* Interface for components that need cleanup
|
|
||||||
*/
|
|
||||||
export interface IDestroyable {
|
|
||||||
/**
|
|
||||||
* Clean up all resources (timers, listeners, etc)
|
|
||||||
*/
|
|
||||||
destroy(): void | Promise<void>;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP authentication credentials
|
|
||||||
*/
|
|
||||||
export interface ISmtpAuth {
|
|
||||||
/**
|
|
||||||
* Username for authentication
|
|
||||||
*/
|
|
||||||
username: string;
|
|
||||||
/**
|
|
||||||
* Password for authentication
|
|
||||||
*/
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP envelope (sender and recipients)
|
|
||||||
*/
|
|
||||||
export interface ISmtpEnvelope {
|
|
||||||
/**
|
|
||||||
* Mail from address
|
|
||||||
*/
|
|
||||||
mailFrom: {
|
|
||||||
address: string;
|
|
||||||
args?: Record<string, string>;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Recipients list
|
|
||||||
*/
|
|
||||||
rcptTo: Array<{
|
|
||||||
address: string;
|
|
||||||
args?: Record<string, string>;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP session representing a client connection
|
|
||||||
*/
|
|
||||||
export interface ISmtpSession {
|
|
||||||
/**
|
|
||||||
* Unique session identifier
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* Current state of the SMTP session
|
|
||||||
*/
|
|
||||||
state: SmtpState;
|
|
||||||
/**
|
|
||||||
* Client's hostname from EHLO/HELO
|
|
||||||
*/
|
|
||||||
clientHostname: string | null;
|
|
||||||
/**
|
|
||||||
* Whether TLS is active for this session
|
|
||||||
*/
|
|
||||||
secure: boolean;
|
|
||||||
/**
|
|
||||||
* Authentication status
|
|
||||||
*/
|
|
||||||
authenticated: boolean;
|
|
||||||
/**
|
|
||||||
* Authentication username if authenticated
|
|
||||||
*/
|
|
||||||
username?: string;
|
|
||||||
/**
|
|
||||||
* Transaction envelope
|
|
||||||
*/
|
|
||||||
envelope: ISmtpEnvelope;
|
|
||||||
/**
|
|
||||||
* When the session was created
|
|
||||||
*/
|
|
||||||
createdAt: Date;
|
|
||||||
/**
|
|
||||||
* Last activity timestamp
|
|
||||||
*/
|
|
||||||
lastActivity: number;
|
|
||||||
/**
|
|
||||||
* Client's IP address
|
|
||||||
*/
|
|
||||||
remoteAddress: string;
|
|
||||||
/**
|
|
||||||
* Client's port
|
|
||||||
*/
|
|
||||||
remotePort: number;
|
|
||||||
/**
|
|
||||||
* Additional session data
|
|
||||||
*/
|
|
||||||
data?: Record<string, any>;
|
|
||||||
/**
|
|
||||||
* Message size if SIZE extension is used
|
|
||||||
*/
|
|
||||||
messageSize?: number;
|
|
||||||
/**
|
|
||||||
* Server capabilities advertised to client
|
|
||||||
*/
|
|
||||||
capabilities?: string[];
|
|
||||||
/**
|
|
||||||
* Buffer for incomplete data
|
|
||||||
*/
|
|
||||||
dataBuffer?: string;
|
|
||||||
/**
|
|
||||||
* Flag to track if we're currently receiving DATA
|
|
||||||
*/
|
|
||||||
receivingData?: boolean;
|
|
||||||
/**
|
|
||||||
* The raw email data being received
|
|
||||||
*/
|
|
||||||
rawData?: string;
|
|
||||||
/**
|
|
||||||
* Greeting sent to client
|
|
||||||
*/
|
|
||||||
greeting?: string;
|
|
||||||
/**
|
|
||||||
* Whether EHLO has been sent
|
|
||||||
*/
|
|
||||||
ehloSent?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether HELO has been sent
|
|
||||||
*/
|
|
||||||
heloSent?: boolean;
|
|
||||||
/**
|
|
||||||
* TLS options for this session
|
|
||||||
*/
|
|
||||||
tlsOptions?: any;
|
|
||||||
/**
|
|
||||||
* Whether TLS is being used
|
|
||||||
*/
|
|
||||||
useTLS?: boolean;
|
|
||||||
/**
|
|
||||||
* Mail from address for this transaction
|
|
||||||
*/
|
|
||||||
mailFrom?: string;
|
|
||||||
/**
|
|
||||||
* Recipients for this transaction
|
|
||||||
*/
|
|
||||||
rcptTo?: string[];
|
|
||||||
/**
|
|
||||||
* Email data being received
|
|
||||||
*/
|
|
||||||
emailData?: string;
|
|
||||||
/**
|
|
||||||
* Chunks of email data
|
|
||||||
*/
|
|
||||||
emailDataChunks?: string[];
|
|
||||||
/**
|
|
||||||
* Timeout ID for data reception
|
|
||||||
*/
|
|
||||||
dataTimeoutId?: NodeJS.Timeout;
|
|
||||||
/**
|
|
||||||
* Whether connection has ended
|
|
||||||
*/
|
|
||||||
connectionEnded?: boolean;
|
|
||||||
/**
|
|
||||||
* Size of email data being received
|
|
||||||
*/
|
|
||||||
emailDataSize?: number;
|
|
||||||
/**
|
|
||||||
* Processing mode for this session
|
|
||||||
*/
|
|
||||||
processingMode?: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Session manager interface
|
|
||||||
*/
|
|
||||||
export interface ISessionManager extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Create a new session for a socket
|
|
||||||
*/
|
|
||||||
createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure?: boolean): ISmtpSession;
|
|
||||||
/**
|
|
||||||
* Get session by socket
|
|
||||||
*/
|
|
||||||
getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined;
|
|
||||||
/**
|
|
||||||
* Update session state
|
|
||||||
*/
|
|
||||||
updateSessionState(session: ISmtpSession, newState: SmtpState): void;
|
|
||||||
/**
|
|
||||||
* Remove a session
|
|
||||||
*/
|
|
||||||
removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Clear all sessions
|
|
||||||
*/
|
|
||||||
clearAllSessions(): void;
|
|
||||||
/**
|
|
||||||
* Get all active sessions
|
|
||||||
*/
|
|
||||||
getAllSessions(): ISmtpSession[];
|
|
||||||
/**
|
|
||||||
* Get session count
|
|
||||||
*/
|
|
||||||
getSessionCount(): number;
|
|
||||||
/**
|
|
||||||
* Update last activity for a session
|
|
||||||
*/
|
|
||||||
updateLastActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Check for timed out sessions
|
|
||||||
*/
|
|
||||||
checkTimeouts(timeoutMs: number): ISmtpSession[];
|
|
||||||
/**
|
|
||||||
* Update session activity timestamp
|
|
||||||
*/
|
|
||||||
updateSessionActivity(session: ISmtpSession): void;
|
|
||||||
/**
|
|
||||||
* Replace socket in session (for TLS upgrade)
|
|
||||||
*/
|
|
||||||
replaceSocket(oldSocket: plugins.net.Socket | plugins.tls.TLSSocket, newSocket: plugins.net.Socket | plugins.tls.TLSSocket): boolean;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Connection manager interface
|
|
||||||
*/
|
|
||||||
export interface IConnectionManager extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Handle a new connection
|
|
||||||
*/
|
|
||||||
handleConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Close all active connections
|
|
||||||
*/
|
|
||||||
closeAllConnections(): void;
|
|
||||||
/**
|
|
||||||
* Get active connection count
|
|
||||||
*/
|
|
||||||
getConnectionCount(): number;
|
|
||||||
/**
|
|
||||||
* Check if accepting new connections
|
|
||||||
*/
|
|
||||||
canAcceptConnection(): boolean;
|
|
||||||
/**
|
|
||||||
* Handle new connection (legacy method name)
|
|
||||||
*/
|
|
||||||
handleNewConnection(socket: plugins.net.Socket): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Handle new secure connection (legacy method name)
|
|
||||||
*/
|
|
||||||
handleNewSecureConnection(socket: plugins.tls.TLSSocket): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Setup socket event handlers
|
|
||||||
*/
|
|
||||||
setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Command handler interface
|
|
||||||
*/
|
|
||||||
export interface ICommandHandler extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Handle an SMTP command
|
|
||||||
*/
|
|
||||||
handleCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, command: SmtpCommand, args: string, session: ISmtpSession): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Get supported commands for current session state
|
|
||||||
*/
|
|
||||||
getSupportedCommands(session: ISmtpSession): SmtpCommand[];
|
|
||||||
/**
|
|
||||||
* Process command (legacy method name)
|
|
||||||
*/
|
|
||||||
processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, command: string): Promise<void>;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Data handler interface
|
|
||||||
*/
|
|
||||||
export interface IDataHandler extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Handle email data
|
|
||||||
*/
|
|
||||||
handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string, session: ISmtpSession): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Process a complete email
|
|
||||||
*/
|
|
||||||
processEmail(rawData: string, session: ISmtpSession): Promise<Email>;
|
|
||||||
/**
|
|
||||||
* Handle data received (legacy method name)
|
|
||||||
*/
|
|
||||||
handleDataReceived(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Process email data (legacy method name)
|
|
||||||
*/
|
|
||||||
processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* TLS handler interface
|
|
||||||
*/
|
|
||||||
export interface ITlsHandler extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Handle STARTTLS command
|
|
||||||
*/
|
|
||||||
handleStartTls(socket: plugins.net.Socket, session: ISmtpSession): Promise<plugins.tls.TLSSocket | null>;
|
|
||||||
/**
|
|
||||||
* Check if TLS is available
|
|
||||||
*/
|
|
||||||
isTlsAvailable(): boolean;
|
|
||||||
/**
|
|
||||||
* Get TLS options
|
|
||||||
*/
|
|
||||||
getTlsOptions(): plugins.tls.TlsOptions;
|
|
||||||
/**
|
|
||||||
* Check if TLS is enabled
|
|
||||||
*/
|
|
||||||
isTlsEnabled(): boolean;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Security handler interface
|
|
||||||
*/
|
|
||||||
export interface ISecurityHandler extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Check IP reputation
|
|
||||||
*/
|
|
||||||
checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<boolean>;
|
|
||||||
/**
|
|
||||||
* Validate email address
|
|
||||||
*/
|
|
||||||
isValidEmail(email: string): boolean;
|
|
||||||
/**
|
|
||||||
* Authenticate user
|
|
||||||
*/
|
|
||||||
authenticate(auth: ISmtpAuth): Promise<boolean>;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP server options
|
|
||||||
*/
|
|
||||||
export interface ISmtpServerOptions {
|
|
||||||
/**
|
|
||||||
* Port to listen on
|
|
||||||
*/
|
|
||||||
port: number;
|
|
||||||
/**
|
|
||||||
* Hostname of the server
|
|
||||||
*/
|
|
||||||
hostname: string;
|
|
||||||
/**
|
|
||||||
* Host to bind to (optional, defaults to 0.0.0.0)
|
|
||||||
*/
|
|
||||||
host?: string;
|
|
||||||
/**
|
|
||||||
* Secure port for TLS connections
|
|
||||||
*/
|
|
||||||
securePort?: number;
|
|
||||||
/**
|
|
||||||
* TLS/SSL private key (PEM format)
|
|
||||||
*/
|
|
||||||
key?: string;
|
|
||||||
/**
|
|
||||||
* TLS/SSL certificate (PEM format)
|
|
||||||
*/
|
|
||||||
cert?: string;
|
|
||||||
/**
|
|
||||||
* CA certificates for TLS (PEM format)
|
|
||||||
*/
|
|
||||||
ca?: string;
|
|
||||||
/**
|
|
||||||
* Maximum size of messages in bytes
|
|
||||||
*/
|
|
||||||
maxSize?: number;
|
|
||||||
/**
|
|
||||||
* Maximum number of concurrent connections
|
|
||||||
*/
|
|
||||||
maxConnections?: number;
|
|
||||||
/**
|
|
||||||
* Authentication options
|
|
||||||
*/
|
|
||||||
auth?: {
|
|
||||||
/**
|
|
||||||
* Whether authentication is required
|
|
||||||
*/
|
|
||||||
required: boolean;
|
|
||||||
/**
|
|
||||||
* Allowed authentication methods
|
|
||||||
*/
|
|
||||||
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Socket timeout in milliseconds (default: 5 minutes / 300000ms)
|
|
||||||
*/
|
|
||||||
socketTimeout?: number;
|
|
||||||
/**
|
|
||||||
* Initial connection timeout in milliseconds (default: 30 seconds / 30000ms)
|
|
||||||
*/
|
|
||||||
connectionTimeout?: number;
|
|
||||||
/**
|
|
||||||
* Interval for checking idle sessions in milliseconds (default: 5 seconds / 5000ms)
|
|
||||||
* For testing, can be set lower (e.g. 1000ms) to detect timeouts more quickly
|
|
||||||
*/
|
|
||||||
cleanupInterval?: number;
|
|
||||||
/**
|
|
||||||
* Maximum number of recipients allowed per message (default: 100)
|
|
||||||
*/
|
|
||||||
maxRecipients?: number;
|
|
||||||
/**
|
|
||||||
* Maximum message size in bytes (default: 10MB / 10485760 bytes)
|
|
||||||
* This is advertised in the EHLO SIZE extension
|
|
||||||
*/
|
|
||||||
size?: number;
|
|
||||||
/**
|
|
||||||
* Timeout for the DATA command in milliseconds (default: 60000ms / 1 minute)
|
|
||||||
* This controls how long to wait for the complete email data
|
|
||||||
*/
|
|
||||||
dataTimeout?: number;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Result of SMTP transaction
|
|
||||||
*/
|
|
||||||
export interface ISmtpTransactionResult {
|
|
||||||
/**
|
|
||||||
* Whether the transaction was successful
|
|
||||||
*/
|
|
||||||
success: boolean;
|
|
||||||
/**
|
|
||||||
* Error message if failed
|
|
||||||
*/
|
|
||||||
error?: string;
|
|
||||||
/**
|
|
||||||
* Message ID if successful
|
|
||||||
*/
|
|
||||||
messageId?: string;
|
|
||||||
/**
|
|
||||||
* Resulting email if successful
|
|
||||||
*/
|
|
||||||
email?: Email;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Interface for SMTP session events
|
|
||||||
* These events are emitted by the session manager
|
|
||||||
*/
|
|
||||||
export interface ISessionEvents {
|
|
||||||
created: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
|
|
||||||
stateChanged: (session: ISmtpSession, previousState: SmtpState, newState: SmtpState) => void;
|
|
||||||
timeout: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
|
|
||||||
completed: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
|
|
||||||
error: (session: ISmtpSession, error: Error) => void;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP Server interface
|
|
||||||
*/
|
|
||||||
export interface ISmtpServer extends IDestroyable {
|
|
||||||
/**
|
|
||||||
* Start the SMTP server
|
|
||||||
*/
|
|
||||||
listen(): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Stop the SMTP server
|
|
||||||
*/
|
|
||||||
close(): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Get the session manager
|
|
||||||
*/
|
|
||||||
getSessionManager(): ISessionManager;
|
|
||||||
/**
|
|
||||||
* Get the connection manager
|
|
||||||
*/
|
|
||||||
getConnectionManager(): IConnectionManager;
|
|
||||||
/**
|
|
||||||
* Get the command handler
|
|
||||||
*/
|
|
||||||
getCommandHandler(): ICommandHandler;
|
|
||||||
/**
|
|
||||||
* Get the data handler
|
|
||||||
*/
|
|
||||||
getDataHandler(): IDataHandler;
|
|
||||||
/**
|
|
||||||
* Get the TLS handler
|
|
||||||
*/
|
|
||||||
getTlsHandler(): ITlsHandler;
|
|
||||||
/**
|
|
||||||
* Get the security handler
|
|
||||||
*/
|
|
||||||
getSecurityHandler(): ISecurityHandler;
|
|
||||||
/**
|
|
||||||
* Get the server options
|
|
||||||
*/
|
|
||||||
getOptions(): ISmtpServerOptions;
|
|
||||||
/**
|
|
||||||
* Get the email server reference
|
|
||||||
*/
|
|
||||||
getEmailServer(): UnifiedEmailServer;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Configuration for creating SMTP server
|
|
||||||
*/
|
|
||||||
export interface ISmtpServerConfig {
|
|
||||||
/**
|
|
||||||
* Email server instance
|
|
||||||
*/
|
|
||||||
emailServer: UnifiedEmailServer;
|
|
||||||
/**
|
|
||||||
* Server options
|
|
||||||
*/
|
|
||||||
options: ISmtpServerOptions;
|
|
||||||
/**
|
|
||||||
* Optional custom session manager
|
|
||||||
*/
|
|
||||||
sessionManager?: ISessionManager;
|
|
||||||
/**
|
|
||||||
* Optional custom connection manager
|
|
||||||
*/
|
|
||||||
connectionManager?: IConnectionManager;
|
|
||||||
/**
|
|
||||||
* Optional custom command handler
|
|
||||||
*/
|
|
||||||
commandHandler?: ICommandHandler;
|
|
||||||
/**
|
|
||||||
* Optional custom data handler
|
|
||||||
*/
|
|
||||||
dataHandler?: IDataHandler;
|
|
||||||
/**
|
|
||||||
* Optional custom TLS handler
|
|
||||||
*/
|
|
||||||
tlsHandler?: ITlsHandler;
|
|
||||||
/**
|
|
||||||
* Optional custom security handler
|
|
||||||
*/
|
|
||||||
securityHandler?: ISecurityHandler;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server Interfaces
|
|
||||||
* Defines all the interfaces used by the SMTP server implementation
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
// Re-export types from other modules
|
|
||||||
import { SmtpState } from '../interfaces.js';
|
|
||||||
import { SmtpCommand } from './constants.js';
|
|
||||||
export { SmtpState, SmtpCommand };
|
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW50ZXJmYWNlcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3RzL21haWwvZGVsaXZlcnkvc210cHNlcnZlci9pbnRlcmZhY2VzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7R0FHRztBQUVILE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFJL0MsdUNBQXVDO0FBQ3ZDLE9BQU8sRUFBRSxTQUFTLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUM3QyxPQUFPLEVBQUUsV0FBVyxFQUFFLE1BQU0sZ0JBQWdCLENBQUM7QUFDN0MsT0FBTyxFQUFFLFNBQVMsRUFBRSxXQUFXLEVBQUUsQ0FBQyJ9
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Secure SMTP Server Utility Functions
|
|
||||||
* Provides helper functions for creating and managing secure TLS server
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
/**
|
|
||||||
* Create a secure TLS server for direct TLS connections
|
|
||||||
* @param options - TLS certificate options
|
|
||||||
* @returns A configured TLS server or undefined if TLS is not available
|
|
||||||
*/
|
|
||||||
export declare function createSecureTlsServer(options: {
|
|
||||||
key: string;
|
|
||||||
cert: string;
|
|
||||||
ca?: string;
|
|
||||||
}): plugins.tls.Server | undefined;
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* Secure SMTP Server Utility Functions
|
|
||||||
* Provides helper functions for creating and managing secure TLS server
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import { loadCertificatesFromString, generateSelfSignedCertificates, createTlsOptions } from './certificate-utils.js';
|
|
||||||
import { SmtpLogger } from './utils/logging.js';
|
|
||||||
/**
|
|
||||||
* Create a secure TLS server for direct TLS connections
|
|
||||||
* @param options - TLS certificate options
|
|
||||||
* @returns A configured TLS server or undefined if TLS is not available
|
|
||||||
*/
|
|
||||||
export function createSecureTlsServer(options) {
|
|
||||||
try {
|
|
||||||
// Log the creation attempt
|
|
||||||
SmtpLogger.info('Creating secure TLS server for direct connections');
|
|
||||||
// Load certificates from strings
|
|
||||||
let certificates;
|
|
||||||
try {
|
|
||||||
certificates = loadCertificatesFromString({
|
|
||||||
key: options.key,
|
|
||||||
cert: options.cert,
|
|
||||||
ca: options.ca
|
|
||||||
});
|
|
||||||
SmtpLogger.info('Successfully loaded TLS certificates for secure server');
|
|
||||||
}
|
|
||||||
catch (certificateError) {
|
|
||||||
SmtpLogger.warn(`Failed to load certificates, using self-signed: ${certificateError instanceof Error ? certificateError.message : String(certificateError)}`);
|
|
||||||
certificates = generateSelfSignedCertificates();
|
|
||||||
}
|
|
||||||
// Create server-side TLS options
|
|
||||||
const tlsOptions = createTlsOptions(certificates, true);
|
|
||||||
// Log details for debugging
|
|
||||||
SmtpLogger.debug('Creating secure server with options', {
|
|
||||||
certificates: {
|
|
||||||
keyLength: certificates.key.length,
|
|
||||||
certLength: certificates.cert.length,
|
|
||||||
caLength: certificates.ca ? certificates.ca.length : 0
|
|
||||||
},
|
|
||||||
tlsOptions: {
|
|
||||||
minVersion: tlsOptions.minVersion,
|
|
||||||
maxVersion: tlsOptions.maxVersion,
|
|
||||||
ciphers: tlsOptions.ciphers?.substring(0, 50) + '...' // Truncate long cipher list
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Create the TLS server
|
|
||||||
const server = new plugins.tls.Server(tlsOptions);
|
|
||||||
// Set up error handlers
|
|
||||||
server.on('error', (err) => {
|
|
||||||
SmtpLogger.error(`Secure server error: ${err.message}`, {
|
|
||||||
component: 'secure-server',
|
|
||||||
error: err,
|
|
||||||
stack: err.stack
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Log secure connections
|
|
||||||
server.on('secureConnection', (socket) => {
|
|
||||||
const protocol = socket.getProtocol();
|
|
||||||
const cipher = socket.getCipher();
|
|
||||||
SmtpLogger.info('New direct TLS connection established', {
|
|
||||||
component: 'secure-server',
|
|
||||||
remoteAddress: socket.remoteAddress,
|
|
||||||
remotePort: socket.remotePort,
|
|
||||||
protocol: protocol || 'unknown',
|
|
||||||
cipher: cipher?.name || 'unknown'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
SmtpLogger.error(`Failed to create secure TLS server: ${error instanceof Error ? error.message : String(error)}`, {
|
|
||||||
component: 'secure-server',
|
|
||||||
error: error instanceof Error ? error : new Error(String(error)),
|
|
||||||
stack: error instanceof Error ? error.stack : 'No stack trace available'
|
|
||||||
});
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2VjdXJlLXNlcnZlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3RzL21haWwvZGVsaXZlcnkvc210cHNlcnZlci9zZWN1cmUtc2VydmVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7R0FHRztBQUVILE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFDL0MsT0FBTyxFQUNMLDBCQUEwQixFQUMxQiw4QkFBOEIsRUFDOUIsZ0JBQWdCLEVBRWpCLE1BQU0sd0JBQXdCLENBQUM7QUFDaEMsT0FBTyxFQUFFLFVBQVUsRUFBRSxNQUFNLG9CQUFvQixDQUFDO0FBRWhEOzs7O0dBSUc7QUFDSCxNQUFNLFVBQVUscUJBQXFCLENBQUMsT0FJckM7SUFDQyxJQUFJLENBQUM7UUFDSCwyQkFBMkI7UUFDM0IsVUFBVSxDQUFDLElBQUksQ0FBQyxtREFBbUQsQ0FBQyxDQUFDO1FBRXJFLGlDQUFpQztRQUNqQyxJQUFJLFlBQThCLENBQUM7UUFDbkMsSUFBSSxDQUFDO1lBQ0gsWUFBWSxHQUFHLDBCQUEwQixDQUFDO2dCQUN4QyxHQUFHLEVBQUUsT0FBTyxDQUFDLEdBQUc7Z0JBQ2hCLElBQUksRUFBRSxPQUFPLENBQUMsSUFBSTtnQkFDbEIsRUFBRSxFQUFFLE9BQU8sQ0FBQyxFQUFFO2FBQ2YsQ0FBQyxDQUFDO1lBRUgsVUFBVSxDQUFDLElBQUksQ0FBQyx3REFBd0QsQ0FBQyxDQUFDO1FBQzVFLENBQUM7UUFBQyxPQUFPLGdCQUFnQixFQUFFLENBQUM7WUFDMUIsVUFBVSxDQUFDLElBQUksQ0FBQyxtREFBbUQsZ0JBQWdCLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxnQkFBZ0IsQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxnQkFBZ0IsQ0FBQyxFQUFFLENBQUMsQ0FBQztZQUM5SixZQUFZLEdBQUcsOEJBQThCLEVBQUUsQ0FBQztRQUNsRCxDQUFDO1FBRUQsaUNBQWlDO1FBQ2pDLE1BQU0sVUFBVSxHQUFHLGdCQUFnQixDQUFDLFlBQVksRUFBRSxJQUFJLENBQUMsQ0FBQztRQUV4RCw0QkFBNEI7UUFDNUIsVUFBVSxDQUFDLEtBQUssQ0FBQyxxQ0FBcUMsRUFBRTtZQUN0RCxZQUFZLEVBQUU7Z0JBQ1osU0FBUyxFQUFFLFlBQVksQ0FBQyxHQUFHLENBQUMsTUFBTTtnQkFDbEMsVUFBVSxFQUFFLFlBQVksQ0FBQyxJQUFJLENBQUMsTUFBTTtnQkFDcEMsUUFBUSxFQUFFLFlBQVksQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLFlBQVksQ0FBQyxFQUFFLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQyxDQUFDO2FBQ3ZEO1lBQ0QsVUFBVSxFQUFFO2dCQUNWLFVBQVUsRUFBRSxVQUFVLENBQUMsVUFBVTtnQkFDakMsVUFBVSxFQUFFLFVBQVUsQ0FBQyxVQUFVO2dCQUNqQyxPQUFPLEVBQUUsVUFBVSxDQUFDLE9BQU8sRUFBRSxTQUFTLENBQUMsQ0FBQyxFQUFFLEVBQUUsQ0FBQyxHQUFHLEtBQUssQ0FBQyw0QkFBNEI7YUFDbkY7U0FDRixDQUFDLENBQUM7UUFFSCx3QkFBd0I7UUFDeEIsTUFBTSxNQUFNLEdBQUcsSUFBSSxPQUFPLENBQUMsR0FBRyxDQUFDLE1BQU0sQ0FBQyxVQUFVLENBQUMsQ0FBQztRQUVsRCx3QkFBd0I7UUFDeEIsTUFBTSxDQUFDLEVBQUUsQ0FBQyxPQUFPLEVBQUUsQ0FBQyxHQUFHLEVBQUUsRUFBRTtZQUN6QixVQUFVLENBQUMsS0FBSyxDQUFDLHdCQUF3QixHQUFHLENBQUMsT0FBTyxFQUFFLEVBQUU7Z0JBQ3RELFNBQVMsRUFBRSxlQUFlO2dCQUMxQixLQUFLLEVBQUUsR0FBRztnQkFDVixLQUFLLEVBQUUsR0FBRyxDQUFDLEtBQUs7YUFDakIsQ0FBQyxDQUFDO1FBQ0wsQ0FBQyxDQUFDLENBQUM7UUFFSCx5QkFBeUI7UUFDekIsTUFBTSxDQUFDLEVBQUUsQ0FBQyxrQkFBa0IsRUFBRSxDQUFDLE1BQU0sRUFBRSxFQUFFO1lBQ3ZDLE1BQU0sUUFBUSxHQUFHLE1BQU0sQ0FBQyxXQUFXLEVBQUUsQ0FBQztZQUN0QyxNQUFNLE1BQU0sR0FBRyxNQUFNLENBQUMsU0FBUyxFQUFFLENBQUM7WUFFbEMsVUFBVSxDQUFDLElBQUksQ0FBQyx1Q0FBdUMsRUFBRTtnQkFDdkQsU0FBUyxFQUFFLGVBQWU7Z0JBQzFCLGFBQWEsRUFBRSxNQUFNLENBQUMsYUFBYTtnQkFDbkMsVUFBVSxFQUFFLE1BQU0sQ0FBQyxVQUFVO2dCQUM3QixRQUFRLEVBQUUsUUFBUSxJQUFJLFNBQVM7Z0JBQy9CLE1BQU0sRUFBRSxNQUFNLEVBQUUsSUFBSSxJQUFJLFNBQVM7YUFDbEMsQ0FBQyxDQUFDO1FBQ0wsQ0FBQyxDQUFDLENBQUM7UUFFSCxPQUFPLE1BQU0sQ0FBQztJQUNoQixDQUFDO0lBQUMsT0FBTyxLQUFLLEVBQUUsQ0FBQztRQUNmLFVBQVUsQ0FBQyxLQUFLLENBQUMsdUNBQXVDLEtBQUssWUFBWSxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsRUFBRSxFQUFFO1lBQ2hILFNBQVMsRUFBRSxlQUFlO1lBQzFCLEtBQUssRUFBRSxLQUFLLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLElBQUksS0FBSyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQztZQUNoRSxLQUFLLEVBQUUsS0FBSyxZQUFZLEtBQUssQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsMEJBQTBCO1NBQ3pFLENBQUMsQ0FBQztRQUVILE9BQU8sU0FBUyxDQUFDO0lBQ25CLENBQUM7QUFDSCxDQUFDIn0=
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Security Handler
|
|
||||||
* Responsible for security aspects including IP reputation checking,
|
|
||||||
* email validation, and authentication
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { ISmtpAuth } from './interfaces.js';
|
|
||||||
import type { ISecurityHandler, ISmtpServer } from './interfaces.js';
|
|
||||||
/**
|
|
||||||
* Handles security aspects for SMTP server
|
|
||||||
*/
|
|
||||||
export declare class SecurityHandler implements ISecurityHandler {
|
|
||||||
/**
|
|
||||||
* Reference to the SMTP server instance
|
|
||||||
*/
|
|
||||||
private smtpServer;
|
|
||||||
/**
|
|
||||||
* IP reputation checker service
|
|
||||||
*/
|
|
||||||
private ipReputationService;
|
|
||||||
/**
|
|
||||||
* Simple in-memory IP denylist
|
|
||||||
*/
|
|
||||||
private ipDenylist;
|
|
||||||
/**
|
|
||||||
* Cleanup interval timer
|
|
||||||
*/
|
|
||||||
private cleanupInterval;
|
|
||||||
/**
|
|
||||||
* Creates a new security handler
|
|
||||||
* @param smtpServer - SMTP server instance
|
|
||||||
*/
|
|
||||||
constructor(smtpServer: ISmtpServer);
|
|
||||||
/**
|
|
||||||
* Check IP reputation for a connection
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @returns Promise that resolves to true if IP is allowed, false if blocked
|
|
||||||
*/
|
|
||||||
checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<boolean>;
|
|
||||||
/**
|
|
||||||
* Validate an email address
|
|
||||||
* @param email - Email address to validate
|
|
||||||
* @returns Whether the email address is valid
|
|
||||||
*/
|
|
||||||
isValidEmail(email: string): boolean;
|
|
||||||
/**
|
|
||||||
* Validate authentication credentials
|
|
||||||
* @param auth - Authentication credentials
|
|
||||||
* @returns Promise that resolves to true if authenticated
|
|
||||||
*/
|
|
||||||
authenticate(auth: ISmtpAuth): Promise<boolean>;
|
|
||||||
/**
|
|
||||||
* Log a security event
|
|
||||||
* @param event - Event type
|
|
||||||
* @param level - Log level
|
|
||||||
* @param details - Event details
|
|
||||||
*/
|
|
||||||
logSecurityEvent(event: string, level: string, message: string, details: Record<string, any>): void;
|
|
||||||
/**
|
|
||||||
* Add an IP to the denylist
|
|
||||||
* @param ip - IP address
|
|
||||||
* @param reason - Reason for denylisting
|
|
||||||
* @param duration - Duration in milliseconds (optional, indefinite if not specified)
|
|
||||||
*/
|
|
||||||
private addToDenylist;
|
|
||||||
/**
|
|
||||||
* Check if an IP is denylisted
|
|
||||||
* @param ip - IP address
|
|
||||||
* @returns Whether the IP is denylisted
|
|
||||||
*/
|
|
||||||
private isIpDenylisted;
|
|
||||||
/**
|
|
||||||
* Get the reason an IP was denylisted
|
|
||||||
* @param ip - IP address
|
|
||||||
* @returns Reason for denylisting or undefined if not denylisted
|
|
||||||
*/
|
|
||||||
private getDenylistReason;
|
|
||||||
/**
|
|
||||||
* Clean expired denylist entries
|
|
||||||
*/
|
|
||||||
private cleanExpiredDenylistEntries;
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,140 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Session Manager
|
|
||||||
* Responsible for creating, managing, and cleaning up SMTP sessions
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import { SmtpState } from './interfaces.js';
|
|
||||||
import type { ISmtpSession } from './interfaces.js';
|
|
||||||
import type { ISessionManager, ISessionEvents } from './interfaces.js';
|
|
||||||
/**
|
|
||||||
* Manager for SMTP sessions
|
|
||||||
* Handles session creation, tracking, timeout management, and cleanup
|
|
||||||
*/
|
|
||||||
export declare class SessionManager implements ISessionManager {
|
|
||||||
/**
|
|
||||||
* Map of socket ID to session
|
|
||||||
*/
|
|
||||||
private sessions;
|
|
||||||
/**
|
|
||||||
* Map of socket to socket ID
|
|
||||||
*/
|
|
||||||
private socketIds;
|
|
||||||
/**
|
|
||||||
* SMTP server options
|
|
||||||
*/
|
|
||||||
private options;
|
|
||||||
/**
|
|
||||||
* Event listeners
|
|
||||||
*/
|
|
||||||
private eventListeners;
|
|
||||||
/**
|
|
||||||
* Timer for cleanup interval
|
|
||||||
*/
|
|
||||||
private cleanupTimer;
|
|
||||||
/**
|
|
||||||
* Creates a new session manager
|
|
||||||
* @param options - Session manager options
|
|
||||||
*/
|
|
||||||
constructor(options?: {
|
|
||||||
socketTimeout?: number;
|
|
||||||
connectionTimeout?: number;
|
|
||||||
cleanupInterval?: number;
|
|
||||||
});
|
|
||||||
/**
|
|
||||||
* Creates a new session for a socket connection
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param secure - Whether the connection is secure (TLS)
|
|
||||||
* @returns New SMTP session
|
|
||||||
*/
|
|
||||||
createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): ISmtpSession;
|
|
||||||
/**
|
|
||||||
* Updates the session state
|
|
||||||
* @param session - SMTP session
|
|
||||||
* @param newState - New state
|
|
||||||
*/
|
|
||||||
updateSessionState(session: ISmtpSession, newState: SmtpState): void;
|
|
||||||
/**
|
|
||||||
* Updates the session's last activity timestamp
|
|
||||||
* @param session - SMTP session
|
|
||||||
*/
|
|
||||||
updateSessionActivity(session: ISmtpSession): void;
|
|
||||||
/**
|
|
||||||
* Removes a session
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Gets a session for a socket
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @returns SMTP session or undefined if not found
|
|
||||||
*/
|
|
||||||
getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined;
|
|
||||||
/**
|
|
||||||
* Cleans up idle sessions
|
|
||||||
*/
|
|
||||||
cleanupIdleSessions(): void;
|
|
||||||
/**
|
|
||||||
* Gets the current number of active sessions
|
|
||||||
* @returns Number of active sessions
|
|
||||||
*/
|
|
||||||
getSessionCount(): number;
|
|
||||||
/**
|
|
||||||
* Clears all sessions (used when shutting down)
|
|
||||||
*/
|
|
||||||
clearAllSessions(): void;
|
|
||||||
/**
|
|
||||||
* Register an event listener
|
|
||||||
* @param event - Event name
|
|
||||||
* @param listener - Event listener function
|
|
||||||
*/
|
|
||||||
on<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void;
|
|
||||||
/**
|
|
||||||
* Remove an event listener
|
|
||||||
* @param event - Event name
|
|
||||||
* @param listener - Event listener function
|
|
||||||
*/
|
|
||||||
off<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void;
|
|
||||||
/**
|
|
||||||
* Emit an event to registered listeners
|
|
||||||
* @param event - Event name
|
|
||||||
* @param args - Event arguments
|
|
||||||
*/
|
|
||||||
private emitEvent;
|
|
||||||
/**
|
|
||||||
* Start the cleanup timer
|
|
||||||
*/
|
|
||||||
private startCleanupTimer;
|
|
||||||
/**
|
|
||||||
* Stop the cleanup timer
|
|
||||||
*/
|
|
||||||
private stopCleanupTimer;
|
|
||||||
/**
|
|
||||||
* Replace socket mapping for STARTTLS upgrades
|
|
||||||
* @param oldSocket - Original plain socket
|
|
||||||
* @param newSocket - New TLS socket
|
|
||||||
* @returns Whether the replacement was successful
|
|
||||||
*/
|
|
||||||
replaceSocket(oldSocket: plugins.net.Socket | plugins.tls.TLSSocket, newSocket: plugins.net.Socket | plugins.tls.TLSSocket): boolean;
|
|
||||||
/**
|
|
||||||
* Gets a unique key for a socket
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @returns Socket key
|
|
||||||
*/
|
|
||||||
private getSocketKey;
|
|
||||||
/**
|
|
||||||
* Get all active sessions
|
|
||||||
*/
|
|
||||||
getAllSessions(): ISmtpSession[];
|
|
||||||
/**
|
|
||||||
* Update last activity for a session by socket
|
|
||||||
*/
|
|
||||||
updateLastActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Check for timed out sessions
|
|
||||||
*/
|
|
||||||
checkTimeouts(timeoutMs: number): ISmtpSession[];
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
137
dist_ts/mail/delivery/smtpserver/smtp-server.d.ts
vendored
137
dist_ts/mail/delivery/smtpserver/smtp-server.d.ts
vendored
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Server
|
|
||||||
* Core implementation for the refactored SMTP server
|
|
||||||
*/
|
|
||||||
import type { ISmtpServerOptions } from './interfaces.js';
|
|
||||||
import type { ISmtpServer, ISmtpServerConfig, ISessionManager, IConnectionManager, ICommandHandler, IDataHandler, ITlsHandler, ISecurityHandler } from './interfaces.js';
|
|
||||||
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
|
|
||||||
/**
|
|
||||||
* SMTP Server implementation
|
|
||||||
* The main server class that coordinates all components
|
|
||||||
*/
|
|
||||||
export declare class SmtpServer implements ISmtpServer {
|
|
||||||
/**
|
|
||||||
* Email server reference
|
|
||||||
*/
|
|
||||||
private emailServer;
|
|
||||||
/**
|
|
||||||
* Session manager
|
|
||||||
*/
|
|
||||||
private sessionManager;
|
|
||||||
/**
|
|
||||||
* Connection manager
|
|
||||||
*/
|
|
||||||
private connectionManager;
|
|
||||||
/**
|
|
||||||
* Command handler
|
|
||||||
*/
|
|
||||||
private commandHandler;
|
|
||||||
/**
|
|
||||||
* Data handler
|
|
||||||
*/
|
|
||||||
private dataHandler;
|
|
||||||
/**
|
|
||||||
* TLS handler
|
|
||||||
*/
|
|
||||||
private tlsHandler;
|
|
||||||
/**
|
|
||||||
* Security handler
|
|
||||||
*/
|
|
||||||
private securityHandler;
|
|
||||||
/**
|
|
||||||
* SMTP server options
|
|
||||||
*/
|
|
||||||
private options;
|
|
||||||
/**
|
|
||||||
* Net server instance
|
|
||||||
*/
|
|
||||||
private server;
|
|
||||||
/**
|
|
||||||
* Secure server instance
|
|
||||||
*/
|
|
||||||
private secureServer;
|
|
||||||
/**
|
|
||||||
* Whether the server is running
|
|
||||||
*/
|
|
||||||
private running;
|
|
||||||
/**
|
|
||||||
* Server recovery state
|
|
||||||
*/
|
|
||||||
private recoveryState;
|
|
||||||
/**
|
|
||||||
* Creates a new SMTP server
|
|
||||||
* @param config - Server configuration
|
|
||||||
*/
|
|
||||||
constructor(config: ISmtpServerConfig);
|
|
||||||
/**
|
|
||||||
* Start the SMTP server
|
|
||||||
* @returns Promise that resolves when server is started
|
|
||||||
*/
|
|
||||||
listen(): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Stop the SMTP server
|
|
||||||
* @returns Promise that resolves when server is stopped
|
|
||||||
*/
|
|
||||||
close(): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Get the session manager
|
|
||||||
* @returns Session manager instance
|
|
||||||
*/
|
|
||||||
getSessionManager(): ISessionManager;
|
|
||||||
/**
|
|
||||||
* Get the connection manager
|
|
||||||
* @returns Connection manager instance
|
|
||||||
*/
|
|
||||||
getConnectionManager(): IConnectionManager;
|
|
||||||
/**
|
|
||||||
* Get the command handler
|
|
||||||
* @returns Command handler instance
|
|
||||||
*/
|
|
||||||
getCommandHandler(): ICommandHandler;
|
|
||||||
/**
|
|
||||||
* Get the data handler
|
|
||||||
* @returns Data handler instance
|
|
||||||
*/
|
|
||||||
getDataHandler(): IDataHandler;
|
|
||||||
/**
|
|
||||||
* Get the TLS handler
|
|
||||||
* @returns TLS handler instance
|
|
||||||
*/
|
|
||||||
getTlsHandler(): ITlsHandler;
|
|
||||||
/**
|
|
||||||
* Get the security handler
|
|
||||||
* @returns Security handler instance
|
|
||||||
*/
|
|
||||||
getSecurityHandler(): ISecurityHandler;
|
|
||||||
/**
|
|
||||||
* Get the server options
|
|
||||||
* @returns SMTP server options
|
|
||||||
*/
|
|
||||||
getOptions(): ISmtpServerOptions;
|
|
||||||
/**
|
|
||||||
* Get the email server reference
|
|
||||||
* @returns Email server instance
|
|
||||||
*/
|
|
||||||
getEmailServer(): UnifiedEmailServer;
|
|
||||||
/**
|
|
||||||
* Check if the server is running
|
|
||||||
* @returns Whether the server is running
|
|
||||||
*/
|
|
||||||
isRunning(): boolean;
|
|
||||||
/**
|
|
||||||
* Check if we should attempt to recover from an error
|
|
||||||
* @param error - The error that occurred
|
|
||||||
* @returns Whether recovery should be attempted
|
|
||||||
*/
|
|
||||||
private shouldAttemptRecovery;
|
|
||||||
/**
|
|
||||||
* Attempt to recover the server after a critical error
|
|
||||||
* @param serverType - The type of server to recover ('standard' or 'secure')
|
|
||||||
* @param error - The error that triggered recovery
|
|
||||||
*/
|
|
||||||
private attemptServerRecovery;
|
|
||||||
/**
|
|
||||||
* Clean up all component resources
|
|
||||||
*/
|
|
||||||
destroy(): Promise<void>;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* STARTTLS Implementation
|
|
||||||
* Provides an improved implementation for STARTTLS upgrades
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { ISmtpSession, ISessionManager, IConnectionManager } from './interfaces.js';
|
|
||||||
import { SmtpState } from '../interfaces.js';
|
|
||||||
/**
|
|
||||||
* Enhanced STARTTLS handler for more reliable TLS upgrades
|
|
||||||
*/
|
|
||||||
export declare function performStartTLS(socket: plugins.net.Socket, options: {
|
|
||||||
key: string;
|
|
||||||
cert: string;
|
|
||||||
ca?: string;
|
|
||||||
session?: ISmtpSession;
|
|
||||||
sessionManager?: ISessionManager;
|
|
||||||
connectionManager?: IConnectionManager;
|
|
||||||
onSuccess?: (tlsSocket: plugins.tls.TLSSocket) => void;
|
|
||||||
onFailure?: (error: Error) => void;
|
|
||||||
updateSessionState?: (session: ISmtpSession, state: SmtpState) => void;
|
|
||||||
}): Promise<plugins.tls.TLSSocket | undefined>;
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,66 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP TLS Handler
|
|
||||||
* Responsible for handling TLS-related SMTP functionality
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../plugins.js';
|
|
||||||
import type { ITlsHandler, ISmtpServer, ISmtpSession } from './interfaces.js';
|
|
||||||
/**
|
|
||||||
* Handles TLS functionality for SMTP server
|
|
||||||
*/
|
|
||||||
export declare class TlsHandler implements ITlsHandler {
|
|
||||||
/**
|
|
||||||
* Reference to the SMTP server instance
|
|
||||||
*/
|
|
||||||
private smtpServer;
|
|
||||||
/**
|
|
||||||
* Certificate data
|
|
||||||
*/
|
|
||||||
private certificates;
|
|
||||||
/**
|
|
||||||
* TLS options
|
|
||||||
*/
|
|
||||||
private options;
|
|
||||||
/**
|
|
||||||
* Creates a new TLS handler
|
|
||||||
* @param smtpServer - SMTP server instance
|
|
||||||
*/
|
|
||||||
constructor(smtpServer: ISmtpServer);
|
|
||||||
/**
|
|
||||||
* Handle STARTTLS command
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
handleStartTls(socket: plugins.net.Socket, session: ISmtpSession): Promise<plugins.tls.TLSSocket | null>;
|
|
||||||
/**
|
|
||||||
* Upgrade a connection to TLS
|
|
||||||
* @param socket - Client socket
|
|
||||||
*/
|
|
||||||
startTLS(socket: plugins.net.Socket): Promise<plugins.tls.TLSSocket>;
|
|
||||||
/**
|
|
||||||
* Create a secure server
|
|
||||||
* @returns TLS server instance or undefined if TLS is not enabled
|
|
||||||
*/
|
|
||||||
createSecureServer(): plugins.tls.Server | undefined;
|
|
||||||
/**
|
|
||||||
* Check if TLS is enabled
|
|
||||||
* @returns Whether TLS is enabled
|
|
||||||
*/
|
|
||||||
isTlsEnabled(): boolean;
|
|
||||||
/**
|
|
||||||
* Send a response to the client
|
|
||||||
* @param socket - Client socket
|
|
||||||
* @param response - Response message
|
|
||||||
*/
|
|
||||||
private sendResponse;
|
|
||||||
/**
|
|
||||||
* Check if TLS is available (interface requirement)
|
|
||||||
*/
|
|
||||||
isTlsAvailable(): boolean;
|
|
||||||
/**
|
|
||||||
* Get TLS options (interface requirement)
|
|
||||||
*/
|
|
||||||
getTlsOptions(): plugins.tls.TlsOptions;
|
|
||||||
/**
|
|
||||||
* Clean up resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,117 +0,0 @@
|
|||||||
/**
|
|
||||||
* Adaptive SMTP Logging System
|
|
||||||
* Automatically switches between logging modes based on server load (active connections)
|
|
||||||
* to maintain performance during high-concurrency scenarios
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../../plugins.js';
|
|
||||||
import { SecurityLogLevel, SecurityEventType } from '../constants.js';
|
|
||||||
import type { ISmtpSession } from '../interfaces.js';
|
|
||||||
import type { LogLevel, ISmtpLogOptions } from './logging.js';
|
|
||||||
/**
|
|
||||||
* Log modes based on server load
|
|
||||||
*/
|
|
||||||
export declare enum LogMode {
|
|
||||||
VERBOSE = "VERBOSE",// < 20 connections: Full detailed logging
|
|
||||||
REDUCED = "REDUCED",// 20-40 connections: Limited command/response logging, full error logging
|
|
||||||
MINIMAL = "MINIMAL"
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Configuration for adaptive logging thresholds
|
|
||||||
*/
|
|
||||||
export interface IAdaptiveLogConfig {
|
|
||||||
verboseThreshold: number;
|
|
||||||
reducedThreshold: number;
|
|
||||||
aggregationInterval: number;
|
|
||||||
maxAggregatedEntries: number;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Connection metadata for aggregation tracking
|
|
||||||
*/
|
|
||||||
interface IConnectionTracker {
|
|
||||||
activeConnections: number;
|
|
||||||
peakConnections: number;
|
|
||||||
totalConnections: number;
|
|
||||||
connectionsPerSecond: number;
|
|
||||||
lastConnectionTime: number;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Adaptive SMTP Logger that scales logging based on server load
|
|
||||||
*/
|
|
||||||
export declare class AdaptiveSmtpLogger {
|
|
||||||
private static instance;
|
|
||||||
private currentMode;
|
|
||||||
private config;
|
|
||||||
private aggregatedEntries;
|
|
||||||
private aggregationTimer;
|
|
||||||
private connectionTracker;
|
|
||||||
private constructor();
|
|
||||||
/**
|
|
||||||
* Get singleton instance
|
|
||||||
*/
|
|
||||||
static getInstance(config?: Partial<IAdaptiveLogConfig>): AdaptiveSmtpLogger;
|
|
||||||
/**
|
|
||||||
* Update active connection count and adjust log mode if needed
|
|
||||||
*/
|
|
||||||
updateConnectionCount(activeConnections: number): void;
|
|
||||||
/**
|
|
||||||
* Track new connection for rate calculation
|
|
||||||
*/
|
|
||||||
trackConnection(): void;
|
|
||||||
/**
|
|
||||||
* Get current logging mode
|
|
||||||
*/
|
|
||||||
getCurrentMode(): LogMode;
|
|
||||||
/**
|
|
||||||
* Get connection statistics
|
|
||||||
*/
|
|
||||||
getConnectionStats(): IConnectionTracker;
|
|
||||||
/**
|
|
||||||
* Log a message with adaptive behavior
|
|
||||||
*/
|
|
||||||
log(level: LogLevel, message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log command with adaptive behavior
|
|
||||||
*/
|
|
||||||
logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void;
|
|
||||||
/**
|
|
||||||
* Log response with adaptive behavior
|
|
||||||
*/
|
|
||||||
logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Log connection event with adaptive behavior
|
|
||||||
*/
|
|
||||||
logConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, eventType: 'connect' | 'close' | 'error', session?: ISmtpSession, error?: Error): void;
|
|
||||||
/**
|
|
||||||
* Log security event (always logged regardless of mode)
|
|
||||||
*/
|
|
||||||
logSecurityEvent(level: SecurityLogLevel, type: SecurityEventType, message: string, details: Record<string, any>, ipAddress?: string, domain?: string, success?: boolean): void;
|
|
||||||
/**
|
|
||||||
* Determine appropriate log mode based on connection count
|
|
||||||
*/
|
|
||||||
private determineLogMode;
|
|
||||||
/**
|
|
||||||
* Switch to a new log mode
|
|
||||||
*/
|
|
||||||
private switchLogMode;
|
|
||||||
/**
|
|
||||||
* Add entry to aggregation buffer
|
|
||||||
*/
|
|
||||||
private aggregateEntry;
|
|
||||||
/**
|
|
||||||
* Start the aggregation timer
|
|
||||||
*/
|
|
||||||
private startAggregationTimer;
|
|
||||||
/**
|
|
||||||
* Flush aggregated entries to logs
|
|
||||||
*/
|
|
||||||
private flushAggregatedEntries;
|
|
||||||
/**
|
|
||||||
* Cleanup resources
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Default instance for easy access
|
|
||||||
*/
|
|
||||||
export declare const adaptiveLogger: AdaptiveSmtpLogger;
|
|
||||||
export {};
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Helper Functions
|
|
||||||
* Provides utility functions for SMTP server implementation
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../../plugins.js';
|
|
||||||
import type { ISmtpServerOptions } from '../interfaces.js';
|
|
||||||
/**
|
|
||||||
* Formats a multi-line SMTP response according to RFC 5321
|
|
||||||
* @param code - Response code
|
|
||||||
* @param lines - Response lines
|
|
||||||
* @returns Formatted SMTP response
|
|
||||||
*/
|
|
||||||
export declare function formatMultilineResponse(code: number, lines: string[]): string;
|
|
||||||
/**
|
|
||||||
* Generates a unique session ID
|
|
||||||
* @returns Unique session ID
|
|
||||||
*/
|
|
||||||
export declare function generateSessionId(): string;
|
|
||||||
/**
|
|
||||||
* Safely parses an integer from string with a default value
|
|
||||||
* @param value - String value to parse
|
|
||||||
* @param defaultValue - Default value if parsing fails
|
|
||||||
* @returns Parsed integer or default value
|
|
||||||
*/
|
|
||||||
export declare function safeParseInt(value: string | undefined, defaultValue: number): number;
|
|
||||||
/**
|
|
||||||
* Safely gets the socket details
|
|
||||||
* @param socket - Socket to get details from
|
|
||||||
* @returns Socket details object
|
|
||||||
*/
|
|
||||||
export declare function getSocketDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): {
|
|
||||||
remoteAddress: string;
|
|
||||||
remotePort: number;
|
|
||||||
remoteFamily: string;
|
|
||||||
localAddress: string;
|
|
||||||
localPort: number;
|
|
||||||
encrypted: boolean;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Gets TLS details if socket is TLS
|
|
||||||
* @param socket - Socket to get TLS details from
|
|
||||||
* @returns TLS details or undefined if not TLS
|
|
||||||
*/
|
|
||||||
export declare function getTlsDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): {
|
|
||||||
protocol?: string;
|
|
||||||
cipher?: string;
|
|
||||||
authorized?: boolean;
|
|
||||||
} | undefined;
|
|
||||||
/**
|
|
||||||
* Merges default options with provided options
|
|
||||||
* @param options - User provided options
|
|
||||||
* @returns Merged options with defaults
|
|
||||||
*/
|
|
||||||
export declare function mergeWithDefaults(options: Partial<ISmtpServerOptions>): ISmtpServerOptions;
|
|
||||||
/**
|
|
||||||
* Creates a text response formatter for the SMTP server
|
|
||||||
* @param socket - Socket to send responses to
|
|
||||||
* @returns Function to send formatted response
|
|
||||||
*/
|
|
||||||
export declare function createResponseFormatter(socket: plugins.net.Socket | plugins.tls.TLSSocket): (response: string) => void;
|
|
||||||
/**
|
|
||||||
* Extracts SMTP command name from a command line
|
|
||||||
* @param commandLine - Full command line
|
|
||||||
* @returns Command name in uppercase
|
|
||||||
*/
|
|
||||||
export declare function extractCommandName(commandLine: string): string;
|
|
||||||
/**
|
|
||||||
* Extracts SMTP command arguments from a command line
|
|
||||||
* @param commandLine - Full command line
|
|
||||||
* @returns Arguments string
|
|
||||||
*/
|
|
||||||
export declare function extractCommandArgs(commandLine: string): string;
|
|
||||||
/**
|
|
||||||
* Sanitizes data for logging (hides sensitive info)
|
|
||||||
* @param data - Data to sanitize
|
|
||||||
* @returns Sanitized data
|
|
||||||
*/
|
|
||||||
export declare function sanitizeForLogging(data: any): any;
|
|
||||||
File diff suppressed because one or more lines are too long
106
dist_ts/mail/delivery/smtpserver/utils/logging.d.ts
vendored
106
dist_ts/mail/delivery/smtpserver/utils/logging.d.ts
vendored
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Logging Utilities
|
|
||||||
* Provides structured logging for SMTP server components
|
|
||||||
*/
|
|
||||||
import * as plugins from '../../../../plugins.js';
|
|
||||||
import { SecurityLogLevel, SecurityEventType } from '../constants.js';
|
|
||||||
import type { ISmtpSession } from '../interfaces.js';
|
|
||||||
/**
|
|
||||||
* SMTP connection metadata to include in logs
|
|
||||||
*/
|
|
||||||
export interface IConnectionMetadata {
|
|
||||||
remoteAddress?: string;
|
|
||||||
remotePort?: number;
|
|
||||||
socketId?: string;
|
|
||||||
secure?: boolean;
|
|
||||||
sessionId?: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Log levels for SMTP server
|
|
||||||
*/
|
|
||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
||||||
/**
|
|
||||||
* Options for SMTP log
|
|
||||||
*/
|
|
||||||
export interface ISmtpLogOptions {
|
|
||||||
level?: LogLevel;
|
|
||||||
sessionId?: string;
|
|
||||||
sessionState?: string;
|
|
||||||
remoteAddress?: string;
|
|
||||||
remotePort?: number;
|
|
||||||
command?: string;
|
|
||||||
error?: Error;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* SMTP logger - provides structured logging for SMTP server
|
|
||||||
*/
|
|
||||||
export declare class SmtpLogger {
|
|
||||||
/**
|
|
||||||
* Log a message with context
|
|
||||||
* @param level - Log level
|
|
||||||
* @param message - Log message
|
|
||||||
* @param options - Additional log options
|
|
||||||
*/
|
|
||||||
static log(level: LogLevel, message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log debug level message
|
|
||||||
* @param message - Log message
|
|
||||||
* @param options - Additional log options
|
|
||||||
*/
|
|
||||||
static debug(message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log info level message
|
|
||||||
* @param message - Log message
|
|
||||||
* @param options - Additional log options
|
|
||||||
*/
|
|
||||||
static info(message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log warning level message
|
|
||||||
* @param message - Log message
|
|
||||||
* @param options - Additional log options
|
|
||||||
*/
|
|
||||||
static warn(message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log error level message
|
|
||||||
* @param message - Log message
|
|
||||||
* @param options - Additional log options
|
|
||||||
*/
|
|
||||||
static error(message: string, options?: ISmtpLogOptions): void;
|
|
||||||
/**
|
|
||||||
* Log command received from client
|
|
||||||
* @param command - The command string
|
|
||||||
* @param socket - The client socket
|
|
||||||
* @param session - The SMTP session
|
|
||||||
*/
|
|
||||||
static logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void;
|
|
||||||
/**
|
|
||||||
* Log response sent to client
|
|
||||||
* @param response - The response string
|
|
||||||
* @param socket - The client socket
|
|
||||||
*/
|
|
||||||
static logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
|
|
||||||
/**
|
|
||||||
* Log client connection event
|
|
||||||
* @param socket - The client socket
|
|
||||||
* @param eventType - Type of connection event (connect, close, error)
|
|
||||||
* @param session - The SMTP session
|
|
||||||
* @param error - Optional error object for error events
|
|
||||||
*/
|
|
||||||
static logConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, eventType: 'connect' | 'close' | 'error', session?: ISmtpSession, error?: Error): void;
|
|
||||||
/**
|
|
||||||
* Log security event
|
|
||||||
* @param level - Security log level
|
|
||||||
* @param type - Security event type
|
|
||||||
* @param message - Log message
|
|
||||||
* @param details - Event details
|
|
||||||
* @param ipAddress - Client IP address
|
|
||||||
* @param domain - Optional domain involved
|
|
||||||
* @param success - Whether the security check was successful
|
|
||||||
*/
|
|
||||||
static logSecurityEvent(level: SecurityLogLevel, type: SecurityEventType, message: string, details: Record<string, any>, ipAddress?: string, domain?: string, success?: boolean): void;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Default instance for backward compatibility
|
|
||||||
*/
|
|
||||||
export declare const smtpLogger: typeof SmtpLogger;
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Validation Utilities
|
|
||||||
* Provides validation functions for SMTP server
|
|
||||||
*/
|
|
||||||
import { SmtpState } from '../interfaces.js';
|
|
||||||
/**
|
|
||||||
* Detects header injection attempts in input strings
|
|
||||||
* @param input - The input string to check
|
|
||||||
* @param context - The context where this input is being used ('smtp-command' or 'email-header')
|
|
||||||
* @returns true if header injection is detected, false otherwise
|
|
||||||
*/
|
|
||||||
export declare function detectHeaderInjection(input: string, context?: 'smtp-command' | 'email-header'): boolean;
|
|
||||||
/**
|
|
||||||
* Sanitizes input by removing or escaping potentially dangerous characters
|
|
||||||
* @param input - The input string to sanitize
|
|
||||||
* @returns Sanitized string
|
|
||||||
*/
|
|
||||||
export declare function sanitizeInput(input: string): string;
|
|
||||||
/**
|
|
||||||
* Validates an email address
|
|
||||||
* @param email - Email address to validate
|
|
||||||
* @returns Whether the email address is valid
|
|
||||||
*/
|
|
||||||
export declare function isValidEmail(email: string): boolean;
|
|
||||||
/**
|
|
||||||
* Validates the MAIL FROM command syntax
|
|
||||||
* @param args - Arguments string from the MAIL FROM command
|
|
||||||
* @returns Object with validation result and extracted data
|
|
||||||
*/
|
|
||||||
export declare function validateMailFrom(args: string): {
|
|
||||||
isValid: boolean;
|
|
||||||
address?: string;
|
|
||||||
params?: Record<string, string>;
|
|
||||||
errorMessage?: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Validates the RCPT TO command syntax
|
|
||||||
* @param args - Arguments string from the RCPT TO command
|
|
||||||
* @returns Object with validation result and extracted data
|
|
||||||
*/
|
|
||||||
export declare function validateRcptTo(args: string): {
|
|
||||||
isValid: boolean;
|
|
||||||
address?: string;
|
|
||||||
params?: Record<string, string>;
|
|
||||||
errorMessage?: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Validates the EHLO command syntax
|
|
||||||
* @param args - Arguments string from the EHLO command
|
|
||||||
* @returns Object with validation result and extracted data
|
|
||||||
*/
|
|
||||||
export declare function validateEhlo(args: string): {
|
|
||||||
isValid: boolean;
|
|
||||||
hostname?: string;
|
|
||||||
errorMessage?: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Validates command in the current SMTP state
|
|
||||||
* @param command - SMTP command
|
|
||||||
* @param currentState - Current SMTP state
|
|
||||||
* @returns Whether the command is valid in the current state
|
|
||||||
*/
|
|
||||||
export declare function isValidCommandSequence(command: string, currentState: SmtpState): boolean;
|
|
||||||
/**
|
|
||||||
* Validates if a hostname is valid according to RFC 5321
|
|
||||||
* @param hostname - Hostname to validate
|
|
||||||
* @returns Whether the hostname is valid
|
|
||||||
*/
|
|
||||||
export declare function isValidHostname(hostname: string): boolean;
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,3 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||||
interface IIPWarmupConfig {
|
interface IIPWarmupConfig {
|
||||||
@@ -134,6 +133,7 @@ export declare class UnifiedEmailServer extends EventEmitter {
|
|||||||
private servers;
|
private servers;
|
||||||
private stats;
|
private stats;
|
||||||
dkimCreator: DKIMCreator;
|
dkimCreator: DKIMCreator;
|
||||||
|
private rustBridge;
|
||||||
private ipReputationChecker;
|
private ipReputationChecker;
|
||||||
private bounceManager;
|
private bounceManager;
|
||||||
private ipWarmupManager;
|
private ipWarmupManager;
|
||||||
@@ -153,16 +153,26 @@ export declare class UnifiedEmailServer extends EventEmitter {
|
|||||||
* Start the unified email server
|
* Start the unified email server
|
||||||
*/
|
*/
|
||||||
start(): Promise<void>;
|
start(): Promise<void>;
|
||||||
/**
|
|
||||||
* Handle a socket from smartproxy in socket-handler mode
|
|
||||||
* @param socket The socket to handle
|
|
||||||
* @param port The port this connection is for (25, 587, 465)
|
|
||||||
*/
|
|
||||||
handleSocket(socket: plugins.net.Socket | plugins.tls.TLSSocket, port: number): Promise<void>;
|
|
||||||
/**
|
/**
|
||||||
* Stop the unified email server
|
* Stop the unified email server
|
||||||
*/
|
*/
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
/**
|
/**
|
||||||
* Process email based on routing rules
|
* Process email based on routing rules
|
||||||
*/
|
*/
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
25
dist_ts/mail/security/classes.dkimverifier.d.ts
vendored
25
dist_ts/mail/security/classes.dkimverifier.d.ts
vendored
@@ -11,36 +11,19 @@ export interface IDkimVerificationResult {
|
|||||||
signatureFields?: Record<string, string>;
|
signatureFields?: Record<string, string>;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Enhanced DKIM verifier using smartmail capabilities
|
* DKIM verifier — delegates to the Rust security bridge.
|
||||||
*/
|
*/
|
||||||
export declare class DKIMVerifier {
|
export declare class DKIMVerifier {
|
||||||
private verificationCache;
|
|
||||||
private cacheTtl;
|
|
||||||
constructor();
|
constructor();
|
||||||
/**
|
/**
|
||||||
* Verify DKIM signature for an email
|
* Verify DKIM signature for an email via Rust bridge
|
||||||
* @param emailData The raw email data
|
|
||||||
* @param options Verification options
|
|
||||||
* @returns Verification result
|
|
||||||
*/
|
*/
|
||||||
verify(emailData: string, options?: {
|
verify(emailData: string, options?: {
|
||||||
useCache?: boolean;
|
useCache?: boolean;
|
||||||
returnDetails?: boolean;
|
returnDetails?: boolean;
|
||||||
}): Promise<IDkimVerificationResult>;
|
}): Promise<IDkimVerificationResult>;
|
||||||
/**
|
/** No-op — Rust bridge handles its own caching */
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
clearCache(): void;
|
clearCache(): void;
|
||||||
/**
|
/** Always 0 — cache is managed by the Rust side */
|
||||||
* Get the size of the verification cache
|
|
||||||
* @returns Number of cached items
|
|
||||||
*/
|
|
||||||
getCacheSize(): number;
|
getCacheSize(): number;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
46
dist_ts/mail/security/classes.spfverifier.d.ts
vendored
46
dist_ts/mail/security/classes.spfverifier.d.ts
vendored
@@ -50,54 +50,22 @@ export interface SpfResult {
|
|||||||
error?: string;
|
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 {
|
export declare class SpfVerifier {
|
||||||
private dnsManager?;
|
constructor(_dnsManager?: any);
|
||||||
private lookupCount;
|
|
||||||
constructor(dnsManager?: any);
|
|
||||||
/**
|
/**
|
||||||
* Parse SPF record from TXT record
|
* Parse SPF record from TXT record (pure string parsing, no DNS)
|
||||||
* @param record SPF TXT record
|
|
||||||
* @returns Parsed SPF record or null if invalid
|
|
||||||
*/
|
*/
|
||||||
parseSpfRecord(record: string): SpfRecord | null;
|
parseSpfRecord(record: string): SpfRecord | null;
|
||||||
/**
|
/**
|
||||||
* Check if IP is in CIDR range
|
* Verify SPF for a given email — delegates to Rust bridge
|
||||||
* @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(email: Email, ip: string, heloDomain: string): Promise<SpfResult>;
|
verify(email: Email, ip: string, heloDomain: string): Promise<SpfResult>;
|
||||||
/**
|
/**
|
||||||
* Check SPF record against IP address
|
* Check if email passes SPF verification and apply headers
|
||||||
* @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
|
|
||||||
*/
|
*/
|
||||||
verifyAndApply(email: Email, ip: string, heloDomain: string): Promise<boolean>;
|
verifyAndApply(email: Email, ip: string, heloDomain: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
8
dist_ts/plugins.d.ts
vendored
8
dist_ts/plugins.d.ts
vendored
@@ -32,18 +32,16 @@ import * as smartproxy from '@push.rocks/smartproxy';
|
|||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
import * as smartrequest from '@push.rocks/smartrequest';
|
import * as smartrequest from '@push.rocks/smartrequest';
|
||||||
import * as smartrule from '@push.rocks/smartrule';
|
import * as smartrule from '@push.rocks/smartrule';
|
||||||
|
import * as smartrust from '@push.rocks/smartrust';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
import * as smartunique from '@push.rocks/smartunique';
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
export declare const smartfs: SmartFs;
|
export declare const smartfs: SmartFs;
|
||||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, SmartFs, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrx, smartunique };
|
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, SmartFs, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrust, smartrx, smartunique };
|
||||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||||
export { cloudflare, };
|
export { cloudflare, };
|
||||||
import * as tsclass from '@tsclass/tsclass';
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
export { tsclass, };
|
export { tsclass, };
|
||||||
import * as mailauth from 'mailauth';
|
|
||||||
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
|
|
||||||
import mailparser from 'mailparser';
|
import mailparser from 'mailparser';
|
||||||
import * as uuid from 'uuid';
|
import * as uuid from 'uuid';
|
||||||
import * as ip from 'ip';
|
export { mailparser, uuid, };
|
||||||
export { mailauth, dkimSign, mailparser, uuid, ip, };
|
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ import * as smartproxy from '@push.rocks/smartproxy';
|
|||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
import * as smartrequest from '@push.rocks/smartrequest';
|
import * as smartrequest from '@push.rocks/smartrequest';
|
||||||
import * as smartrule from '@push.rocks/smartrule';
|
import * as smartrule from '@push.rocks/smartrule';
|
||||||
|
import * as smartrust from '@push.rocks/smartrust';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
import * as smartunique from '@push.rocks/smartunique';
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
export const smartfs = new SmartFs(new SmartFsProviderNode());
|
export const smartfs = new SmartFs(new SmartFsProviderNode());
|
||||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, SmartFs, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrx, smartunique };
|
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, SmartFs, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrust, smartrx, smartunique };
|
||||||
// apiclient.xyz scope
|
// apiclient.xyz scope
|
||||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||||
export { cloudflare, };
|
export { cloudflare, };
|
||||||
@@ -47,10 +48,7 @@ export { cloudflare, };
|
|||||||
import * as tsclass from '@tsclass/tsclass';
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
export { tsclass, };
|
export { tsclass, };
|
||||||
// third party
|
// third party
|
||||||
import * as mailauth from 'mailauth';
|
|
||||||
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
|
|
||||||
import mailparser from 'mailparser';
|
import mailparser from 'mailparser';
|
||||||
import * as uuid from 'uuid';
|
import * as uuid from 'uuid';
|
||||||
import * as ip from 'ip';
|
export { mailparser, uuid, };
|
||||||
export { mailauth, dkimSign, mailparser, uuid, ip, };
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsRUFBRSxFQUNGLE1BQU0sRUFDTixJQUFJLEVBQ0osR0FBRyxFQUNILEVBQUUsRUFDRixJQUFJLEVBQ0osR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxtQkFBbUIsTUFBTSx3QkFBd0IsQ0FBQztBQUU5RCxPQUFPLEVBQ0wsbUJBQW1CLEVBQ3BCLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssV0FBVyxNQUFNLHlCQUF5QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFFdkQsT0FBTyxFQUNMLFlBQVksRUFDWixXQUFXLEVBQ1gsV0FBVyxHQUNaLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxFQUFFLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBQ25FLE9BQU8sS0FBSyxVQUFVLE1BQU0sd0JBQXdCLENBQUM7QUFDckQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFVBQVUsTUFBTSx3QkFBd0IsQ0FBQztBQUNyRCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBQ25ELE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFDL0MsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUV2RCxNQUFNLENBQUMsTUFBTSxPQUFPLEdBQUcsSUFBSSxPQUFPLENBQUMsSUFBSSxtQkFBbUIsRUFBRSxDQUFDLENBQUM7QUFFOUQsT0FBTyxFQUFFLFdBQVcsRUFBRSxJQUFJLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLE9BQU8sRUFBRSxVQUFVLEVBQUUsUUFBUSxFQUFFLFFBQVEsRUFBRSxTQUFTLEVBQUUsWUFBWSxFQUFFLFlBQVksRUFBRSxTQUFTLEVBQUUsVUFBVSxFQUFFLFlBQVksRUFBRSxZQUFZLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsV0FBVyxFQUFFLENBQUM7QUFLdlAsc0JBQXNCO0FBQ3RCLE9BQU8sS0FBSyxVQUFVLE1BQU0sMkJBQTJCLENBQUM7QUFFeEQsT0FBTyxFQUNMLFVBQVUsR0FDWCxDQUFBO0FBRUQsZ0JBQWdCO0FBQ2hCLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUNMLE9BQU8sR0FDUixDQUFBO0FBRUQsY0FBYztBQUNkLE9BQU8sVUFBVSxNQUFNLFlBQVksQ0FBQztBQUNwQyxPQUFPLEtBQUssSUFBSSxNQUFNLE1BQU0sQ0FBQztBQUU3QixPQUFPLEVBQ0wsVUFBVSxFQUNWLElBQUksR0FDTCxDQUFBIn0=
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsRUFBRSxFQUNGLE1BQU0sRUFDTixJQUFJLEVBQ0osR0FBRyxFQUNILEVBQUUsRUFDRixJQUFJLEVBQ0osR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxtQkFBbUIsTUFBTSx3QkFBd0IsQ0FBQztBQUU5RCxPQUFPLEVBQ0wsbUJBQW1CLEVBQ3BCLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssV0FBVyxNQUFNLHlCQUF5QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFFdkQsT0FBTyxFQUNMLFlBQVksRUFDWixXQUFXLEVBQ1gsV0FBVyxHQUNaLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxFQUFFLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBQ25FLE9BQU8sS0FBSyxVQUFVLE1BQU0sd0JBQXdCLENBQUM7QUFDckQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFVBQVUsTUFBTSx3QkFBd0IsQ0FBQztBQUNyRCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssT0FBTyxNQUFNLHFCQUFxQixDQUFDO0FBQy9DLE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFFdkQsTUFBTSxDQUFDLE1BQU0sT0FBTyxHQUFHLElBQUksT0FBTyxDQUFDLElBQUksbUJBQW1CLEVBQUUsQ0FBQyxDQUFDO0FBRTlELE9BQU8sRUFBRSxXQUFXLEVBQUUsSUFBSSxFQUFFLFNBQVMsRUFBRSxTQUFTLEVBQUUsUUFBUSxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsVUFBVSxFQUFFLFFBQVEsRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLFlBQVksRUFBRSxZQUFZLEVBQUUsU0FBUyxFQUFFLFVBQVUsRUFBRSxZQUFZLEVBQUUsWUFBWSxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsV0FBVyxFQUFFLENBQUM7QUFLNU8sc0JBQXNCO0FBQ3RCLE9BQU8sS0FBSyxVQUFVLE1BQU0sMkJBQTJCLENBQUM7QUFFeEQsT0FBTyxFQUNMLFVBQVUsR0FDWCxDQUFBO0FBRUQsZ0JBQWdCO0FBQ2hCLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUNMLE9BQU8sR0FDUixDQUFBO0FBRUQsY0FBYztBQUNkLE9BQU8sS0FBSyxRQUFRLE1BQU0sVUFBVSxDQUFDO0FBQ3JDLE9BQU8sRUFBRSxRQUFRLEVBQUUsTUFBTSwyQkFBMkIsQ0FBQztBQUNyRCxPQUFPLFVBQVUsTUFBTSxZQUFZLENBQUM7QUFDcEMsT0FBTyxLQUFLLElBQUksTUFBTSxNQUFNLENBQUM7QUFDN0IsT0FBTyxLQUFLLEVBQUUsTUFBTSxJQUFJLENBQUM7QUFFekIsT0FBTyxFQUNMLFFBQVEsRUFDUixRQUFRLEVBQ1IsVUFBVSxFQUNWLElBQUksRUFDSixFQUFFLEdBQ0gsQ0FBQSJ9
|
|
||||||
52
dist_ts/security/classes.contentscanner.d.ts
vendored
52
dist_ts/security/classes.contentscanner.d.ts
vendored
@@ -54,9 +54,6 @@ export declare class ContentScanner {
|
|||||||
private static instance;
|
private static instance;
|
||||||
private scanCache;
|
private scanCache;
|
||||||
private options;
|
private options;
|
||||||
private static readonly MALICIOUS_PATTERNS;
|
|
||||||
private static readonly EXECUTABLE_EXTENSIONS;
|
|
||||||
private static readonly MACRO_DOCUMENT_EXTENSIONS;
|
|
||||||
/**
|
/**
|
||||||
* Default options for the content scanner
|
* Default options for the content scanner
|
||||||
*/
|
*/
|
||||||
@@ -73,7 +70,9 @@ export declare class ContentScanner {
|
|||||||
*/
|
*/
|
||||||
static getInstance(options?: IContentScannerOptions): 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
|
* @param email The email to scan
|
||||||
* @returns Scan result
|
* @returns Scan result
|
||||||
*/
|
*/
|
||||||
@@ -85,41 +84,19 @@ export declare class ContentScanner {
|
|||||||
*/
|
*/
|
||||||
private generateCacheKey;
|
private generateCacheKey;
|
||||||
/**
|
/**
|
||||||
* Scan email subject for threats
|
* Scan attachment binary content for PE headers and VBA macros.
|
||||||
* @param subject The subject to scan
|
* This stays in TS because it accesses raw Buffer data (too large for IPC).
|
||||||
* @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
|
|
||||||
* @param attachment The attachment to scan
|
* @param attachment The attachment to scan
|
||||||
* @param result The scan result to update
|
* @param result The scan result to update
|
||||||
*/
|
*/
|
||||||
private scanAttachment;
|
private scanAttachmentBinary;
|
||||||
/**
|
/**
|
||||||
* Extract links from HTML content
|
* Apply custom rules (runtime-configured patterns) to the email.
|
||||||
* @param html HTML content
|
* These stay in TS because they are configured at runtime.
|
||||||
* @returns Array of extracted links
|
* @param email The email to check
|
||||||
|
* @param result The scan result to update
|
||||||
*/
|
*/
|
||||||
private extractLinksFromHtml;
|
private applyCustomRules;
|
||||||
/**
|
|
||||||
* Extract plain text from HTML
|
|
||||||
* @param html HTML content
|
|
||||||
* @returns Extracted text
|
|
||||||
*/
|
|
||||||
private extractTextFromHtml;
|
|
||||||
/**
|
/**
|
||||||
* Extract text from a binary buffer for scanning
|
* Extract text from a binary buffer for scanning
|
||||||
* @param buffer Binary content
|
* @param buffer Binary content
|
||||||
@@ -128,17 +105,10 @@ export declare class ContentScanner {
|
|||||||
private extractTextFromBuffer;
|
private extractTextFromBuffer;
|
||||||
/**
|
/**
|
||||||
* Check if an Office document likely contains macros
|
* 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
|
* @param attachment The attachment to check
|
||||||
* @returns Whether the file likely contains macros
|
* @returns Whether the file likely contains macros
|
||||||
*/
|
*/
|
||||||
private likelyContainsMacros;
|
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
|
* Log a high threat finding to the security logger
|
||||||
* @param email The email containing the threat
|
* @param email The email containing the threat
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -48,103 +48,26 @@ export interface IIPReputationOptions {
|
|||||||
enableIPInfo?: boolean;
|
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 {
|
export declare class IPReputationChecker {
|
||||||
private static instance;
|
private static instance;
|
||||||
private reputationCache;
|
private reputationCache;
|
||||||
private options;
|
private options;
|
||||||
private storageManager?;
|
private storageManager?;
|
||||||
private static readonly DEFAULT_DNSBL_SERVERS;
|
|
||||||
private static readonly DEFAULT_OPTIONS;
|
private static readonly DEFAULT_OPTIONS;
|
||||||
/**
|
|
||||||
* Constructor for IPReputationChecker
|
|
||||||
* @param options Configuration options
|
|
||||||
* @param storageManager Optional StorageManager instance for persistence
|
|
||||||
*/
|
|
||||||
constructor(options?: IIPReputationOptions, storageManager?: any);
|
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;
|
static getInstance(options?: IIPReputationOptions, storageManager?: any): IPReputationChecker;
|
||||||
/**
|
/**
|
||||||
* Check an IP address's reputation
|
* Check an IP address's reputation via the Rust bridge
|
||||||
* @param ip IP address to check
|
|
||||||
* @returns Reputation check result
|
|
||||||
*/
|
*/
|
||||||
checkReputation(ip: string): Promise<IReputationResult>;
|
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;
|
private createErrorResult;
|
||||||
/**
|
|
||||||
* Validate IP address format
|
|
||||||
* @param ip IP address to validate
|
|
||||||
* @returns Whether the IP is valid
|
|
||||||
*/
|
|
||||||
private isValidIPAddress;
|
private isValidIPAddress;
|
||||||
/**
|
|
||||||
* Log reputation check to security logger
|
|
||||||
* @param ip IP address
|
|
||||||
* @param result Reputation result
|
|
||||||
*/
|
|
||||||
private logReputationCheck;
|
private logReputationCheck;
|
||||||
/**
|
|
||||||
* Save cache to disk or storage manager
|
|
||||||
*/
|
|
||||||
private saveCache;
|
private saveCache;
|
||||||
/**
|
|
||||||
* Load cache from disk or storage manager
|
|
||||||
*/
|
|
||||||
private loadCache;
|
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';
|
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;
|
updateStorageManager(storageManager: any): void;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
229
dist_ts/security/classes.rustsecuritybridge.d.ts
vendored
Normal file
229
dist_ts/security/classes.rustsecuritybridge.d.ts
vendored
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
interface IDkimVerificationResult {
|
||||||
|
is_valid: boolean;
|
||||||
|
domain: string | null;
|
||||||
|
selector: string | null;
|
||||||
|
status: string;
|
||||||
|
details: string | null;
|
||||||
|
}
|
||||||
|
interface ISpfResult {
|
||||||
|
result: string;
|
||||||
|
domain: string;
|
||||||
|
ip: string;
|
||||||
|
explanation: string | null;
|
||||||
|
}
|
||||||
|
interface IDmarcResult {
|
||||||
|
passed: boolean;
|
||||||
|
policy: string;
|
||||||
|
domain: string;
|
||||||
|
dkim_result: string;
|
||||||
|
spf_result: string;
|
||||||
|
action: string;
|
||||||
|
details: string | null;
|
||||||
|
}
|
||||||
|
interface IEmailSecurityResult {
|
||||||
|
dkim: IDkimVerificationResult[];
|
||||||
|
spf: ISpfResult | null;
|
||||||
|
dmarc: IDmarcResult | null;
|
||||||
|
}
|
||||||
|
interface IValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
formatValid: boolean;
|
||||||
|
score: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
interface IBounceDetection {
|
||||||
|
bounce_type: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
interface IReputationResult {
|
||||||
|
ip: string;
|
||||||
|
score: number;
|
||||||
|
risk_level: string;
|
||||||
|
ip_type: string;
|
||||||
|
dnsbl_results: Array<{
|
||||||
|
server: string;
|
||||||
|
listed: boolean;
|
||||||
|
response: string | null;
|
||||||
|
}>;
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* Uses `@push.rocks/smartrust` for JSON-over-stdin/stdout IPC.
|
||||||
|
* Singleton — access via `RustSecurityBridge.getInstance()`.
|
||||||
|
*/
|
||||||
|
export declare class RustSecurityBridge {
|
||||||
|
private static instance;
|
||||||
|
private bridge;
|
||||||
|
private _running;
|
||||||
|
private constructor();
|
||||||
|
/** Get or create the singleton instance. */
|
||||||
|
static getInstance(): RustSecurityBridge;
|
||||||
|
/** Whether the Rust process is currently running and accepting commands. */
|
||||||
|
get running(): boolean;
|
||||||
|
/**
|
||||||
|
* Spawn the Rust binary and wait for the ready signal.
|
||||||
|
* @returns `true` if the binary started successfully, `false` otherwise.
|
||||||
|
*/
|
||||||
|
start(): Promise<boolean>;
|
||||||
|
/** Kill the Rust process. */
|
||||||
|
stop(): Promise<void>;
|
||||||
|
/** Ping the Rust process. */
|
||||||
|
ping(): Promise<boolean>;
|
||||||
|
/** Get version information for all Rust crates. */
|
||||||
|
getVersion(): Promise<IVersionInfo>;
|
||||||
|
/** Validate an email address. */
|
||||||
|
validateEmail(email: string): Promise<IValidationResult>;
|
||||||
|
/** Detect bounce type from SMTP response / diagnostic code. */
|
||||||
|
detectBounce(opts: {
|
||||||
|
smtpResponse?: string;
|
||||||
|
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. */
|
||||||
|
verifyDkim(rawMessage: string): Promise<IDkimVerificationResult[]>;
|
||||||
|
/** Sign an email with DKIM. */
|
||||||
|
signDkim(opts: {
|
||||||
|
rawMessage: string;
|
||||||
|
domain: string;
|
||||||
|
selector?: string;
|
||||||
|
privateKey: string;
|
||||||
|
}): Promise<{
|
||||||
|
header: string;
|
||||||
|
signedMessage: string;
|
||||||
|
}>;
|
||||||
|
/** Check SPF for a sender. */
|
||||||
|
checkSpf(opts: {
|
||||||
|
ip: string;
|
||||||
|
heloDomain: string;
|
||||||
|
hostname?: string;
|
||||||
|
mailFrom: string;
|
||||||
|
}): Promise<ISpfResult>;
|
||||||
|
/**
|
||||||
|
* Compound email security verification: DKIM + SPF + DMARC in one IPC call.
|
||||||
|
*
|
||||||
|
* This is the preferred method for inbound email verification — it avoids
|
||||||
|
* 3 sequential round-trips and correctly passes raw mail-auth types internally.
|
||||||
|
*/
|
||||||
|
verifyEmail(opts: {
|
||||||
|
rawMessage: string;
|
||||||
|
ip: string;
|
||||||
|
heloDomain: string;
|
||||||
|
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, IContentScanResult, IReputationResult as IRustReputationResult, IVersionInfo, ISmtpServerConfig, IRateLimitConfig, IEmailData, IEmailReceivedEvent, IAuthRequestEvent, };
|
||||||
204
dist_ts/security/classes.rustsecuritybridge.js
Normal file
204
dist_ts/security/classes.rustsecuritybridge.js
Normal file
File diff suppressed because one or more lines are too long
1
dist_ts/security/index.d.ts
vendored
1
dist_ts/security/index.d.ts
vendored
@@ -1,3 +1,4 @@
|
|||||||
export { SecurityLogger, SecurityLogLevel, SecurityEventType, type ISecurityEvent } from './classes.securitylogger.js';
|
export { SecurityLogger, SecurityLogLevel, SecurityEventType, type ISecurityEvent } from './classes.securitylogger.js';
|
||||||
export { IPReputationChecker, ReputationThreshold, IPType, type IReputationResult, type IIPReputationOptions } from './classes.ipreputationchecker.js';
|
export { IPReputationChecker, ReputationThreshold, IPType, type IReputationResult, type IIPReputationOptions } from './classes.ipreputationchecker.js';
|
||||||
export { ContentScanner, ThreatCategory, type IScanResult, type IContentScannerOptions } from './classes.contentscanner.js';
|
export { ContentScanner, ThreatCategory, type IScanResult, type IContentScannerOptions } from './classes.contentscanner.js';
|
||||||
|
export { RustSecurityBridge, type IDkimVerificationResult, type ISpfResult, type IDmarcResult, type IEmailSecurityResult, type IValidationResult, type IBounceDetection, type IRustReputationResult, type IVersionInfo, } from './classes.rustsecuritybridge.js';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
export { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||||||
export { IPReputationChecker, ReputationThreshold, IPType } from './classes.ipreputationchecker.js';
|
export { IPReputationChecker, ReputationThreshold, IPType } from './classes.ipreputationchecker.js';
|
||||||
export { ContentScanner, ThreatCategory } from './classes.contentscanner.js';
|
export { ContentScanner, ThreatCategory } from './classes.contentscanner.js';
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi90cy9zZWN1cml0eS9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQ0wsY0FBYyxFQUNkLGdCQUFnQixFQUNoQixpQkFBaUIsRUFFbEIsTUFBTSw2QkFBNkIsQ0FBQztBQUVyQyxPQUFPLEVBQ0wsbUJBQW1CLEVBQ25CLG1CQUFtQixFQUNuQixNQUFNLEVBR1AsTUFBTSxrQ0FBa0MsQ0FBQztBQUUxQyxPQUFPLEVBQ0wsY0FBYyxFQUNkLGNBQWMsRUFHZixNQUFNLDZCQUE2QixDQUFDIn0=
|
export { RustSecurityBridge, } from './classes.rustsecuritybridge.js';
|
||||||
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi90cy9zZWN1cml0eS9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQ0wsY0FBYyxFQUNkLGdCQUFnQixFQUNoQixpQkFBaUIsRUFFbEIsTUFBTSw2QkFBNkIsQ0FBQztBQUVyQyxPQUFPLEVBQ0wsbUJBQW1CLEVBQ25CLG1CQUFtQixFQUNuQixNQUFNLEVBR1AsTUFBTSxrQ0FBa0MsQ0FBQztBQUUxQyxPQUFPLEVBQ0wsY0FBYyxFQUNkLGNBQWMsRUFHZixNQUFNLDZCQUE2QixDQUFDO0FBRXJDLE9BQU8sRUFDTCxrQkFBa0IsR0FTbkIsTUFBTSxpQ0FBaUMsQ0FBQyJ9
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
{
|
{
|
||||||
"@git.zone/tsrust": {
|
"@git.zone/tsrust": {
|
||||||
"targets": ["linux_amd64", "linux_arm64"]
|
"targets": [
|
||||||
|
"linux_amd64",
|
||||||
|
"linux_arm64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@git.zone/cli": {
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartmta",
|
"name": "@push.rocks/smartmta",
|
||||||
"version": "2.0.0",
|
"version": "2.3.0",
|
||||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mta",
|
"mta",
|
||||||
|
|||||||
352
readme.md
352
readme.md
@@ -1,6 +1,6 @@
|
|||||||
# @push.rocks/smartmta
|
# @push.rocks/smartmta
|
||||||
|
|
||||||
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration — no nodemailer, no shortcuts. 🚀
|
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. 🚀
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -14,64 +14,83 @@ pnpm install @push.rocks/smartmta
|
|||||||
npm install @push.rocks/smartmta
|
npm install @push.rocks/smartmta
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`). The Rust binary is **required** — `smartmta` will not start without it.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`@push.rocks/smartmta` is a **complete mail server solution** — SMTP server, SMTP client, email security, content scanning, and delivery management — all built with a custom SMTP implementation. No wrappers around nodemailer. No half-measures.
|
`@push.rocks/smartmta` is a **complete mail server solution** — SMTP server, SMTP client, email security, content scanning, and delivery management — all built with a custom SMTP implementation. The SMTP server itself runs as a Rust binary for maximum performance, communicating with the TypeScript orchestration layer via IPC.
|
||||||
|
|
||||||
### ✨ What's Inside
|
### ⚡ What's Inside
|
||||||
|
|
||||||
| Module | What It Does |
|
| Module | What It Does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **SMTP Server** | RFC 5321-compliant server with TLS/STARTTLS, authentication, pipelining |
|
| **Rust SMTP Server** | High-performance SMTP engine written in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
|
||||||
| **SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation |
|
| **SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation |
|
||||||
| **DKIM** | Key generation, signing, and verification — per domain |
|
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
|
||||||
| **SPF** | Full SPF record validation |
|
| **SPF** | Full SPF record validation via Rust |
|
||||||
| **DMARC** | Policy enforcement and verification |
|
| **DMARC** | Policy enforcement and verification |
|
||||||
| **Email Router** | Pattern-based routing with priority, forward/deliver/reject/process actions |
|
| **Email Router** | Pattern-based routing with priority, forward/deliver/reject/process actions |
|
||||||
| **Bounce Manager** | Automatic bounce detection, classification (hard/soft), and tracking |
|
| **Bounce Manager** | Automatic bounce detection via Rust, classification (hard/soft), and suppression tracking |
|
||||||
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection |
|
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection — powered by Rust |
|
||||||
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring |
|
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring via Rust |
|
||||||
| **Rate Limiter** | Hierarchical rate limiting (global, per-domain, per-sender) |
|
| **Rate Limiter** | Hierarchical rate limiting (global, per-domain, per-IP) |
|
||||||
| **Delivery Queue** | Persistent queue with exponential backoff retry |
|
| **Delivery Queue** | Persistent queue with exponential backoff retry |
|
||||||
| **Template Engine** | Email templates with variable substitution |
|
| **Template Engine** | Email templates with variable substitution |
|
||||||
| **Domain Registry** | Multi-domain management with per-domain configuration |
|
| **Domain Registry** | Multi-domain management with per-domain configuration |
|
||||||
| **DNS Manager** | Automatic DNS record management with Cloudflare API integration |
|
| **DNS Manager** | Automatic DNS record management with Cloudflare API integration |
|
||||||
| **Rust Accelerator** | Performance-critical operations (DKIM, MIME, validation) in Rust via IPC |
|
| **Rust Security Bridge** | All security ops (DKIM+SPF+DMARC+DNSBL+content scanning) run in Rust via IPC |
|
||||||
|
|
||||||
### 🏗️ Architecture
|
### 🏗️ Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
│ UnifiedEmailServer │
|
│ UnifiedEmailServer │
|
||||||
│ (orchestrates all components, emits events) │
|
│ (orchestrates all components, emits events) │
|
||||||
├──────────┬──────────┬───────────┬───────────────────┤
|
├───────────┬───────────┬──────────────┬───────────────────────┤
|
||||||
│ SMTP │ Email │ Security │ Delivery │
|
│ Email │ Security │ Delivery │ Configuration │
|
||||||
│ Server │ Router │ Stack │ System │
|
│ Router │ Stack │ System │ │
|
||||||
│ ┌─────┐ │ ┌─────┐ │ ┌──────┐ │ ┌─────────────┐ │
|
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
|
||||||
│ │ TLS │ │ │Match│ │ │ DKIM │ │ │ Queue │ │
|
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
|
||||||
│ │ Auth│ │ │Route│ │ │ SPF │ │ │ Rate Limit │ │
|
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
|
||||||
│ │ Cmd │ │ │ Act │ │ │DMARC │ │ │ SMTP Client │ │
|
│ │ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator │ │
|
||||||
│ │ Data│ │ │ │ │ │IPRep │ │ │ Retry Logic │ │
|
│ └──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates │ │
|
||||||
│ └─────┘ │ └─────┘ │ │Scan │ │ └─────────────┘ │
|
│ │ │ Scan │ │ └──────────┘ │ └────────────────┘ │
|
||||||
│ │ │ └──────┘ │ │
|
│ │ └───────┘ │ │ │
|
||||||
├──────────┴──────────┴───────────┴───────────────────┤
|
├───────────┴───────────┴──────────────┴───────────────────────┤
|
||||||
|
│ Rust Security Bridge (smartrust IPC) │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
│ Rust Acceleration Layer │
|
│ Rust Acceleration Layer │
|
||||||
│ (mailer-core, mailer-security via smartrust IPC) │
|
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
|
||||||
└─────────────────────────────────────────────────────┘
|
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
|
||||||
|
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
|
||||||
|
│ │ TLS/AUTH │ │IP Rep/Content │ │ MIME/Bounce │ │
|
||||||
|
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Data flow for inbound mail:**
|
||||||
|
|
||||||
|
1. Rust SMTP server accepts the connection and handles the SMTP protocol
|
||||||
|
2. On `DATA` completion, Rust emits an `emailReceived` event via IPC
|
||||||
|
3. TypeScript processes the email (routing, scanning, delivery decisions)
|
||||||
|
4. TypeScript sends the processing result back to Rust via IPC
|
||||||
|
5. Rust sends the final SMTP response to the client
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### 🔧 Setting Up the Email Server
|
### 🚀 Setting Up the Email Server
|
||||||
|
|
||||||
The central entry point is `UnifiedEmailServer`, which orchestrates SMTP, routing, security, and delivery:
|
The central entry point is `UnifiedEmailServer`, which orchestrates the Rust SMTP server, routing, security, and delivery:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { UnifiedEmailServer } from '@push.rocks/smartmta';
|
import { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||||
|
// Ports to listen on (465 = implicit TLS, 25/587 = STARTTLS)
|
||||||
ports: [25, 587, 465],
|
ports: [25, 587, 465],
|
||||||
hostname: 'mail.example.com',
|
hostname: 'mail.example.com',
|
||||||
|
|
||||||
|
// Multi-domain configuration
|
||||||
domains: [
|
domains: [
|
||||||
{
|
{
|
||||||
domain: 'example.com',
|
domain: 'example.com',
|
||||||
@@ -83,11 +102,13 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
|||||||
rotationInterval: 90,
|
rotationInterval: 90,
|
||||||
},
|
},
|
||||||
rateLimits: {
|
rateLimits: {
|
||||||
maxMessagesPerMinute: 100,
|
outbound: { messagesPerMinute: 100 },
|
||||||
maxRecipientsPerMessage: 50,
|
inbound: { messagesPerMinute: 200, connectionsPerIp: 20 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Routing rules (evaluated by priority, highest first)
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
name: 'catch-all-forward',
|
name: 'catch-all-forward',
|
||||||
@@ -118,31 +139,39 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Authentication settings for the SMTP server
|
||||||
auth: {
|
auth: {
|
||||||
required: false,
|
required: false,
|
||||||
methods: ['PLAIN', 'LOGIN'],
|
methods: ['PLAIN', 'LOGIN'],
|
||||||
users: [{ username: 'outbound', password: 'secret' }],
|
users: [{ username: 'outbound', password: 'secret' }],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// TLS certificates
|
||||||
tls: {
|
tls: {
|
||||||
certPath: '/etc/ssl/mail.crt',
|
certPath: '/etc/ssl/mail.crt',
|
||||||
keyPath: '/etc/ssl/mail.key',
|
keyPath: '/etc/ssl/mail.key',
|
||||||
},
|
},
|
||||||
|
|
||||||
maxMessageSize: 25 * 1024 * 1024, // 25 MB
|
maxMessageSize: 25 * 1024 * 1024, // 25 MB
|
||||||
maxClients: 500,
|
maxClients: 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// start() boots the Rust SMTP server, security bridge, DNS records, and delivery queue
|
||||||
await emailServer.start();
|
await emailServer.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
|
||||||
|
|
||||||
### 📧 Sending Emails with the SMTP Client
|
### 📧 Sending Emails with the SMTP Client
|
||||||
|
|
||||||
Create and send emails using the built-in SMTP client with connection pooling:
|
Create and send emails using the built-in SMTP client with connection pooling:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Email, createSmtpClient } from '@push.rocks/smartmta';
|
import { Email, Delivery } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
// Create a client with connection pooling
|
// Create a client with connection pooling
|
||||||
const client = createSmtpClient({
|
const client = Delivery.smtpClientMod.createSmtpClient({
|
||||||
host: 'smtp.example.com',
|
host: 'smtp.example.com',
|
||||||
port: 587,
|
port: 587,
|
||||||
secure: false, // will upgrade via STARTTLS
|
secure: false, // will upgrade via STARTTLS
|
||||||
@@ -177,9 +206,22 @@ const result = await client.sendMail(email);
|
|||||||
console.log(`Message sent: ${result.messageId}`);
|
console.log(`Message sent: ${result.messageId}`);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🔐 DKIM Signing
|
Additional client factories are available:
|
||||||
|
|
||||||
Automatic DKIM key generation, storage, and signing per domain:
|
```typescript
|
||||||
|
// Pooled client for high-throughput scenarios
|
||||||
|
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
|
||||||
|
|
||||||
|
// Optimized for bulk sending
|
||||||
|
const bulk = Delivery.smtpClientMod.createBulkSmtpClient({ /* ... */ });
|
||||||
|
|
||||||
|
// Optimized for transactional emails
|
||||||
|
const transactional = Delivery.smtpClientMod.createTransactionalSmtpClient({ /* ... */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔑 DKIM Signing
|
||||||
|
|
||||||
|
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by `UnifiedEmailServer` during outbound delivery:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { DKIMCreator } from '@push.rocks/smartmta';
|
import { DKIMCreator } from '@push.rocks/smartmta';
|
||||||
@@ -192,36 +234,45 @@ await dkimCreator.handleDKIMKeysForDomain('example.com');
|
|||||||
// Get the DNS record you need to publish
|
// Get the DNS record you need to publish
|
||||||
const dnsRecord = await dkimCreator.getDNSRecordForDomain('example.com');
|
const dnsRecord = await dkimCreator.getDNSRecordForDomain('example.com');
|
||||||
console.log(dnsRecord);
|
console.log(dnsRecord);
|
||||||
// → { type: 'TXT', name: 'default._domainkey.example.com', value: 'v=DKIM1; k=rsa; p=...' }
|
// -> { type: 'TXT', name: 'default._domainkey.example.com', value: 'v=DKIM1; k=rsa; p=...' }
|
||||||
|
|
||||||
// Sign an email
|
// Check if keys need rotation
|
||||||
const signedEmail = await dkimCreator.signEmail(email);
|
const needsRotation = await dkimCreator.needsRotation('example.com', 'default', 90);
|
||||||
|
if (needsRotation) {
|
||||||
|
const newSelector = await dkimCreator.rotateDkimKeys('example.com', 'default', 2048);
|
||||||
|
console.log(`Rotated to selector: ${newSelector}`);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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:
|
Verify incoming emails against all three authentication standards. All verification is powered by the Rust binary:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
|
import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
// SPF verification
|
// SPF verification — first arg is an Email object
|
||||||
const spfVerifier = new SpfVerifier();
|
const spfVerifier = new SpfVerifier();
|
||||||
const spfResult = await spfVerifier.verify(senderIP, senderDomain, ehloHostname);
|
const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
|
||||||
// → { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror' }
|
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror',
|
||||||
|
// domain: string, ip: string }
|
||||||
|
|
||||||
// DKIM verification
|
// DKIM verification — takes raw email content
|
||||||
const dkimVerifier = new DKIMVerifier();
|
const dkimVerifier = new DKIMVerifier();
|
||||||
const dkimResult = await dkimVerifier.verify(rawEmailContent);
|
const dkimResult = await dkimVerifier.verify(rawEmailContent);
|
||||||
|
|
||||||
// DMARC verification
|
// DMARC verification — first arg is an Email object
|
||||||
const dmarcVerifier = new DmarcVerifier();
|
const dmarcVerifier = new DmarcVerifier();
|
||||||
const dmarcResult = await dmarcVerifier.verify(fromDomain, spfResult, dkimResult);
|
const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
|
||||||
|
// -> { action: 'pass' | 'quarantine' | 'reject', hasDmarc: boolean,
|
||||||
|
// spfDomainAligned: boolean, dkimDomainAligned: boolean, ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🔀 Email Routing
|
### 🔀 Email Routing
|
||||||
|
|
||||||
Pattern-based routing engine with priority ordering and flexible match criteria:
|
Pattern-based routing engine with priority ordering and flexible match criteria. Routes are evaluated by priority (highest first):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { EmailRouter } from '@push.rocks/smartmta';
|
import { EmailRouter } from '@push.rocks/smartmta';
|
||||||
@@ -271,13 +322,26 @@ const router = new EmailRouter([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Routes are evaluated by priority (highest first)
|
// Evaluate routes against an email context
|
||||||
const matchedRoute = router.route(email, context);
|
const matchedRoute = await router.evaluateRoutes(emailContext);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🕵️ Content Scanning
|
**Match criteria available:**
|
||||||
|
|
||||||
Built-in content scanner for detecting spam, phishing, malware, and other threats:
|
| Criterion | Description |
|
||||||
|
|---|---|
|
||||||
|
| `recipients` | Glob patterns for recipient addresses (`*@example.com`) |
|
||||||
|
| `senders` | Glob patterns for sender addresses |
|
||||||
|
| `clientIp` | IP addresses or CIDR ranges |
|
||||||
|
| `authenticated` | Require authentication status |
|
||||||
|
| `headers` | Match specific headers (string or RegExp) |
|
||||||
|
| `sizeRange` | Message size constraints (`{ min?, max? }`) |
|
||||||
|
| `subject` | Subject line pattern (string or RegExp) |
|
||||||
|
| `hasAttachments` | Filter by attachment presence |
|
||||||
|
|
||||||
|
### 🔍 Content Scanning
|
||||||
|
|
||||||
|
Built-in content scanner for detecting spam, phishing, malware, and other threats. Text pattern scanning runs in Rust for performance; binary attachment scanning (PE headers, VBA macros) runs in TypeScript:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { ContentScanner } from '@push.rocks/smartmta';
|
import { ContentScanner } from '@push.rocks/smartmta';
|
||||||
@@ -300,25 +364,25 @@ const scanner = new ContentScanner({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await scanner.scan(email);
|
const result = await scanner.scanEmail(email);
|
||||||
// → { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] }
|
// -> { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🌐 IP Reputation Checking
|
### 🌐 IP Reputation Checking
|
||||||
|
|
||||||
Check sender IP addresses against DNSBL blacklists and classify IP types:
|
Check sender IP addresses against DNSBL blacklists and classify IP types. DNSBL lookups run in Rust:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { IPReputationChecker } from '@push.rocks/smartmta';
|
import { IPReputationChecker } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
const ipChecker = new IPReputationChecker({
|
const ipChecker = IPReputationChecker.getInstance({
|
||||||
enableDNSBL: true,
|
enableDNSBL: true,
|
||||||
dnsblServers: ['zen.spamhaus.org', 'bl.spamcop.net'],
|
dnsblServers: ['zen.spamhaus.org', 'bl.spamcop.net'],
|
||||||
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
|
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
});
|
});
|
||||||
|
|
||||||
const reputation = await ipChecker.checkReputation('192.168.1.1');
|
const reputation = await ipChecker.checkReputation('192.168.1.1');
|
||||||
// → { score: 85, isSpam: false, isProxy: false, isTor: false, blacklists: [] }
|
// -> { score: 85, isSpam: false, isProxy: false, isTor: false, blacklists: [] }
|
||||||
```
|
```
|
||||||
|
|
||||||
### ⏱️ Rate Limiting
|
### ⏱️ Rate Limiting
|
||||||
@@ -326,32 +390,47 @@ const reputation = await ipChecker.checkReputation('192.168.1.1');
|
|||||||
Hierarchical rate limiting to protect your server and maintain deliverability:
|
Hierarchical rate limiting to protect your server and maintain deliverability:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { UnifiedRateLimiter } from '@push.rocks/smartmta';
|
import { Delivery } from '@push.rocks/smartmta';
|
||||||
|
const { UnifiedRateLimiter } = Delivery;
|
||||||
|
|
||||||
const rateLimiter = new UnifiedRateLimiter({
|
const rateLimiter = new UnifiedRateLimiter({
|
||||||
global: {
|
global: {
|
||||||
maxPerMinute: 1000,
|
maxMessagesPerMinute: 1000,
|
||||||
maxPerHour: 10000,
|
maxRecipientsPerMessage: 500,
|
||||||
|
maxConnectionsPerIP: 20,
|
||||||
|
maxErrorsPerIP: 10,
|
||||||
|
maxAuthFailuresPerIP: 5,
|
||||||
|
blockDuration: 600000, // 10 minutes
|
||||||
},
|
},
|
||||||
perDomain: {
|
domains: {
|
||||||
'example.com': {
|
'example.com': {
|
||||||
maxPerMinute: 100,
|
maxMessagesPerMinute: 100,
|
||||||
maxPerHour: 1000,
|
maxRecipientsPerMessage: 50,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
perSender: {
|
|
||||||
maxPerMinute: 20,
|
|
||||||
maxPerHour: 200,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check before sending
|
||||||
|
const allowed = rateLimiter.checkMessageLimit(
|
||||||
|
'sender@example.com',
|
||||||
|
'192.168.1.1',
|
||||||
|
recipientCount,
|
||||||
|
undefined,
|
||||||
|
'example.com'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allowed.allowed) {
|
||||||
|
console.log(`Rate limited: ${allowed.reason}`);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 📬 Bounce Management
|
### 📬 Bounce Management
|
||||||
|
|
||||||
Automatic bounce detection, classification, and tracking:
|
Automatic bounce detection (via Rust), classification, and suppression tracking:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { BounceManager } from '@push.rocks/smartmta';
|
import { Core } from '@push.rocks/smartmta';
|
||||||
|
const { BounceManager } = Core;
|
||||||
|
|
||||||
const bounceManager = new BounceManager();
|
const bounceManager = new BounceManager();
|
||||||
|
|
||||||
@@ -361,10 +440,14 @@ const bounce = await bounceManager.processSmtpFailure(
|
|||||||
'550 5.1.1 User unknown',
|
'550 5.1.1 User unknown',
|
||||||
{ originalEmailId: 'msg-123' }
|
{ originalEmailId: 'msg-123' }
|
||||||
);
|
);
|
||||||
// → { bounceType: 'invalid_recipient', bounceCategory: 'hard', ... }
|
// -> { bounceType: 'invalid_recipient', bounceCategory: 'hard', ... }
|
||||||
|
|
||||||
// Check if an address is known to bounce
|
// Check if an address is suppressed due to bounces
|
||||||
const shouldSuppress = bounceManager.shouldSuppressDelivery('recipient@example.com');
|
const suppressed = bounceManager.isEmailSuppressed('recipient@example.com');
|
||||||
|
|
||||||
|
// Manually manage the suppression list
|
||||||
|
bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
|
||||||
|
bounceManager.removeFromSuppressionList('recovered@example.com');
|
||||||
```
|
```
|
||||||
|
|
||||||
### 📝 Email Templates
|
### 📝 Email Templates
|
||||||
@@ -372,11 +455,12 @@ const shouldSuppress = bounceManager.shouldSuppressDelivery('recipient@example.c
|
|||||||
Template engine with variable substitution for transactional and notification emails:
|
Template engine with variable substitution for transactional and notification emails:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { TemplateManager } from '@push.rocks/smartmta';
|
import { Core } from '@push.rocks/smartmta';
|
||||||
|
const { TemplateManager } = Core;
|
||||||
|
|
||||||
const templates = new TemplateManager({
|
const templates = new TemplateManager({
|
||||||
from: 'noreply@example.com',
|
from: 'noreply@example.com',
|
||||||
footerHtml: '<p>© 2026 Example Corp</p>',
|
footerHtml: '<p>© 2026 Example Corp</p>',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register a template
|
// Register a template
|
||||||
@@ -391,52 +475,120 @@ templates.registerTemplate({
|
|||||||
category: 'transactional',
|
category: 'transactional',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render and send
|
// Create an Email object from the template
|
||||||
const email = templates.renderTemplate('welcome', {
|
const email = await templates.createEmail('welcome', {
|
||||||
to: 'newuser@example.com',
|
to: 'newuser@example.com',
|
||||||
variables: { name: 'Alice' },
|
variables: { name: 'Alice' },
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🌍 DNS Management with Cloudflare
|
### 🌍 DNS Management
|
||||||
|
|
||||||
Automatic DNS record setup for MX, SPF, DKIM, and DMARC via the Cloudflare API:
|
DNS record management for email authentication is handled automatically by `UnifiedEmailServer`. When the server starts, it ensures MX, SPF, DKIM, and DMARC records are in place for all configured domains via the Cloudflare API:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { DnsManager } from '@push.rocks/smartmta';
|
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||||
|
hostname: 'mail.example.com',
|
||||||
const dnsManager = new DnsManager({
|
|
||||||
domains: [
|
domains: [
|
||||||
{
|
{
|
||||||
domain: 'example.com',
|
domain: 'example.com',
|
||||||
dnsMode: 'external-dns', // managed via Cloudflare API
|
dnsMode: 'external-dns', // managed via Cloudflare API
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// ... other config
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-configure all required DNS records
|
// DNS records are set up automatically on start:
|
||||||
await dnsManager.setupDnsForDomain('example.com', {
|
// - MX records pointing to your mail server
|
||||||
serverIp: '203.0.113.10',
|
// - SPF TXT records authorizing your server IP
|
||||||
mxHostname: 'mail.example.com',
|
// - DKIM TXT records with public keys from DKIMCreator
|
||||||
});
|
// - DMARC TXT records with your policy
|
||||||
|
await emailServer.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🦀 Rust Acceleration
|
### 🦀 RustSecurityBridge
|
||||||
|
|
||||||
Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC):
|
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:
|
||||||
|
|
||||||
- **mailer-core**: Email type validation, MIME building, bounce detection
|
```typescript
|
||||||
- **mailer-security**: DKIM signing/verification, SPF checks, DMARC policy, IP reputation/DNSBL
|
import { RustSecurityBridge } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
The Rust workspace is at `rust/` with five crates:
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
await bridge.start();
|
||||||
|
|
||||||
|
// Compound verification: DKIM + SPF + DMARC in a single IPC call
|
||||||
|
const securityResult = await bridge.verifyEmail({
|
||||||
|
rawMessage: rawEmailString,
|
||||||
|
ip: '203.0.113.10',
|
||||||
|
heloDomain: 'sender.example.com',
|
||||||
|
mailFrom: 'user@example.com',
|
||||||
|
});
|
||||||
|
// -> { dkim: [...], spf: { result, explanation }, dmarc: { result, policy } }
|
||||||
|
|
||||||
|
// Individual security operations
|
||||||
|
const dkimResults = await bridge.verifyDkim(rawEmailString);
|
||||||
|
const spfResult = await bridge.checkSpf({
|
||||||
|
ip: '203.0.113.10',
|
||||||
|
heloDomain: 'sender.example.com',
|
||||||
|
mailFrom: 'user@example.com',
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **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 |
|
| Crate | Status | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `mailer-core` | ✅ Complete | Email types, validation, MIME, bounce detection |
|
| `mailer-core` | ✅ Complete (26 tests) | Email types, validation, MIME building, bounce detection |
|
||||||
| `mailer-security` | ✅ Complete | DKIM, SPF, DMARC, IP reputation |
|
| `mailer-security` | ✅ Complete (22 tests) | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
|
||||||
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge |
|
| `mailer-smtp` | ✅ Complete (72 tests) | Full SMTP protocol engine — TCP/TLS server, STARTTLS, AUTH, pipelining, rate limiting |
|
||||||
| `mailer-smtp` | 🔜 Phase 2 | SMTP protocol in Rust |
|
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — security, content scanning, SMTP server lifecycle |
|
||||||
| `mailer-napi` | 🔜 Phase 2 | Native Node.js addon |
|
| `mailer-napi` | 🔜 Planned | Native Node.js addon (N-API) |
|
||||||
|
|
||||||
|
### What Runs in Rust
|
||||||
|
|
||||||
|
| Operation | Runs In | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| SMTP server (port listening, protocol, TLS) | Rust | Performance, memory safety, zero-copy parsing |
|
||||||
|
| DKIM signing & verification | Rust | Crypto-heavy, benefits from native speed |
|
||||||
|
| SPF validation | Rust | DNS lookups with async resolver |
|
||||||
|
| DMARC policy checking | Rust | Integrates with SPF/DKIM results |
|
||||||
|
| IP reputation / DNSBL | Rust | Parallel DNS queries |
|
||||||
|
| Content scanning (text patterns) | Rust | Regex engine performance |
|
||||||
|
| Bounce detection (pattern matching) | Rust | Regex engine performance |
|
||||||
|
| Email validation & MIME building | Rust | Parsing performance |
|
||||||
|
| Binary attachment scanning | TypeScript | Buffer data too large for IPC |
|
||||||
|
| Email routing & orchestration | TypeScript | Business logic, flexibility |
|
||||||
|
| Delivery queue & retry | TypeScript | State management, persistence |
|
||||||
|
| Template rendering | TypeScript | String interpolation |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -447,14 +599,20 @@ smartmta/
|
|||||||
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
|
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
|
||||||
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
|
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
|
||||||
│ │ │ ├── smtpclient/ # SMTP client with connection pooling
|
│ │ │ ├── smtpclient/ # SMTP client with connection pooling
|
||||||
│ │ │ └── smtpserver/ # SMTP server with TLS, auth, pipelining
|
│ │ │ └── smtpserver/ # Legacy TS SMTP server (socket-handler fallback)
|
||||||
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
|
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
|
||||||
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
|
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
|
||||||
│ └── security/ # ContentScanner, IPReputationChecker, SecurityLogger
|
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
|
||||||
├── rust/ # Rust workspace
|
├── rust/ # Rust workspace
|
||||||
│ └── crates/ # mailer-core, mailer-security, mailer-bin, mailer-smtp, mailer-napi
|
│ └── crates/
|
||||||
├── test/ # Comprehensive test suite (RFC compliance, security, performance, edge cases)
|
│ ├── mailer-core/ # Email types, validation, MIME, bounce detection
|
||||||
└── dist_ts/ # Compiled output
|
│ ├── mailer-security/ # DKIM, SPF, DMARC, IP reputation, content scanning
|
||||||
|
│ ├── mailer-smtp/ # Full SMTP server (TCP/TLS, state machine, rate limiting)
|
||||||
|
│ ├── mailer-bin/ # CLI + smartrust IPC bridge
|
||||||
|
│ └── mailer-napi/ # N-API addon (planned)
|
||||||
|
├── test/ # Test suite
|
||||||
|
├── dist_ts/ # Compiled TypeScript output
|
||||||
|
└── dist_rust/ # Compiled Rust binaries
|
||||||
```
|
```
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|||||||
212
readme.plan.md
212
readme.plan.md
@@ -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:
|
### Phase 3: Rust Primary Backend (DKIM/SPF/DMARC/IP Reputation)
|
||||||
1. CLI interface similar to nupst/spark
|
- Rust is the mandatory security backend — no TS fallbacks
|
||||||
2. SMTP server and client (ported from dcrouter)
|
- All DKIM signing/verification, SPF, DMARC, IP reputation through Rust bridge
|
||||||
3. HTTP REST API (Mailgun-compatible)
|
|
||||||
4. Automatic DNS management via Cloudflare
|
|
||||||
5. Systemd daemon service
|
|
||||||
6. Binary distribution via npm
|
|
||||||
|
|
||||||
## 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
|
## Deferred
|
||||||
- [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
|
|
||||||
|
|
||||||
### ✅ Phase 2: Mail Implementation (Ported from dcrouter)
|
| Component | Rationale |
|
||||||
- [x] Copied and adapted mail/core/ (Email, EmailValidator, BounceManager, TemplateManager)
|
|-----------|-----------|
|
||||||
- [x] Copied and adapted mail/delivery/ (SMTP client, SMTP server, queues, rate limiting)
|
| EmailValidator | Already thin; uses smartmail; minimal gain |
|
||||||
- [x] Copied and adapted mail/routing/ (EmailRouter, DomainRegistry, DnsManager)
|
| DNS record generation | Pure string building; zero benefit from Rust |
|
||||||
- [x] Copied and adapted mail/security/ (DKIM, SPF, DMARC)
|
| MIME building (`toRFC822String`) | Sync in TS, async via IPC; too much blast radius |
|
||||||
- [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.
|
|
||||||
|
|||||||
20
rust/Cargo.lock
generated
20
rust/Cargo.lock
generated
@@ -1005,6 +1005,7 @@ name = "mailer-bin"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
|
"dashmap",
|
||||||
"hickory-resolver 0.25.2",
|
"hickory-resolver 0.25.2",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
"mailer-security",
|
"mailer-security",
|
||||||
@@ -1054,6 +1055,7 @@ dependencies = [
|
|||||||
"mail-auth",
|
"mail-auth",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
"psl",
|
"psl",
|
||||||
|
"regex",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1067,15 +1069,24 @@ dependencies = [
|
|||||||
name = "mailer-smtp"
|
name = "mailer-smtp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"hickory-resolver 0.25.2",
|
"hickory-resolver 0.25.2",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
|
"mailer-security",
|
||||||
|
"mailparse",
|
||||||
|
"regex",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1490,6 +1501,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
hickory-resolver.workspace = true
|
hickory-resolver.workspace = true
|
||||||
|
dashmap.workspace = true
|
||||||
|
|||||||
@@ -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:
|
//! Supports two modes:
|
||||||
//! 1. **CLI mode** — traditional subcommands for testing and standalone use
|
//! 1. **CLI mode** — traditional subcommands for testing and standalone use
|
||||||
@@ -6,9 +6,16 @@
|
|||||||
//! integration with `@push.rocks/smartrust` from TypeScript
|
//! integration with `@push.rocks/smartrust` from TypeScript
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
use dashmap::DashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::io::{self, BufRead, Write};
|
use std::io::{self, BufRead, Write};
|
||||||
use std::net::IpAddr;
|
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
|
/// mailer-bin: Rust-powered email security tools
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -105,6 +112,43 @@ struct IpcEvent {
|
|||||||
data: serde_json::Value,
|
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() {
|
fn main() {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
@@ -278,7 +322,17 @@ fn main() {
|
|||||||
|
|
||||||
use std::io::Read;
|
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.
|
/// 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() {
|
fn run_management_mode() {
|
||||||
// Signal readiness
|
// Signal readiness
|
||||||
let ready_event = IpcEvent {
|
let ready_event = IpcEvent {
|
||||||
@@ -294,17 +348,40 @@ fn run_management_mode() {
|
|||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
|
||||||
let stdin = io::stdin();
|
let callbacks = Arc::new(PendingCallbacks::new());
|
||||||
for line in stdin.lock().lines() {
|
let mut state = ManagementState {
|
||||||
let line = match line {
|
callbacks: callbacks.clone(),
|
||||||
Ok(l) => l,
|
smtp_handle: None,
|
||||||
Err(_) => break,
|
smtp_event_rx: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if line.trim().is_empty() {
|
// We need to read stdin in a separate thread (blocking I/O)
|
||||||
continue;
|
// 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) {
|
let req: IpcRequest = match serde_json::from_str(&line) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -314,19 +391,110 @@ fn run_management_mode() {
|
|||||||
result: None,
|
result: None,
|
||||||
error: Some(format!("Invalid request: {}", e)),
|
error: Some(format!("Invalid request: {}", e)),
|
||||||
};
|
};
|
||||||
println!("{}", serde_json::to_string(&resp).unwrap());
|
emit_line(&serde_json::to_string(&resp).unwrap());
|
||||||
io::stdout().flush().unwrap();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = rt.block_on(handle_ipc_request(&req));
|
let response = handle_ipc_request(&req, &mut state).await;
|
||||||
println!("{}", serde_json::to_string(&response).unwrap());
|
emit_line(&serde_json::to_string(&response).unwrap());
|
||||||
io::stdout().flush().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() {
|
match req.method.as_str() {
|
||||||
"ping" => IpcResponse {
|
"ping" => IpcResponse {
|
||||||
id: req.id.clone(),
|
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" => {
|
"checkSpf" => {
|
||||||
let ip_str = req.params.get("ip").and_then(|v| v.as_str()).unwrap_or("");
|
let ip_str = req.params.get("ip").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let helo = req
|
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 {
|
_ => IpcResponse {
|
||||||
id: req.id.clone(),
|
id: req.id.clone(),
|
||||||
success: false,
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ hickory-resolver.workspace = true
|
|||||||
ipnet.workspace = true
|
ipnet.workspace = true
|
||||||
rustls-pki-types.workspace = true
|
rustls-pki-types.workspace = true
|
||||||
psl.workspace = true
|
psl.workspace = true
|
||||||
|
regex.workspace = true
|
||||||
|
|||||||
515
rust/crates/mailer-security/src/content_scanner.rs
Normal file
515
rust/crates/mailer-security/src/content_scanner.rs
Normal 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(" ", " ")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking.
|
//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking.
|
||||||
|
|
||||||
|
pub mod content_scanner;
|
||||||
pub mod dkim;
|
pub mod dkim;
|
||||||
pub mod dmarc;
|
pub mod dmarc;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mailer-core = { path = "../mailer-core" }
|
mailer-core = { path = "../mailer-core" }
|
||||||
|
mailer-security = { path = "../mailer-security" }
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tokio-rustls.workspace = true
|
tokio-rustls.workspace = true
|
||||||
hickory-resolver.workspace = true
|
hickory-resolver.workspace = true
|
||||||
@@ -14,3 +15,11 @@ thiserror.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
serde.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
|
||||||
|
|||||||
421
rust/crates/mailer-smtp/src/command.rs
Normal file
421
rust/crates/mailer-smtp/src/command.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
rust/crates/mailer-smtp/src/config.rs
Normal file
86
rust/crates/mailer-smtp/src/config.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
1308
rust/crates/mailer-smtp/src/connection.rs
Normal file
1308
rust/crates/mailer-smtp/src/connection.rs
Normal file
File diff suppressed because it is too large
Load Diff
289
rust/crates/mailer-smtp/src/data.rs
Normal file
289
rust/crates/mailer-smtp/src/data.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,39 @@
|
|||||||
//! mailer-smtp: SMTP protocol engine (server + client).
|
//! 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;
|
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 {
|
pub fn version() -> &'static str {
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_version() {
|
|
||||||
assert!(!version().is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
198
rust/crates/mailer-smtp/src/rate_limiter.rs
Normal file
198
rust/crates/mailer-smtp/src/rate_limiter.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
284
rust/crates/mailer-smtp/src/response.rs
Normal file
284
rust/crates/mailer-smtp/src/response.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
331
rust/crates/mailer-smtp/src/server.rs
Normal file
331
rust/crates/mailer-smtp/src/server.rs
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
206
rust/crates/mailer-smtp/src/session.rs
Normal file
206
rust/crates/mailer-smtp/src/session.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
219
rust/crates/mailer-smtp/src/state.rs
Normal file
219
rust/crates/mailer-smtp/src/state.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
169
rust/crates/mailer-smtp/src/validation.rs
Normal file
169
rust/crates/mailer-smtp/src/validation.rs
Normal 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(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import * as plugins from '../../ts/plugins.js';
|
import * as plugins from '../../ts/plugins.js';
|
||||||
import { UnifiedEmailServer } from '../../ts/mail/routing/classes.unified.email.server.js';
|
|
||||||
import { createSmtpServer } from '../../ts/mail/delivery/smtpserver/index.js';
|
|
||||||
import type { ISmtpServerOptions } from '../../ts/mail/delivery/smtpserver/interfaces.js';
|
|
||||||
import type { net } from '../../ts/plugins.js';
|
|
||||||
|
|
||||||
export interface ITestServerConfig {
|
export interface ITestServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -27,165 +23,18 @@ export interface ITestServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a test SMTP server with the given configuration
|
* Starts a test SMTP server with the given configuration.
|
||||||
|
*
|
||||||
|
* NOTE: The TS SMTP server implementation was removed in Phase 7B
|
||||||
|
* (replaced by the Rust SMTP server). This stub preserves the interface
|
||||||
|
* for smtpclient tests that import it, but those tests require `node-forge`
|
||||||
|
* which is not installed (pre-existing issue).
|
||||||
*/
|
*/
|
||||||
export async function startTestServer(config: ITestServerConfig): Promise<ITestServer> {
|
export async function startTestServer(_config: ITestServerConfig): Promise<ITestServer> {
|
||||||
// Find a free port if one wasn't specified
|
throw new Error(
|
||||||
// Using smartnetwork to find an available port in the range 10000-60000
|
'startTestServer is no longer available — the TS SMTP server was removed in Phase 7B. ' +
|
||||||
let port = config.port;
|
'Use the Rust SMTP server (via UnifiedEmailServer) for integration testing.'
|
||||||
if (port === undefined || port === 0) {
|
);
|
||||||
const network = new plugins.smartnetwork.Network();
|
|
||||||
port = await network.findFreePort(10000, 60000, { randomize: true });
|
|
||||||
if (!port) {
|
|
||||||
throw new Error('No free ports available in range 10000-60000');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverConfig = {
|
|
||||||
port: port, // Use the found free port
|
|
||||||
hostname: config.hostname || 'localhost',
|
|
||||||
tlsEnabled: config.tlsEnabled || false,
|
|
||||||
authRequired: config.authRequired || false,
|
|
||||||
timeout: config.timeout || 30000,
|
|
||||||
maxConnections: config.maxConnections || 100,
|
|
||||||
size: config.size || 10 * 1024 * 1024, // 10MB default
|
|
||||||
maxRecipients: config.maxRecipients || 100
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create a mock email server for testing
|
|
||||||
const mockEmailServer = {
|
|
||||||
processEmailByMode: async (emailData: any) => {
|
|
||||||
console.log(`📧 [Test Server] Processing email:`, emailData.subject || 'No subject');
|
|
||||||
return emailData;
|
|
||||||
},
|
|
||||||
getRateLimiter: () => {
|
|
||||||
// Return a mock rate limiter for testing
|
|
||||||
return {
|
|
||||||
recordConnection: (_ip: string) => ({ allowed: true, remaining: 100 }),
|
|
||||||
checkConnectionLimit: async (_ip: string) => ({ allowed: true, remaining: 100 }),
|
|
||||||
checkMessageLimit: (_senderAddress: string, _ip: string, _recipientCount?: number, _pattern?: string, _domain?: string) => ({ allowed: true, remaining: 1000 }),
|
|
||||||
checkRecipientLimit: async (_session: any) => ({ allowed: true, remaining: 50 }),
|
|
||||||
recordAuthenticationFailure: async (_ip: string) => {},
|
|
||||||
recordSyntaxError: async (_ip: string) => {},
|
|
||||||
recordCommandError: async (_ip: string) => {},
|
|
||||||
recordError: (_key: string) => false, // Return false to not block during tests
|
|
||||||
isBlocked: async (_ip: string) => false,
|
|
||||||
cleanup: async () => {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
// Load test certificates
|
|
||||||
let key: string;
|
|
||||||
let cert: string;
|
|
||||||
|
|
||||||
if (serverConfig.tlsEnabled) {
|
|
||||||
try {
|
|
||||||
const certPath = config.testCertPath || './test/fixtures/test-cert.pem';
|
|
||||||
const keyPath = config.testKeyPath || './test/fixtures/test-key.pem';
|
|
||||||
|
|
||||||
cert = await plugins.fs.promises.readFile(certPath, 'utf8');
|
|
||||||
key = await plugins.fs.promises.readFile(keyPath, 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('⚠️ Failed to load TLS certificates, falling back to self-signed');
|
|
||||||
// Generate self-signed certificate for testing
|
|
||||||
const forge = await import('node-forge');
|
|
||||||
const pki = forge.default.pki;
|
|
||||||
|
|
||||||
// Generate key pair
|
|
||||||
const keys = pki.rsa.generateKeyPair(2048);
|
|
||||||
|
|
||||||
// Create certificate
|
|
||||||
const certificate = pki.createCertificate();
|
|
||||||
certificate.publicKey = keys.publicKey;
|
|
||||||
certificate.serialNumber = '01';
|
|
||||||
certificate.validity.notBefore = new Date();
|
|
||||||
certificate.validity.notAfter = new Date();
|
|
||||||
certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1);
|
|
||||||
|
|
||||||
const attrs = [{
|
|
||||||
name: 'commonName',
|
|
||||||
value: serverConfig.hostname
|
|
||||||
}];
|
|
||||||
certificate.setSubject(attrs);
|
|
||||||
certificate.setIssuer(attrs);
|
|
||||||
certificate.sign(keys.privateKey);
|
|
||||||
|
|
||||||
// Convert to PEM
|
|
||||||
cert = pki.certificateToPem(certificate);
|
|
||||||
key = pki.privateKeyToPem(keys.privateKey);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Always provide a self-signed certificate for non-TLS servers
|
|
||||||
// This is required by the interface
|
|
||||||
const forge = await import('node-forge');
|
|
||||||
const pki = forge.default.pki;
|
|
||||||
|
|
||||||
// Generate key pair
|
|
||||||
const keys = pki.rsa.generateKeyPair(2048);
|
|
||||||
|
|
||||||
// Create certificate
|
|
||||||
const certificate = pki.createCertificate();
|
|
||||||
certificate.publicKey = keys.publicKey;
|
|
||||||
certificate.serialNumber = '01';
|
|
||||||
certificate.validity.notBefore = new Date();
|
|
||||||
certificate.validity.notAfter = new Date();
|
|
||||||
certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1);
|
|
||||||
|
|
||||||
const attrs = [{
|
|
||||||
name: 'commonName',
|
|
||||||
value: serverConfig.hostname
|
|
||||||
}];
|
|
||||||
certificate.setSubject(attrs);
|
|
||||||
certificate.setIssuer(attrs);
|
|
||||||
certificate.sign(keys.privateKey);
|
|
||||||
|
|
||||||
// Convert to PEM
|
|
||||||
cert = pki.certificateToPem(certificate);
|
|
||||||
key = pki.privateKeyToPem(keys.privateKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SMTP server options
|
|
||||||
const smtpOptions: ISmtpServerOptions = {
|
|
||||||
port: serverConfig.port,
|
|
||||||
hostname: serverConfig.hostname,
|
|
||||||
key: key,
|
|
||||||
cert: cert,
|
|
||||||
maxConnections: serverConfig.maxConnections,
|
|
||||||
size: serverConfig.size,
|
|
||||||
maxRecipients: serverConfig.maxRecipients,
|
|
||||||
socketTimeout: serverConfig.timeout,
|
|
||||||
connectionTimeout: serverConfig.timeout * 2,
|
|
||||||
cleanupInterval: 300000,
|
|
||||||
auth: serverConfig.authRequired ? ({
|
|
||||||
required: true,
|
|
||||||
methods: ['PLAIN', 'LOGIN'] as ('PLAIN' | 'LOGIN' | 'OAUTH2')[],
|
|
||||||
validateUser: async (username: string, password: string) => {
|
|
||||||
// Test server accepts these credentials
|
|
||||||
return username === 'testuser' && password === 'testpass';
|
|
||||||
}
|
|
||||||
} as any) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create SMTP server
|
|
||||||
const smtpServer = await createSmtpServer(mockEmailServer, smtpOptions);
|
|
||||||
|
|
||||||
// Start the server
|
|
||||||
await smtpServer.listen();
|
|
||||||
|
|
||||||
// Wait for server to be ready
|
|
||||||
await waitForServerReady(serverConfig.hostname, serverConfig.port);
|
|
||||||
|
|
||||||
console.log(`✅ Test SMTP server started on ${serverConfig.hostname}:${serverConfig.port}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
server: mockEmailServer,
|
|
||||||
smtpServer: smtpServer,
|
|
||||||
port: serverConfig.port, // Return the port we already know
|
|
||||||
hostname: serverConfig.hostname,
|
|
||||||
config: serverConfig,
|
|
||||||
startTime: Date.now()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,77 +42,29 @@ export async function startTestServer(config: ITestServerConfig): Promise<ITestS
|
|||||||
*/
|
*/
|
||||||
export async function stopTestServer(testServer: ITestServer): Promise<void> {
|
export async function stopTestServer(testServer: ITestServer): Promise<void> {
|
||||||
if (!testServer || !testServer.smtpServer) {
|
if (!testServer || !testServer.smtpServer) {
|
||||||
console.warn('⚠️ No test server to stop');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`🛑 Stopping test SMTP server on ${testServer.hostname}:${testServer.port}`);
|
|
||||||
|
|
||||||
// Stop the SMTP server
|
|
||||||
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
|
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
|
||||||
await testServer.smtpServer.close();
|
await testServer.smtpServer.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for port to be free
|
|
||||||
await waitForPortFree(testServer.port);
|
|
||||||
|
|
||||||
console.log(`✅ Test SMTP server stopped`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error stopping test server:', error);
|
console.error('Error stopping test server:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for server to be ready to accept connections
|
* Get an available port for testing
|
||||||
*/
|
*/
|
||||||
async function waitForServerReady(hostname: string, port: number, timeout: number = 10000): Promise<void> {
|
export async function getAvailablePort(startPort: number = 25000): Promise<number> {
|
||||||
const startTime = Date.now();
|
for (let port = startPort; port < startPort + 1000; port++) {
|
||||||
|
if (await isPortFree(port)) {
|
||||||
while (Date.now() - startTime < timeout) {
|
return port;
|
||||||
try {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const socket = plugins.net.createConnection({ port, host: hostname });
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
socket.end();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', reject);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
reject(new Error('Connection timeout'));
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
return; // Server is ready
|
|
||||||
} catch {
|
|
||||||
// Server not ready yet, wait and retry
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
throw new Error(`No available ports found starting from ${startPort}`);
|
||||||
throw new Error(`Server did not become ready within ${timeout}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for port to be free
|
|
||||||
*/
|
|
||||||
async function waitForPortFree(port: number, timeout: number = 5000): Promise<void> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
while (Date.now() - startTime < timeout) {
|
|
||||||
const isFree = await isPortFree(port);
|
|
||||||
if (isFree) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`⚠️ Port ${port} still in use after ${timeout}ms`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -281,18 +82,6 @@ async function isPortFree(port: number): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an available port for testing
|
|
||||||
*/
|
|
||||||
export async function getAvailablePort(startPort: number = 25000): Promise<number> {
|
|
||||||
for (let port = startPort; port < startPort + 1000; port++) {
|
|
||||||
if (await isPortFree(port)) {
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`No available ports found starting from ${startPort}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create test email data
|
* Create test email data
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-01: EHLO Command - server responds with proper capabilities', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
// Parse response - only lines that start with 250
|
|
||||||
const lines = receivedData.split('\r\n')
|
|
||||||
.filter(line => line.startsWith('250'))
|
|
||||||
.filter(line => line.length > 0);
|
|
||||||
|
|
||||||
// Check for required ESMTP extensions
|
|
||||||
const capabilities = lines.map(line => line.substring(4).trim());
|
|
||||||
console.log('📋 Server capabilities:', capabilities);
|
|
||||||
|
|
||||||
// Verify essential capabilities
|
|
||||||
expect(capabilities.some(cap => cap.includes('SIZE'))).toBeTruthy();
|
|
||||||
expect(capabilities.some(cap => cap.includes('8BITMIME'))).toBeTruthy();
|
|
||||||
|
|
||||||
// The last line should be "250 " (without hyphen)
|
|
||||||
const lastLine = lines[lines.length - 1];
|
|
||||||
expect(lastLine.startsWith('250 ')).toBeTruthy();
|
|
||||||
|
|
||||||
currentStep = 'quit';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-01: EHLO with invalid hostname - server handles gracefully', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let testIndex = 0;
|
|
||||||
|
|
||||||
const invalidHostnames = [
|
|
||||||
'', // Empty hostname
|
|
||||||
' ', // Whitespace only
|
|
||||||
'invalid..hostname', // Double dots
|
|
||||||
'.invalid', // Leading dot
|
|
||||||
'invalid.', // Trailing dot
|
|
||||||
'very-long-hostname-that-exceeds-reasonable-limits-' + 'x'.repeat(200)
|
|
||||||
];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'testing';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
console.log(`Testing invalid hostname: "${invalidHostnames[testIndex]}"`);
|
|
||||||
socket.write(`EHLO ${invalidHostnames[testIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'testing' && (receivedData.includes('250') || receivedData.includes('5'))) {
|
|
||||||
// Server should either accept with warning or reject with 5xx
|
|
||||||
expect(receivedData).toMatch(/^(250|5\d\d)/);
|
|
||||||
|
|
||||||
testIndex++;
|
|
||||||
if (testIndex < invalidHostnames.length) {
|
|
||||||
currentStep = 'reset';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'reset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'testing';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
console.log(`Testing invalid hostname: "${invalidHostnames[testIndex]}"`);
|
|
||||||
socket.write(`EHLO ${invalidHostnames[testIndex]}\r\n`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-01: EHLO command pipelining - multiple EHLO commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'first_ehlo';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('EHLO first.example.com\r\n');
|
|
||||||
} else if (currentStep === 'first_ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'second_ehlo';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
// Second EHLO (should reset session)
|
|
||||||
socket.write('EHLO second.example.com\r\n');
|
|
||||||
} else if (currentStep === 'second_ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
// Verify session was reset by trying MAIL FROM
|
|
||||||
socket.write('MAIL FROM:<test@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-02: MAIL FROM - accepts valid sender addresses', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let testIndex = 0;
|
|
||||||
|
|
||||||
const validAddresses = [
|
|
||||||
'sender@example.com',
|
|
||||||
'test.user+tag@example.com',
|
|
||||||
'user@[192.168.1.1]', // IP literal
|
|
||||||
'user@subdomain.example.com',
|
|
||||||
'user@very-long-domain-name-that-is-still-valid.example.com',
|
|
||||||
'test_user@example.com' // underscore in local part
|
|
||||||
];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
console.log(`Testing valid address: ${validAddresses[testIndex]}`);
|
|
||||||
socket.write(`MAIL FROM:<${validAddresses[testIndex]}>\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
testIndex++;
|
|
||||||
if (testIndex < validAddresses.length) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
console.log(`Testing valid address: ${validAddresses[testIndex]}`);
|
|
||||||
socket.write(`MAIL FROM:<${validAddresses[testIndex]}>\r\n`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-02: MAIL FROM - rejects invalid sender addresses', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let testIndex = 0;
|
|
||||||
|
|
||||||
const invalidAddresses = [
|
|
||||||
'notanemail', // No @ symbol
|
|
||||||
'@example.com', // Missing local part
|
|
||||||
'user@', // Missing domain
|
|
||||||
'user@.com', // Invalid domain
|
|
||||||
'user@domain..com', // Double dot
|
|
||||||
'user with spaces@example.com', // Unquoted spaces
|
|
||||||
'user@<example.com>', // Invalid characters
|
|
||||||
'user@@example.com', // Double @
|
|
||||||
'user@localhost' // localhost not valid domain
|
|
||||||
];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
console.log(`Testing invalid address: "${invalidAddresses[testIndex]}"`);
|
|
||||||
socket.write(`MAIL FROM:<${invalidAddresses[testIndex]}>\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && (receivedData.includes('250') || receivedData.includes('5'))) {
|
|
||||||
// Server might accept some addresses or reject with 5xx error
|
|
||||||
// For this test, we just verify the server responds appropriately
|
|
||||||
console.log(` Response: ${receivedData.trim()}`);
|
|
||||||
|
|
||||||
testIndex++;
|
|
||||||
if (testIndex < invalidAddresses.length) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
console.log(`Testing invalid address: "${invalidAddresses[testIndex]}"`);
|
|
||||||
socket.write(`MAIL FROM:<${invalidAddresses[testIndex]}>\r\n`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-02: MAIL FROM with SIZE parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from_small';
|
|
||||||
receivedData = '';
|
|
||||||
// Test small size
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> SIZE=1024\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_small' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_large';
|
|
||||||
receivedData = '';
|
|
||||||
// Test large size (should be rejected if exceeds limit)
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> SIZE=99999999\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_large') {
|
|
||||||
// Should get either 250 (accepted) or 552 (message size exceeds limit)
|
|
||||||
expect(receivedData).toMatch(/^(250|552)/);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-02: MAIL FROM with parameters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from_8bitmime';
|
|
||||||
receivedData = '';
|
|
||||||
// Test BODY=8BITMIME
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> BODY=8BITMIME\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_8bitmime' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_unknown';
|
|
||||||
receivedData = '';
|
|
||||||
// Test unknown parameter (should be ignored or rejected)
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> UNKNOWN=value\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_unknown') {
|
|
||||||
// Should get either 250 (ignored) or 555 (parameter not recognized)
|
|
||||||
expect(receivedData).toMatch(/^(250|555|501)/);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CMD-02: MAIL FROM sequence violations', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'mail_without_ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
// Try MAIL FROM without EHLO/HELO first
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_without_ehlo' && receivedData.includes('503')) {
|
|
||||||
// Should get 503 (bad sequence)
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'first_mail';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<first@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'first_mail' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'second_mail';
|
|
||||||
receivedData = '';
|
|
||||||
// Try second MAIL FROM without RSET
|
|
||||||
socket.write('MAIL FROM:<second@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'second_mail' && (receivedData.includes('503') || receivedData.includes('250'))) {
|
|
||||||
// Server might accept or reject the second MAIL FROM
|
|
||||||
// Some servers allow resetting the sender, others require RSET
|
|
||||||
console.log(`Second MAIL FROM response: ${receivedData.trim()}`);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RCPT TO - should accept valid recipient after MAIL FROM', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RCPT TO - should reject without MAIL FROM', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'rcpt_to_without_mail';
|
|
||||||
receivedData = '';
|
|
||||||
// Try RCPT TO without MAIL FROM
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to_without_mail' && receivedData.includes('503')) {
|
|
||||||
// Should get 503 (bad sequence)
|
|
||||||
expect(receivedData).toInclude('503');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RCPT TO - should accept multiple recipients', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let recipientCount = 0;
|
|
||||||
const maxRecipients = 3;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write(`RCPT TO:<recipient${recipientCount + 1}@example.com>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
recipientCount++;
|
|
||||||
receivedData = '';
|
|
||||||
|
|
||||||
if (recipientCount < maxRecipients) {
|
|
||||||
socket.write(`RCPT TO:<recipient${recipientCount + 1}@example.com>\r\n`);
|
|
||||||
} else {
|
|
||||||
expect(recipientCount).toEqual(maxRecipients);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RCPT TO - should reject invalid email format', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let testIndex = 0;
|
|
||||||
|
|
||||||
const invalidRecipients = [
|
|
||||||
'notanemail',
|
|
||||||
'@example.com',
|
|
||||||
'user@',
|
|
||||||
'user@.com',
|
|
||||||
'user@domain..com'
|
|
||||||
];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
console.log(`Testing invalid recipient: "${invalidRecipients[testIndex]}"`);
|
|
||||||
socket.write(`RCPT TO:<${invalidRecipients[testIndex]}>\r\n`);
|
|
||||||
} else if (currentStep === 'rcpt_to' && (receivedData.includes('501') || receivedData.includes('5'))) {
|
|
||||||
// Should reject with 5xx error
|
|
||||||
console.log(` Response: ${receivedData.trim()}`);
|
|
||||||
|
|
||||||
testIndex++;
|
|
||||||
if (testIndex < invalidRecipients.length) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('RCPT TO - should handle SIZE parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to_with_size';
|
|
||||||
receivedData = '';
|
|
||||||
// RCPT TO doesn't typically have SIZE parameter, but test server response
|
|
||||||
socket.write('RCPT TO:<recipient@example.com> SIZE=1024\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to_with_size') {
|
|
||||||
// Server might accept or reject the parameter
|
|
||||||
expect(receivedData).toMatch(/^(250|555|501)/);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 15000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should accept email data after RCPT TO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_command';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'message_body';
|
|
||||||
receivedData = '';
|
|
||||||
// Send email content
|
|
||||||
socket.write('From: sender@example.com\r\n');
|
|
||||||
socket.write('To: recipient@example.com\r\n');
|
|
||||||
socket.write('Subject: Test message\r\n');
|
|
||||||
socket.write('\r\n'); // Empty line to separate headers from body
|
|
||||||
socket.write('This is a test message.\r\n');
|
|
||||||
socket.write('.\r\n'); // End of message
|
|
||||||
} else if (currentStep === 'message_body' && receivedData.includes('250')) {
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should reject without RCPT TO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'data_without_rcpt';
|
|
||||||
receivedData = '';
|
|
||||||
// Try DATA without MAIL FROM or RCPT TO
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_without_rcpt' && receivedData.includes('503')) {
|
|
||||||
// Should get 503 (bad sequence)
|
|
||||||
expect(receivedData).toInclude('503');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should accept empty message body', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_command';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'empty_message';
|
|
||||||
receivedData = '';
|
|
||||||
// Send only the terminator
|
|
||||||
socket.write('.\r\n');
|
|
||||||
} else if (currentStep === 'empty_message') {
|
|
||||||
// Server should accept empty message
|
|
||||||
expect(receivedData).toMatch(/^(250|5\d\d)/);
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should handle dot stuffing correctly', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_command';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'dot_stuffed_message';
|
|
||||||
receivedData = '';
|
|
||||||
// Send message with dots that need stuffing
|
|
||||||
socket.write('This line is normal.\r\n');
|
|
||||||
socket.write('..This line starts with two dots (one will be removed).\r\n');
|
|
||||||
socket.write('.This line starts with a single dot.\r\n');
|
|
||||||
socket.write('...This line starts with three dots.\r\n');
|
|
||||||
socket.write('.\r\n'); // End of message
|
|
||||||
} else if (currentStep === 'dot_stuffed_message' && receivedData.includes('250')) {
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should handle large messages', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_command';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'large_message';
|
|
||||||
receivedData = '';
|
|
||||||
// Send a large message (100KB)
|
|
||||||
socket.write('From: sender@example.com\r\n');
|
|
||||||
socket.write('To: recipient@example.com\r\n');
|
|
||||||
socket.write('Subject: Large test message\r\n');
|
|
||||||
socket.write('\r\n');
|
|
||||||
|
|
||||||
// Generate 100KB of data
|
|
||||||
const lineContent = 'This is a test line that will be repeated many times. ';
|
|
||||||
const linesNeeded = Math.ceil(100000 / lineContent.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < linesNeeded; i++) {
|
|
||||||
socket.write(lineContent + '\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('.\r\n'); // End of message
|
|
||||||
} else if (currentStep === 'large_message' && receivedData.includes('250')) {
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DATA - should handle binary data in message', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data_command';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'binary_message';
|
|
||||||
receivedData = '';
|
|
||||||
// Send message with binary data (base64 encoded attachment)
|
|
||||||
socket.write('From: sender@example.com\r\n');
|
|
||||||
socket.write('To: recipient@example.com\r\n');
|
|
||||||
socket.write('Subject: Binary test message\r\n');
|
|
||||||
socket.write('MIME-Version: 1.0\r\n');
|
|
||||||
socket.write('Content-Type: multipart/mixed; boundary="boundary123"\r\n');
|
|
||||||
socket.write('\r\n');
|
|
||||||
socket.write('--boundary123\r\n');
|
|
||||||
socket.write('Content-Type: text/plain\r\n');
|
|
||||||
socket.write('\r\n');
|
|
||||||
socket.write('This message contains binary data.\r\n');
|
|
||||||
socket.write('--boundary123\r\n');
|
|
||||||
socket.write('Content-Type: application/octet-stream\r\n');
|
|
||||||
socket.write('Content-Transfer-Encoding: base64\r\n');
|
|
||||||
socket.write('\r\n');
|
|
||||||
socket.write('SGVsbG8gV29ybGQhIFRoaXMgaXMgYmluYXJ5IGRhdGEu\r\n');
|
|
||||||
socket.write('--boundary123--\r\n');
|
|
||||||
socket.write('.\r\n'); // End of message
|
|
||||||
} else if (currentStep === 'binary_message' && receivedData.includes('250')) {
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
import * as plugins from '@git.zone/tstest/tapbundle';
|
|
||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic NOOP command
|
|
||||||
tap.test('NOOP - should accept NOOP command', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'noop';
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else if (currentStep === 'noop' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250'); // NOOP response
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Multiple NOOP commands
|
|
||||||
tap.test('NOOP - should handle multiple consecutive NOOP commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let noopCount = 0;
|
|
||||||
const maxNoops = 3;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = ''; // Clear buffer after processing
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'noop';
|
|
||||||
receivedData = ''; // Clear buffer after processing
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else if (currentStep === 'noop' && receivedData.includes('250 OK')) {
|
|
||||||
noopCount++;
|
|
||||||
receivedData = ''; // Clear buffer after processing
|
|
||||||
|
|
||||||
if (noopCount < maxNoops) {
|
|
||||||
// Send another NOOP command
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(noopCount).toEqual(maxNoops);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: NOOP during transaction
|
|
||||||
tap.test('NOOP - should work during email transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'noop_after_mail';
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else if (currentStep === 'noop_after_mail' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'noop_after_rcpt';
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else if (currentStep === 'noop_after_rcpt' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: NOOP with parameter (should be ignored)
|
|
||||||
tap.test('NOOP - should handle NOOP with parameters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'noop_with_param';
|
|
||||||
socket.write('NOOP ignored parameter\r\n'); // Parameters should be ignored
|
|
||||||
} else if (currentStep === 'noop_with_param' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: NOOP before EHLO/HELO
|
|
||||||
tap.test('NOOP - should work before EHLO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'noop_before_ehlo';
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
} else if (currentStep === 'noop_before_ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Rapid NOOP commands (stress test)
|
|
||||||
tap.test('NOOP - should handle rapid NOOP commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let noopsSent = 0;
|
|
||||||
let noopsReceived = 0;
|
|
||||||
const rapidNoops = 10;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rapid_noop';
|
|
||||||
// Send multiple NOOPs rapidly
|
|
||||||
for (let i = 0; i < rapidNoops; i++) {
|
|
||||||
socket.write('NOOP\r\n');
|
|
||||||
noopsSent++;
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rapid_noop') {
|
|
||||||
// Count 250 responses
|
|
||||||
const matches = receivedData.match(/250 /g);
|
|
||||||
if (matches) {
|
|
||||||
noopsReceived = matches.length - 1; // -1 for EHLO response
|
|
||||||
}
|
|
||||||
|
|
||||||
if (noopsReceived >= rapidNoops) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(noopsReceived).toBeGreaterThan(rapidNoops - 1);
|
|
||||||
done.resolve();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic RSET command
|
|
||||||
tap.test('RSET - should reset transaction after MAIL FROM', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
// RSET successful, try to send MAIL FROM again to verify reset
|
|
||||||
currentStep = 'mail_from_after_rset';
|
|
||||||
socket.write('MAIL FROM:<newsender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_after_rset' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250 OK'); // RSET response
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RSET after RCPT TO
|
|
||||||
tap.test('RSET - should reset transaction after RCPT TO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
// After RSET, should need MAIL FROM before RCPT TO
|
|
||||||
currentStep = 'rcpt_to_after_rset';
|
|
||||||
socket.write('RCPT TO:<newrecipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to_after_rset' && receivedData.includes('503')) {
|
|
||||||
// Should get 503 bad sequence
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('503'); // Bad sequence after RSET
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RSET during DATA
|
|
||||||
tap.test('RSET - should reset transaction during DATA phase', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
// Start sending data but then RSET
|
|
||||||
currentStep = 'rset_during_data';
|
|
||||||
socket.write('Subject: Test\r\n\r\nPartial message...\r\n');
|
|
||||||
socket.write('RSET\r\n'); // This should be treated as part of data
|
|
||||||
socket.write('\r\n.\r\n'); // End data
|
|
||||||
} else if (currentStep === 'rset_during_data' && receivedData.includes('250')) {
|
|
||||||
// Message accepted, now send actual RSET
|
|
||||||
currentStep = 'rset_after_data';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset_after_data' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Multiple RSET commands
|
|
||||||
tap.test('RSET - should handle multiple consecutive RSET commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let rsetCount = 0;
|
|
||||||
const maxRsets = 3;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'multiple_rsets';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'multiple_rsets' && receivedData.includes('250')) {
|
|
||||||
rsetCount++;
|
|
||||||
receivedData = ''; // Clear buffer after processing
|
|
||||||
|
|
||||||
if (rsetCount < maxRsets) {
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(rsetCount).toEqual(maxRsets);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RSET without transaction
|
|
||||||
tap.test('RSET - should work without active transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset_without_transaction';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
} else if (currentStep === 'rset_without_transaction' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250'); // RSET should work even without transaction
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RSET with multiple recipients
|
|
||||||
tap.test('RSET - should clear all recipients', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let recipientCount = 0;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'add_recipients';
|
|
||||||
recipientCount++;
|
|
||||||
socket.write(`RCPT TO:<recipient${recipientCount}@example.com>\r\n`);
|
|
||||||
} else if (currentStep === 'add_recipients' && receivedData.includes('250')) {
|
|
||||||
if (recipientCount < 3) {
|
|
||||||
recipientCount++;
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`RCPT TO:<recipient${recipientCount}@example.com>\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'rset';
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
// After RSET, all recipients should be cleared
|
|
||||||
currentStep = 'data_after_rset';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data_after_rset' && receivedData.includes('503')) {
|
|
||||||
// Should get 503 bad sequence (no recipients)
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('503');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: RSET with parameter (should be ignored)
|
|
||||||
tap.test('RSET - should ignore parameters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rset_with_param';
|
|
||||||
socket.write('RSET ignored parameter\r\n'); // Parameters should be ignored
|
|
||||||
} else if (currentStep === 'rset_with_param' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic VRFY command
|
|
||||||
tap.test('VRFY - should respond to VRFY command', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write('VRFY postmaster\r\n');
|
|
||||||
} else if (currentStep === 'vrfy' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const vrfyResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = vrfyResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// VRFY may be:
|
|
||||||
// 250/251 - User found/will forward
|
|
||||||
// 252 - Cannot verify but will try
|
|
||||||
// 502 - Command not implemented (common for security)
|
|
||||||
// 503 - Bad sequence of commands (this server rejects VRFY due to sequence validation)
|
|
||||||
// 550 - User not found
|
|
||||||
expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: VRFY multiple users
|
|
||||||
tap.test('VRFY - should handle multiple VRFY requests', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const testUsers = ['postmaster', 'admin', 'test', 'nonexistent'];
|
|
||||||
let currentUserIndex = 0;
|
|
||||||
const vrfyResults: Array<{ user: string; responseCode: string; supported: boolean }> = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write(`VRFY ${testUsers[currentUserIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'vrfy' && receivedData.includes('503') && currentUserIndex < testUsers.length) {
|
|
||||||
// This server always returns 503 for VRFY
|
|
||||||
vrfyResults.push({
|
|
||||||
user: testUsers[currentUserIndex],
|
|
||||||
responseCode: '503',
|
|
||||||
supported: false
|
|
||||||
});
|
|
||||||
|
|
||||||
currentUserIndex++;
|
|
||||||
|
|
||||||
if (currentUserIndex < testUsers.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`VRFY ${testUsers[currentUserIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should have results for all users
|
|
||||||
expect(vrfyResults.length).toEqual(testUsers.length);
|
|
||||||
|
|
||||||
// All responses should be valid SMTP codes
|
|
||||||
vrfyResults.forEach(result => {
|
|
||||||
expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: VRFY without parameter
|
|
||||||
tap.test('VRFY - should reject VRFY without parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy_empty';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write('VRFY\r\n'); // No user specified
|
|
||||||
} else if (currentStep === 'vrfy_empty' && receivedData.includes(' ')) {
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should be 501 (syntax error), 502 (not implemented), or 503 (bad sequence)
|
|
||||||
expect(responseCode).toMatch(/^(501|502|503)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: VRFY during transaction
|
|
||||||
tap.test('VRFY - should work during mail transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy_during_transaction';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write('VRFY test@example.com\r\n');
|
|
||||||
} else if (currentStep === 'vrfy_during_transaction' && receivedData.includes('503')) {
|
|
||||||
const responseCode = '503'; // We know this server always returns 503
|
|
||||||
|
|
||||||
// VRFY may be rejected with 503 during transaction in this server
|
|
||||||
expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: VRFY special addresses
|
|
||||||
tap.test('VRFY - should handle special addresses', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const specialAddresses = [
|
|
||||||
'postmaster',
|
|
||||||
'postmaster@localhost',
|
|
||||||
'abuse',
|
|
||||||
'abuse@localhost',
|
|
||||||
'noreply',
|
|
||||||
'<postmaster@localhost>' // With angle brackets
|
|
||||||
];
|
|
||||||
let currentIndex = 0;
|
|
||||||
const results: Array<{ address: string; responseCode: string }> = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy_special';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write(`VRFY ${specialAddresses[currentIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'vrfy_special' && receivedData.includes('503') && currentIndex < specialAddresses.length) {
|
|
||||||
// This server always returns 503 for VRFY
|
|
||||||
results.push({
|
|
||||||
address: specialAddresses[currentIndex],
|
|
||||||
responseCode: '503'
|
|
||||||
});
|
|
||||||
|
|
||||||
currentIndex++;
|
|
||||||
|
|
||||||
if (currentIndex < specialAddresses.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`VRFY ${specialAddresses[currentIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// All addresses should get valid responses
|
|
||||||
results.forEach(result => {
|
|
||||||
expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: VRFY security considerations
|
|
||||||
tap.test('VRFY - verify security behavior', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let commandDisabled = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'vrfy_security';
|
|
||||||
receivedData = ''; // Clear buffer before sending VRFY
|
|
||||||
socket.write('VRFY randomuser123\r\n');
|
|
||||||
} else if (currentStep === 'vrfy_security' && receivedData.includes(' ')) {
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
|
|
||||||
// Check if command is disabled for security or sequence validation
|
|
||||||
if (responseCode === '502' || responseCode === '252' || responseCode === '503') {
|
|
||||||
commandDisabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Note: Many servers disable VRFY for security reasons
|
|
||||||
// Both enabled and disabled are valid configurations
|
|
||||||
// This server rejects VRFY with 503 due to sequence validation
|
|
||||||
if (responseCode === '503' || commandDisabled) {
|
|
||||||
expect(responseCode).toMatch(/^(502|252|503)$/);
|
|
||||||
} else {
|
|
||||||
expect(responseCode).toMatch(/^(250|251|550)$/);
|
|
||||||
}
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic EXPN command
|
|
||||||
tap.test('EXPN - should respond to EXPN command', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write('EXPN postmaster\r\n');
|
|
||||||
} else if (currentStep === 'expn' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const expnResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = expnResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// EXPN may be:
|
|
||||||
// 250/251 - List expanded
|
|
||||||
// 252 - Cannot expand but will try to deliver
|
|
||||||
// 502 - Command not implemented (common for security)
|
|
||||||
// 503 - Bad sequence of commands (this server rejects EXPN due to sequence validation)
|
|
||||||
// 550 - List not found
|
|
||||||
expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN multiple lists
|
|
||||||
tap.test('EXPN - should handle multiple EXPN requests', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const testLists = ['postmaster', 'admin', 'staff', 'all', 'users'];
|
|
||||||
let currentListIndex = 0;
|
|
||||||
const expnResults: Array<{ list: string; responseCode: string; supported: boolean }> = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write(`EXPN ${testLists[currentListIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'expn' && receivedData.includes('503') && currentListIndex < testLists.length) {
|
|
||||||
// This server always returns 503 for EXPN
|
|
||||||
const responseCode = '503';
|
|
||||||
expnResults.push({
|
|
||||||
list: testLists[currentListIndex],
|
|
||||||
responseCode: responseCode,
|
|
||||||
supported: responseCode.startsWith('2')
|
|
||||||
});
|
|
||||||
|
|
||||||
currentListIndex++;
|
|
||||||
|
|
||||||
if (currentListIndex < testLists.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`EXPN ${testLists[currentListIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should have results for all lists
|
|
||||||
expect(expnResults.length).toEqual(testLists.length);
|
|
||||||
|
|
||||||
// All responses should be valid SMTP codes
|
|
||||||
expnResults.forEach(result => {
|
|
||||||
expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN without parameter
|
|
||||||
tap.test('EXPN - should reject EXPN without parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn_empty';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write('EXPN\r\n'); // No list specified
|
|
||||||
} else if (currentStep === 'expn_empty' && receivedData.includes(' ')) {
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should be 501 (syntax error), 502 (not implemented), or 503 (bad sequence)
|
|
||||||
expect(responseCode).toMatch(/^(501|502|503)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN during transaction
|
|
||||||
tap.test('EXPN - should work during mail transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn_during_transaction';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write('EXPN admin\r\n');
|
|
||||||
} else if (currentStep === 'expn_during_transaction' && receivedData.includes('503')) {
|
|
||||||
const responseCode = '503'; // We know this server always returns 503
|
|
||||||
|
|
||||||
// EXPN may be rejected with 503 during transaction in this server
|
|
||||||
expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN special lists
|
|
||||||
tap.test('EXPN - should handle special mailing lists', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const specialLists = [
|
|
||||||
'postmaster',
|
|
||||||
'postmaster@localhost',
|
|
||||||
'abuse',
|
|
||||||
'webmaster',
|
|
||||||
'noreply',
|
|
||||||
'<admin@localhost>' // With angle brackets
|
|
||||||
];
|
|
||||||
let currentIndex = 0;
|
|
||||||
const results: Array<{ list: string; responseCode: string }> = [];
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn_special';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write(`EXPN ${specialLists[currentIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'expn_special' && receivedData.includes('503') && currentIndex < specialLists.length) {
|
|
||||||
// This server always returns 503 for EXPN
|
|
||||||
results.push({
|
|
||||||
list: specialLists[currentIndex],
|
|
||||||
responseCode: '503'
|
|
||||||
});
|
|
||||||
|
|
||||||
currentIndex++;
|
|
||||||
|
|
||||||
if (currentIndex < specialLists.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`EXPN ${specialLists[currentIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// All lists should get valid responses
|
|
||||||
results.forEach(result => {
|
|
||||||
expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN security considerations
|
|
||||||
tap.test('EXPN - verify security behavior', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let commandDisabled = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn_security';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write('EXPN randomlist123\r\n');
|
|
||||||
} else if (currentStep === 'expn_security' && receivedData.includes(' ')) {
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
|
|
||||||
// Check if command is disabled for security or sequence validation
|
|
||||||
if (responseCode === '502' || responseCode === '252' || responseCode === '503') {
|
|
||||||
commandDisabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Note: Many servers disable EXPN for security reasons
|
|
||||||
// to prevent email address harvesting
|
|
||||||
// Both enabled and disabled are valid configurations
|
|
||||||
// This server rejects EXPN with 503 due to sequence validation
|
|
||||||
if (responseCode === '503' || commandDisabled) {
|
|
||||||
expect(responseCode).toMatch(/^(502|252|503)$/);
|
|
||||||
console.log('EXPN disabled - good security practice');
|
|
||||||
} else {
|
|
||||||
expect(responseCode).toMatch(/^(250|251|550)$/);
|
|
||||||
}
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: EXPN response format
|
|
||||||
tap.test('EXPN - verify proper response format when supported', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'expn_format';
|
|
||||||
receivedData = ''; // Clear buffer before sending EXPN
|
|
||||||
socket.write('EXPN postmaster\r\n');
|
|
||||||
} else if (currentStep === 'expn_format' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
|
|
||||||
// This server returns 503 for EXPN commands
|
|
||||||
if (receivedData.includes('503')) {
|
|
||||||
// Server doesn't support EXPN in the current state
|
|
||||||
expect(receivedData).toInclude('503');
|
|
||||||
} else if (receivedData.includes('250-') || receivedData.includes('250 ')) {
|
|
||||||
// Multi-line response format check
|
|
||||||
const expansionLines = lines.filter(l => l.startsWith('250'));
|
|
||||||
expect(expansionLines.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 15000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SIZE extension advertised in EHLO
|
|
||||||
tap.test('SIZE Extension - should advertise SIZE in EHLO response', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let sizeSupported = false;
|
|
||||||
let maxMessageSize: number | null = null;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
// Check if SIZE extension is advertised
|
|
||||||
if (receivedData.includes('SIZE')) {
|
|
||||||
sizeSupported = true;
|
|
||||||
|
|
||||||
// Extract maximum message size if specified
|
|
||||||
const sizeMatch = receivedData.match(/SIZE\s+(\d+)/);
|
|
||||||
if (sizeMatch) {
|
|
||||||
maxMessageSize = parseInt(sizeMatch[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(sizeSupported).toEqual(true);
|
|
||||||
if (maxMessageSize !== null) {
|
|
||||||
expect(maxMessageSize).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: MAIL FROM with SIZE parameter
|
|
||||||
tap.test('SIZE Extension - should accept MAIL FROM with SIZE parameter', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const messageSize = 1000;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_size';
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${messageSize}\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from_size' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250 OK');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SIZE parameter with various sizes
|
|
||||||
tap.test('SIZE Extension - should handle different message sizes', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const testSizes = [1000, 10000, 100000, 1000000]; // 1KB, 10KB, 100KB, 1MB
|
|
||||||
let currentSizeIndex = 0;
|
|
||||||
const sizeResults: Array<{ size: number; accepted: boolean; response: string }> = [];
|
|
||||||
|
|
||||||
const testNextSize = () => {
|
|
||||||
if (currentSizeIndex < testSizes.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
const size = testSizes[currentSizeIndex];
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${size}\r\n`);
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// At least some sizes should be accepted
|
|
||||||
const acceptedCount = sizeResults.filter(r => r.accepted).length;
|
|
||||||
expect(acceptedCount).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Verify larger sizes may be rejected
|
|
||||||
const largeRejected = sizeResults
|
|
||||||
.filter(r => r.size >= 1000000 && !r.accepted)
|
|
||||||
.length;
|
|
||||||
expect(largeRejected + acceptedCount).toEqual(sizeResults.length);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_sizes';
|
|
||||||
testNextSize();
|
|
||||||
} else if (currentStep === 'mail_from_sizes') {
|
|
||||||
if (receivedData.includes('250')) {
|
|
||||||
// Size accepted
|
|
||||||
sizeResults.push({
|
|
||||||
size: testSizes[currentSizeIndex],
|
|
||||||
accepted: true,
|
|
||||||
response: receivedData.trim()
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
currentSizeIndex++;
|
|
||||||
currentStep = 'rset';
|
|
||||||
} else if (receivedData.includes('552') || receivedData.includes('5')) {
|
|
||||||
// Size rejected
|
|
||||||
sizeResults.push({
|
|
||||||
size: testSizes[currentSizeIndex],
|
|
||||||
accepted: false,
|
|
||||||
response: receivedData.trim()
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
currentSizeIndex++;
|
|
||||||
currentStep = 'rset';
|
|
||||||
}
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_sizes';
|
|
||||||
testNextSize();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SIZE parameter exceeding limit
|
|
||||||
tap.test('SIZE Extension - should reject SIZE exceeding server limit', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let maxSize: number | null = null;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
// Extract max size if advertised
|
|
||||||
const sizeMatch = receivedData.match(/SIZE\s+(\d+)/);
|
|
||||||
if (sizeMatch) {
|
|
||||||
maxSize = parseInt(sizeMatch[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentStep = 'mail_from_oversized';
|
|
||||||
// Try to send a message larger than any reasonable limit
|
|
||||||
const oversizedValue = maxSize ? maxSize + 1 : 100000000; // 100MB or maxSize+1
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${oversizedValue}\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from_oversized') {
|
|
||||||
if (receivedData.includes('552') || receivedData.includes('5')) {
|
|
||||||
// Size limit exceeded - expected
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toMatch(/552|5\d{2}/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
} else if (receivedData.includes('250')) {
|
|
||||||
// If accepted, server has very high or no limit
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SIZE=0 (empty message)
|
|
||||||
tap.test('SIZE Extension - should handle SIZE=0', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from_zero_size';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com> SIZE=0\r\n');
|
|
||||||
} else if (currentStep === 'mail_from_zero_size' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Invalid SIZE parameter
|
|
||||||
tap.test('SIZE Extension - should reject invalid SIZE values', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const invalidSizes = ['abc', '-1', '1.5', '']; // Invalid size values
|
|
||||||
let currentIndex = 0;
|
|
||||||
const results: Array<{ value: string; rejected: boolean }> = [];
|
|
||||||
|
|
||||||
const testNextInvalidSize = () => {
|
|
||||||
if (currentIndex < invalidSizes.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
const invalidSize = invalidSizes[currentIndex];
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${invalidSize}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// This server accepts invalid SIZE values without strict validation
|
|
||||||
// This is permissive but not necessarily incorrect
|
|
||||||
// Just verify we got responses for all test cases
|
|
||||||
expect(results.length).toEqual(invalidSizes.length);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'invalid_sizes';
|
|
||||||
testNextInvalidSize();
|
|
||||||
} else if (currentStep === 'invalid_sizes' && currentIndex < invalidSizes.length) {
|
|
||||||
if (receivedData.includes('250')) {
|
|
||||||
// This server accepts invalid size values
|
|
||||||
results.push({
|
|
||||||
value: invalidSizes[currentIndex],
|
|
||||||
rejected: false
|
|
||||||
});
|
|
||||||
} else if (receivedData.includes('501') || receivedData.includes('552')) {
|
|
||||||
// Invalid parameter - proper validation
|
|
||||||
results.push({
|
|
||||||
value: invalidSizes[currentIndex],
|
|
||||||
rejected: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('RSET\r\n');
|
|
||||||
currentIndex++;
|
|
||||||
currentStep = 'rset';
|
|
||||||
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'invalid_sizes';
|
|
||||||
testNextInvalidSize();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SIZE with actual message data
|
|
||||||
tap.test('SIZE Extension - should enforce SIZE during DATA phase', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const declaredSize = 100; // Declare 100 bytes
|
|
||||||
const actualMessage = 'X'.repeat(200); // Send 200 bytes (more than declared)
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write(`MAIL FROM:<sender@example.com> SIZE=${declaredSize}\r\n`);
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'data';
|
|
||||||
socket.write('DATA\r\n');
|
|
||||||
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
||||||
currentStep = 'message';
|
|
||||||
// Send message larger than declared size
|
|
||||||
socket.write(`Subject: Size Test\r\n\r\n${actualMessage}\r\n.\r\n`);
|
|
||||||
} else if (currentStep === 'message') {
|
|
||||||
// Server may accept or reject based on enforcement
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
// Either accepted (250) or rejected (552)
|
|
||||||
expect(receivedData).toMatch(/250|552/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic HELP command
|
|
||||||
tap.test('HELP - should respond to general HELP command', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help';
|
|
||||||
receivedData = ''; // Clear buffer before sending HELP
|
|
||||||
socket.write('HELP\r\n');
|
|
||||||
} else if (currentStep === 'help' && receivedData.includes('214')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const helpResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = helpResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// HELP may return:
|
|
||||||
// 214 - Help message
|
|
||||||
// 502 - Command not implemented
|
|
||||||
// 504 - Command parameter not implemented
|
|
||||||
expect(responseCode).toMatch(/^(214|502|504)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP with specific topics
|
|
||||||
tap.test('HELP - should respond to HELP with specific command topics', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const helpTopics = ['EHLO', 'MAIL', 'RCPT', 'DATA', 'QUIT'];
|
|
||||||
let currentTopicIndex = 0;
|
|
||||||
const helpResults: Array<{ topic: string; responseCode: string; supported: boolean }> = [];
|
|
||||||
|
|
||||||
const getLastResponse = (data: string): string => {
|
|
||||||
const lines = data.split('\r\n');
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const line = lines[i].trim();
|
|
||||||
if (line && /^\d{3}/.test(line)) {
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help_topics';
|
|
||||||
receivedData = ''; // Clear buffer before sending first HELP topic
|
|
||||||
socket.write(`HELP ${helpTopics[currentTopicIndex]}\r\n`);
|
|
||||||
} else if (currentStep === 'help_topics' && (receivedData.includes('214') || receivedData.includes('502') || receivedData.includes('504'))) {
|
|
||||||
const lastResponse = getLastResponse(receivedData);
|
|
||||||
|
|
||||||
if (lastResponse && lastResponse.match(/^\d{3}/)) {
|
|
||||||
const responseCode = lastResponse.substring(0, 3);
|
|
||||||
helpResults.push({
|
|
||||||
topic: helpTopics[currentTopicIndex],
|
|
||||||
responseCode: responseCode,
|
|
||||||
supported: responseCode === '214'
|
|
||||||
});
|
|
||||||
|
|
||||||
currentTopicIndex++;
|
|
||||||
|
|
||||||
if (currentTopicIndex < helpTopics.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`HELP ${helpTopics[currentTopicIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should have results for all topics
|
|
||||||
expect(helpResults.length).toEqual(helpTopics.length);
|
|
||||||
|
|
||||||
// All responses should be valid
|
|
||||||
helpResults.forEach(result => {
|
|
||||||
expect(result.responseCode).toMatch(/^(214|502|504)$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP response format
|
|
||||||
tap.test('HELP - should return properly formatted help text', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let helpResponse = '';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help';
|
|
||||||
receivedData = ''; // Clear to capture only HELP response
|
|
||||||
socket.write('HELP\r\n');
|
|
||||||
} else if (currentStep === 'help') {
|
|
||||||
helpResponse = receivedData;
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
|
|
||||||
if (responseCode === '214') {
|
|
||||||
// Help is supported - check format
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const helpLines = lines.filter(l => l.startsWith('214'));
|
|
||||||
|
|
||||||
// Should have at least one help line
|
|
||||||
expect(helpLines.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Multi-line help should use 214- prefix
|
|
||||||
if (helpLines.length > 1) {
|
|
||||||
const hasMultilineFormat = helpLines.some(l => l.startsWith('214-'));
|
|
||||||
expect(hasMultilineFormat).toEqual(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP during transaction
|
|
||||||
tap.test('HELP - should work during mail transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help_during_transaction';
|
|
||||||
receivedData = ''; // Clear buffer before sending HELP
|
|
||||||
socket.write('HELP RCPT\r\n');
|
|
||||||
} else if (currentStep === 'help_during_transaction' && receivedData.includes('214')) {
|
|
||||||
const responseCode = '214'; // We know HELP works on this server
|
|
||||||
|
|
||||||
// HELP should work even during transaction
|
|
||||||
expect(responseCode).toMatch(/^(214|502|504)$/);
|
|
||||||
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP with invalid topic
|
|
||||||
tap.test('HELP - should handle HELP with invalid topic', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help_invalid';
|
|
||||||
receivedData = ''; // Clear buffer before sending HELP
|
|
||||||
socket.write('HELP INVALID_COMMAND_XYZ\r\n');
|
|
||||||
} else if (currentStep === 'help_invalid' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const helpResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = helpResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Should return 504 (command parameter not implemented) or
|
|
||||||
// 214 (general help) or 502 (not implemented)
|
|
||||||
expect(responseCode).toMatch(/^(214|502|504)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP availability check
|
|
||||||
tap.test('HELP - verify HELP command optional status', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let helpSupported = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
// Check if HELP is advertised in EHLO response
|
|
||||||
if (receivedData.includes('HELP')) {
|
|
||||||
console.log('HELP command advertised in EHLO response');
|
|
||||||
}
|
|
||||||
|
|
||||||
currentStep = 'help_test';
|
|
||||||
receivedData = ''; // Clear buffer before sending HELP
|
|
||||||
socket.write('HELP\r\n');
|
|
||||||
} else if (currentStep === 'help_test' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const helpResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = helpResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
if (responseCode === '214') {
|
|
||||||
helpSupported = true;
|
|
||||||
console.log('HELP command is supported');
|
|
||||||
} else if (responseCode === '502') {
|
|
||||||
console.log('HELP command not implemented (optional per RFC 5321)');
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Both supported and not supported are valid
|
|
||||||
expect(responseCode).toMatch(/^(214|502)$/);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELP content usefulness
|
|
||||||
tap.test('HELP - check if help content is useful when supported', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'help_data';
|
|
||||||
receivedData = ''; // Clear buffer before sending HELP
|
|
||||||
socket.write('HELP DATA\r\n');
|
|
||||||
} else if (currentStep === 'help_data' && receivedData.includes(' ')) {
|
|
||||||
const lines = receivedData.split('\r\n');
|
|
||||||
const helpResponse = lines.find(line => line.match(/^\d{3}/));
|
|
||||||
const responseCode = helpResponse?.substring(0, 3);
|
|
||||||
|
|
||||||
if (responseCode === '214') {
|
|
||||||
// Check if help text mentions relevant DATA command info
|
|
||||||
const helpText = receivedData.toLowerCase();
|
|
||||||
if (helpText.includes('data') || helpText.includes('message') || helpText.includes('354')) {
|
|
||||||
console.log('HELP provides relevant information about DATA command');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Command Pipelining - should advertise PIPELINING in EHLO response', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
const banner = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(banner).toInclude('220');
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
const ehloResponse = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('EHLO response:', ehloResponse);
|
|
||||||
|
|
||||||
// Check if PIPELINING is advertised
|
|
||||||
const pipeliningAdvertised = ehloResponse.includes('250-PIPELINING') || ehloResponse.includes('250 PIPELINING');
|
|
||||||
console.log('PIPELINING advertised:', pipeliningAdvertised);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
// Note: PIPELINING is optional per RFC 2920
|
|
||||||
expect(ehloResponse).toInclude('250');
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Command Pipelining - should handle pipelined MAIL FROM and RCPT TO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send pipelined commands (all at once)
|
|
||||||
const pipelinedCommands =
|
|
||||||
'MAIL FROM:<sender@example.com>\r\n' +
|
|
||||||
'RCPT TO:<recipient@example.com>\r\n';
|
|
||||||
|
|
||||||
console.log('Sending pipelined commands...');
|
|
||||||
socket.write(pipelinedCommands);
|
|
||||||
|
|
||||||
// Collect responses
|
|
||||||
const responses = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
let responseCount = 0;
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
const lines = data.split('\r\n').filter(line => line.trim());
|
|
||||||
|
|
||||||
// Count responses that look like complete SMTP responses
|
|
||||||
const completeResponses = lines.filter(line => /^[0-9]{3}(\s|-)/.test(line));
|
|
||||||
|
|
||||||
// We expect 2 responses (one for MAIL FROM, one for RCPT TO)
|
|
||||||
if (completeResponses.length >= 2) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
|
|
||||||
// Timeout if we don't get responses
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Pipelined command responses:', responses);
|
|
||||||
|
|
||||||
// Parse responses
|
|
||||||
const responseLines = responses.split('\r\n').filter(line => line.trim());
|
|
||||||
const mailFromResponse = responseLines.find(line => line.match(/^250.*/) && responseLines.indexOf(line) === 0);
|
|
||||||
const rcptToResponse = responseLines.find(line => line.match(/^250.*/) && responseLines.indexOf(line) === 1);
|
|
||||||
|
|
||||||
// Both commands should succeed
|
|
||||||
expect(mailFromResponse).toBeDefined();
|
|
||||||
expect(rcptToResponse).toBeDefined();
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Command Pipelining - should handle pipelined commands with DATA', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send pipelined MAIL FROM, RCPT TO, and DATA commands
|
|
||||||
const pipelinedCommands =
|
|
||||||
'MAIL FROM:<sender@example.com>\r\n' +
|
|
||||||
'RCPT TO:<recipient@example.com>\r\n' +
|
|
||||||
'DATA\r\n';
|
|
||||||
|
|
||||||
console.log('Sending pipelined commands with DATA...');
|
|
||||||
socket.write(pipelinedCommands);
|
|
||||||
|
|
||||||
// Collect responses
|
|
||||||
const responses = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
|
|
||||||
// Look for the DATA prompt (354)
|
|
||||||
if (data.includes('354')) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Responses including DATA:', responses);
|
|
||||||
|
|
||||||
// Should get 250 for MAIL FROM, 250 for RCPT TO, and 354 for DATA
|
|
||||||
expect(responses).toInclude('250'); // MAIL FROM OK
|
|
||||||
expect(responses).toInclude('354'); // Start mail input
|
|
||||||
|
|
||||||
// Send email content
|
|
||||||
const emailContent = 'Subject: Pipelining Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\nTest email with pipelining.\r\n.\r\n';
|
|
||||||
socket.write(emailContent);
|
|
||||||
|
|
||||||
// Get final response
|
|
||||||
const finalResponse = await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Final response:', finalResponse);
|
|
||||||
expect(finalResponse).toInclude('250');
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('Command Pipelining - should handle pipelined NOOP commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('connect', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get banner
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send EHLO
|
|
||||||
socket.write('EHLO testhost\r\n');
|
|
||||||
|
|
||||||
await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send multiple pipelined NOOP commands
|
|
||||||
const pipelinedNoops =
|
|
||||||
'NOOP\r\n' +
|
|
||||||
'NOOP\r\n' +
|
|
||||||
'NOOP\r\n';
|
|
||||||
|
|
||||||
console.log('Sending pipelined NOOP commands...');
|
|
||||||
socket.write(pipelinedNoops);
|
|
||||||
|
|
||||||
// Collect responses
|
|
||||||
const responses = await new Promise<string>((resolve) => {
|
|
||||||
let data = '';
|
|
||||||
const handler = (chunk: Buffer) => {
|
|
||||||
data += chunk.toString();
|
|
||||||
const responseCount = (data.match(/^250.*OK/gm) || []).length;
|
|
||||||
|
|
||||||
// We expect 3 NOOP responses
|
|
||||||
if (responseCount >= 3) {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on('data', handler);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.removeListener('data', handler);
|
|
||||||
resolve(data);
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('NOOP responses:', responses);
|
|
||||||
|
|
||||||
// Count OK responses
|
|
||||||
const okResponses = (responses.match(/^250.*OK/gm) || []).length;
|
|
||||||
expect(okResponses).toBeGreaterThanOrEqual(3);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic HELO command
|
|
||||||
tap.test('HELO - should accept HELO command', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo';
|
|
||||||
socket.write('HELO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'helo' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELO without hostname
|
|
||||||
tap.test('HELO - should reject HELO without hostname', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo_no_hostname';
|
|
||||||
socket.write('HELO\r\n'); // Missing hostname
|
|
||||||
} else if (currentStep === 'helo_no_hostname' && receivedData.includes('501')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('501'); // Syntax error
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Multiple HELO commands
|
|
||||||
tap.test('HELO - should accept multiple HELO commands', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let heloCount = 0;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'first_helo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('HELO test1.example.com\r\n');
|
|
||||||
} else if (currentStep === 'first_helo' && receivedData.includes('250 ')) {
|
|
||||||
heloCount++;
|
|
||||||
currentStep = 'second_helo';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('HELO test2.example.com\r\n');
|
|
||||||
} else if (currentStep === 'second_helo' && receivedData.includes('250 ')) {
|
|
||||||
heloCount++;
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(heloCount).toEqual(2);
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELO after EHLO
|
|
||||||
tap.test('HELO - should accept HELO after EHLO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'helo_after_ehlo';
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write('HELO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'helo_after_ehlo' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELO response format
|
|
||||||
tap.test('HELO - should return simple 250 response', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let heloResponse = '';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo';
|
|
||||||
receivedData = ''; // Clear to capture only HELO response
|
|
||||||
socket.write('HELO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'helo' && receivedData.includes('250')) {
|
|
||||||
heloResponse = receivedData.trim();
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// This server returns multi-line response even for HELO
|
|
||||||
// (technically incorrect per RFC, but we test actual behavior)
|
|
||||||
expect(heloResponse).toStartWith('250');
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: SMTP commands after HELO
|
|
||||||
tap.test('HELO - should process SMTP commands after HELO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo';
|
|
||||||
socket.write('HELO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'helo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('250');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELO with special characters
|
|
||||||
tap.test('HELO - should handle hostnames with special characters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
const specialHostnames = [
|
|
||||||
'test-host.example.com', // Hyphen
|
|
||||||
'test_host.example.com', // Underscore (technically invalid but common)
|
|
||||||
'192.168.1.1', // IP address
|
|
||||||
'[192.168.1.1]', // Bracketed IP
|
|
||||||
'localhost', // Single label
|
|
||||||
'UPPERCASE.EXAMPLE.COM' // Uppercase
|
|
||||||
];
|
|
||||||
let currentIndex = 0;
|
|
||||||
const results: Array<{ hostname: string; accepted: boolean }> = [];
|
|
||||||
|
|
||||||
const testNextHostname = () => {
|
|
||||||
if (currentIndex < specialHostnames.length) {
|
|
||||||
receivedData = ''; // Clear buffer
|
|
||||||
socket.write(`HELO ${specialHostnames[currentIndex]}\r\n`);
|
|
||||||
} else {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
// Most hostnames should be accepted
|
|
||||||
const acceptedCount = results.filter(r => r.accepted).length;
|
|
||||||
expect(acceptedCount).toBeGreaterThan(specialHostnames.length / 2);
|
|
||||||
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo_special';
|
|
||||||
testNextHostname();
|
|
||||||
} else if (currentStep === 'helo_special') {
|
|
||||||
if (receivedData.includes('250')) {
|
|
||||||
results.push({
|
|
||||||
hostname: specialHostnames[currentIndex],
|
|
||||||
accepted: true
|
|
||||||
});
|
|
||||||
} else if (receivedData.includes('501')) {
|
|
||||||
results.push({
|
|
||||||
hostname: specialHostnames[currentIndex],
|
|
||||||
accepted: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
currentIndex++;
|
|
||||||
testNextHostname();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: HELO vs EHLO feature availability
|
|
||||||
tap.test('HELO - verify no extensions with HELO', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'helo';
|
|
||||||
socket.write('HELO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'helo' && receivedData.includes('250')) {
|
|
||||||
// Note: This server returns ESMTP extensions even for HELO commands
|
|
||||||
// This differs from strict RFC compliance but matches the server's behavior
|
|
||||||
// expect(receivedData).not.toInclude('SIZE');
|
|
||||||
// expect(receivedData).not.toInclude('STARTTLS');
|
|
||||||
// expect(receivedData).not.toInclude('AUTH');
|
|
||||||
// expect(receivedData).not.toInclude('8BITMIME');
|
|
||||||
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_PORT = 2525;
|
|
||||||
|
|
||||||
let testServer;
|
|
||||||
const TEST_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
tap.test('prepare server', async () => {
|
|
||||||
testServer = await startTestServer({ port: TEST_PORT });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Basic QUIT command
|
|
||||||
tap.test('QUIT - should close connection gracefully', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let connectionClosed = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
// Don't destroy immediately, wait for server to close connection
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!connectionClosed) {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('221'); // Closing connection message
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
connectionClosed = true;
|
|
||||||
expect(receivedData).toInclude('221');
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: QUIT during transaction
|
|
||||||
tap.test('QUIT - should work during active transaction', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'mail_from';
|
|
||||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'rcpt_to';
|
|
||||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
||||||
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('221');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: QUIT immediately after connect
|
|
||||||
tap.test('QUIT - should work immediately after connection', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(receivedData).toInclude('221');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: QUIT with parameters (should be ignored or rejected)
|
|
||||||
tap.test('QUIT - should handle QUIT with parameters', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'quit_with_param';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('QUIT unexpected parameter\r\n');
|
|
||||||
} else if (currentStep === 'quit_with_param' && (receivedData.includes('221') || receivedData.includes('501'))) {
|
|
||||||
// Server may accept (221) or reject (501) QUIT with parameters
|
|
||||||
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
||||||
socket.destroy();
|
|
||||||
expect(['221', '501']).toInclude(responseCode);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Multiple QUITs (second should fail)
|
|
||||||
tap.test('QUIT - second QUIT should fail after connection closed', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let quitSent = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
receivedData = '';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
quitSent = true;
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
// Try to send another QUIT
|
|
||||||
try {
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
// If write succeeds, wait a bit to see if we get a response
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve(); // Test passes either way
|
|
||||||
}, 500);
|
|
||||||
} catch (err) {
|
|
||||||
// Write failed because connection closed - this is expected
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
if (quitSent) {
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
if (quitSent && error.message.includes('EPIPE')) {
|
|
||||||
// Expected error when writing to closed socket
|
|
||||||
done.resolve();
|
|
||||||
} else {
|
|
||||||
done.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: QUIT response format
|
|
||||||
tap.test('QUIT - should return proper 221 response', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let quitResponse = '';
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
receivedData = ''; // Clear buffer to capture only QUIT response
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
quitResponse = receivedData.trim();
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
expect(quitResponse).toStartWith('221');
|
|
||||||
expect(quitResponse.toLowerCase()).toInclude('closing');
|
|
||||||
done.resolve();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Connection cleanup after QUIT
|
|
||||||
tap.test('QUIT - verify clean connection shutdown', async (tools) => {
|
|
||||||
const done = tools.defer();
|
|
||||||
|
|
||||||
const socket = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: TEST_PORT,
|
|
||||||
timeout: TEST_TIMEOUT
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedData = '';
|
|
||||||
let currentStep = 'connecting';
|
|
||||||
let closeEventFired = false;
|
|
||||||
let endEventFired = false;
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
receivedData += data.toString();
|
|
||||||
|
|
||||||
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
||||||
currentStep = 'ehlo';
|
|
||||||
socket.write('EHLO test.example.com\r\n');
|
|
||||||
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
||||||
currentStep = 'quit';
|
|
||||||
socket.write('QUIT\r\n');
|
|
||||||
} else if (currentStep === 'quit' && receivedData.includes('221')) {
|
|
||||||
// Wait for clean shutdown
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!closeEventFired && !endEventFired) {
|
|
||||||
socket.destroy();
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('end', () => {
|
|
||||||
endEventFired = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
closeEventFired = true;
|
|
||||||
if (currentStep === 'quit') {
|
|
||||||
expect(endEventFired || closeEventFired).toEqual(true);
|
|
||||||
done.resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
done.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await done.promise;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Teardown
|
|
||||||
tap.test('cleanup server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the test
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
||||||
import { connectToSmtp, performSmtpHandshake, closeSmtpConnection } from '../../helpers/utils.js';
|
|
||||||
|
|
||||||
let testServer: ITestServer;
|
|
||||||
|
|
||||||
tap.test('setup - start SMTP server with TLS support', async () => {
|
|
||||||
testServer = await startTestServer({
|
|
||||||
port: 2525,
|
|
||||||
tlsEnabled: true // Enable TLS support
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
expect(testServer.port).toEqual(2525);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CM-01: TLS Connection Test - server should advertise STARTTLS capability', async () => {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Connect to SMTP server
|
|
||||||
const socket = await connectToSmtp(testServer.hostname, testServer.port);
|
|
||||||
expect(socket).toBeInstanceOf(Object);
|
|
||||||
|
|
||||||
// Perform handshake and get capabilities
|
|
||||||
const capabilities = await performSmtpHandshake(socket, 'test.example.com');
|
|
||||||
expect(capabilities).toBeArray();
|
|
||||||
|
|
||||||
// Check for STARTTLS support
|
|
||||||
const supportsStarttls = capabilities.some(cap => cap.toUpperCase().includes('STARTTLS'));
|
|
||||||
expect(supportsStarttls).toEqual(true);
|
|
||||||
|
|
||||||
// Close connection gracefully
|
|
||||||
await closeSmtpConnection(socket);
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
console.log(`✅ TLS capability test completed in ${duration}ms`);
|
|
||||||
console.log(`📋 Server capabilities: ${capabilities.join(', ')}`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
console.error(`❌ TLS connection test failed after ${duration}ms:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('CM-01: TLS Connection Test - verify TLS certificate configuration', async () => {
|
|
||||||
// This test verifies that the server has TLS certificates configured
|
|
||||||
expect(testServer.config.tlsEnabled).toEqual(true);
|
|
||||||
|
|
||||||
// The server should have loaded certificates during startup
|
|
||||||
// In production, this would validate actual certificate properties
|
|
||||||
console.log('✅ TLS configuration verified');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('cleanup - stop SMTP server', async () => {
|
|
||||||
await stopTestServer(testServer);
|
|
||||||
console.log('✅ Test server stopped');
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user