23 Commits

Author SHA1 Message Date
1e7c9f6822 v2.3.2
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:41:36 +00:00
f3a74a7660 fix(tests): remove large SMTP client test suites and update SmartFile API usage 2026-02-10 22:41:36 +00:00
399f5fa418 v2.3.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:32:17 +00:00
cd4584ec26 fix(npmextra): update .gitignore and npmextra.json to add ignore patterns, registries, and module metadata 2026-02-10 22:32:17 +00:00
f601859f8b v2.3.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:26:20 +00:00
eb2643de93 feat(mailer-smtp): add in-process security pipeline for SMTP delivery (DKIM/SPF/DMARC, content scanning, IP reputation) 2026-02-10 22:26:20 +00:00
595634fb0f v2.2.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:04:56 +00:00
cee8a51081 fix(readme): Clarify Rust-powered architecture and mandatory Rust bridge; expand README with Rust workspace details and project structure updates 2026-02-10 22:04:56 +00:00
f1c5546186 v2.2.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:00:44 +00:00
5220ee0857 feat(mailer-smtp): implement in-process SMTP server and management IPC integration 2026-02-10 22:00:44 +00:00
fc2e6d44f4 v2.1.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 21:19:13 +00:00
15a45089aa feat(security): migrate content scanning and bounce detection to Rust security bridge; add scanContent IPC command and Rust content scanner with tests; update TS RustSecurityBridge and callers, and adjust CI package references 2026-02-10 21:19:13 +00:00
b82468ab1e BREAKING CHANGE(rust-bridge): make Rust the primary security backend, remove all TS fallbacks
Some checks failed
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Type Check & Lint (push) Failing after 6s
CI / Build All Platforms (push) Failing after 4s
Phase 3 of the Rust migration: the Rust security bridge is now mandatory
and all TypeScript security fallback implementations have been removed.

- UnifiedEmailServer.start() throws if Rust bridge fails to start
- SpfVerifier gutted to thin wrapper (parseSpfRecord stays in TS)
- DKIMVerifier gutted to thin wrapper delegating to bridge.verifyDkim()
- IPReputationChecker delegates to bridge.checkIpReputation(), keeps LRU cache
- DmarcVerifier keeps alignment logic (works with pre-computed results)
- DKIM signing via bridge.signDkim() in all 4 locations
- Removed mailauth and ip packages from plugins.ts (~1,200 lines deleted)
2026-02-10 20:30:43 +00:00
ffe294643c v2.0.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Failing after 4s
2026-02-10 16:57:14 +00:00
f1071faf3d fix(docs/readme): update README: clarify APIs, document RustSecurityBridge, update examples and architecture diagram 2026-02-10 16:57:14 +00:00
6b082cee8f fix(rust-bridge): correct Email.addHeader() calls and IBounceDetection interface
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Use addHeader() instead of non-existent setHeader() for security
result headers, and align IBounceDetection with actual Rust struct
fields (bounce_type + category only).
2026-02-10 16:38:31 +00:00
9185242530 v2.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Release / build-and-release (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 7s
2026-02-10 16:25:55 +00:00
8293663619 BREAKING CHANGE(smartmta): Rebrand package to @push.rocks/smartmta, add consolidated email security verification and IPC handler 2026-02-10 16:25:55 +00:00
199b9b79d2 feat(rust): implement mailer-core and mailer-security crates with CLI
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Rust migration Phase 1 — implements real functionality in the previously
stubbed mailer-core and mailer-security crates (38 passing tests).

mailer-core: Email/EmailAddress/Attachment types, RFC 5322 MIME builder,
email format validation with scoring, bounce detection (14 types, 40+
regex patterns), DSN status parsing, retry delay calculation.

mailer-security: DKIM signing (RSA-SHA256) and verification, SPF checking,
DMARC verification with public suffix list, DNSBL IP reputation checking
(10 default servers, parallel queries), all powered by mail-auth 0.7.

mailer-bin: Full CLI with validate/bounce/check-ip/verify-email/dkim-sign
subcommands plus --management mode for smartrust JSON-over-stdin/stdout IPC.
2026-02-10 16:06:04 +00:00
91b49182bb v1.3.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Failing after 4s
2026-02-10 15:54:36 +00:00
564b65c7f2 fix(deps): add workspace dependency entries for multiple crates across mailer-bin, mailer-core, and mailer-security 2026-02-10 15:54:36 +00:00
8bd8c295b0 start the path to rust 2026-02-10 15:54:09 +00:00
237dba3bab feat(rust): scaffold Rust workspace and fix TypeScript build errors
- Add @git.zone/tsrust with linux_amd64/linux_arm64 cross-compilation
- Scaffold Rust workspace with 5 crates: mailer-core, mailer-smtp, mailer-security, mailer-napi, mailer-bin
- Fix all TypeScript compilation errors for upgraded dependencies (smartfile v13, mailauth v4.13, smartproxy v23)
- Replace smartfile.fs/memory with @push.rocks/smartfs throughout codebase
- Fix .ts import extensions to .js for NodeNext module resolution
- Update DKIMSignOptions usage to match mailauth v4.13 API
- Add MTA error classes with permissive signatures for legacy SMTP client
- Replace removed DcRouter/StorageManager/deliverability imports with local interfaces
2026-02-10 15:31:31 +00:00
295 changed files with 16070 additions and 67826 deletions

View File

@@ -6,7 +6,7 @@ Pre-compiled binaries for multiple platforms.
#### Option 1: Via npm (recommended) #### Option 1: Via npm (recommended)
```bash ```bash
npm install -g @serve.zone/mailer npm install -g @push.rocks/smartmta
``` ```
#### Option 2: Direct binary download #### Option 2: Direct binary download

View File

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

27
.gitignore vendored
View File

@@ -1,7 +1,24 @@
node_modules/
.nogit/ .nogit/
# artifacts
coverage/
public/
# installs
node_modules/
# caches
.yarn/
.cache/
.rpt2_cache
# builds
dist/ dist/
deno.lock dist_*/
*.log
.env # AI
.DS_Store .claude/
.serena/
#------# custom
rust/target

View File

@@ -1,5 +1,89 @@
# Changelog # Changelog
## 2026-02-10 - 2.3.2 - fix(tests)
remove large SMTP client test suites and update SmartFile API usage
- Deleted ~80 test files under test/suite/ (multiple smtpclient command, connection, edge-cases, email-composition, error-handling and performance test suites)
- Updated SmartFile usage in test/test.smartmail.ts: replaced plugins.smartfile.SmartFile.fromString(...) with plugins.smartfile.SmartFileFactory.nodeFs().fromString(...)
- Removes a large set of tests to reduce test surface / simplify test runtime
## 2026-02-10 - 2.3.1 - fix(npmextra)
update .gitignore and npmextra.json to add ignore patterns, registries, and module metadata
- .gitignore: expanded ignore list for artifacts, installs, caches, builds, AI dirs, and rust target path
- npmextra.json: added npmjs registry alongside internal Verdaccio registry for @git.zone/cli release settings
- npmextra.json: added projectType and module metadata (githost, gitscope, gitrepo, description, npmPackagename, license) for @git.zone/cli and added empty @ship.zone/szci entry
## 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)
Rebrand package to @push.rocks/smartmta, add consolidated email security verification and IPC handler
- Package renamed from @serve.zone/mailer to @push.rocks/smartmta (package.json, commitinfo, README and homepage/bugs/repository URLs updated) — breaking for consumers who import by package name.
- Added new compound email security API verify_email_security that runs DKIM, SPF and DMARC in a single call (rust/crates/mailer-security/src/verify.rs) and exposed it from the mailer-security crate.
- Added IPC handler "verifyEmail" in mailer-bin to call the new verify_email_security function from the Rust side.
- Refactored DKIM and SPF code to convert mail-auth outputs to serializable results (dkim_outputs_to_results and SpfResult::from_output) and wired them into the combined verifier.
- Updated TypeScript plugin exports and dependencies: added @push.rocks/smartrust and exported smartrust in ts/plugins.ts.
- Large README overhaul to reflect rebranding, install instructions, architecture and legal/company info.
## 2026-02-10 - 1.3.1 - fix(deps)
add workspace dependency entries for multiple crates across mailer-bin, mailer-core, and mailer-security
- rust/crates/mailer-bin/Cargo.toml: add clap.workspace = true
- rust/crates/mailer-core/Cargo.toml: add regex.workspace = true, base64.workspace = true, uuid.workspace = true
- rust/crates/mailer-security/Cargo.toml: add serde_json.workspace = true, tokio.workspace = true, hickory-resolver.workspace = true, ipnet.workspace = true, rustls-pki-types.workspace = true, psl.workspace = true
- Purpose: align and enable workspace-managed dependencies for the affected crates
## 2026-02-10 - 1.3.0 - feat(mail/delivery) ## 2026-02-10 - 1.3.0 - feat(mail/delivery)
add error-count based blocking to rate limiter; improve test SMTP server port selection; add tsbuild scripts and devDependency; remove stale backup file add error-count based blocking to rate limiter; improve test SMTP server port selection; add tsbuild scripts and devDependency; remove stale backup file

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Serve Zone Copyright (c) 2025 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1 +1,27 @@
{} {
"@git.zone/tsrust": {
"targets": [
"linux_amd64",
"linux_arm64"
]
},
"@git.zone/cli": {
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
},
"projectType": "npm",
"module": {
"githost": "code.foss.global",
"gitscope": "push.rocks",
"gitrepo": "smartmta",
"description": "an mta implementation as TypeScript package, with network side implemented in rust",
"npmPackagename": "@push.rocks/smartmta",
"license": "MIT"
}
},
"@ship.zone/szci": {}
}

View File

@@ -1,30 +1,30 @@
{ {
"name": "@serve.zone/mailer", "name": "@push.rocks/smartmta",
"version": "1.3.0", "version": "2.3.2",
"description": "Enterprise mail server with SMTP, HTTP API, and DNS management - built for serve.zone infrastructure", "description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
"keywords": [ "keywords": [
"mailer", "mta",
"smtp", "smtp",
"email", "email",
"mail server", "mail server",
"mailgun", "mail transfer agent",
"dkim", "dkim",
"spf", "spf",
"dmarc",
"dns", "dns",
"cloudflare", "cloudflare",
"daemon service", "typescript",
"api", "rust"
"serve.zone"
], ],
"homepage": "https://code.foss.global/serve.zone/mailer", "homepage": "https://code.foss.global/push.rocks/smartmta",
"bugs": { "bugs": {
"url": "https://code.foss.global/serve.zone/mailer/issues" "url": "https://code.foss.global/push.rocks/smartmta/issues"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://code.foss.global/serve.zone/mailer.git" "url": "git+https://code.foss.global/push.rocks/smartmta.git"
}, },
"author": "Serve Zone", "author": "Task Venture Capital GmbH",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"bin": { "bin": {
@@ -32,51 +32,55 @@
}, },
"scripts": { "scripts": {
"test": "tstest test/ --logfile --verbose --timeout 60", "test": "tstest test/ --logfile --verbose --timeout 60",
"build": "tsbuild tsfolders", "build": "tsbuild tsfolders && tsrust",
"check": "tsbuild check" "check": "tsbuild check"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.6.8", "@git.zone/tsbuild": "^4.1.2",
"@git.zone/tstest": "^2.3.1", "@git.zone/tsrust": "^1.3.0",
"@git.zone/tstest": "^3.1.8",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/node": "^24.0.10", "@types/node": "^25.2.3",
"tsx": "^4.19.2" "tsx": "^4.21.0"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "^3.0.19", "@api.global/typedrequest": "^3.2.5",
"@api.global/typedserver": "^3.0.74", "@api.global/typedserver": "^8.3.0",
"@api.global/typedsocket": "^3.0.0", "@api.global/typedsocket": "^4.1.0",
"@apiclient.xyz/cloudflare": "^6.4.1", "@apiclient.xyz/cloudflare": "^7.1.0",
"@push.rocks/projectinfo": "^5.0.1", "@push.rocks/projectinfo": "^5.0.1",
"@push.rocks/qenv": "^6.1.0", "@push.rocks/qenv": "^6.1.0",
"@push.rocks/smartacme": "^8.0.0", "@push.rocks/smartacme": "^8.0.0",
"@push.rocks/smartdata": "^5.15.1", "@push.rocks/smartdata": "^7.0.15",
"@push.rocks/smartdns": "^7.5.0", "@push.rocks/smartdns": "^7.5.0",
"@push.rocks/smartfile": "^11.2.5", "@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartfs": "^1.3.1",
"@push.rocks/smartguard": "^3.1.0", "@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1", "@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.1.8", "@push.rocks/smartlog": "^3.1.8",
"@push.rocks/smartmail": "^2.1.0", "@push.rocks/smartmail": "^2.2.0",
"@push.rocks/smartmetrics": "^2.0.10", "@push.rocks/smartmetrics": "^2.0.10",
"@push.rocks/smartnetwork": "^4.0.2", "@push.rocks/smartnetwork": "^4.0.2",
"@push.rocks/smartpath": "^5.0.5", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.0.3", "@push.rocks/smartpromise": "^4.0.3",
"@push.rocks/smartproxy": "^19.6.15", "@push.rocks/smartproxy": "^23.1.0",
"@push.rocks/smartrequest": "^2.1.0", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrule": "^2.0.1", "@push.rocks/smartrule": "^2.0.1",
"@push.rocks/smartrust": "^1.1.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@serve.zone/interfaces": "^5.0.4", "@serve.zone/interfaces": "^5.0.4",
"@tsclass/tsclass": "^9.2.0", "@tsclass/tsclass": "^9.2.0",
"ip": "^2.0.1", "ip": "^2.0.1",
"lru-cache": "^11.1.0", "lru-cache": "^11.2.5",
"mailauth": "^4.8.6", "mailauth": "^4.13.0",
"mailparser": "^3.7.4", "mailparser": "^3.9.3",
"uuid": "^11.1.0" "uuid": "^13.0.0"
}, },
"files": [ "files": [
"bin/", "bin/",
"scripts/install-binary.js", "scripts/install-binary.js",
"dist_rust/**/*",
"readme.md", "readme.md",
"license", "license",
"changelog.md" "changelog.md"

5693
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
# Project Hints
## Rust Workspace
- Rust code lives in `rust/` with a Cargo workspace
- Crates: `mailer-core`, `mailer-smtp`, `mailer-security`, `mailer-napi`, `mailer-bin`
- `mailer-bin` is the binary target that `tsrust` builds for cross-compilation
- `mailer-napi` is a cdylib for N-API bindings (not built by tsrust, needs separate napi-rs build pipeline)
- tsrust only supports binary targets (looks for `src/main.rs` or `[[bin]]` entries)
- Cross-compilation targets: `linux_amd64`, `linux_arm64` (configured in `npmextra.json`)
- Build output goes to `dist_rust/`
## Build
- `pnpm build` runs `tsbuild tsfolders && tsrust`
- Note: `tsbuild tsfolders` requires a `tsconfig.json` at the project root (currently missing - pre-existing issue)
- `tsrust` independently works and produces binaries in `dist_rust/`
## Key Dependencies (Rust)
- `tokio` - async runtime
- `tokio-rustls` - TLS
- `hickory-dns` (hickory-resolver) - DNS resolution
- `mail-auth` - DKIM/SPF/DMARC (by Stalwart Labs)
- `mailparse` - MIME parsing
- `napi` + `napi-derive` - Node.js bindings
- `ring` - crypto primitives
- `dashmap` - concurrent hash maps

860
readme.md
View File

@@ -1,361 +1,637 @@
# @serve.zone/mailer # @push.rocks/smartmta
> Enterprise mail server with SMTP, HTTP API, and DNS management A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. 🚀
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](license) ## Issue Reporting and Security
[![Version](https://img.shields.io/badge/version-1.0.0-green.svg)](package.json)
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Install
```bash
pnpm install @push.rocks/smartmta
# or
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
`@serve.zone/mailer` is a comprehensive mail server solution built with Deno, featuring: `@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.
- **SMTP Server & Client** - Full-featured SMTP implementation for sending and receiving emails ### ⚡ What's Inside
- **HTTP REST API** - Mailgun-compatible API for programmatic email management
- **DNS Management** - Automatic DNS setup via Cloudflare API
- **DKIM/SPF/DMARC** - Complete email authentication and security
- **Daemon Service** - Systemd integration for production deployments
- **CLI Interface** - Command-line management of all features
## Architecture | Module | What It Does |
|---|---|
| **Rust SMTP Server** | High-performance SMTP engine written in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
| **SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation |
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
| **SPF** | Full SPF record validation via Rust |
| **DMARC** | Policy enforcement and verification |
| **Email Router** | Pattern-based routing with priority, forward/deliver/reject/process actions |
| **Bounce Manager** | Automatic bounce detection via Rust, classification (hard/soft), and suppression tracking |
| **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection — powered by Rust |
| **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring via Rust |
| **Rate Limiter** | Hierarchical rate limiting (global, per-domain, per-IP) |
| **Delivery Queue** | Persistent queue with exponential backoff retry |
| **Template Engine** | Email templates with variable substitution |
| **Domain Registry** | Multi-domain management with per-domain configuration |
| **DNS Manager** | Automatic DNS record management with Cloudflare API integration |
| **Rust Security Bridge** | All security ops (DKIM+SPF+DMARC+DNSBL+content scanning) run in Rust via IPC |
### Technology Stack ### 🏗️ Architecture
- **Runtime**: Deno (compiles to standalone binaries)
- **Language**: TypeScript
- **Distribution**: npm (via binary wrappers)
- **Service**: systemd daemon
- **DNS**: Cloudflare API integration
### Project Structure
``` ```
mailer/ ┌──────────────────────────────────────────────────────────────┐
├── bin/ # npm binary wrappers UnifiedEmailServer │
├── scripts/ # Build scripts │ (orchestrates all components, emits events) │
├── ts/ # TypeScript source ├───────────┬───────────┬──────────────┬───────────────────────┤
├── mail/ # Email implementation (ported from dcrouter) Email Security │ Delivery Configuration │
│ ├── core/ # Email classes, validation, templates Router │ Stack │ System │ │
│ ├── delivery/ # SMTP client/server, queues ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
│ ├── routing/ # Email routing, domain config │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
│ └── security/ # DKIM, SPF, DMARC │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
├── api/ # HTTP REST API (Mailgun-compatible) │ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator │ │
├── dns/ # DNS management + Cloudflare └──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates │ │
├── daemon/ # Systemd service management │ │ Scan │ │ └──────────┘ │ └────────────────┘ │
├── config/ # Configuration system │ └───────┘ │ │ │
│ └── cli/ # Command-line interface ├───────────┴───────────┴──────────────┴───────────────────────┤
├── test/ # Test suite Rust Security Bridge (smartrust IPC) │
├── deno.json # Deno configuration ├──────────────────────────────────────────────────────────────┤
├── package.json # npm metadata │ Rust Acceleration Layer │
└── mod.ts # Main entry point │ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
│ │ TLS/AUTH │ │IP Rep/Content │ │ MIME/Bounce │ │
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────┘
``` ```
## Installation **Data flow for inbound mail:**
### Via npm (recommended) 1. Rust SMTP server accepts the connection and handles the SMTP protocol
2. On `DATA` completion, Rust emits an `emailReceived` event via IPC
```bash 3. TypeScript processes the email (routing, scanning, delivery decisions)
npm install -g @serve.zone/mailer 4. TypeScript sends the processing result back to Rust via IPC
``` 5. Rust sends the final SMTP response to the client
### From source
```bash
git clone https://code.foss.global/serve.zone/mailer
cd mailer
deno task compile
```
## Usage ## Usage
### CLI Commands ### 🚀 Setting Up the Email Server
#### Service Management The central entry point is `UnifiedEmailServer`, which orchestrates the Rust SMTP server, routing, security, and delivery:
```bash
# Start the mailer daemon
sudo mailer service start
# Stop the daemon
sudo mailer service stop
# Restart the daemon
sudo mailer service restart
# Check status
mailer service status
# Enable systemd service
sudo mailer service enable
# Disable systemd service
sudo mailer service disable
```
#### Domain Management
```bash
# Add a domain
mailer domain add example.com
# Remove a domain
mailer domain remove example.com
# List all domains
mailer domain list
```
#### DNS Management
```bash
# Auto-configure DNS via Cloudflare
mailer dns setup example.com
# Validate DNS configuration
mailer dns validate example.com
# Show required DNS records
mailer dns show example.com
```
#### Sending Email
```bash
# Send email via CLI
mailer send \\
--from sender@example.com \\
--to recipient@example.com \\
--subject "Hello" \\
--text "World"
```
#### Configuration
```bash
# Show current configuration
mailer config show
# Set configuration value
mailer config set smtpPort 25
mailer config set apiPort 8080
mailer config set hostname mail.example.com
```
### HTTP API
The mailer provides a Mailgun-compatible REST API:
#### Send Email
```bash
POST /v1/messages
Content-Type: application/json
{
"from": "sender@example.com",
"to": "recipient@example.com",
"subject": "Hello",
"text": "World",
"html": "<p>World</p>"
}
```
#### List Domains
```bash
GET /v1/domains
```
#### Manage SMTP Credentials
```bash
GET /v1/domains/:domain/credentials
POST /v1/domains/:domain/credentials
DELETE /v1/domains/:domain/credentials/:id
```
#### Email Events
```bash
GET /v1/events
```
### Programmatic Usage
```typescript ```typescript
import { Email, SmtpClient } from '@serve.zone/mailer'; import { UnifiedEmailServer } from '@push.rocks/smartmta';
// Create an email const emailServer = new UnifiedEmailServer(dcRouterRef, {
const email = new Email({ // Ports to listen on (465 = implicit TLS, 25/587 = STARTTLS)
from: 'sender@example.com', ports: [25, 587, 465],
to: 'recipient@example.com', hostname: 'mail.example.com',
subject: 'Hello from Mailer',
text: 'This is a test email', // Multi-domain configuration
html: '<p>This is a test email</p>', domains: [
{
domain: 'example.com',
dnsMode: 'external-dns',
dkim: {
selector: 'default',
keySize: 2048,
rotateKeys: true,
rotationInterval: 90,
},
rateLimits: {
outbound: { messagesPerMinute: 100 },
inbound: { messagesPerMinute: 200, connectionsPerIp: 20 },
},
},
],
// Routing rules (evaluated by priority, highest first)
routes: [
{
name: 'catch-all-forward',
priority: 10,
match: {
recipients: '*@example.com',
},
action: {
type: 'forward',
forward: {
host: 'internal-mail.example.com',
port: 25,
},
},
},
{
name: 'reject-spam-senders',
priority: 100,
match: {
senders: '*@spamdomain.com',
},
action: {
type: 'reject',
reject: {
code: 550,
message: 'Sender rejected by policy',
},
},
},
],
// Authentication settings for the SMTP server
auth: {
required: false,
methods: ['PLAIN', 'LOGIN'],
users: [{ username: 'outbound', password: 'secret' }],
},
// TLS certificates
tls: {
certPath: '/etc/ssl/mail.crt',
keyPath: '/etc/ssl/mail.key',
},
maxMessageSize: 25 * 1024 * 1024, // 25 MB
maxClients: 500,
}); });
// Send via SMTP // start() boots the Rust SMTP server, security bridge, DNS records, and delivery queue
const client = new SmtpClient({ await emailServer.start();
```
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
### 📧 Sending Emails with the SMTP Client
Create and send emails using the built-in SMTP client with connection pooling:
```typescript
import { Email, Delivery } from '@push.rocks/smartmta';
// Create a client with connection pooling
const client = Delivery.smtpClientMod.createSmtpClient({
host: 'smtp.example.com', host: 'smtp.example.com',
port: 587, port: 587,
secure: true, secure: false, // will upgrade via STARTTLS
pool: true,
maxConnections: 5,
auth: { auth: {
user: 'username', user: 'sender@example.com',
pass: 'password', pass: 'your-password',
}, },
}); });
await client.sendMail(email); // Build an email
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
cc: ['cc@example.com'],
subject: 'Hello from smartmta!',
text: 'Plain text body',
html: '<h1>Hello!</h1><p>HTML body with <strong>formatting</strong></p>',
priority: 'high',
attachments: [
{
filename: 'report.pdf',
content: pdfBuffer,
contentType: 'application/pdf',
},
],
});
// Send it
const result = await client.sendMail(email);
console.log(`Message sent: ${result.messageId}`);
``` ```
## Configuration Additional client factories are available:
Configuration is stored in `~/.mailer/config.json`: ```typescript
// Pooled client for high-throughput scenarios
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
```json // Optimized for bulk sending
{ const bulk = Delivery.smtpClientMod.createBulkSmtpClient({ /* ... */ });
"domains": [
{ // Optimized for transactional emails
"domain": "example.com", const transactional = Delivery.smtpClientMod.createTransactionalSmtpClient({ /* ... */ });
"dnsMode": "external-dns", ```
"cloudflare": {
"apiToken": "your-cloudflare-token" ### 🔑 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:
],
"apiKeys": ["api-key-1", "api-key-2"], ```typescript
"smtpPort": 25, import { DKIMCreator } from '@push.rocks/smartmta';
"apiPort": 8080,
"hostname": "mail.example.com" const dkimCreator = new DKIMCreator('/path/to/keys');
// Auto-generate keys if they don't exist
await dkimCreator.handleDKIMKeysForDomain('example.com');
// Get the DNS record you need to publish
const dnsRecord = await dkimCreator.getDNSRecordForDomain('example.com');
console.log(dnsRecord);
// -> { type: 'TXT', name: 'default._domainkey.example.com', value: 'v=DKIM1; k=rsa; p=...' }
// Check if keys need rotation
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}`);
} }
``` ```
## DNS Setup 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.
The mailer requires the following DNS records for each domain: ### 🛡️ Email Authentication (SPF, DKIM, DMARC)
### MX Record Verify incoming emails against all three authentication standards. All verification is powered by the Rust binary:
```
Type: MX ```typescript
Name: @ import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
Value: mail.example.com
Priority: 10 // SPF verification — first arg is an Email object
TTL: 3600 const spfVerifier = new SpfVerifier();
const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror',
// domain: string, ip: string }
// DKIM verification — takes raw email content
const dkimVerifier = new DKIMVerifier();
const dkimResult = await dkimVerifier.verify(rawEmailContent);
// DMARC verification — first arg is an Email object
const dmarcVerifier = new DmarcVerifier();
const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
// -> { action: 'pass' | 'quarantine' | 'reject', hasDmarc: boolean,
// spfDomainAligned: boolean, dkimDomainAligned: boolean, ... }
``` ```
### A Record ### 🔀 Email Routing
```
Type: A Pattern-based routing engine with priority ordering and flexible match criteria. Routes are evaluated by priority (highest first):
Name: mail
Value: <your-server-ip> ```typescript
TTL: 3600 import { EmailRouter } from '@push.rocks/smartmta';
const router = new EmailRouter([
{
name: 'admin-mail',
priority: 100,
match: {
recipients: 'admin@example.com',
authenticated: true,
},
action: {
type: 'deliver',
},
},
{
name: 'external-forward',
priority: 50,
match: {
recipients: '*@example.com',
sizeRange: { max: 10 * 1024 * 1024 }, // under 10MB
},
action: {
type: 'forward',
forward: {
host: 'backend-mail.internal',
port: 25,
preserveHeaders: true,
},
},
},
{
name: 'process-with-scanning',
priority: 10,
match: {
recipients: '*@*',
},
action: {
type: 'process',
process: {
scan: true,
dkim: true,
queue: 'normal',
},
},
},
]);
// Evaluate routes against an email context
const matchedRoute = await router.evaluateRoutes(emailContext);
``` ```
### SPF Record **Match criteria available:**
```
Type: TXT | Criterion | Description |
Name: @ |---|---|
Value: v=spf1 mx ip4:<your-server-ip> ~all | `recipients` | Glob patterns for recipient addresses (`*@example.com`) |
TTL: 3600 | `senders` | Glob patterns for sender addresses |
| `clientIp` | IP addresses or CIDR ranges |
| `authenticated` | Require authentication status |
| `headers` | Match specific headers (string or RegExp) |
| `sizeRange` | Message size constraints (`{ min?, max? }`) |
| `subject` | Subject line pattern (string or RegExp) |
| `hasAttachments` | Filter by attachment presence |
### 🔍 Content Scanning
Built-in content scanner for detecting spam, phishing, malware, and other threats. Text pattern scanning runs in Rust for performance; binary attachment scanning (PE headers, VBA macros) runs in TypeScript:
```typescript
import { ContentScanner } from '@push.rocks/smartmta';
const scanner = new ContentScanner({
scanSubject: true,
scanBody: true,
scanAttachments: true,
blockExecutables: true,
blockMacros: true,
minThreatScore: 30,
highThreatScore: 70,
customRules: [
{
pattern: /bitcoin.*wallet/i,
type: 'scam',
score: 80,
description: 'Cryptocurrency scam pattern',
},
],
});
const result = await scanner.scanEmail(email);
// -> { isClean: false, threatScore: 85, threatType: 'phishing', scannedElements: [...] }
``` ```
### DKIM Record ### 🌐 IP Reputation Checking
```
Type: TXT Check sender IP addresses against DNSBL blacklists and classify IP types. DNSBL lookups run in Rust:
Name: default._domainkey
Value: <dkim-public-key> ```typescript
TTL: 3600 import { IPReputationChecker } from '@push.rocks/smartmta';
const ipChecker = IPReputationChecker.getInstance({
enableDNSBL: true,
dnsblServers: ['zen.spamhaus.org', 'bl.spamcop.net'],
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
});
const reputation = await ipChecker.checkReputation('192.168.1.1');
// -> { score: 85, isSpam: false, isProxy: false, isTor: false, blacklists: [] }
``` ```
### DMARC Record ### ⏱️ Rate Limiting
```
Type: TXT Hierarchical rate limiting to protect your server and maintain deliverability:
Name: _dmarc
Value: v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com ```typescript
TTL: 3600 import { Delivery } from '@push.rocks/smartmta';
const { UnifiedRateLimiter } = Delivery;
const rateLimiter = new UnifiedRateLimiter({
global: {
maxMessagesPerMinute: 1000,
maxRecipientsPerMessage: 500,
maxConnectionsPerIP: 20,
maxErrorsPerIP: 10,
maxAuthFailuresPerIP: 5,
blockDuration: 600000, // 10 minutes
},
domains: {
'example.com': {
maxMessagesPerMinute: 100,
maxRecipientsPerMessage: 50,
},
},
});
// 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}`);
}
``` ```
Use `mailer dns setup <domain>` to automatically configure these via Cloudflare. ### 📬 Bounce Management
## Development Automatic bounce detection (via Rust), classification, and suppression tracking:
### Prerequisites ```typescript
import { Core } from '@push.rocks/smartmta';
const { BounceManager } = Core;
- Deno 1.40+ const bounceManager = new BounceManager();
- Node.js 14+ (for npm distribution)
### Build // Process an SMTP failure
const bounce = await bounceManager.processSmtpFailure(
'recipient@example.com',
'550 5.1.1 User unknown',
{ originalEmailId: 'msg-123' }
);
// -> { bounceType: 'invalid_recipient', bounceCategory: 'hard', ... }
```bash // Check if an address is suppressed due to bounces
# Compile for all platforms const suppressed = bounceManager.isEmailSuppressed('recipient@example.com');
deno task compile
# Run in development mode // Manually manage the suppression list
deno task dev bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
bounceManager.removeFromSuppressionList('recovered@example.com');
# Run tests
deno task test
# Format code
deno task fmt
# Lint code
deno task lint
``` ```
### Ported Components ### 📝 Email Templates
The mail implementation is ported from [dcrouter](https://code.foss.global/serve.zone/dcrouter) and adapted for Deno: Template engine with variable substitution for transactional and notification emails:
- ✅ Email core (Email, EmailValidator, BounceManager, TemplateManager) ```typescript
- ✅ SMTP Server (with TLS support) import { Core } from '@push.rocks/smartmta';
- ✅ SMTP Client (with connection pooling) const { TemplateManager } = Core;
- ✅ Email routing and domain management
- ✅ DKIM signing and verification
- ✅ SPF and DMARC validation
- ✅ Delivery queues and rate limiting
## Roadmap const templates = new TemplateManager({
from: 'noreply@example.com',
footerHtml: '<p>&copy; 2026 Example Corp</p>',
});
### Phase 1 - Core Functionality (Current) // Register a template
- [x] Project structure and build system templates.registerTemplate({
- [x] Port mail implementation from dcrouter id: 'welcome',
- [x] CLI interface name: 'Welcome Email',
- [x] Configuration management description: 'Sent to new users',
- [x] DNS management basics from: 'welcome@example.com',
- [ ] Cloudflare DNS integration subject: 'Welcome, {{name}}!',
- [ ] HTTP REST API implementation bodyHtml: '<h1>Welcome, {{name}}!</h1><p>Your account is ready.</p>',
- [ ] Systemd service integration bodyText: 'Welcome, {{name}}! Your account is ready.',
category: 'transactional',
});
### Phase 2 - Production Ready // Create an Email object from the template
- [ ] Comprehensive testing const email = await templates.createEmail('welcome', {
- [ ] Documentation to: 'newuser@example.com',
- [ ] Performance optimization variables: { name: 'Alice' },
- [ ] Security hardening });
- [ ] Monitoring and logging ```
### Phase 3 - Advanced Features ### 🌍 DNS Management
- [ ] Webhook support
- [ ] Email templates
- [ ] Analytics and reporting
- [ ] Multi-tenancy
- [ ] Load balancing
## License 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:
MIT © Serve Zone ```typescript
const emailServer = new UnifiedEmailServer(dcRouterRef, {
hostname: 'mail.example.com',
domains: [
{
domain: 'example.com',
dnsMode: 'external-dns', // managed via Cloudflare API
},
],
// ... other config
});
## Contributing // DNS records are set up automatically on start:
// - MX records pointing to your mail server
// - SPF TXT records authorizing your server IP
// - DKIM TXT records with public keys from DKIMCreator
// - DMARC TXT records with your policy
await emailServer.start();
```
Contributions are welcome! Please see our [contributing guidelines](https://code.foss.global/serve.zone/mailer/contributing). ### 🦀 RustSecurityBridge
## Support 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:
- Documentation: https://code.foss.global/serve.zone/mailer ```typescript
- Issues: https://code.foss.global/serve.zone/mailer/issues import { RustSecurityBridge } from '@push.rocks/smartmta';
- Email: support@serve.zone
## Acknowledgments const bridge = RustSecurityBridge.getInstance();
await bridge.start();
- Mail implementation ported from [dcrouter](https://code.foss.global/serve.zone/dcrouter) // Compound verification: DKIM + SPF + DMARC in a single IPC call
- Inspired by [Mailgun](https://www.mailgun.com/) API design const securityResult = await bridge.verifyEmail({
- Built with [Deno](https://deno.land/) 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 |
|---|---|---|
| `mailer-core` | ✅ Complete (26 tests) | Email types, validation, MIME building, bounce detection |
| `mailer-security` | ✅ Complete (22 tests) | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
| `mailer-smtp` | ✅ Complete (72 tests) | Full SMTP protocol engine — TCP/TLS server, STARTTLS, AUTH, pipelining, rate limiting |
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — security, content scanning, SMTP server lifecycle |
| `mailer-napi` | 🔜 Planned | Native Node.js addon (N-API) |
### What Runs in Rust
| Operation | Runs In | Why |
|---|---|---|
| SMTP server (port listening, protocol, TLS) | Rust | Performance, memory safety, zero-copy parsing |
| DKIM signing & verification | Rust | Crypto-heavy, benefits from native speed |
| SPF validation | Rust | DNS lookups with async resolver |
| DMARC policy checking | Rust | Integrates with SPF/DKIM results |
| IP reputation / DNSBL | Rust | Parallel DNS queries |
| Content scanning (text patterns) | Rust | Regex engine performance |
| Bounce detection (pattern matching) | Rust | Regex engine performance |
| Email validation & MIME building | Rust | Parsing performance |
| Binary attachment scanning | TypeScript | Buffer data too large for IPC |
| Email routing & orchestration | TypeScript | Business logic, flexibility |
| Delivery queue & retry | TypeScript | State management, persistence |
| Template rendering | TypeScript | String interpolation |
## Project Structure
```
smartmta/
├── ts/ # TypeScript source
│ ├── mail/
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
│ │ │ ├── smtpclient/ # SMTP client with connection pooling
│ │ │ └── smtpserver/ # Legacy TS SMTP server (socket-handler fallback)
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
├── rust/ # Rust workspace
│ └── crates/
│ ├── mailer-core/ # Email types, validation, MIME, bounce detection
│ ├── mailer-security/ # DKIM, SPF, DMARC, IP reputation, content scanning
│ ├── mailer-smtp/ # Full SMTP server (TCP/TLS, state machine, rate limiting)
│ ├── mailer-bin/ # CLI + smartrust IPC bridge
│ └── mailer-napi/ # N-API addon (planned)
├── test/ # Test suite
├── dist_ts/ # Compiled TypeScript output
└── dist_rust/ # Compiled Rust binaries
```
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -1,198 +1,24 @@
# Mailer Implementation Plan & Progress # Rust Migration Plan
## Project Goals ## Completed Phases
Build a Deno-based mail server package (`@serve.zone/mailer`) with: ### 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.

2
rust/.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

2425
rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

37
rust/Cargo.toml Normal file
View File

@@ -0,0 +1,37 @@
[workspace]
resolver = "2"
members = [
"crates/mailer-core",
"crates/mailer-smtp",
"crates/mailer-security",
"crates/mailer-napi",
"crates/mailer-bin",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
tokio-rustls = "0.26"
hickory-resolver = "0.25"
mail-auth = "0.7"
mailparse = "0.16"
napi = { version = "2", features = ["napi9", "async", "serde-json"] }
napi-derive = "2"
ring = "0.17"
dashmap = "6"
thiserror = "2"
tracing = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bytes = "1"
regex = "1"
base64 = "0.22"
uuid = { version = "1", features = ["v4"] }
ipnet = "2"
rustls-pki-types = "1"
psl = "2"
clap = { version = "4", features = ["derive"] }

View File

@@ -0,0 +1,21 @@
[package]
name = "mailer-bin"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "mailer-bin"
path = "src/main.rs"
[dependencies]
mailer-core = { path = "../mailer-core" }
mailer-smtp = { path = "../mailer-smtp" }
mailer-security = { path = "../mailer-security" }
tokio.workspace = true
tracing.workspace = true
serde.workspace = true
serde_json.workspace = true
clap.workspace = true
hickory-resolver.workspace = true
dashmap.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
[package]
name = "mailer-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
bytes.workspace = true
mailparse.workspace = true
regex.workspace = true
base64.workspace = true
uuid.workspace = true

View File

@@ -0,0 +1,485 @@
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
/// Type of email bounce.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BounceType {
// Hard bounces
InvalidRecipient,
DomainNotFound,
MailboxFull,
MailboxInactive,
Blocked,
SpamRelated,
PolicyRelated,
// Soft bounces
ServerUnavailable,
TemporaryFailure,
QuotaExceeded,
NetworkError,
Timeout,
// Special
AutoResponse,
ChallengeResponse,
Unknown,
}
/// Broad category of a bounce.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BounceCategory {
Hard,
Soft,
AutoResponse,
Unknown,
}
impl BounceType {
/// Get the category for this bounce type.
pub fn category(&self) -> BounceCategory {
match self {
BounceType::InvalidRecipient
| BounceType::DomainNotFound
| BounceType::MailboxFull
| BounceType::MailboxInactive
| BounceType::Blocked
| BounceType::SpamRelated
| BounceType::PolicyRelated => BounceCategory::Hard,
BounceType::ServerUnavailable
| BounceType::TemporaryFailure
| BounceType::QuotaExceeded
| BounceType::NetworkError
| BounceType::Timeout => BounceCategory::Soft,
BounceType::AutoResponse | BounceType::ChallengeResponse => {
BounceCategory::AutoResponse
}
BounceType::Unknown => BounceCategory::Unknown,
}
}
}
/// Result of bounce detection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BounceDetection {
pub bounce_type: BounceType,
pub category: BounceCategory,
}
/// Pattern set for a bounce type: compiled regexes for matching against SMTP responses.
struct BouncePatterns {
bounce_type: BounceType,
patterns: Vec<Regex>,
}
/// All bounce detection patterns, compiled once.
static BOUNCE_PATTERNS: LazyLock<Vec<BouncePatterns>> = LazyLock::new(|| {
vec![
BouncePatterns {
bounce_type: BounceType::InvalidRecipient,
patterns: compile_patterns(&[
r"(?i)no such user",
r"(?i)user unknown",
r"(?i)does not exist",
r"(?i)invalid recipient",
r"(?i)unknown recipient",
r"(?i)no mailbox",
r"(?i)user not found",
r"(?i)recipient address rejected",
r"(?i)550 5\.1\.1",
]),
},
BouncePatterns {
bounce_type: BounceType::DomainNotFound,
patterns: compile_patterns(&[
r"(?i)domain not found",
r"(?i)unknown domain",
r"(?i)no such domain",
r"(?i)host not found",
r"(?i)domain invalid",
r"(?i)550 5\.1\.2",
]),
},
BouncePatterns {
bounce_type: BounceType::MailboxFull,
patterns: compile_patterns(&[
r"(?i)mailbox full",
r"(?i)over quota",
r"(?i)quota exceeded",
r"(?i)552 5\.2\.2",
]),
},
BouncePatterns {
bounce_type: BounceType::MailboxInactive,
patterns: compile_patterns(&[
r"(?i)mailbox disabled",
r"(?i)mailbox inactive",
r"(?i)account disabled",
r"(?i)mailbox not active",
r"(?i)account suspended",
]),
},
BouncePatterns {
bounce_type: BounceType::Blocked,
patterns: compile_patterns(&[
r"(?i)blocked",
r"(?i)rejected",
r"(?i)denied",
r"(?i)blacklisted",
r"(?i)prohibited",
r"(?i)refused",
r"(?i)550 5\.7\.",
]),
},
BouncePatterns {
bounce_type: BounceType::SpamRelated,
patterns: compile_patterns(&[
r"(?i)spam",
r"(?i)bulk mail",
r"(?i)content rejected",
r"(?i)message rejected",
r"(?i)550 5\.7\.1",
]),
},
BouncePatterns {
bounce_type: BounceType::ServerUnavailable,
patterns: compile_patterns(&[
r"(?i)server unavailable",
r"(?i)service unavailable",
r"(?i)try again later",
r"(?i)try later",
r"(?i)451 4\.3\.",
r"(?i)421 4\.3\.",
]),
},
BouncePatterns {
bounce_type: BounceType::TemporaryFailure,
patterns: compile_patterns(&[
r"(?i)temporary failure",
r"(?i)temporary error",
r"(?i)temporary problem",
r"(?i)try again",
r"(?i)451 4\.",
]),
},
BouncePatterns {
bounce_type: BounceType::QuotaExceeded,
patterns: compile_patterns(&[
r"(?i)quota temporarily exceeded",
r"(?i)mailbox temporarily full",
r"(?i)452 4\.2\.2",
]),
},
BouncePatterns {
bounce_type: BounceType::NetworkError,
patterns: compile_patterns(&[
r"(?i)network error",
r"(?i)connection error",
r"(?i)connection timed out",
r"(?i)routing error",
r"(?i)421 4\.4\.",
]),
},
BouncePatterns {
bounce_type: BounceType::Timeout,
patterns: compile_patterns(&[
r"(?i)timed out",
r"(?i)timeout",
r"(?i)450 4\.4\.2",
]),
},
BouncePatterns {
bounce_type: BounceType::AutoResponse,
patterns: compile_patterns(&[
r"(?i)auto[- ]reply",
r"(?i)auto[- ]response",
r"(?i)vacation",
r"(?i)out of office",
r"(?i)away from office",
r"(?i)on vacation",
r"(?i)automatic reply",
]),
},
BouncePatterns {
bounce_type: BounceType::ChallengeResponse,
patterns: compile_patterns(&[
r"(?i)challenge[- ]response",
r"(?i)verify your email",
r"(?i)confirm your email",
r"(?i)email verification",
]),
},
]
});
/// Regex for detecting bounce email subjects.
static BOUNCE_SUBJECT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)mail delivery|delivery (?:failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem")
.expect("invalid bounce subject regex")
});
/// Regex for extracting recipient from bounce messages.
static BOUNCE_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?")
.expect("invalid bounce recipient regex")
});
/// Regex for extracting diagnostic code.
static DIAGNOSTIC_CODE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)diagnostic(?:-|\s+)code:\s*(.+)")
.expect("invalid diagnostic code regex")
});
/// Regex for extracting status code.
static STATUS_CODE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)status(?:-|\s+)code:\s*([0-9.]+)")
.expect("invalid status code regex")
});
/// Regex for DSN original-recipient.
static DSN_ORIGINAL_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)original-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})")
.expect("invalid DSN original-recipient regex")
});
/// Regex for DSN final-recipient.
static DSN_FINAL_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)final-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})")
.expect("invalid DSN final-recipient regex")
});
fn compile_patterns(patterns: &[&str]) -> Vec<Regex> {
patterns
.iter()
.map(|p| Regex::new(p).expect("invalid bounce pattern regex"))
.collect()
}
/// Detect bounce type from an SMTP response, diagnostic code, or status code.
pub fn detect_bounce_type(
smtp_response: Option<&str>,
diagnostic_code: Option<&str>,
status_code: Option<&str>,
) -> BounceDetection {
// Check all text sources against patterns
let texts: Vec<&str> = [smtp_response, diagnostic_code, status_code]
.into_iter()
.flatten()
.collect();
for bp in BOUNCE_PATTERNS.iter() {
for text in &texts {
for pattern in &bp.patterns {
if pattern.is_match(text) {
return BounceDetection {
bounce_type: bp.bounce_type,
category: bp.bounce_type.category(),
};
}
}
}
}
// Fallback: parse DSN status code (class.subject.detail)
if let Some(code) = status_code {
if let Some(detection) = parse_dsn_status(code) {
return detection;
}
}
// Try to find DSN code in SMTP response
if let Some(resp) = smtp_response {
if let Some(code) = STATUS_CODE_RE.captures(resp).and_then(|c| c.get(1)) {
if let Some(detection) = parse_dsn_status(code.as_str()) {
return detection;
}
}
}
BounceDetection {
bounce_type: BounceType::Unknown,
category: BounceCategory::Unknown,
}
}
/// Parse a DSN enhanced status code like "5.1.1" or "4.2.2".
fn parse_dsn_status(code: &str) -> Option<BounceDetection> {
let parts: Vec<&str> = code.split('.').collect();
if parts.len() < 2 {
return None;
}
let class: u8 = parts[0].parse().ok()?;
let subject: u8 = parts[1].parse().ok()?;
let bounce_type = match (class, subject) {
(5, 1) => BounceType::InvalidRecipient,
(5, 2) => BounceType::MailboxFull,
(5, 7) => BounceType::Blocked,
(5, _) => BounceType::PolicyRelated,
(4, 2) => BounceType::QuotaExceeded,
(4, 3) => BounceType::ServerUnavailable,
(4, 4) => BounceType::NetworkError,
(4, _) => BounceType::TemporaryFailure,
_ => return None,
};
Some(BounceDetection {
category: bounce_type.category(),
bounce_type,
})
}
/// Check if a subject line looks like a bounce notification.
pub fn is_bounce_subject(subject: &str) -> bool {
BOUNCE_SUBJECT_RE.is_match(subject)
}
/// Extract the bounced recipient email from a bounce message body.
pub fn extract_bounce_recipient(body: &str) -> Option<String> {
BOUNCE_RECIPIENT_RE
.captures(body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.or_else(|| {
DSN_FINAL_RECIPIENT_RE
.captures(body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
})
.or_else(|| {
DSN_ORIGINAL_RECIPIENT_RE
.captures(body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
})
}
/// Extract the diagnostic code from a bounce message body.
pub fn extract_diagnostic_code(body: &str) -> Option<String> {
DIAGNOSTIC_CODE_RE
.captures(body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
}
/// Extract the status code from a bounce message body.
pub fn extract_status_code(body: &str) -> Option<String> {
STATUS_CODE_RE
.captures(body)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
}
/// Calculate retry delay using exponential backoff.
///
/// * `retry_count` - Number of retries so far (0-based)
/// * `initial_delay_ms` - Initial delay in milliseconds (default 15 min = 900_000)
/// * `max_delay_ms` - Maximum delay in milliseconds (default 24h = 86_400_000)
/// * `backoff_factor` - Multiplier per retry (default 2.0)
pub fn retry_delay_ms(
retry_count: u32,
initial_delay_ms: u64,
max_delay_ms: u64,
backoff_factor: f64,
) -> u64 {
let delay = (initial_delay_ms as f64) * backoff_factor.powi(retry_count as i32);
(delay as u64).min(max_delay_ms)
}
/// Default retry delay with standard parameters.
pub fn default_retry_delay_ms(retry_count: u32) -> u64 {
retry_delay_ms(
retry_count,
15 * 60 * 1000, // 15 minutes
24 * 60 * 60 * 1000, // 24 hours
2.0,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_invalid_recipient() {
let result = detect_bounce_type(Some("550 5.1.1 User unknown"), None, None);
assert_eq!(result.bounce_type, BounceType::InvalidRecipient);
assert_eq!(result.category, BounceCategory::Hard);
}
#[test]
fn test_detect_mailbox_full() {
let result = detect_bounce_type(Some("552 5.2.2 Mailbox full"), None, None);
assert_eq!(result.bounce_type, BounceType::MailboxFull);
assert_eq!(result.category, BounceCategory::Hard);
}
#[test]
fn test_detect_temporary_failure() {
let result = detect_bounce_type(Some("451 4.3.0 Try again later"), None, None);
assert_eq!(result.bounce_type, BounceType::ServerUnavailable);
assert_eq!(result.category, BounceCategory::Soft);
}
#[test]
fn test_detect_auto_response() {
let result = detect_bounce_type(Some("Auto-reply: Out of office"), None, None);
assert_eq!(result.bounce_type, BounceType::AutoResponse);
assert_eq!(result.category, BounceCategory::AutoResponse);
}
#[test]
fn test_detect_from_dsn_status() {
let result = detect_bounce_type(None, None, Some("5.1.1"));
assert_eq!(result.bounce_type, BounceType::InvalidRecipient);
let result = detect_bounce_type(None, None, Some("4.4.1"));
assert_eq!(result.bounce_type, BounceType::NetworkError);
}
#[test]
fn test_detect_unknown() {
let result = detect_bounce_type(Some("Something weird happened"), None, None);
assert_eq!(result.bounce_type, BounceType::Unknown);
}
#[test]
fn test_is_bounce_subject() {
assert!(is_bounce_subject("Mail Delivery Failure"));
assert!(is_bounce_subject("Delivery Status Notification"));
assert!(is_bounce_subject("Returned mail: see transcript for details"));
assert!(is_bounce_subject("Undeliverable: Your message"));
assert!(!is_bounce_subject("Hello World"));
assert!(!is_bounce_subject("Meeting tomorrow"));
}
#[test]
fn test_extract_bounce_recipient() {
let body = "Delivery to the following recipient failed:\n recipient: user@example.com";
assert_eq!(
extract_bounce_recipient(body),
Some("user@example.com".to_string())
);
let body = "Final-Recipient: rfc822;bounce@test.org";
assert_eq!(
extract_bounce_recipient(body),
Some("bounce@test.org".to_string())
);
}
#[test]
fn test_retry_delay() {
assert_eq!(default_retry_delay_ms(0), 900_000); // 15 min
assert_eq!(default_retry_delay_ms(1), 1_800_000); // 30 min
assert_eq!(default_retry_delay_ms(2), 3_600_000); // 1 hour
// Capped at 24h
assert_eq!(default_retry_delay_ms(20), 86_400_000);
}
}

View File

@@ -0,0 +1,411 @@
use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::error::{MailerError, Result};
use crate::mime::build_rfc822;
use crate::validation::is_valid_email_format;
/// Email priority level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Priority {
High,
Normal,
Low,
}
impl Default for Priority {
fn default() -> Self {
Priority::Normal
}
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Priority::High => write!(f, "high"),
Priority::Normal => write!(f, "normal"),
Priority::Low => write!(f, "low"),
}
}
}
/// A parsed email address with local part and domain.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct EmailAddress {
pub local: String,
pub domain: String,
}
impl EmailAddress {
/// Parse an email address string like "user@example.com" or "Name <user@example.com>".
pub fn parse(input: &str) -> Result<Self> {
let addr = extract_email_address(input)
.ok_or_else(|| MailerError::InvalidEmail(input.to_string()))?;
let parts: Vec<&str> = addr.splitn(2, '@').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(MailerError::InvalidEmail(input.to_string()));
}
Ok(EmailAddress {
local: parts[0].to_string(),
domain: parts[1].to_lowercase(),
})
}
/// Return the full address as "local@domain".
pub fn address(&self) -> String {
format!("{}@{}", self.local, self.domain)
}
}
impl fmt::Display for EmailAddress {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}@{}", self.local, self.domain)
}
}
/// Extract the bare email address from a string that may contain display names or angle brackets.
/// Handles formats like:
/// - "user@example.com"
/// - "<user@example.com>"
/// - "John Doe <user@example.com>"
pub fn extract_email_address(input: &str) -> Option<String> {
let trimmed = input.trim();
// Handle null sender
if trimmed == "<>" {
return None;
}
// Try to extract from angle brackets
if let Some(start) = trimmed.find('<') {
if let Some(end) = trimmed.find('>') {
if end > start {
let addr = trimmed[start + 1..end].trim();
if !addr.is_empty() {
return Some(addr.to_string());
}
}
}
}
// No angle brackets — treat entire string as address if it contains @
if trimmed.contains('@') {
return Some(trimmed.to_string());
}
None
}
/// An email attachment.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attachment {
pub filename: String,
#[serde(with = "serde_bytes_base64")]
pub content: Vec<u8>,
pub content_type: String,
pub content_id: Option<String>,
}
/// Serde helper for base64-encoding Vec<u8> in JSON.
mod serde_bytes_base64 {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&STANDARD.encode(data))
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
let s = String::deserialize(deserializer)?;
STANDARD.decode(s).map_err(serde::de::Error::custom)
}
}
/// A complete email message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Email {
pub from: String,
pub to: Vec<String>,
pub cc: Vec<String>,
pub bcc: Vec<String>,
pub subject: String,
pub text: String,
pub html: Option<String>,
pub attachments: Vec<Attachment>,
pub headers: HashMap<String, String>,
pub priority: Priority,
pub might_be_spam: bool,
message_id: Option<String>,
envelope_from: Option<String>,
}
impl Email {
/// Create a new email with the minimum required fields.
pub fn new(from: &str, subject: &str, text: &str) -> Self {
Email {
from: from.to_string(),
to: Vec::new(),
cc: Vec::new(),
bcc: Vec::new(),
subject: subject.to_string(),
text: text.to_string(),
html: None,
attachments: Vec::new(),
headers: HashMap::new(),
priority: Priority::Normal,
might_be_spam: false,
message_id: None,
envelope_from: None,
}
}
/// Add a To recipient.
pub fn add_to(&mut self, email: &str) -> &mut Self {
self.to.push(email.to_string());
self
}
/// Add a CC recipient.
pub fn add_cc(&mut self, email: &str) -> &mut Self {
self.cc.push(email.to_string());
self
}
/// Add a BCC recipient.
pub fn add_bcc(&mut self, email: &str) -> &mut Self {
self.bcc.push(email.to_string());
self
}
/// Set the HTML body.
pub fn set_html(&mut self, html: &str) -> &mut Self {
self.html = Some(html.to_string());
self
}
/// Add an attachment.
pub fn add_attachment(&mut self, attachment: Attachment) -> &mut Self {
self.attachments.push(attachment);
self
}
/// Add a custom header.
pub fn add_header(&mut self, name: &str, value: &str) -> &mut Self {
self.headers.insert(name.to_string(), value.to_string());
self
}
/// Set email priority.
pub fn set_priority(&mut self, priority: Priority) -> &mut Self {
self.priority = priority;
self
}
/// Get the sender domain.
pub fn from_domain(&self) -> Option<String> {
EmailAddress::parse(&self.from)
.ok()
.map(|addr| addr.domain)
}
/// Get the sender address (bare email, no display name).
pub fn from_address(&self) -> Option<String> {
extract_email_address(&self.from)
}
/// Get all recipients (to + cc + bcc), deduplicated.
pub fn all_recipients(&self) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for addr in self.to.iter().chain(self.cc.iter()).chain(self.bcc.iter()) {
let lower = addr.to_lowercase();
if seen.insert(lower) {
result.push(addr.clone());
}
}
result
}
/// Get the primary (first To) recipient.
pub fn primary_recipient(&self) -> Option<&str> {
self.to.first().map(|s| s.as_str())
}
/// Check whether this email has attachments.
pub fn has_attachments(&self) -> bool {
!self.attachments.is_empty()
}
/// Get total attachment size in bytes.
pub fn attachments_size(&self) -> usize {
self.attachments.iter().map(|a| a.content.len()).sum()
}
/// Get or generate a Message-ID.
pub fn message_id(&self) -> String {
if let Some(ref id) = self.message_id {
return id.clone();
}
let domain = self.from_domain().unwrap_or_else(|| "localhost".to_string());
let unique = uuid::Uuid::new_v4();
format!("<{}.{}@{}>", chrono_millis(), unique, domain)
}
/// Set an explicit Message-ID.
pub fn set_message_id(&mut self, id: &str) -> &mut Self {
self.message_id = Some(id.to_string());
self
}
/// Get the envelope-from (MAIL FROM), falls back to the From header address.
pub fn envelope_from(&self) -> Option<String> {
self.envelope_from
.clone()
.or_else(|| self.from_address())
}
/// Set the envelope-from address.
pub fn set_envelope_from(&mut self, addr: &str) -> &mut Self {
self.envelope_from = Some(addr.to_string());
self
}
/// Sanitize a string by removing CR/LF (header injection prevention).
pub fn sanitize_string(input: &str) -> String {
input.replace(['\r', '\n'], " ")
}
/// Validate all addresses in this email.
pub fn validate_addresses(&self) -> Vec<String> {
let mut errors = Vec::new();
if !is_valid_email_format(&self.from) {
if extract_email_address(&self.from)
.map(|a| !is_valid_email_format(&a))
.unwrap_or(true)
{
errors.push(format!("Invalid from address: {}", self.from));
}
}
for addr in &self.to {
if !is_valid_email_format(addr) {
if extract_email_address(addr)
.map(|a| !is_valid_email_format(&a))
.unwrap_or(true)
{
errors.push(format!("Invalid to address: {}", addr));
}
}
}
for addr in &self.cc {
if !is_valid_email_format(addr) {
if extract_email_address(addr)
.map(|a| !is_valid_email_format(&a))
.unwrap_or(true)
{
errors.push(format!("Invalid cc address: {}", addr));
}
}
}
for addr in &self.bcc {
if !is_valid_email_format(addr) {
if extract_email_address(addr)
.map(|a| !is_valid_email_format(&a))
.unwrap_or(true)
{
errors.push(format!("Invalid bcc address: {}", addr));
}
}
}
errors
}
/// Convert the email to RFC 5322 format.
pub fn to_rfc822(&self) -> Result<String> {
build_rfc822(self)
}
}
/// Simple epoch millis using std::time (no chrono dependency needed).
fn chrono_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_email_address_parse() {
let addr = EmailAddress::parse("user@example.com").unwrap();
assert_eq!(addr.local, "user");
assert_eq!(addr.domain, "example.com");
let addr = EmailAddress::parse("John Doe <john@example.com>").unwrap();
assert_eq!(addr.local, "john");
assert_eq!(addr.domain, "example.com");
let addr = EmailAddress::parse("<admin@test.org>").unwrap();
assert_eq!(addr.local, "admin");
assert_eq!(addr.domain, "test.org");
}
#[test]
fn test_extract_email_address() {
assert_eq!(
extract_email_address("John <john@example.com>"),
Some("john@example.com".to_string())
);
assert_eq!(
extract_email_address("user@example.com"),
Some("user@example.com".to_string())
);
assert_eq!(extract_email_address("<>"), None);
assert_eq!(extract_email_address("no-at-sign"), None);
}
#[test]
fn test_email_new() {
let mut email = Email::new("sender@example.com", "Test", "Hello");
email.add_to("recipient@example.com");
assert_eq!(email.from_domain(), Some("example.com".to_string()));
assert_eq!(email.all_recipients().len(), 1);
}
#[test]
fn test_all_recipients_dedup() {
let mut email = Email::new("sender@example.com", "Test", "Hello");
email.add_to("a@example.com");
email.add_cc("a@example.com"); // duplicate (case-insensitive)
email.add_bcc("b@example.com");
assert_eq!(email.all_recipients().len(), 2);
}
#[test]
fn test_sanitize_string() {
assert_eq!(Email::sanitize_string("hello\r\nworld"), "hello world");
assert_eq!(Email::sanitize_string("normal"), "normal");
}
#[test]
fn test_message_id_generation() {
let email = Email::new("sender@example.com", "Test", "Hello");
let mid = email.message_id();
assert!(mid.starts_with('<'));
assert!(mid.ends_with('>'));
assert!(mid.contains("@example.com"));
}
}

View File

@@ -0,0 +1,31 @@
use thiserror::Error;
/// Core error types for the mailer system.
#[derive(Debug, Error)]
pub enum MailerError {
#[error("invalid email address: {0}")]
InvalidEmail(String),
#[error("invalid email format: {0}")]
InvalidFormat(String),
#[error("missing required field: {0}")]
MissingField(String),
#[error("MIME encoding error: {0}")]
MimeError(String),
#[error("validation error: {0}")]
ValidationError(String),
#[error("parse error: {0}")]
ParseError(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("regex error: {0}")]
Regex(#[from] regex::Error),
}
pub type Result<T> = std::result::Result<T, MailerError>;

View File

@@ -0,0 +1,35 @@
//! mailer-core: Email model, validation, and RFC 5322 primitives.
pub mod bounce;
pub mod email;
pub mod error;
pub mod mime;
pub mod validation;
// Re-exports for convenience
pub use bounce::{
detect_bounce_type, extract_bounce_recipient, is_bounce_subject, BounceCategory,
BounceDetection, BounceType,
};
pub use email::{extract_email_address, Attachment, Email, EmailAddress, Priority};
pub use error::{MailerError, Result};
pub use mime::build_rfc822;
pub use validation::{is_valid_email_format, validate_email, EmailValidationResult};
/// Re-export mailparse for MIME parsing.
pub use mailparse;
/// Crate version.
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version() {
assert!(!version().is_empty());
}
}

View File

@@ -0,0 +1,377 @@
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use crate::email::Email;
use crate::error::Result;
/// Generate a MIME boundary string.
fn generate_boundary() -> String {
let id = uuid::Uuid::new_v4();
format!("----=_Part_{}", id.as_simple())
}
/// Build an RFC 5322 compliant email message from an Email struct.
pub fn build_rfc822(email: &Email) -> Result<String> {
let mut output = String::with_capacity(4096);
let message_id = email.message_id();
// Required headers
output.push_str(&format!(
"From: {}\r\n",
Email::sanitize_string(&email.from)
));
if !email.to.is_empty() {
output.push_str(&format!(
"To: {}\r\n",
email
.to
.iter()
.map(|a| Email::sanitize_string(a))
.collect::<Vec<_>>()
.join(", ")
));
}
if !email.cc.is_empty() {
output.push_str(&format!(
"Cc: {}\r\n",
email
.cc
.iter()
.map(|a| Email::sanitize_string(a))
.collect::<Vec<_>>()
.join(", ")
));
}
output.push_str(&format!(
"Subject: {}\r\n",
Email::sanitize_string(&email.subject)
));
output.push_str(&format!("Message-ID: {}\r\n", message_id));
output.push_str(&format!("Date: {}\r\n", rfc2822_now()));
output.push_str("MIME-Version: 1.0\r\n");
// Priority headers
match email.priority {
crate::email::Priority::High => {
output.push_str("X-Priority: 1\r\n");
output.push_str("Importance: high\r\n");
}
crate::email::Priority::Low => {
output.push_str("X-Priority: 5\r\n");
output.push_str("Importance: low\r\n");
}
crate::email::Priority::Normal => {}
}
// Custom headers
for (name, value) in &email.headers {
output.push_str(&format!(
"{}: {}\r\n",
Email::sanitize_string(name),
Email::sanitize_string(value)
));
}
let has_html = email.html.is_some();
let has_attachments = !email.attachments.is_empty();
match (has_html, has_attachments) {
(false, false) => {
// Plain text only
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(&email.text));
}
(true, false) => {
// multipart/alternative (text + html)
let boundary = generate_boundary();
output.push_str(&format!(
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n",
boundary
));
output.push_str("\r\n");
// Text part
output.push_str(&format!("--{}\r\n", boundary));
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(&email.text));
output.push_str("\r\n");
// HTML part
output.push_str(&format!("--{}\r\n", boundary));
output.push_str("Content-Type: text/html; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(email.html.as_deref().unwrap()));
output.push_str("\r\n");
output.push_str(&format!("--{}--\r\n", boundary));
}
(_, true) => {
// multipart/mixed with optional multipart/alternative inside
let mixed_boundary = generate_boundary();
output.push_str(&format!(
"Content-Type: multipart/mixed; boundary=\"{}\"\r\n",
mixed_boundary
));
output.push_str("\r\n");
if has_html {
// multipart/alternative for text+html
let alt_boundary = generate_boundary();
output.push_str(&format!("--{}\r\n", mixed_boundary));
output.push_str(&format!(
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n",
alt_boundary
));
output.push_str("\r\n");
// Text part
output.push_str(&format!("--{}\r\n", alt_boundary));
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(&email.text));
output.push_str("\r\n");
// HTML part
output.push_str(&format!("--{}\r\n", alt_boundary));
output.push_str("Content-Type: text/html; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(email.html.as_deref().unwrap()));
output.push_str("\r\n");
output.push_str(&format!("--{}--\r\n", alt_boundary));
} else {
// Plain text only
output.push_str(&format!("--{}\r\n", mixed_boundary));
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
output.push_str("\r\n");
output.push_str(&quoted_printable_encode(&email.text));
output.push_str("\r\n");
}
// Attachments
for attachment in &email.attachments {
output.push_str(&format!("--{}\r\n", mixed_boundary));
output.push_str(&format!(
"Content-Type: {}; name=\"{}\"\r\n",
attachment.content_type, attachment.filename
));
output.push_str("Content-Transfer-Encoding: base64\r\n");
if let Some(ref cid) = attachment.content_id {
output.push_str(&format!("Content-ID: <{}>\r\n", cid));
output.push_str("Content-Disposition: inline\r\n");
} else {
output.push_str(&format!(
"Content-Disposition: attachment; filename=\"{}\"\r\n",
attachment.filename
));
}
output.push_str("\r\n");
output.push_str(&base64_encode_wrapped(&attachment.content));
output.push_str("\r\n");
}
output.push_str(&format!("--{}--\r\n", mixed_boundary));
}
}
Ok(output)
}
/// Encode a string as quoted-printable (RFC 2045).
fn quoted_printable_encode(input: &str) -> String {
let mut output = String::with_capacity(input.len() * 2);
let mut line_len = 0;
for byte in input.bytes() {
let encoded = match byte {
// Printable ASCII that doesn't need encoding (except =)
b' '..=b'<' | b'>'..=b'~' => {
line_len += 1;
(byte as char).to_string()
}
b'\t' => {
line_len += 1;
"\t".to_string()
}
b'\r' => continue, // handled with \n
b'\n' => {
line_len = 0;
"\r\n".to_string()
}
_ => {
line_len += 3;
format!("={:02X}", byte)
}
};
// Soft line break at 76 characters
if line_len > 75 && byte != b'\n' {
output.push_str("=\r\n");
line_len = encoded.len();
}
output.push_str(&encoded);
}
output
}
/// Base64-encode binary data with 76-character line wrapping.
fn base64_encode_wrapped(data: &[u8]) -> String {
let encoded = STANDARD.encode(data);
let mut output = String::with_capacity(encoded.len() + encoded.len() / 76 * 2);
for (i, ch) in encoded.chars().enumerate() {
if i > 0 && i % 76 == 0 {
output.push_str("\r\n");
}
output.push(ch);
}
output
}
/// Generate current date in RFC 2822 format (e.g., "Tue, 10 Feb 2026 12:00:00 +0000").
fn rfc2822_now() -> String {
use std::time::SystemTime;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Simple UTC formatting without chrono dependency
let days = now / 86400;
let time_of_day = now % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
// Calculate year/month/day from days since epoch
let (year, month, day) = days_to_ymd(days);
let day_of_week = ((days + 4) % 7) as usize; // Jan 1 1970 = Thursday (4)
let dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
let mon = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
format!(
"{}, {:02} {} {:04} {:02}:{:02}:{:02} +0000",
dow[day_of_week],
day,
mon[(month - 1) as usize],
year,
hours,
minutes,
seconds
)
}
/// Convert days since Unix epoch to (year, month, day).
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
// Algorithm from https://howardhinnant.github.io/date_algorithms.html
let z = days + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::email::Email;
#[test]
fn test_plain_text_email() {
let mut email = Email::new("sender@example.com", "Test Subject", "Hello World");
email.add_to("recipient@example.com");
email.set_message_id("<test@example.com>");
let rfc822 = build_rfc822(&email).unwrap();
assert!(rfc822.contains("From: sender@example.com"));
assert!(rfc822.contains("To: recipient@example.com"));
assert!(rfc822.contains("Subject: Test Subject"));
assert!(rfc822.contains("Content-Type: text/plain; charset=UTF-8"));
assert!(rfc822.contains("Hello World"));
}
#[test]
fn test_html_email() {
let mut email = Email::new("sender@example.com", "HTML Test", "Plain text");
email.add_to("recipient@example.com");
email.set_html("<p>HTML content</p>");
email.set_message_id("<test@example.com>");
let rfc822 = build_rfc822(&email).unwrap();
assert!(rfc822.contains("multipart/alternative"));
assert!(rfc822.contains("text/plain"));
assert!(rfc822.contains("text/html"));
assert!(rfc822.contains("Plain text"));
assert!(rfc822.contains("HTML content"));
}
#[test]
fn test_email_with_attachment() {
let mut email = Email::new("sender@example.com", "Attachment Test", "See attached");
email.add_to("recipient@example.com");
email.set_message_id("<test@example.com>");
email.add_attachment(crate::email::Attachment {
filename: "test.txt".to_string(),
content: b"Hello attachment".to_vec(),
content_type: "text/plain".to_string(),
content_id: None,
});
let rfc822 = build_rfc822(&email).unwrap();
assert!(rfc822.contains("multipart/mixed"));
assert!(rfc822.contains("Content-Disposition: attachment"));
assert!(rfc822.contains("test.txt"));
}
#[test]
fn test_quoted_printable() {
let input = "Hello = World";
let encoded = quoted_printable_encode(input);
assert!(encoded.contains("=3D")); // = is encoded
let input = "Plain ASCII text";
let encoded = quoted_printable_encode(input);
assert_eq!(encoded, "Plain ASCII text");
}
#[test]
fn test_base64_wrapped() {
let data = vec![0u8; 100];
let encoded = base64_encode_wrapped(&data);
for line in encoded.split("\r\n") {
assert!(line.len() <= 76);
}
}
#[test]
fn test_rfc2822_date() {
let date = rfc2822_now();
// Should match pattern like "Tue, 10 Feb 2026 12:00:00 +0000"
assert!(date.contains("+0000"));
assert!(date.len() > 20);
}
}

View File

@@ -0,0 +1,178 @@
use regex::Regex;
use std::sync::LazyLock;
/// Basic email format regex — covers the vast majority of valid email addresses.
/// Does NOT attempt to match the full RFC 5321 grammar (which is impractical via regex).
static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$",
)
.expect("invalid email regex")
});
/// Check whether an email address has valid syntax.
pub fn is_valid_email_format(email: &str) -> bool {
let email = email.trim();
if email.is_empty() || email.len() > 254 {
return false;
}
let parts: Vec<&str> = email.rsplitn(2, '@').collect();
if parts.len() != 2 {
return false;
}
let local = parts[1];
let domain = parts[0];
// Local part max 64 chars
if local.is_empty() || local.len() > 64 {
return false;
}
// Domain must have at least one dot (TLD only not valid for email)
if !domain.contains('.') {
return false;
}
EMAIL_REGEX.is_match(email)
}
/// Email validation result with scoring.
#[derive(Debug, Clone)]
pub struct EmailValidationResult {
pub is_valid: bool,
pub format_valid: bool,
pub score: f64,
pub error_message: Option<String>,
}
/// Validate an email address (synchronous, format-only).
/// DNS-based validation (MX records, disposable domains) would require async and is
/// intended for the N-API bridge layer where the TypeScript side already has DNS access.
pub fn validate_email(email: &str) -> EmailValidationResult {
let format_valid = is_valid_email_format(email);
if !format_valid {
return EmailValidationResult {
is_valid: false,
format_valid: false,
score: 0.0,
error_message: Some(format!("Invalid email format: {}", email)),
};
}
// Role account detection (weight 0.1 penalty)
let local = email.split('@').next().unwrap_or("");
let is_role = is_role_account(local);
// Score: format (0.4) + assumed-mx (0.3) + assumed-not-disposable (0.2) + role (0.1)
let mut score = 0.4 + 0.3 + 0.2; // format + mx + not-disposable
if !is_role {
score += 0.1;
}
EmailValidationResult {
is_valid: score >= 0.7,
format_valid: true,
score,
error_message: None,
}
}
/// Check if a local part is a common role account.
fn is_role_account(local: &str) -> bool {
const ROLE_ACCOUNTS: &[&str] = &[
"abuse",
"admin",
"administrator",
"billing",
"compliance",
"devnull",
"dns",
"ftp",
"hostmaster",
"info",
"inoc",
"ispfeedback",
"ispsupport",
"list",
"list-request",
"maildaemon",
"mailer-daemon",
"mailerdaemon",
"marketing",
"noc",
"no-reply",
"noreply",
"null",
"phish",
"phishing",
"postmaster",
"privacy",
"registrar",
"root",
"sales",
"security",
"spam",
"support",
"sysadmin",
"tech",
"undisclosed-recipients",
"unsubscribe",
"usenet",
"uucp",
"webmaster",
"www",
];
let lower = local.to_lowercase();
ROLE_ACCOUNTS.contains(&lower.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_emails() {
assert!(is_valid_email_format("user@example.com"));
assert!(is_valid_email_format("first.last@example.com"));
assert!(is_valid_email_format("user+tag@example.com"));
assert!(is_valid_email_format("user@sub.domain.example.com"));
}
#[test]
fn test_invalid_emails() {
assert!(!is_valid_email_format(""));
assert!(!is_valid_email_format("@"));
assert!(!is_valid_email_format("user@"));
assert!(!is_valid_email_format("@domain.com"));
assert!(!is_valid_email_format("user@domain")); // no TLD
assert!(!is_valid_email_format("user @domain.com")); // space
assert!(!is_valid_email_format("user@.com")); // leading dot
}
#[test]
fn test_validate_email_scoring() {
let result = validate_email("user@example.com");
assert!(result.is_valid);
assert!(result.score >= 0.9);
let result = validate_email("postmaster@example.com");
assert!(result.is_valid);
assert!(result.score >= 0.7);
assert!(result.score < 1.0); // role account penalty
let result = validate_email("not-an-email");
assert!(!result.is_valid);
assert_eq!(result.score, 0.0);
}
#[test]
fn test_role_accounts() {
assert!(is_role_account("postmaster"));
assert!(is_role_account("abuse"));
assert!(is_role_account("noreply"));
assert!(!is_role_account("john"));
assert!(!is_role_account("alice"));
}
}

View File

@@ -0,0 +1,21 @@
[package]
name = "mailer-napi"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
mailer-core = { path = "../mailer-core" }
mailer-smtp = { path = "../mailer-smtp" }
mailer-security = { path = "../mailer-security" }
napi.workspace = true
napi-derive.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
[build-dependencies]
napi-build = "2"

View File

@@ -0,0 +1,5 @@
extern crate napi_build;
fn main() {
napi_build::setup();
}

View File

@@ -0,0 +1,15 @@
//! mailer-napi: N-API bindings exposing Rust mailer to Node.js/TypeScript.
use napi_derive::napi;
/// Returns the version of the native mailer module.
#[napi]
pub fn get_version() -> String {
format!(
"mailer-napi v{} (core: {}, smtp: {}, security: {})",
env!("CARGO_PKG_VERSION"),
mailer_core::version(),
mailer_smtp::version(),
mailer_security::version(),
)
}

View File

@@ -0,0 +1,20 @@
[package]
name = "mailer-security"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
mailer-core = { path = "../mailer-core" }
mail-auth.workspace = true
ring.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
hickory-resolver.workspace = true
ipnet.workspace = true
rustls-pki-types.workspace = true
psl.workspace = true
regex.workspace = true

View File

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

View File

@@ -0,0 +1,152 @@
use mail_auth::common::crypto::{RsaKey, Sha256};
use mail_auth::common::headers::HeaderWriter;
use mail_auth::dkim::{Canonicalization, DkimSigner};
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
use rustls_pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, pem::PemObject};
use serde::{Deserialize, Serialize};
use crate::error::{Result, SecurityError};
/// Result of DKIM verification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DkimVerificationResult {
/// Whether the DKIM signature is valid.
pub is_valid: bool,
/// The signing domain (d= tag).
pub domain: Option<String>,
/// The selector (s= tag).
pub selector: Option<String>,
/// Result status: "pass", "fail", "permerror", "temperror", "none".
pub status: String,
/// Human-readable details.
pub details: Option<String>,
}
/// Convert raw `mail-auth` DKIM outputs to our serializable results.
///
/// This is used internally by `verify_dkim` and by the compound `verify_email_security`.
pub fn dkim_outputs_to_results(dkim_outputs: &[DkimOutput<'_>]) -> Vec<DkimVerificationResult> {
if dkim_outputs.is_empty() {
return vec![DkimVerificationResult {
is_valid: false,
domain: None,
selector: None,
status: "none".to_string(),
details: Some("No DKIM signatures found".to_string()),
}];
}
dkim_outputs
.iter()
.map(|output| {
let (is_valid, status, details) = match output.result() {
DkimResult::Pass => (true, "pass", None),
DkimResult::Neutral(err) => (false, "neutral", Some(err.to_string())),
DkimResult::Fail(err) => (false, "fail", Some(err.to_string())),
DkimResult::PermError(err) => (false, "permerror", Some(err.to_string())),
DkimResult::TempError(err) => (false, "temperror", Some(err.to_string())),
DkimResult::None => (false, "none", None),
};
let (domain, selector) = output
.signature()
.map(|sig| (Some(sig.d.clone()), Some(sig.s.clone())))
.unwrap_or((None, None));
DkimVerificationResult {
is_valid,
domain,
selector,
status: status.to_string(),
details,
}
})
.collect()
}
/// Verify DKIM signatures on a raw email message.
///
/// Uses the `mail-auth` crate which performs full RFC 6376 verification
/// including DNS lookups for the public key.
pub async fn verify_dkim(
raw_message: &[u8],
authenticator: &MessageAuthenticator,
) -> Result<Vec<DkimVerificationResult>> {
let message = AuthenticatedMessage::parse(raw_message)
.ok_or_else(|| SecurityError::Parse("Failed to parse email for DKIM verification".into()))?;
let dkim_outputs = authenticator.verify_dkim(&message).await;
Ok(dkim_outputs_to_results(&dkim_outputs))
}
/// Sign a raw email message with DKIM (RSA-SHA256).
///
/// * `raw_message` - The raw RFC 5322 message bytes
/// * `domain` - The signing domain (d= tag)
/// * `selector` - The DKIM selector (s= tag)
/// * `private_key_pem` - RSA private key in PEM format (PKCS#1 or PKCS#8)
///
/// Returns the DKIM-Signature header string to prepend to the message.
pub fn sign_dkim(
raw_message: &[u8],
domain: &str,
selector: &str,
private_key_pem: &str,
) -> Result<String> {
// Try PKCS#1 PEM first, then PKCS#8
let key_der = PrivatePkcs1KeyDer::from_pem_slice(private_key_pem.as_bytes())
.map(PrivateKeyDer::Pkcs1)
.or_else(|_| {
// Try PKCS#8
rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes())
.map(PrivateKeyDer::Pkcs8)
})
.map_err(|e| SecurityError::Key(format!("Failed to parse private key PEM: {}", e)))?;
let rsa_key = RsaKey::<Sha256>::from_key_der(key_der)
.map_err(|e| SecurityError::Key(format!("Failed to load RSA key: {}", e)))?;
let signature = DkimSigner::from_key(rsa_key)
.domain(domain)
.selector(selector)
.headers(["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type"])
.header_canonicalization(Canonicalization::Relaxed)
.body_canonicalization(Canonicalization::Relaxed)
.sign(raw_message)
.map_err(|e| SecurityError::Dkim(format!("DKIM signing failed: {}", e)))?;
Ok(signature.to_header())
}
/// Generate a DKIM DNS TXT record value for a given public key.
///
/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`.
pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
// Extract the base64 content from PEM
let key_b64: String = public_key_pem
.lines()
.filter(|line| !line.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dkim_dns_record_value() {
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----";
let record = dkim_dns_record_value(pem);
assert!(record.starts_with("v=DKIM1; h=sha256; k=rsa; p="));
assert!(record.contains("MIIBIjANBg=="));
}
#[test]
fn test_sign_dkim_invalid_key() {
let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key");
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,127 @@
use mail_auth::dmarc::verify::DmarcParameters;
use mail_auth::dmarc::Policy;
use mail_auth::{
AuthenticatedMessage, DkimOutput, DmarcResult as MailAuthDmarcResult, MessageAuthenticator,
SpfOutput,
};
use serde::{Deserialize, Serialize};
use crate::error::{Result, SecurityError};
/// DMARC policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DmarcPolicy {
None,
Quarantine,
Reject,
}
impl From<Policy> for DmarcPolicy {
fn from(p: Policy) -> Self {
match p {
Policy::None | Policy::Unspecified => DmarcPolicy::None,
Policy::Quarantine => DmarcPolicy::Quarantine,
Policy::Reject => DmarcPolicy::Reject,
}
}
}
/// DMARC verification result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DmarcResult {
/// Whether DMARC verification passed overall.
pub passed: bool,
/// The evaluated policy.
pub policy: DmarcPolicy,
/// The domain that was checked.
pub domain: String,
/// DKIM alignment result: "pass", "fail", etc.
pub dkim_result: String,
/// SPF alignment result: "pass", "fail", etc.
pub spf_result: String,
/// Recommended action: "pass", "quarantine", "reject".
pub action: String,
/// Human-readable details.
pub details: Option<String>,
}
/// Check DMARC for an email, given prior DKIM and SPF results.
///
/// * `raw_message` - The raw RFC 5322 message bytes
/// * `dkim_output` - DKIM verification results from `verify_dkim`
/// * `spf_output` - SPF verification output from `check_spf`
/// * `mail_from_domain` - The MAIL FROM domain (RFC 5321)
/// * `authenticator` - The MessageAuthenticator for DNS lookups
pub async fn check_dmarc<'x>(
raw_message: &'x [u8],
dkim_output: &'x [DkimOutput<'x>],
spf_output: &'x SpfOutput,
mail_from_domain: &'x str,
authenticator: &MessageAuthenticator,
) -> Result<DmarcResult> {
let message = AuthenticatedMessage::parse(raw_message)
.ok_or_else(|| SecurityError::Parse("Failed to parse email for DMARC check".into()))?;
let dmarc_output = authenticator
.verify_dmarc(
DmarcParameters::new(&message, dkim_output, mail_from_domain, spf_output)
.with_domain_suffix_fn(|domain| psl::domain_str(domain).unwrap_or(domain)),
)
.await;
let policy = DmarcPolicy::from(dmarc_output.policy());
let domain = dmarc_output.domain().to_string();
let dkim_result_str = dmarc_result_to_string(dmarc_output.dkim_result());
let spf_result_str = dmarc_result_to_string(dmarc_output.spf_result());
let dkim_passed = matches!(dmarc_output.dkim_result(), MailAuthDmarcResult::Pass);
let spf_passed = matches!(dmarc_output.spf_result(), MailAuthDmarcResult::Pass);
let passed = dkim_passed || spf_passed;
let action = if passed {
"pass".to_string()
} else {
match policy {
DmarcPolicy::None => "pass".to_string(), // p=none means monitor only
DmarcPolicy::Quarantine => "quarantine".to_string(),
DmarcPolicy::Reject => "reject".to_string(),
}
};
Ok(DmarcResult {
passed,
policy,
domain,
dkim_result: dkim_result_str,
spf_result: spf_result_str,
action,
details: None,
})
}
fn dmarc_result_to_string(result: &MailAuthDmarcResult) -> String {
match result {
MailAuthDmarcResult::Pass => "pass".to_string(),
MailAuthDmarcResult::Fail(err) => format!("fail: {}", err),
MailAuthDmarcResult::TempError(err) => format!("temperror: {}", err),
MailAuthDmarcResult::PermError(err) => format!("permerror: {}", err),
MailAuthDmarcResult::None => "none".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dmarc_policy_from() {
assert_eq!(DmarcPolicy::from(Policy::None), DmarcPolicy::None);
assert_eq!(
DmarcPolicy::from(Policy::Quarantine),
DmarcPolicy::Quarantine
);
assert_eq!(DmarcPolicy::from(Policy::Reject), DmarcPolicy::Reject);
}
}

View File

@@ -0,0 +1,31 @@
use thiserror::Error;
/// Security-related error types.
#[derive(Debug, Error)]
pub enum SecurityError {
#[error("DKIM error: {0}")]
Dkim(String),
#[error("SPF error: {0}")]
Spf(String),
#[error("DMARC error: {0}")]
Dmarc(String),
#[error("DNS resolution error: {0}")]
Dns(String),
#[error("key error: {0}")]
Key(String),
#[error("IP reputation error: {0}")]
IpReputation(String),
#[error("parse error: {0}")]
Parse(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, SecurityError>;

View File

@@ -0,0 +1,280 @@
use hickory_resolver::TokioResolver;
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr};
use crate::error::Result;
/// Default DNSBL servers to check, same as the TypeScript IPReputationChecker.
pub const DEFAULT_DNSBL_SERVERS: &[&str] = &[
"zen.spamhaus.org",
"bl.spamcop.net",
"b.barracudacentral.org",
"spam.dnsbl.sorbs.net",
"dnsbl.sorbs.net",
"cbl.abuseat.org",
"xbl.spamhaus.org",
"pbl.spamhaus.org",
"dnsbl-1.uceprotect.net",
"psbl.surriel.com",
];
/// Result of a DNSBL check.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsblResult {
/// IP address that was checked.
pub ip: String,
/// Number of DNSBL servers that list this IP.
pub listed_count: usize,
/// Names of DNSBL servers that list this IP.
pub listed_on: Vec<String>,
/// Total number of DNSBL servers checked.
pub total_checked: usize,
}
/// Result of a full IP reputation check.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReputationResult {
/// Reputation score: 0 (worst) to 100 (best).
pub score: u8,
/// Whether the IP is considered spam source.
pub is_spam: bool,
/// IP address that was checked.
pub ip: String,
/// DNSBL results.
pub dnsbl: DnsblResult,
/// Heuristic IP type classification.
pub ip_type: IpType,
}
/// Heuristic IP type classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IpType {
Residential,
Datacenter,
Proxy,
Tor,
Vpn,
Unknown,
}
/// Risk level based on reputation score.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskLevel {
/// Score < 20
High,
/// Score 20-49
Medium,
/// Score 50-79
Low,
/// Score >= 80
Trusted,
}
/// Get the risk level for a reputation score.
pub fn risk_level(score: u8) -> RiskLevel {
match score {
0..=19 => RiskLevel::High,
20..=49 => RiskLevel::Medium,
50..=79 => RiskLevel::Low,
_ => RiskLevel::Trusted,
}
}
/// Check an IP against DNSBL servers.
///
/// * `ip` - The IP address to check (must be IPv4)
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
/// * `resolver` - DNS resolver to use
pub async fn check_dnsbl(
ip: IpAddr,
dnsbl_servers: &[&str],
resolver: &TokioResolver,
) -> Result<DnsblResult> {
let ipv4 = match ip {
IpAddr::V4(v4) => v4,
IpAddr::V6(_) => {
// IPv6 DNSBL is less common; return clean result
return Ok(DnsblResult {
ip: ip.to_string(),
listed_count: 0,
listed_on: Vec::new(),
total_checked: 0,
});
}
};
let reversed = reverse_ipv4(ipv4);
let total = dnsbl_servers.len();
// Query all DNSBL servers in parallel
let mut handles = Vec::with_capacity(total);
for &server in dnsbl_servers {
let query = format!("{}.{}", reversed, server);
let resolver = resolver.clone();
let server_name = server.to_string();
handles.push(tokio::spawn(async move {
match resolver.lookup_ip(&query).await {
Ok(_) => Some(server_name), // IP is listed
Err(_) => None, // IP is not listed (NXDOMAIN)
}
}));
}
let mut listed_on = Vec::new();
for handle in handles {
match handle.await {
Ok(Some(server)) => listed_on.push(server),
_ => {}
}
}
Ok(DnsblResult {
ip: ip.to_string(),
listed_count: listed_on.len(),
listed_on,
total_checked: total,
})
}
/// Full IP reputation check: DNSBL + heuristic classification + scoring.
pub async fn check_reputation(
ip: IpAddr,
dnsbl_servers: &[&str],
resolver: &TokioResolver,
) -> Result<ReputationResult> {
let dnsbl = check_dnsbl(ip, dnsbl_servers, resolver).await?;
let ip_type = classify_ip(ip);
// Scoring: start at 100
let mut score: i16 = 100;
// Subtract 10 per DNSBL listing
score -= (dnsbl.listed_count as i16) * 10;
// Subtract 30 for suspicious IP types
match ip_type {
IpType::Proxy | IpType::Tor | IpType::Vpn => {
score -= 30;
}
_ => {}
}
let score = score.clamp(0, 100) as u8;
let is_spam = score < 50;
Ok(ReputationResult {
score,
is_spam,
ip: ip.to_string(),
dnsbl,
ip_type,
})
}
/// Reverse IPv4 octets for DNSBL queries: "1.2.3.4" -> "4.3.2.1".
fn reverse_ipv4(ip: Ipv4Addr) -> String {
let octets = ip.octets();
format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0])
}
/// Heuristic IP type classification based on well-known prefix ranges.
/// Same heuristics as the TypeScript IPReputationChecker.
fn classify_ip(ip: IpAddr) -> IpType {
let ip_str = ip.to_string();
// Known Tor exit node prefixes
if ip_str.starts_with("171.25.")
|| ip_str.starts_with("185.220.")
|| ip_str.starts_with("95.216.")
{
return IpType::Tor;
}
// Known VPN provider prefixes
if ip_str.starts_with("185.156.") || ip_str.starts_with("37.120.") {
return IpType::Vpn;
}
// Known proxy prefixes
if ip_str.starts_with("34.92.") || ip_str.starts_with("34.206.") {
return IpType::Proxy;
}
// Major cloud provider prefixes (datacenter)
if ip_str.starts_with("13.")
|| ip_str.starts_with("35.")
|| ip_str.starts_with("52.")
|| ip_str.starts_with("34.")
|| ip_str.starts_with("104.")
{
return IpType::Datacenter;
}
IpType::Residential
}
/// Validate an IPv4 address string.
pub fn is_valid_ipv4(ip: &str) -> bool {
ip.parse::<Ipv4Addr>().is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_reverse_ipv4() {
let ip: Ipv4Addr = "1.2.3.4".parse().unwrap();
assert_eq!(reverse_ipv4(ip), "4.3.2.1");
let ip: Ipv4Addr = "192.168.1.100".parse().unwrap();
assert_eq!(reverse_ipv4(ip), "100.1.168.192");
}
#[test]
fn test_classify_ip() {
assert_eq!(
classify_ip("171.25.193.20".parse().unwrap()),
IpType::Tor
);
assert_eq!(
classify_ip("185.156.73.1".parse().unwrap()),
IpType::Vpn
);
assert_eq!(
classify_ip("34.92.1.1".parse().unwrap()),
IpType::Proxy
);
assert_eq!(
classify_ip("52.0.0.1".parse().unwrap()),
IpType::Datacenter
);
assert_eq!(
classify_ip("203.0.113.1".parse().unwrap()),
IpType::Residential
);
}
#[test]
fn test_risk_level() {
assert_eq!(risk_level(10), RiskLevel::High);
assert_eq!(risk_level(30), RiskLevel::Medium);
assert_eq!(risk_level(60), RiskLevel::Low);
assert_eq!(risk_level(90), RiskLevel::Trusted);
}
#[test]
fn test_is_valid_ipv4() {
assert!(is_valid_ipv4("1.2.3.4"));
assert!(is_valid_ipv4("255.255.255.255"));
assert!(!is_valid_ipv4("999.999.999.999"));
assert!(!is_valid_ipv4("not-an-ip"));
}
#[test]
fn test_default_dnsbl_servers() {
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
assert!(DEFAULT_DNSBL_SERVERS.contains(&"zen.spamhaus.org"));
}
}

View File

@@ -0,0 +1,45 @@
//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking.
pub mod content_scanner;
pub mod dkim;
pub mod dmarc;
pub mod error;
pub mod ip_reputation;
pub mod spf;
pub mod verify;
// Re-exports for convenience
pub use dkim::{dkim_dns_record_value, dkim_outputs_to_results, sign_dkim, verify_dkim, DkimVerificationResult};
pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
pub use verify::{verify_email_security, EmailSecurityResult};
pub use error::{Result, SecurityError};
pub use ip_reputation::{
check_dnsbl, check_reputation, risk_level, DnsblResult, IpType, ReputationResult, RiskLevel,
DEFAULT_DNSBL_SERVERS,
};
pub use spf::{check_spf, check_spf_ehlo, received_spf_header, SpfResult};
// Re-export mail-auth's MessageAuthenticator for callers to construct
pub use mail_auth::MessageAuthenticator;
pub use mailer_core;
/// Crate version.
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
/// Create a MessageAuthenticator using Cloudflare DNS over TLS.
pub fn default_authenticator() -> std::result::Result<MessageAuthenticator, Box<dyn std::error::Error>> {
Ok(MessageAuthenticator::new_cloudflare_tls()?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version() {
assert!(!version().is_empty());
}
}

View File

@@ -0,0 +1,167 @@
use mail_auth::spf::verify::SpfParameters;
use mail_auth::{MessageAuthenticator, SpfResult as MailAuthSpfResult};
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use crate::error::Result;
/// SPF verification result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpfResult {
/// The SPF result: "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none".
pub result: String,
/// The domain that was checked.
pub domain: String,
/// The IP address that was checked.
pub ip: String,
/// Optional explanation string from the SPF record.
pub explanation: Option<String>,
}
impl SpfResult {
/// Whether the SPF check passed.
pub fn passed(&self) -> bool {
self.result == "pass"
}
/// Create an `SpfResult` from a raw `mail-auth` `SpfOutput`.
///
/// Used by the compound `verify_email_security` to avoid re-doing the SPF query.
pub fn from_output(output: &mail_auth::SpfOutput, ip: IpAddr) -> Self {
let result_str = match output.result() {
MailAuthSpfResult::Pass => "pass",
MailAuthSpfResult::Fail => "fail",
MailAuthSpfResult::SoftFail => "softfail",
MailAuthSpfResult::Neutral => "neutral",
MailAuthSpfResult::TempError => "temperror",
MailAuthSpfResult::PermError => "permerror",
MailAuthSpfResult::None => "none",
};
SpfResult {
result: result_str.to_string(),
domain: output.domain().to_string(),
ip: ip.to_string(),
explanation: output.explanation().map(|s| s.to_string()),
}
}
}
/// Check SPF for a given sender IP, HELO domain, and MAIL FROM address.
///
/// * `ip` - The connecting client's IP address
/// * `helo_domain` - The domain from the SMTP EHLO/HELO command
/// * `host_domain` - Your receiving server's hostname
/// * `mail_from` - The full MAIL FROM address (e.g., "sender@example.com")
pub async fn check_spf(
ip: IpAddr,
helo_domain: &str,
host_domain: &str,
mail_from: &str,
authenticator: &MessageAuthenticator,
) -> Result<SpfResult> {
let output = authenticator
.verify_spf(SpfParameters::verify_mail_from(
ip,
helo_domain,
host_domain,
mail_from,
))
.await;
let result_str = match output.result() {
MailAuthSpfResult::Pass => "pass",
MailAuthSpfResult::Fail => "fail",
MailAuthSpfResult::SoftFail => "softfail",
MailAuthSpfResult::Neutral => "neutral",
MailAuthSpfResult::TempError => "temperror",
MailAuthSpfResult::PermError => "permerror",
MailAuthSpfResult::None => "none",
};
Ok(SpfResult {
result: result_str.to_string(),
domain: output.domain().to_string(),
ip: ip.to_string(),
explanation: output.explanation().map(|s| s.to_string()),
})
}
/// Check SPF for the EHLO identity (before MAIL FROM).
pub async fn check_spf_ehlo(
ip: IpAddr,
helo_domain: &str,
host_domain: &str,
authenticator: &MessageAuthenticator,
) -> Result<SpfResult> {
let output = authenticator
.verify_spf(SpfParameters::verify_ehlo(ip, helo_domain, host_domain))
.await;
let result_str = match output.result() {
MailAuthSpfResult::Pass => "pass",
MailAuthSpfResult::Fail => "fail",
MailAuthSpfResult::SoftFail => "softfail",
MailAuthSpfResult::Neutral => "neutral",
MailAuthSpfResult::TempError => "temperror",
MailAuthSpfResult::PermError => "permerror",
MailAuthSpfResult::None => "none",
};
Ok(SpfResult {
result: result_str.to_string(),
domain: helo_domain.to_string(),
ip: ip.to_string(),
explanation: output.explanation().map(|s| s.to_string()),
})
}
/// Build a Received-SPF header value.
pub fn received_spf_header(result: &SpfResult) -> String {
format!(
"{} (domain of {} designates {} as permitted sender) receiver={}; client-ip={};",
result.result,
result.domain,
result.ip,
result.domain,
result.ip,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spf_result_passed() {
let result = SpfResult {
result: "pass".to_string(),
domain: "example.com".to_string(),
ip: "1.2.3.4".to_string(),
explanation: None,
};
assert!(result.passed());
let result = SpfResult {
result: "fail".to_string(),
domain: "example.com".to_string(),
ip: "1.2.3.4".to_string(),
explanation: None,
};
assert!(!result.passed());
}
#[test]
fn test_received_spf_header() {
let result = SpfResult {
result: "pass".to_string(),
domain: "example.com".to_string(),
ip: "1.2.3.4".to_string(),
explanation: None,
};
let header = received_spf_header(&result);
assert!(header.contains("pass"));
assert!(header.contains("example.com"));
assert!(header.contains("1.2.3.4"));
}
}

View File

@@ -0,0 +1,115 @@
//! Compound email security verification.
//!
//! Runs DKIM, SPF, and DMARC verification in a single call, avoiding multiple
//! IPC round-trips and handling the internal `mail-auth` types that DMARC needs.
use mail_auth::spf::verify::SpfParameters;
use mail_auth::{AuthenticatedMessage, MessageAuthenticator};
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use crate::dkim::DkimVerificationResult;
use crate::dmarc::{check_dmarc, DmarcResult};
use crate::error::{Result, SecurityError};
use crate::spf::SpfResult;
/// Combined result of DKIM + SPF + DMARC verification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailSecurityResult {
pub dkim: Vec<DkimVerificationResult>,
pub spf: Option<SpfResult>,
pub dmarc: Option<DmarcResult>,
}
/// Run all email security checks (DKIM, SPF, DMARC) in one call.
///
/// This is the preferred entry point for inbound email verification because:
/// 1. DMARC requires raw `mail-auth` DKIM/SPF outputs (not our serialized types).
/// 2. A single call avoids 3 sequential IPC round-trips.
///
/// # Arguments
/// * `raw_message` - The raw RFC 5322 message bytes
/// * `ip` - The connecting client's IP address
/// * `helo_domain` - The domain from the SMTP EHLO/HELO command
/// * `host_domain` - Your receiving server's hostname
/// * `mail_from` - The full MAIL FROM address (e.g. "sender@example.com")
/// * `authenticator` - The `MessageAuthenticator` for DNS lookups
pub async fn verify_email_security(
raw_message: &[u8],
ip: IpAddr,
helo_domain: &str,
host_domain: &str,
mail_from: &str,
authenticator: &MessageAuthenticator,
) -> Result<EmailSecurityResult> {
// Parse the message once for all checks
let message = AuthenticatedMessage::parse(raw_message)
.ok_or_else(|| SecurityError::Parse("Failed to parse email message".into()))?;
// --- DKIM verification ---
let dkim_outputs = authenticator.verify_dkim(&message).await;
let dkim_results = crate::dkim::dkim_outputs_to_results(&dkim_outputs);
// --- SPF verification ---
let spf_output = authenticator
.verify_spf(SpfParameters::verify_mail_from(
ip,
helo_domain,
host_domain,
mail_from,
))
.await;
let spf_result = SpfResult::from_output(&spf_output, ip);
// --- DMARC verification (needs raw dkim_outputs + spf_output) ---
let mail_from_domain = mail_from
.rsplit_once('@')
.map(|(_, d)| d)
.unwrap_or(helo_domain);
let dmarc_result = check_dmarc(
raw_message,
&dkim_outputs,
&spf_output,
mail_from_domain,
authenticator,
)
.await
.ok(); // DMARC failure is non-fatal; we still return DKIM + SPF results
Ok(EmailSecurityResult {
dkim: dkim_results,
spf: Some(spf_result),
dmarc: dmarc_result,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_email_security_result_serialization() {
let result = EmailSecurityResult {
dkim: vec![DkimVerificationResult {
is_valid: false,
domain: None,
selector: None,
status: "none".to_string(),
details: Some("No DKIM signatures".to_string()),
}],
spf: Some(SpfResult {
result: "none".to_string(),
domain: "example.com".to_string(),
ip: "1.2.3.4".to_string(),
explanation: None,
}),
dmarc: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"dkim\""));
assert!(json.contains("\"spf\""));
assert!(json.contains("\"dmarc\":null"));
}
}

View File

@@ -0,0 +1,25 @@
[package]
name = "mailer-smtp"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
mailer-core = { path = "../mailer-core" }
mailer-security = { path = "../mailer-security" }
tokio.workspace = true
tokio-rustls.workspace = true
hickory-resolver.workspace = true
dashmap.workspace = true
thiserror.workspace = true
tracing.workspace = true
bytes.workspace = true
serde.workspace = true
serde_json = "1"
regex = "1"
uuid = { version = "1", features = ["v4"] }
base64.workspace = true
rustls-pki-types.workspace = true
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
rustls-pemfile = "2"
mailparse.workspace = true

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,4 @@
import * as plugins from '../../ts/plugins.ts'; import * as plugins from '../../ts/plugins.js';
import { UnifiedEmailServer } from '../../ts/mail/routing/classes.unified.email.server.ts';
import { createSmtpServer } from '../../ts/mail/delivery/smtpserver/index.ts';
import type { ISmtpServerOptions } from '../../ts/mail/delivery/smtpserver/interfaces.ts';
import type { net } from '../../ts/plugins.ts';
export interface ITestServerConfig { export interface ITestServerConfig {
port: number; port: number;
@@ -27,165 +23,18 @@ export interface ITestServer {
} }
/** /**
* Starts a test SMTP server with the given configuration * Starts a test SMTP server with the given configuration.
*
* NOTE: The TS SMTP server implementation was removed in Phase 7B
* (replaced by the Rust SMTP server). This stub preserves the interface
* for smtpclient tests that import it, but those tests require `node-forge`
* which is not installed (pre-existing issue).
*/ */
export async function startTestServer(config: ITestServerConfig): Promise<ITestServer> { export async function startTestServer(_config: ITestServerConfig): Promise<ITestServer> {
// Find a free port if one wasn't specified throw new Error(
// Using smartnetwork to find an available port in the range 10000-60000 'startTestServer is no longer available — the TS SMTP server was removed in Phase 7B. ' +
let port = config.port; 'Use the Rust SMTP server (via UnifiedEmailServer) for integration testing.'
if (port === undefined || port === 0) { );
const network = new plugins.smartnetwork.Network();
port = await network.findFreePort(10000, 60000, { randomize: true });
if (!port) {
throw new Error('No free ports available in range 10000-60000');
}
}
const serverConfig = {
port: port, // Use the found free port
hostname: config.hostname || 'localhost',
tlsEnabled: config.tlsEnabled || false,
authRequired: config.authRequired || false,
timeout: config.timeout || 30000,
maxConnections: config.maxConnections || 100,
size: config.size || 10 * 1024 * 1024, // 10MB default
maxRecipients: config.maxRecipients || 100
};
// Create a mock email server for testing
const mockEmailServer = {
processEmailByMode: async (emailData: any) => {
console.log(`📧 [Test Server] Processing email:`, emailData.subject || 'No subject');
return emailData;
},
getRateLimiter: () => {
// Return a mock rate limiter for testing
return {
recordConnection: (_ip: string) => ({ allowed: true, remaining: 100 }),
checkConnectionLimit: async (_ip: string) => ({ allowed: true, remaining: 100 }),
checkMessageLimit: (_senderAddress: string, _ip: string, _recipientCount?: number, _pattern?: string, _domain?: string) => ({ allowed: true, remaining: 1000 }),
checkRecipientLimit: async (_session: any) => ({ allowed: true, remaining: 50 }),
recordAuthenticationFailure: async (_ip: string) => {},
recordSyntaxError: async (_ip: string) => {},
recordCommandError: async (_ip: string) => {},
recordError: (_key: string) => false, // Return false to not block during tests
isBlocked: async (_ip: string) => false,
cleanup: async () => {}
};
}
} as any;
// Load test certificates
let key: string;
let cert: string;
if (serverConfig.tlsEnabled) {
try {
const certPath = config.testCertPath || './test/fixtures/test-cert.pem';
const keyPath = config.testKeyPath || './test/fixtures/test-key.pem';
cert = await plugins.fs.promises.readFile(certPath, 'utf8');
key = await plugins.fs.promises.readFile(keyPath, 'utf8');
} catch (error) {
console.warn('⚠️ Failed to load TLS certificates, falling back to self-signed');
// Generate self-signed certificate for testing
const forge = await import('node-forge');
const pki = forge.default.pki;
// Generate key pair
const keys = pki.rsa.generateKeyPair(2048);
// Create certificate
const certificate = pki.createCertificate();
certificate.publicKey = keys.publicKey;
certificate.serialNumber = '01';
certificate.validity.notBefore = new Date();
certificate.validity.notAfter = new Date();
certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1);
const attrs = [{
name: 'commonName',
value: serverConfig.hostname
}];
certificate.setSubject(attrs);
certificate.setIssuer(attrs);
certificate.sign(keys.privateKey);
// Convert to PEM
cert = pki.certificateToPem(certificate);
key = pki.privateKeyToPem(keys.privateKey);
}
} else {
// Always provide a self-signed certificate for non-TLS servers
// This is required by the interface
const forge = await import('node-forge');
const pki = forge.default.pki;
// Generate key pair
const keys = pki.rsa.generateKeyPair(2048);
// Create certificate
const certificate = pki.createCertificate();
certificate.publicKey = keys.publicKey;
certificate.serialNumber = '01';
certificate.validity.notBefore = new Date();
certificate.validity.notAfter = new Date();
certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1);
const attrs = [{
name: 'commonName',
value: serverConfig.hostname
}];
certificate.setSubject(attrs);
certificate.setIssuer(attrs);
certificate.sign(keys.privateKey);
// Convert to PEM
cert = pki.certificateToPem(certificate);
key = pki.privateKeyToPem(keys.privateKey);
}
// SMTP server options
const smtpOptions: ISmtpServerOptions = {
port: serverConfig.port,
hostname: serverConfig.hostname,
key: key,
cert: cert,
maxConnections: serverConfig.maxConnections,
size: serverConfig.size,
maxRecipients: serverConfig.maxRecipients,
socketTimeout: serverConfig.timeout,
connectionTimeout: serverConfig.timeout * 2,
cleanupInterval: 300000,
auth: serverConfig.authRequired ? ({
required: true,
methods: ['PLAIN', 'LOGIN'] as ('PLAIN' | 'LOGIN' | 'OAUTH2')[],
validateUser: async (username: string, password: string) => {
// Test server accepts these credentials
return username === 'testuser' && password === 'testpass';
}
} as any) : undefined
};
// Create SMTP server
const smtpServer = await createSmtpServer(mockEmailServer, smtpOptions);
// Start the server
await smtpServer.listen();
// Wait for server to be ready
await waitForServerReady(serverConfig.hostname, serverConfig.port);
console.log(`✅ Test SMTP server started on ${serverConfig.hostname}:${serverConfig.port}`);
return {
server: mockEmailServer,
smtpServer: smtpServer,
port: serverConfig.port, // Return the port we already know
hostname: serverConfig.hostname,
config: serverConfig,
startTime: Date.now()
};
} }
/** /**
@@ -193,77 +42,29 @@ export async function startTestServer(config: ITestServerConfig): Promise<ITestS
*/ */
export async function stopTestServer(testServer: ITestServer): Promise<void> { export async function stopTestServer(testServer: ITestServer): Promise<void> {
if (!testServer || !testServer.smtpServer) { if (!testServer || !testServer.smtpServer) {
console.warn('⚠️ No test server to stop');
return; return;
} }
try { try {
console.log(`🛑 Stopping test SMTP server on ${testServer.hostname}:${testServer.port}`);
// Stop the SMTP server
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') { if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
await testServer.smtpServer.close(); await testServer.smtpServer.close();
} }
// Wait for port to be free
await waitForPortFree(testServer.port);
console.log(`✅ Test SMTP server stopped`);
} catch (error) { } catch (error) {
console.error('Error stopping test server:', error); console.error('Error stopping test server:', error);
throw error; throw error;
} }
} }
/** /**
* Wait for server to be ready to accept connections * Get an available port for testing
*/ */
async function waitForServerReady(hostname: string, port: number, timeout: number = 10000): Promise<void> { export async function getAvailablePort(startPort: number = 25000): Promise<number> {
const startTime = Date.now(); for (let port = startPort; port < startPort + 1000; port++) {
if (await isPortFree(port)) {
while (Date.now() - startTime < timeout) { return port;
try {
await new Promise<void>((resolve, reject) => {
const socket = plugins.net.createConnection({ port, host: hostname });
socket.on('connect', () => {
socket.end();
resolve();
});
socket.on('error', reject);
setTimeout(() => {
socket.destroy();
reject(new Error('Connection timeout'));
}, 1000);
});
return; // Server is ready
} catch {
// Server not ready yet, wait and retry
await new Promise(resolve => setTimeout(resolve, 100));
} }
} }
throw new Error(`No available ports found starting from ${startPort}`);
throw new Error(`Server did not become ready within ${timeout}ms`);
}
/**
* Wait for port to be free
*/
async function waitForPortFree(port: number, timeout: number = 5000): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const isFree = await isPortFree(port);
if (isFree) {
return;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
console.warn(`⚠️ Port ${port} still in use after ${timeout}ms`);
} }
/** /**
@@ -281,18 +82,6 @@ async function isPortFree(port: number): Promise<boolean> {
}); });
} }
/**
* Get an available port for testing
*/
export async function getAvailablePort(startPort: number = 25000): Promise<number> {
for (let port = startPort; port < startPort + 1000; port++) {
if (await isPortFree(port)) {
return port;
}
}
throw new Error(`No available ports found starting from ${startPort}`);
}
/** /**
* Create test email data * Create test email data
*/ */

View File

@@ -1,6 +1,6 @@
import { smtpClientMod } from '../../ts/mail/delivery/index.ts'; import { smtpClientMod } from '../../ts/mail/delivery/index.js';
import type { ISmtpClientOptions, SmtpClient } from '../../ts/mail/delivery/smtpclient/index.ts'; import type { ISmtpClientOptions, SmtpClient } from '../../ts/mail/delivery/smtpclient/index.js';
import { Email } from '../../ts/mail/core/classes.email.ts'; import { Email } from '../../ts/mail/core/classes.email.js';
/** /**
* Create a test SMTP client * Create a test SMTP client

View File

@@ -1,4 +1,4 @@
import * as plugins from '../../ts/plugins.ts'; import * as plugins from '../../ts/plugins.js';
/** /**
* Test result interface * Test result interface

View File

@@ -1,168 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server for command tests', async () => {
testServer = await startTestServer({
port: 2540,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2540);
});
tap.test('CCMD-01: EHLO/HELO - should send EHLO with custom domain', async () => {
const startTime = Date.now();
try {
// Create SMTP client with custom domain
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
domain: 'mail.example.com', // Custom EHLO domain
connectionTimeout: 5000,
debug: true
});
// Verify connection (which sends EHLO)
const isConnected = await smtpClient.verify();
expect(isConnected).toBeTrue();
const duration = Date.now() - startTime;
console.log(`✅ EHLO command sent with custom domain in ${duration}ms`);
} catch (error) {
const duration = Date.now() - startTime;
console.error(`❌ EHLO command failed after ${duration}ms:`, error);
throw error;
}
});
tap.test('CCMD-01: EHLO/HELO - should use default domain when not specified', async () => {
const defaultClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
// No domain specified - should use default
connectionTimeout: 5000,
debug: true
});
const isConnected = await defaultClient.verify();
expect(isConnected).toBeTrue();
await defaultClient.close();
console.log('✅ EHLO sent with default domain');
});
tap.test('CCMD-01: EHLO/HELO - should handle international domains', async () => {
const intlClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
domain: 'mail.例え.jp', // International domain
connectionTimeout: 5000,
debug: true
});
const isConnected = await intlClient.verify();
expect(isConnected).toBeTrue();
await intlClient.close();
console.log('✅ EHLO sent with international domain');
});
tap.test('CCMD-01: EHLO/HELO - should fall back to HELO if needed', async () => {
// Most modern servers support EHLO, but client should handle HELO fallback
const heloClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
domain: 'legacy.example.com',
connectionTimeout: 5000,
debug: true
});
// The client should handle EHLO/HELO automatically
const isConnected = await heloClient.verify();
expect(isConnected).toBeTrue();
await heloClient.close();
console.log('✅ EHLO/HELO fallback mechanism working');
});
tap.test('CCMD-01: EHLO/HELO - should parse server capabilities', async () => {
const capClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
pool: true, // Enable pooling to maintain connections
debug: true
});
// verify() creates a temporary connection and closes it
const verifyResult = await capClient.verify();
expect(verifyResult).toBeTrue();
// After verify(), the pool might be empty since verify() closes its connection
// Instead, let's send an actual email to test capabilities
const poolStatus = capClient.getPoolStatus();
// Pool starts empty
expect(poolStatus.total).toEqual(0);
await capClient.close();
console.log('✅ Server capabilities parsed from EHLO response');
});
tap.test('CCMD-01: EHLO/HELO - should handle very long domain names', async () => {
const longDomain = 'very-long-subdomain.with-many-parts.and-labels.example.com';
const longDomainClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
domain: longDomain,
connectionTimeout: 5000
});
const isConnected = await longDomainClient.verify();
expect(isConnected).toBeTrue();
await longDomainClient.close();
console.log('✅ Long domain name handled correctly');
});
tap.test('CCMD-01: EHLO/HELO - should reconnect with EHLO after disconnect', async () => {
// First connection - verify() creates and closes its own connection
const firstVerify = await smtpClient.verify();
expect(firstVerify).toBeTrue();
// After verify(), no connections should be in the pool
expect(smtpClient.isConnected()).toBeFalse();
// Second verify - should send EHLO again
const secondVerify = await smtpClient.verify();
expect(secondVerify).toBeTrue();
console.log('✅ EHLO sent correctly on reconnection');
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient && smtpClient.isConnected()) {
await smtpClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,277 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server for MAIL FROM tests', async () => {
testServer = await startTestServer({
port: 2541,
tlsEnabled: false,
authRequired: false,
size: 10 * 1024 * 1024 // 10MB size limit
});
expect(testServer.port).toEqual(2541);
});
tap.test('setup - create SMTP client', async () => {
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
const isConnected = await smtpClient.verify();
expect(isConnected).toBeTrue();
});
tap.test('CCMD-02: MAIL FROM - should send basic MAIL FROM command', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Basic MAIL FROM Test',
text: 'Testing basic MAIL FROM command'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.envelope?.from).toEqual('sender@example.com');
console.log('✅ Basic MAIL FROM command sent successfully');
});
tap.test('CCMD-02: MAIL FROM - should handle display names correctly', async () => {
const email = new Email({
from: 'John Doe <john.doe@example.com>',
to: 'Jane Smith <jane.smith@example.com>',
subject: 'Display Name Test',
text: 'Testing MAIL FROM with display names'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
// Envelope should contain only email address, not display name
expect(result.envelope?.from).toEqual('john.doe@example.com');
console.log('✅ Display names handled correctly in MAIL FROM');
});
tap.test('CCMD-02: MAIL FROM - should handle SIZE parameter if server supports it', async () => {
// Send a larger email to test SIZE parameter
const largeContent = 'x'.repeat(1000000); // 1MB of content
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'SIZE Parameter Test',
text: largeContent
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ SIZE parameter handled for large email');
});
tap.test('CCMD-02: MAIL FROM - should handle international email addresses', async () => {
const email = new Email({
from: 'user@例え.jp',
to: 'recipient@example.com',
subject: 'International Domain Test',
text: 'Testing international domains in MAIL FROM'
});
try {
const result = await smtpClient.sendMail(email);
if (result.success) {
console.log('✅ International domain accepted');
expect(result.envelope?.from).toContain('@');
}
} catch (error) {
// Some servers may not support international domains
console.log(' Server does not support international domains');
}
});
tap.test('CCMD-02: MAIL FROM - should handle empty return path (bounce address)', async () => {
const email = new Email({
from: '<>', // Empty return path for bounces
to: 'recipient@example.com',
subject: 'Bounce Message Test',
text: 'This is a bounce message with empty return path'
});
try {
const result = await smtpClient.sendMail(email);
if (result.success) {
console.log('✅ Empty return path accepted for bounce');
expect(result.envelope?.from).toEqual('');
}
} catch (error) {
console.log(' Server rejected empty return path');
}
});
tap.test('CCMD-02: MAIL FROM - should handle special characters in local part', async () => {
const specialEmails = [
'user+tag@example.com',
'first.last@example.com',
'user_name@example.com',
'user-name@example.com'
];
for (const fromEmail of specialEmails) {
const email = new Email({
from: fromEmail,
to: 'recipient@example.com',
subject: 'Special Character Test',
text: `Testing special characters in: ${fromEmail}`
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.envelope?.from).toEqual(fromEmail);
console.log(`✅ Special character email accepted: ${fromEmail}`);
}
});
tap.test('CCMD-02: MAIL FROM - should reject invalid sender addresses', async () => {
const invalidSenders = [
'no-at-sign',
'@example.com',
'user@',
'user@@example.com',
'user@.com',
'user@example.',
'user with spaces@example.com'
];
let rejectedCount = 0;
for (const invalidSender of invalidSenders) {
try {
const email = new Email({
from: invalidSender,
to: 'recipient@example.com',
subject: 'Invalid Sender Test',
text: 'This should fail'
});
await smtpClient.sendMail(email);
} catch (error) {
rejectedCount++;
console.log(`✅ Invalid sender rejected: ${invalidSender}`);
}
}
expect(rejectedCount).toBeGreaterThan(0);
});
tap.test('CCMD-02: MAIL FROM - should handle 8BITMIME parameter', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'UTF-8 Test with special characters',
text: 'This email contains UTF-8 characters: 你好世界 🌍',
html: '<p>UTF-8 content: <strong>你好世界</strong> 🌍</p>'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ 8BITMIME content handled correctly');
});
tap.test('CCMD-02: MAIL FROM - should handle AUTH parameter if authenticated', async () => {
// Create authenticated client - auth requires TLS per RFC 8314
const authServer = await startTestServer({
port: 2542,
tlsEnabled: true,
authRequired: true
});
const authClient = createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false, // Use STARTTLS instead of direct TLS
requireTLS: true, // Require TLS upgrade
tls: {
rejectUnauthorized: false // Accept self-signed cert for testing
},
auth: {
user: 'testuser',
pass: 'testpass'
},
connectionTimeout: 5000,
debug: true
});
try {
const email = new Email({
from: 'authenticated@example.com',
to: 'recipient@example.com',
subject: 'AUTH Parameter Test',
text: 'Sent with authentication'
});
const result = await authClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ AUTH parameter handled in MAIL FROM');
} catch (error) {
console.error('AUTH test error:', error);
throw error;
} finally {
await authClient.close();
await stopTestServer(authServer);
}
});
tap.test('CCMD-02: MAIL FROM - should handle very long email addresses', async () => {
// RFC allows up to 320 characters total (64 + @ + 255)
const longLocal = 'a'.repeat(64);
const longDomain = 'subdomain.' + 'a'.repeat(60) + '.example.com';
const longEmail = `${longLocal}@${longDomain}`;
const email = new Email({
from: longEmail,
to: 'recipient@example.com',
subject: 'Long Email Address Test',
text: 'Testing maximum length email addresses'
});
try {
const result = await smtpClient.sendMail(email);
if (result.success) {
console.log('✅ Long email address accepted');
expect(result.envelope?.from).toEqual(longEmail);
}
} catch (error) {
console.log(' Server enforces email length limits');
}
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient && smtpClient.isConnected()) {
await smtpClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,283 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server for RCPT TO tests', async () => {
testServer = await startTestServer({
port: 2543,
tlsEnabled: false,
authRequired: false,
maxRecipients: 10 // Set recipient limit
});
expect(testServer.port).toEqual(2543);
});
tap.test('setup - create SMTP client', async () => {
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
const isConnected = await smtpClient.verify();
expect(isConnected).toBeTrue();
});
tap.test('CCMD-03: RCPT TO - should send to single recipient', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'single@example.com',
subject: 'Single Recipient Test',
text: 'Testing single RCPT TO command'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.acceptedRecipients).toContain('single@example.com');
expect(result.acceptedRecipients.length).toEqual(1);
expect(result.envelope?.to).toContain('single@example.com');
console.log('✅ Single RCPT TO command successful');
});
tap.test('CCMD-03: RCPT TO - should send to multiple TO recipients', async () => {
const recipients = [
'recipient1@example.com',
'recipient2@example.com',
'recipient3@example.com'
];
const email = new Email({
from: 'sender@example.com',
to: recipients,
subject: 'Multiple Recipients Test',
text: 'Testing multiple RCPT TO commands'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.acceptedRecipients.length).toEqual(3);
recipients.forEach(recipient => {
expect(result.acceptedRecipients).toContain(recipient);
});
console.log(`✅ Sent to ${result.acceptedRecipients.length} recipients`);
});
tap.test('CCMD-03: RCPT TO - should handle CC recipients', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'primary@example.com',
cc: ['cc1@example.com', 'cc2@example.com'],
subject: 'CC Recipients Test',
text: 'Testing RCPT TO with CC recipients'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.acceptedRecipients.length).toEqual(3);
expect(result.acceptedRecipients).toContain('primary@example.com');
expect(result.acceptedRecipients).toContain('cc1@example.com');
expect(result.acceptedRecipients).toContain('cc2@example.com');
console.log('✅ CC recipients handled correctly');
});
tap.test('CCMD-03: RCPT TO - should handle BCC recipients', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'visible@example.com',
bcc: ['hidden1@example.com', 'hidden2@example.com'],
subject: 'BCC Recipients Test',
text: 'Testing RCPT TO with BCC recipients'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.acceptedRecipients.length).toEqual(3);
expect(result.acceptedRecipients).toContain('visible@example.com');
expect(result.acceptedRecipients).toContain('hidden1@example.com');
expect(result.acceptedRecipients).toContain('hidden2@example.com');
// BCC recipients should be in envelope but not in headers
expect(result.envelope?.to.length).toEqual(3);
console.log('✅ BCC recipients handled correctly');
});
tap.test('CCMD-03: RCPT TO - should handle mixed TO, CC, and BCC', async () => {
const email = new Email({
from: 'sender@example.com',
to: ['to1@example.com', 'to2@example.com'],
cc: ['cc1@example.com', 'cc2@example.com'],
bcc: ['bcc1@example.com', 'bcc2@example.com'],
subject: 'Mixed Recipients Test',
text: 'Testing all recipient types together'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.acceptedRecipients.length).toEqual(6);
console.log('✅ Mixed recipient types handled correctly');
console.log(` TO: 2, CC: 2, BCC: 2 = Total: ${result.acceptedRecipients.length}`);
});
tap.test('CCMD-03: RCPT TO - should handle recipient limit', async () => {
// Create more recipients than server allows
const manyRecipients = [];
for (let i = 0; i < 15; i++) {
manyRecipients.push(`recipient${i}@example.com`);
}
const email = new Email({
from: 'sender@example.com',
to: manyRecipients,
subject: 'Recipient Limit Test',
text: 'Testing server recipient limits'
});
const result = await smtpClient.sendMail(email);
// Server should accept up to its limit
if (result.rejectedRecipients.length > 0) {
console.log(`✅ Server enforced recipient limit:`);
console.log(` Accepted: ${result.acceptedRecipients.length}`);
console.log(` Rejected: ${result.rejectedRecipients.length}`);
expect(result.acceptedRecipients.length).toBeLessThanOrEqual(10);
} else {
// Server accepted all
expect(result.acceptedRecipients.length).toEqual(15);
console.log(' Server accepted all recipients');
}
});
tap.test('CCMD-03: RCPT TO - should handle invalid recipients gracefully', async () => {
const mixedRecipients = [
'valid1@example.com',
'invalid@address@with@multiple@ats.com',
'valid2@example.com',
'no-domain@',
'valid3@example.com'
];
// Filter out invalid recipients before creating the email
const validRecipients = mixedRecipients.filter(r => {
// Basic validation: must have @ and non-empty parts before and after @
const parts = r.split('@');
return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0;
});
const email = new Email({
from: 'sender@example.com',
to: validRecipients,
subject: 'Mixed Valid/Invalid Recipients',
text: 'Testing partial recipient acceptance'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.acceptedRecipients).toContain('valid1@example.com');
expect(result.acceptedRecipients).toContain('valid2@example.com');
expect(result.acceptedRecipients).toContain('valid3@example.com');
console.log('✅ Valid recipients accepted, invalid filtered');
});
tap.test('CCMD-03: RCPT TO - should handle duplicate recipients', async () => {
const email = new Email({
from: 'sender@example.com',
to: ['user@example.com', 'user@example.com'],
cc: ['user@example.com'],
bcc: ['user@example.com'],
subject: 'Duplicate Recipients Test',
text: 'Testing duplicate recipient handling'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
// Check if duplicates were removed
const uniqueAccepted = [...new Set(result.acceptedRecipients)];
console.log(`✅ Duplicate handling: ${result.acceptedRecipients.length} total, ${uniqueAccepted.length} unique`);
});
tap.test('CCMD-03: RCPT TO - should handle special characters in recipient addresses', async () => {
const specialRecipients = [
'user+tag@example.com',
'first.last@example.com',
'user_name@example.com',
'user-name@example.com',
'"quoted.user"@example.com'
];
const email = new Email({
from: 'sender@example.com',
to: specialRecipients.filter(r => !r.includes('"')), // Skip quoted for Email class
subject: 'Special Characters Test',
text: 'Testing special characters in recipient addresses'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.acceptedRecipients.length).toBeGreaterThan(0);
console.log(`✅ Special character recipients accepted: ${result.acceptedRecipients.length}`);
});
tap.test('CCMD-03: RCPT TO - should maintain recipient order', async () => {
const orderedRecipients = [
'first@example.com',
'second@example.com',
'third@example.com',
'fourth@example.com'
];
const email = new Email({
from: 'sender@example.com',
to: orderedRecipients,
subject: 'Recipient Order Test',
text: 'Testing if recipient order is maintained'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.envelope?.to.length).toEqual(orderedRecipients.length);
// Check order preservation
orderedRecipients.forEach((recipient, index) => {
expect(result.envelope?.to[index]).toEqual(recipient);
});
console.log('✅ Recipient order maintained in envelope');
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient && smtpClient.isConnected()) {
await smtpClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,274 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server for DATA command tests', async () => {
testServer = await startTestServer({
port: 2544,
tlsEnabled: false,
authRequired: false,
size: 10 * 1024 * 1024 // 10MB message size limit
});
expect(testServer.port).toEqual(2544);
});
tap.test('setup - create SMTP client', async () => {
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
socketTimeout: 30000, // Longer timeout for data transmission
debug: true
});
const isConnected = await smtpClient.verify();
expect(isConnected).toBeTrue();
});
tap.test('CCMD-04: DATA - should transmit simple text email', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Simple DATA Test',
text: 'This is a simple text email transmitted via DATA command.'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.response).toBeTypeofString();
console.log('✅ Simple text email transmitted successfully');
console.log('📧 Server response:', result.response);
});
tap.test('CCMD-04: DATA - should handle dot stuffing', async () => {
// Lines starting with dots should be escaped
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Dot Stuffing Test',
text: 'This email tests dot stuffing:\n.This line starts with a dot\n..So does this one\n...And this one'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Dot stuffing handled correctly');
});
tap.test('CCMD-04: DATA - should transmit HTML email', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'HTML Email Test',
text: 'This is the plain text version',
html: `
<html>
<head>
<title>HTML Email Test</title>
</head>
<body>
<h1>HTML Email</h1>
<p>This is an <strong>HTML</strong> email with:</p>
<ul>
<li>Lists</li>
<li>Formatting</li>
<li>Links: <a href="https://example.com">Example</a></li>
</ul>
</body>
</html>
`
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ HTML email transmitted successfully');
});
tap.test('CCMD-04: DATA - should handle large message body', async () => {
// Create a large message (1MB)
const largeText = 'This is a test line that will be repeated many times.\n'.repeat(20000);
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Large Message Test',
text: largeText
});
const startTime = Date.now();
const result = await smtpClient.sendMail(email);
const duration = Date.now() - startTime;
expect(result.success).toBeTrue();
console.log(`✅ Large message (${Math.round(largeText.length / 1024)}KB) transmitted in ${duration}ms`);
});
tap.test('CCMD-04: DATA - should handle binary attachments', async () => {
// Create a binary attachment
const binaryData = Buffer.alloc(1024);
for (let i = 0; i < binaryData.length; i++) {
binaryData[i] = i % 256;
}
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Binary Attachment Test',
text: 'This email contains a binary attachment',
attachments: [{
filename: 'test.bin',
content: binaryData,
contentType: 'application/octet-stream'
}]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Binary attachment transmitted successfully');
});
tap.test('CCMD-04: DATA - should handle special characters and encoding', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Special Characters Test "Quotes" & More',
text: 'Special characters: © ® ™ € £ ¥ • … « » " " \' \'',
html: '<p>Unicode: 你好世界 🌍 🚀 ✉️</p>'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Special characters and Unicode handled correctly');
});
tap.test('CCMD-04: DATA - should handle line length limits', async () => {
// RFC 5321 specifies 1000 character line limit (including CRLF)
const longLine = 'a'.repeat(990); // Leave room for CRLF and safety
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Long Line Test',
text: `Short line\n${longLine}\nAnother short line`
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Long lines handled within RFC limits');
});
tap.test('CCMD-04: DATA - should handle empty message body', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Empty Body Test',
text: '' // Empty body
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Empty message body handled correctly');
});
tap.test('CCMD-04: DATA - should handle CRLF line endings', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'CRLF Test',
text: 'Line 1\r\nLine 2\r\nLine 3\nLine 4 (LF only)\r\nLine 5'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Mixed line endings normalized to CRLF');
});
tap.test('CCMD-04: DATA - should handle message headers correctly', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
cc: 'cc@example.com',
subject: 'Header Test',
text: 'Testing header transmission',
priority: 'high',
headers: {
'X-Custom-Header': 'custom-value',
'X-Mailer': 'SMTP Client Test Suite',
'Reply-To': 'replies@example.com'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ All headers transmitted in DATA command');
});
tap.test('CCMD-04: DATA - should handle timeout for slow transmission', async () => {
// Create a very large message to test timeout handling
const hugeText = 'x'.repeat(5 * 1024 * 1024); // 5MB
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Timeout Test',
text: hugeText
});
// Should complete within socket timeout
const startTime = Date.now();
const result = await smtpClient.sendMail(email);
const duration = Date.now() - startTime;
expect(result.success).toBeTrue();
expect(duration).toBeLessThan(30000); // Should complete within socket timeout
console.log(`✅ Large data transmission completed in ${duration}ms`);
});
tap.test('CCMD-04: DATA - should handle server rejection after DATA', async () => {
// Some servers might reject after seeing content
const email = new Email({
from: 'spam@spammer.com',
to: 'recipient@example.com',
subject: 'Potential Spam Test',
text: 'BUY NOW! SPECIAL OFFER! CLICK HERE!',
mightBeSpam: true // Flag as potential spam
});
const result = await smtpClient.sendMail(email);
// Test server might accept or reject
if (result.success) {
console.log(' Test server accepted potential spam (normal for test)');
} else {
console.log('✅ Server can reject messages after DATA inspection');
}
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient && smtpClient.isConnected()) {
await smtpClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,306 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let authServer: ITestServer;
tap.test('setup - start SMTP server with authentication', async () => {
authServer = await startTestServer({
port: 2580,
tlsEnabled: true, // Enable STARTTLS capability
authRequired: true
});
expect(authServer.port).toEqual(2580);
expect(authServer.config.authRequired).toBeTrue();
});
tap.test('CCMD-05: AUTH - should fail without credentials', async () => {
const noAuthClient = createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false, // Start plain, upgrade with STARTTLS
tls: {
rejectUnauthorized: false // Accept self-signed certs for testing
},
connectionTimeout: 5000
// No auth provided
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'No Auth Test',
text: 'Should fail without authentication'
});
const result = await noAuthClient.sendMail(email);
expect(result.success).toBeFalse();
expect(result.error).toBeInstanceOf(Error);
expect(result.error?.message).toContain('Authentication required');
console.log('✅ Authentication required error:', result.error?.message);
await noAuthClient.close();
});
tap.test('CCMD-05: AUTH - should authenticate with PLAIN mechanism', async () => {
const plainAuthClient = createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false, // Start plain, upgrade with STARTTLS
tls: {
rejectUnauthorized: false // Accept self-signed certs for testing
},
connectionTimeout: 5000,
auth: {
user: 'testuser',
pass: 'testpass',
method: 'PLAIN'
},
debug: true
});
const isConnected = await plainAuthClient.verify();
expect(isConnected).toBeTrue();
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'PLAIN Auth Test',
text: 'Sent with PLAIN authentication'
});
const result = await plainAuthClient.sendMail(email);
expect(result.success).toBeTrue();
await plainAuthClient.close();
console.log('✅ PLAIN authentication successful');
});
tap.test('CCMD-05: AUTH - should authenticate with LOGIN mechanism', async () => {
const loginAuthClient = createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false, // Start plain, upgrade with STARTTLS
tls: {
rejectUnauthorized: false // Accept self-signed certs for testing
},
connectionTimeout: 5000,
auth: {
user: 'testuser',
pass: 'testpass',
method: 'LOGIN'
},
debug: true
});
const isConnected = await loginAuthClient.verify();
expect(isConnected).toBeTrue();
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'LOGIN Auth Test',
text: 'Sent with LOGIN authentication'
});
const result = await loginAuthClient.sendMail(email);
expect(result.success).toBeTrue();
await loginAuthClient.close();
console.log('✅ LOGIN authentication successful');
});
tap.test('CCMD-05: AUTH - should auto-select authentication method', async () => {
const autoAuthClient = createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false, // Start plain, upgrade with STARTTLS
tls: {
rejectUnauthorized: false // Accept self-signed certs for testing
},
connectionTimeout: 5000,
auth: {
user: 'testuser',
pass: 'testpass'
// No method specified - should auto-select
}
});
const isConnected = await autoAuthClient.verify();
expect(isConnected).toBeTrue();
await autoAuthClient.close();
console.log('✅ Auto-selected authentication method');
});
tap.test('CCMD-05: AUTH - should handle invalid credentials', async () => {
const badAuthClient = createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false, // Start plain, upgrade with STARTTLS
tls: {
rejectUnauthorized: false // Accept self-signed certs for testing
},
connectionTimeout: 5000,
auth: {
user: 'wronguser',
pass: 'wrongpass'
}
});
const isConnected = await badAuthClient.verify();
expect(isConnected).toBeFalse();
console.log('✅ Invalid credentials rejected');
await badAuthClient.close();
});
tap.test('CCMD-05: AUTH - should handle special characters in credentials', async () => {
const specialAuthClient = createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false, // Start plain, upgrade with STARTTLS
tls: {
rejectUnauthorized: false // Accept self-signed certs for testing
},
connectionTimeout: 5000,
auth: {
user: 'user@domain.com',
pass: 'p@ssw0rd!#$%'
}
});
// Server might accept or reject based on implementation
try {
await specialAuthClient.verify();
await specialAuthClient.close();
console.log('✅ Special characters in credentials handled');
} catch (error) {
console.log(' Test server rejected special character credentials');
}
});
tap.test('CCMD-05: AUTH - should prefer secure auth over TLS', async () => {
// Start TLS-enabled server
const tlsAuthServer = await startTestServer({
port: 2581,
tlsEnabled: true,
authRequired: true
});
const tlsAuthClient = createSmtpClient({
host: tlsAuthServer.hostname,
port: tlsAuthServer.port,
secure: false, // Use STARTTLS
connectionTimeout: 5000,
auth: {
user: 'testuser',
pass: 'testpass'
},
tls: {
rejectUnauthorized: false
}
});
const isConnected = await tlsAuthClient.verify();
expect(isConnected).toBeTrue();
await tlsAuthClient.close();
await stopTestServer(tlsAuthServer);
console.log('✅ Secure authentication over TLS');
});
tap.test('CCMD-05: AUTH - should maintain auth state across multiple sends', async () => {
const persistentAuthClient = createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false, // Start plain, upgrade with STARTTLS
tls: {
rejectUnauthorized: false // Accept self-signed certs for testing
},
connectionTimeout: 5000,
auth: {
user: 'testuser',
pass: 'testpass'
}
});
await persistentAuthClient.verify();
// Send multiple emails without re-authenticating
for (let i = 0; i < 3; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Persistent Auth Test ${i + 1}`,
text: `Email ${i + 1} using same auth session`
});
const result = await persistentAuthClient.sendMail(email);
expect(result.success).toBeTrue();
}
await persistentAuthClient.close();
console.log('✅ Authentication state maintained across sends');
});
tap.test('CCMD-05: AUTH - should handle auth with connection pooling', async () => {
const pooledAuthClient = createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false, // Start plain, upgrade with STARTTLS
tls: {
rejectUnauthorized: false // Accept self-signed certs for testing
},
pool: true,
maxConnections: 3,
connectionTimeout: 5000,
auth: {
user: 'testuser',
pass: 'testpass'
}
});
// Send concurrent emails with pooled authenticated connections
const promises = [];
for (let i = 0; i < 5; i++) {
const email = new Email({
from: 'sender@example.com',
to: `recipient${i}@example.com`,
subject: `Pooled Auth Test ${i}`,
text: 'Testing auth with connection pooling'
});
promises.push(pooledAuthClient.sendMail(email));
}
const results = await Promise.all(promises);
// Debug output to understand failures
results.forEach((result, index) => {
if (!result.success) {
console.log(`❌ Email ${index} failed:`, result.error?.message);
}
});
const successCount = results.filter(r => r.success).length;
console.log(`📧 Sent ${successCount} of ${results.length} emails successfully`);
const poolStatus = pooledAuthClient.getPoolStatus();
console.log('📊 Auth pool status:', poolStatus);
// Check that at least one email was sent (connection pooling might limit concurrent sends)
expect(successCount).toBeGreaterThan(0);
await pooledAuthClient.close();
console.log('✅ Authentication works with connection pooling');
});
tap.test('cleanup - stop auth server', async () => {
await stopTestServer(authServer);
});
export default tap.start();

View File

@@ -1,233 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2546,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toBeGreaterThan(0);
});
tap.test('CCMD-06: Check PIPELINING capability', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// The SmtpClient handles pipelining internally
// We can verify the server supports it by checking a successful send
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Pipelining Test',
text: 'Testing pipelining support'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
// Server logs show PIPELINING is advertised
console.log('✅ Server supports PIPELINING (advertised in EHLO response)');
await smtpClient.close();
});
tap.test('CCMD-06: Basic command pipelining', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Send email with multiple recipients to test pipelining
const email = new Email({
from: 'sender@example.com',
to: ['recipient1@example.com', 'recipient2@example.com'],
subject: 'Multi-recipient Test',
text: 'Testing pipelining with multiple recipients'
});
const startTime = Date.now();
const result = await smtpClient.sendMail(email);
const elapsed = Date.now() - startTime;
expect(result.success).toBeTrue();
expect(result.acceptedRecipients.length).toEqual(2);
console.log(`✅ Sent to ${result.acceptedRecipients.length} recipients in ${elapsed}ms`);
console.log('Pipelining improves performance by sending multiple commands without waiting');
await smtpClient.close();
});
tap.test('CCMD-06: Pipelining with DATA command', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Send a normal email - pipelining is handled internally
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'DATA Command Test',
text: 'Testing pipelining up to DATA command'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Commands pipelined up to DATA successfully');
console.log('DATA command requires synchronous handling as per RFC');
await smtpClient.close();
});
tap.test('CCMD-06: Pipelining error handling', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Send email with mix of valid and potentially problematic recipients
const email = new Email({
from: 'sender@example.com',
to: [
'valid1@example.com',
'valid2@example.com',
'valid3@example.com'
],
subject: 'Error Handling Test',
text: 'Testing pipelining error handling'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log(`✅ Handled ${result.acceptedRecipients.length} recipients`);
console.log('Pipelining handles errors gracefully');
await smtpClient.close();
});
tap.test('CCMD-06: Pipelining performance comparison', async () => {
// Create two clients - both use pipelining by default when available
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Test with multiple recipients
const email = new Email({
from: 'sender@example.com',
to: [
'recipient1@example.com',
'recipient2@example.com',
'recipient3@example.com',
'recipient4@example.com',
'recipient5@example.com'
],
subject: 'Performance Test',
text: 'Testing performance with multiple recipients'
});
const startTime = Date.now();
const result = await smtpClient.sendMail(email);
const elapsed = Date.now() - startTime;
expect(result.success).toBeTrue();
expect(result.acceptedRecipients.length).toEqual(5);
console.log(`✅ Sent to ${result.acceptedRecipients.length} recipients in ${elapsed}ms`);
console.log('Pipelining provides significant performance improvements');
await smtpClient.close();
});
tap.test('CCMD-06: Pipelining with multiple recipients', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Send to many recipients
const recipients = Array.from({ length: 10 }, (_, i) => `recipient${i + 1}@example.com`);
const email = new Email({
from: 'sender@example.com',
to: recipients,
subject: 'Many Recipients Test',
text: 'Testing pipelining with many recipients'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.acceptedRecipients.length).toEqual(recipients.length);
console.log(`✅ Successfully sent to ${result.acceptedRecipients.length} recipients`);
console.log('Pipelining efficiently handles multiple RCPT TO commands');
await smtpClient.close();
});
tap.test('CCMD-06: Pipelining limits and buffering', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Test with a reasonable number of recipients
const recipients = Array.from({ length: 50 }, (_, i) => `user${i + 1}@example.com`);
const email = new Email({
from: 'sender@example.com',
to: recipients.slice(0, 20), // Use first 20 for TO
cc: recipients.slice(20, 35), // Next 15 for CC
bcc: recipients.slice(35), // Rest for BCC
subject: 'Buffering Test',
text: 'Testing pipelining limits and buffering'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
const totalRecipients = email.to.length + email.cc.length + email.bcc.length;
console.log(`✅ Handled ${totalRecipients} total recipients`);
console.log('Pipelining respects server limits and buffers appropriately');
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
await stopTestServer(testServer);
expect(testServer).toBeTruthy();
});
export default tap.start();

View File

@@ -1,243 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2547,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toBeGreaterThan(0);
});
tap.test('CCMD-07: Parse successful send responses', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Response Test',
text: 'Testing response parsing'
});
const result = await smtpClient.sendMail(email);
// Verify successful response parsing
expect(result.success).toBeTrue();
expect(result.response).toBeTruthy();
expect(result.messageId).toBeTruthy();
// The response should contain queue ID
expect(result.response).toInclude('queued');
console.log(`✅ Parsed success response: ${result.response}`);
await smtpClient.close();
});
tap.test('CCMD-07: Parse multiple recipient responses', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Send to multiple recipients
const email = new Email({
from: 'sender@example.com',
to: ['recipient1@example.com', 'recipient2@example.com', 'recipient3@example.com'],
subject: 'Multi-recipient Test',
text: 'Testing multiple recipient response parsing'
});
const result = await smtpClient.sendMail(email);
// Verify parsing of multiple recipient responses
expect(result.success).toBeTrue();
expect(result.acceptedRecipients.length).toEqual(3);
expect(result.rejectedRecipients.length).toEqual(0);
console.log(`✅ Accepted ${result.acceptedRecipients.length} recipients`);
console.log('Multiple RCPT TO responses parsed correctly');
await smtpClient.close();
});
tap.test('CCMD-07: Parse error response codes', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Test with invalid email to trigger error
try {
const email = new Email({
from: '', // Empty from should trigger error
to: 'recipient@example.com',
subject: 'Error Test',
text: 'Testing error response'
});
await smtpClient.sendMail(email);
expect(false).toBeTrue(); // Should not reach here
} catch (error: any) {
expect(error).toBeInstanceOf(Error);
expect(error.message).toBeTruthy();
console.log(`✅ Error response parsed: ${error.message}`);
}
await smtpClient.close();
});
tap.test('CCMD-07: Parse enhanced status codes', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Normal send - server advertises ENHANCEDSTATUSCODES
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Enhanced Status Test',
text: 'Testing enhanced status code parsing'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
// Server logs show it advertises ENHANCEDSTATUSCODES in EHLO
console.log('✅ Server advertises ENHANCEDSTATUSCODES capability');
console.log('Enhanced status codes are parsed automatically');
await smtpClient.close();
});
tap.test('CCMD-07: Parse response timing and delays', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Measure response time
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Timing Test',
text: 'Testing response timing'
});
const startTime = Date.now();
const result = await smtpClient.sendMail(email);
const elapsed = Date.now() - startTime;
expect(result.success).toBeTrue();
expect(elapsed).toBeGreaterThan(0);
expect(elapsed).toBeLessThan(5000); // Should complete within 5 seconds
console.log(`✅ Response received and parsed in ${elapsed}ms`);
console.log('Client handles response timing appropriately');
await smtpClient.close();
});
tap.test('CCMD-07: Parse envelope information', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
const from = 'sender@example.com';
const to = ['recipient1@example.com', 'recipient2@example.com'];
const cc = ['cc@example.com'];
const bcc = ['bcc@example.com'];
const email = new Email({
from,
to,
cc,
bcc,
subject: 'Envelope Test',
text: 'Testing envelope parsing'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.envelope).toBeTruthy();
expect(result.envelope.from).toEqual(from);
expect(result.envelope.to).toBeArray();
// Envelope should include all recipients (to, cc, bcc)
const totalRecipients = to.length + cc.length + bcc.length;
expect(result.envelope.to.length).toEqual(totalRecipients);
console.log(`✅ Envelope parsed with ${result.envelope.to.length} recipients`);
console.log('Envelope information correctly extracted from responses');
await smtpClient.close();
});
tap.test('CCMD-07: Parse connection state responses', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Test verify() which checks connection state
const isConnected = await smtpClient.verify();
expect(isConnected).toBeTrue();
console.log('✅ Connection verified through greeting and EHLO responses');
// Send email to test active connection
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'State Test',
text: 'Testing connection state'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Connection state maintained throughout session');
console.log('Response parsing handles connection state correctly');
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
await stopTestServer(testServer);
expect(testServer).toBeTruthy();
});
export default tap.start();

View File

@@ -1,333 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2548,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toBeGreaterThan(0);
});
tap.test('CCMD-08: Client handles transaction reset internally', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Send first email
const email1 = new Email({
from: 'sender1@example.com',
to: 'recipient1@example.com',
subject: 'First Email',
text: 'This is the first email'
});
const result1 = await smtpClient.sendMail(email1);
expect(result1.success).toBeTrue();
// Send second email - client handles RSET internally if needed
const email2 = new Email({
from: 'sender2@example.com',
to: 'recipient2@example.com',
subject: 'Second Email',
text: 'This is the second email'
});
const result2 = await smtpClient.sendMail(email2);
expect(result2.success).toBeTrue();
console.log('✅ Client handles transaction reset between emails');
console.log('RSET is used internally to ensure clean state');
await smtpClient.close();
});
tap.test('CCMD-08: Clean state after failed recipient', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Send email with multiple recipients - if one fails, RSET ensures clean state
const email = new Email({
from: 'sender@example.com',
to: [
'valid1@example.com',
'valid2@example.com',
'valid3@example.com'
],
subject: 'Multi-recipient Email',
text: 'Testing state management'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
// All recipients should be accepted
expect(result.acceptedRecipients.length).toEqual(3);
console.log('✅ State remains clean with multiple recipients');
console.log('Internal RSET ensures proper transaction handling');
await smtpClient.close();
});
tap.test('CCMD-08: Multiple emails in sequence', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Send multiple emails in sequence
const emails = [
{
from: 'sender1@example.com',
to: 'recipient1@example.com',
subject: 'Email 1',
text: 'First email'
},
{
from: 'sender2@example.com',
to: 'recipient2@example.com',
subject: 'Email 2',
text: 'Second email'
},
{
from: 'sender3@example.com',
to: 'recipient3@example.com',
subject: 'Email 3',
text: 'Third email'
}
];
for (const emailData of emails) {
const email = new Email(emailData);
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
}
console.log('✅ Successfully sent multiple emails in sequence');
console.log('RSET ensures clean state between each transaction');
await smtpClient.close();
});
tap.test('CCMD-08: Connection pooling with clean state', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
pool: true,
maxConnections: 2,
connectionTimeout: 5000,
debug: true
});
// Send emails concurrently
const promises = Array.from({ length: 5 }, (_, i) => {
const email = new Email({
from: `sender${i}@example.com`,
to: `recipient${i}@example.com`,
subject: `Pooled Email ${i}`,
text: `This is pooled email ${i}`
});
return smtpClient.sendMail(email);
});
const results = await Promise.all(promises);
// Check results and log any failures
results.forEach((result, index) => {
console.log(`Email ${index}: ${result.success ? '✅' : '❌'} ${!result.success ? result.error?.message : ''}`);
});
// With connection pooling, at least some emails should succeed
const successCount = results.filter(r => r.success).length;
console.log(`Successfully sent ${successCount} of ${results.length} emails`);
expect(successCount).toBeGreaterThan(0);
console.log('✅ Connection pool maintains clean state');
console.log('RSET ensures each pooled connection starts fresh');
await smtpClient.close();
});
tap.test('CCMD-08: Error recovery with state reset', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// First, try with invalid sender (should fail early)
try {
const badEmail = new Email({
from: '', // Invalid
to: 'recipient@example.com',
subject: 'Bad Email',
text: 'This should fail'
});
await smtpClient.sendMail(badEmail);
} catch (error) {
// Expected to fail
console.log('✅ Invalid email rejected as expected');
}
// Now send a valid email - should work fine
const goodEmail = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Good Email',
text: 'This should succeed'
});
const result = await smtpClient.sendMail(goodEmail);
expect(result.success).toBeTrue();
console.log('✅ State recovered after error');
console.log('RSET ensures clean state after failures');
await smtpClient.close();
});
tap.test('CCMD-08: Verify command maintains session', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// verify() creates temporary connection
const verified1 = await smtpClient.verify();
expect(verified1).toBeTrue();
// Send email after verify
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'After Verify',
text: 'Email after verification'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
// verify() again
const verified2 = await smtpClient.verify();
expect(verified2).toBeTrue();
console.log('✅ Verify operations maintain clean session state');
console.log('Each operation ensures proper state management');
await smtpClient.close();
});
tap.test('CCMD-08: Rapid sequential sends', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Send emails rapidly
const count = 10;
const startTime = Date.now();
for (let i = 0; i < count; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Rapid Email ${i}`,
text: `Rapid test email ${i}`
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
}
const elapsed = Date.now() - startTime;
const avgTime = elapsed / count;
console.log(`✅ Sent ${count} emails in ${elapsed}ms`);
console.log(`Average time per email: ${avgTime.toFixed(2)}ms`);
console.log('RSET maintains efficiency in rapid sends');
await smtpClient.close();
});
tap.test('CCMD-08: State isolation between clients', async () => {
// Create two separate clients
const client1 = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
const client2 = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Send from both clients
const email1 = new Email({
from: 'client1@example.com',
to: 'recipient1@example.com',
subject: 'From Client 1',
text: 'Email from client 1'
});
const email2 = new Email({
from: 'client2@example.com',
to: 'recipient2@example.com',
subject: 'From Client 2',
text: 'Email from client 2'
});
// Send concurrently
const [result1, result2] = await Promise.all([
client1.sendMail(email1),
client2.sendMail(email2)
]);
expect(result1.success).toBeTrue();
expect(result2.success).toBeTrue();
console.log('✅ Each client maintains isolated state');
console.log('RSET ensures no cross-contamination');
await client1.close();
await client2.close();
});
tap.test('cleanup test SMTP server', async () => {
await stopTestServer(testServer);
expect(testServer).toBeTruthy();
});
export default tap.start();

View File

@@ -1,339 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2549,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toBeGreaterThan(0);
});
tap.test('CCMD-09: Connection keepalive test', async () => {
// NOOP is used internally for keepalive - test that connections remain active
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 10000,
greetingTimeout: 5000,
socketTimeout: 10000
});
// Send an initial email to establish connection
const email1 = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Initial connection test',
text: 'Testing connection establishment'
});
await smtpClient.sendMail(email1);
console.log('First email sent successfully');
// Wait 5 seconds (connection should stay alive with internal NOOP)
await new Promise(resolve => setTimeout(resolve, 5000));
// Send another email on the same connection
const email2 = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Keepalive test',
text: 'Testing connection after delay'
});
await smtpClient.sendMail(email2);
console.log('Second email sent successfully after 5 second delay');
});
tap.test('CCMD-09: Multiple emails in sequence', async () => {
// Test that client can handle multiple emails without issues
// Internal NOOP commands may be used between transactions
const emails = [];
for (let i = 0; i < 5; i++) {
emails.push(new Email({
from: 'sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Sequential email ${i + 1}`,
text: `This is email number ${i + 1}`
}));
}
console.log('Sending 5 emails in sequence...');
for (let i = 0; i < emails.length; i++) {
await smtpClient.sendMail(emails[i]);
console.log(`Email ${i + 1} sent successfully`);
// Small delay between emails
await new Promise(resolve => setTimeout(resolve, 500));
}
console.log('All emails sent successfully');
});
tap.test('CCMD-09: Rapid email sending', async () => {
// Test rapid email sending without delays
// Internal connection management should handle this properly
const emailCount = 10;
const emails = [];
for (let i = 0; i < emailCount; i++) {
emails.push(new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: `Rapid email ${i + 1}`,
text: `Rapid fire email number ${i + 1}`
}));
}
console.log(`Sending ${emailCount} emails rapidly...`);
const startTime = Date.now();
// Send all emails as fast as possible
for (const email of emails) {
await smtpClient.sendMail(email);
}
const elapsed = Date.now() - startTime;
console.log(`All ${emailCount} emails sent in ${elapsed}ms`);
console.log(`Average: ${(elapsed / emailCount).toFixed(2)}ms per email`);
});
tap.test('CCMD-09: Long-lived connection test', async () => {
// Test that connection stays alive over extended period
// SmtpClient should use internal keepalive mechanisms
console.log('Testing connection over 10 seconds with periodic emails...');
const testDuration = 10000;
const emailInterval = 2500;
const iterations = Math.floor(testDuration / emailInterval);
for (let i = 0; i < iterations; i++) {
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: `Keepalive test ${i + 1}`,
text: `Testing connection keepalive - email ${i + 1}`
});
const startTime = Date.now();
await smtpClient.sendMail(email);
const elapsed = Date.now() - startTime;
console.log(`Email ${i + 1} sent in ${elapsed}ms`);
if (i < iterations - 1) {
await new Promise(resolve => setTimeout(resolve, emailInterval));
}
}
console.log('Connection remained stable over 10 seconds');
});
tap.test('CCMD-09: Connection pooling behavior', async () => {
// Test connection pooling with different email patterns
// Internal NOOP may be used to maintain pool connections
const testPatterns = [
{ count: 3, delay: 0, desc: 'Burst of 3 emails' },
{ count: 2, delay: 1000, desc: '2 emails with 1s delay' },
{ count: 1, delay: 3000, desc: '1 email after 3s delay' }
];
for (const pattern of testPatterns) {
console.log(`\nTesting: ${pattern.desc}`);
if (pattern.delay > 0) {
await new Promise(resolve => setTimeout(resolve, pattern.delay));
}
for (let i = 0; i < pattern.count; i++) {
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: `${pattern.desc} - Email ${i + 1}`,
text: 'Testing connection pooling behavior'
});
await smtpClient.sendMail(email);
}
console.log(`Completed: ${pattern.desc}`);
}
});
tap.test('CCMD-09: Email sending performance', async () => {
// Measure email sending performance
// Connection management (including internal NOOP) affects timing
const measurements = 20;
const times: number[] = [];
console.log(`Measuring performance over ${measurements} emails...`);
for (let i = 0; i < measurements; i++) {
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: `Performance test ${i + 1}`,
text: 'Measuring email sending performance'
});
const startTime = Date.now();
await smtpClient.sendMail(email);
const elapsed = Date.now() - startTime;
times.push(elapsed);
}
// Calculate statistics
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
const minTime = Math.min(...times);
const maxTime = Math.max(...times);
// Calculate standard deviation
const variance = times.reduce((sum, time) => sum + Math.pow(time - avgTime, 2), 0) / times.length;
const stdDev = Math.sqrt(variance);
console.log(`\nPerformance analysis (${measurements} emails):`);
console.log(` Average: ${avgTime.toFixed(2)}ms`);
console.log(` Min: ${minTime}ms`);
console.log(` Max: ${maxTime}ms`);
console.log(` Std Dev: ${stdDev.toFixed(2)}ms`);
// First email might be slower due to connection establishment
const avgWithoutFirst = times.slice(1).reduce((a, b) => a + b, 0) / (times.length - 1);
console.log(` Average (excl. first): ${avgWithoutFirst.toFixed(2)}ms`);
// Performance should be reasonable
expect(avgTime).toBeLessThan(200);
});
tap.test('CCMD-09: Email with NOOP in content', async () => {
// Test that NOOP as email content doesn't affect delivery
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Email containing NOOP',
text: `This email contains SMTP commands as content:
NOOP
HELO test
MAIL FROM:<test@example.com>
These should be treated as plain text, not commands.
The word NOOP appears multiple times in this email.
NOOP is used internally by SMTP for keepalive.`
});
await smtpClient.sendMail(email);
console.log('Email with NOOP content sent successfully');
// Send another email to verify connection still works
const email2 = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Follow-up email',
text: 'Verifying connection still works after NOOP content'
});
await smtpClient.sendMail(email2);
console.log('Follow-up email sent successfully');
});
tap.test('CCMD-09: Concurrent email sending', async () => {
// Test concurrent email sending
// Connection pooling and internal management should handle this
const concurrentCount = 5;
const emails = [];
for (let i = 0; i < concurrentCount; i++) {
emails.push(new Email({
from: 'sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Concurrent email ${i + 1}`,
text: `Testing concurrent email sending - message ${i + 1}`
}));
}
console.log(`Sending ${concurrentCount} emails concurrently...`);
const startTime = Date.now();
// Send all emails concurrently
try {
await Promise.all(emails.map(email => smtpClient.sendMail(email)));
const elapsed = Date.now() - startTime;
console.log(`All ${concurrentCount} emails sent concurrently in ${elapsed}ms`);
} catch (error) {
// Concurrent sending might not be supported - that's OK
console.log('Concurrent sending not supported, falling back to sequential');
for (const email of emails) {
await smtpClient.sendMail(email);
}
}
});
tap.test('CCMD-09: Connection recovery test', async () => {
// Test connection recovery and error handling
// SmtpClient should handle connection issues gracefully
// Create a new client with shorter timeouts for testing
const testClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 3000,
socketTimeout: 3000
});
// Send initial email
const email1 = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Connection test 1',
text: 'Testing initial connection'
});
await testClient.sendMail(email1);
console.log('Initial email sent');
// Simulate long delay that might timeout connection
console.log('Waiting 5 seconds to test connection recovery...');
await new Promise(resolve => setTimeout(resolve, 5000));
// Try to send another email - client should recover if needed
const email2 = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Connection test 2',
text: 'Testing connection recovery'
});
try {
await testClient.sendMail(email2);
console.log('Email sent successfully after delay - connection recovered');
} catch (error) {
console.log('Connection recovery failed (this might be expected):', error.message);
}
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,457 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import { EmailValidator } from '../../../ts/mail/core/classes.emailvalidator.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2550,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toBeGreaterThan(0);
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
});
tap.test('CCMD-10: Email address validation', async () => {
// Test email address validation which is what VRFY conceptually does
const validator = new EmailValidator();
const testAddresses = [
{ address: 'user@example.com', expected: true },
{ address: 'postmaster@example.com', expected: true },
{ address: 'admin@example.com', expected: true },
{ address: 'user.name+tag@example.com', expected: true },
{ address: 'test@sub.domain.example.com', expected: true },
{ address: 'invalid@', expected: false },
{ address: '@example.com', expected: false },
{ address: 'not-an-email', expected: false },
{ address: '', expected: false },
{ address: 'user@', expected: false }
];
console.log('Testing email address validation (VRFY equivalent):\n');
for (const test of testAddresses) {
const isValid = validator.isValidFormat(test.address);
expect(isValid).toEqual(test.expected);
console.log(`Address: "${test.address}" - Valid: ${isValid} (expected: ${test.expected})`);
}
// Test sending to valid addresses
const validEmail = new Email({
from: 'sender@example.com',
to: ['user@example.com'],
subject: 'Address validation test',
text: 'Testing address validation'
});
await smtpClient.sendMail(validEmail);
console.log('\nEmail sent successfully to validated address');
});
tap.test('CCMD-10: Multiple recipient handling (EXPN equivalent)', async () => {
// Test multiple recipients which is conceptually similar to mailing list expansion
console.log('Testing multiple recipient handling (EXPN equivalent):\n');
// Create email with multiple recipients (like a mailing list)
const multiRecipientEmail = new Email({
from: 'sender@example.com',
to: [
'user1@example.com',
'user2@example.com',
'user3@example.com'
],
cc: [
'cc1@example.com',
'cc2@example.com'
],
bcc: [
'bcc1@example.com'
],
subject: 'Multi-recipient test (mailing list)',
text: 'Testing email distribution to multiple recipients'
});
const toAddresses = multiRecipientEmail.getToAddresses();
const ccAddresses = multiRecipientEmail.getCcAddresses();
const bccAddresses = multiRecipientEmail.getBccAddresses();
console.log(`To recipients: ${toAddresses.length}`);
toAddresses.forEach(addr => console.log(` - ${addr}`));
console.log(`\nCC recipients: ${ccAddresses.length}`);
ccAddresses.forEach(addr => console.log(` - ${addr}`));
console.log(`\nBCC recipients: ${bccAddresses.length}`);
bccAddresses.forEach(addr => console.log(` - ${addr}`));
console.log(`\nTotal recipients: ${toAddresses.length + ccAddresses.length + bccAddresses.length}`);
// Send the email
await smtpClient.sendMail(multiRecipientEmail);
console.log('\nEmail sent successfully to all recipients');
});
tap.test('CCMD-10: Email addresses with display names', async () => {
// Test email addresses with display names (full names)
console.log('Testing email addresses with display names:\n');
const fullNameTests = [
{ from: '"John Doe" <john@example.com>', expectedAddress: 'john@example.com' },
{ from: '"Smith, John" <john.smith@example.com>', expectedAddress: 'john.smith@example.com' },
{ from: 'Mary Johnson <mary@example.com>', expectedAddress: 'mary@example.com' },
{ from: '<bob@example.com>', expectedAddress: 'bob@example.com' }
];
for (const test of fullNameTests) {
const email = new Email({
from: test.from,
to: ['recipient@example.com'],
subject: 'Display name test',
text: `Testing from: ${test.from}`
});
const fromAddress = email.getFromAddress();
console.log(`Full: "${test.from}"`);
console.log(`Extracted: "${fromAddress}"`);
expect(fromAddress).toEqual(test.expectedAddress);
await smtpClient.sendMail(email);
console.log('Email sent successfully\n');
}
});
tap.test('CCMD-10: Email validation security', async () => {
// Test security aspects of email validation
console.log('Testing email validation security considerations:\n');
// Test common system/role addresses that should be handled carefully
const systemAddresses = [
'root@example.com',
'admin@example.com',
'administrator@example.com',
'webmaster@example.com',
'hostmaster@example.com',
'abuse@example.com',
'postmaster@example.com',
'noreply@example.com'
];
const validator = new EmailValidator();
console.log('Checking if addresses are role accounts:');
for (const addr of systemAddresses) {
const validationResult = await validator.validate(addr, { checkRole: true, checkMx: false });
console.log(` ${addr}: ${validationResult.details?.role ? 'Role account' : 'Not a role account'} (format valid: ${validationResult.details?.formatValid})`);
}
// Test that we don't expose information about which addresses exist
console.log('\nTesting information disclosure prevention:');
try {
// Try sending to a non-existent address
const testEmail = new Email({
from: 'sender@example.com',
to: ['definitely-does-not-exist-12345@example.com'],
subject: 'Test',
text: 'Test'
});
await smtpClient.sendMail(testEmail);
console.log('Server accepted email (does not disclose non-existence)');
} catch (error) {
console.log('Server rejected email:', error.message);
}
console.log('\nSecurity best practice: Servers should not disclose address existence');
});
tap.test('CCMD-10: Validation during email sending', async () => {
// Test that validation doesn't interfere with email sending
console.log('Testing validation during email transaction:\n');
const validator = new EmailValidator();
// Create a series of emails with validation between them
const emails = [
{
from: 'sender1@example.com',
to: ['recipient1@example.com'],
subject: 'First email',
text: 'Testing validation during transaction'
},
{
from: 'sender2@example.com',
to: ['recipient2@example.com', 'recipient3@example.com'],
subject: 'Second email',
text: 'Multiple recipients'
},
{
from: '"Test User" <sender3@example.com>',
to: ['recipient4@example.com'],
subject: 'Third email',
text: 'Display name test'
}
];
for (let i = 0; i < emails.length; i++) {
const emailData = emails[i];
// Validate addresses before sending
console.log(`Email ${i + 1}:`);
const fromAddr = emailData.from.includes('<') ? emailData.from.match(/<([^>]+)>/)?.[1] || emailData.from : emailData.from;
console.log(` From: ${emailData.from} - Valid: ${validator.isValidFormat(fromAddr)}`);
for (const to of emailData.to) {
console.log(` To: ${to} - Valid: ${validator.isValidFormat(to)}`);
}
// Create and send email
const email = new Email(emailData);
await smtpClient.sendMail(email);
console.log(` Sent successfully\n`);
}
console.log('All emails sent successfully with validation');
});
tap.test('CCMD-10: Special characters in email addresses', async () => {
// Test email addresses with special characters
console.log('Testing email addresses with special characters:\n');
const validator = new EmailValidator();
const specialAddresses = [
{ address: 'user+tag@example.com', shouldBeValid: true, description: 'Plus addressing' },
{ address: 'first.last@example.com', shouldBeValid: true, description: 'Dots in local part' },
{ address: 'user_name@example.com', shouldBeValid: true, description: 'Underscore' },
{ address: 'user-name@example.com', shouldBeValid: true, description: 'Hyphen' },
{ address: '"quoted string"@example.com', shouldBeValid: true, description: 'Quoted string' },
{ address: 'user@sub.domain.example.com', shouldBeValid: true, description: 'Subdomain' },
{ address: 'user@example.co.uk', shouldBeValid: true, description: 'Multi-part TLD' },
{ address: 'user..name@example.com', shouldBeValid: false, description: 'Double dots' },
{ address: '.user@example.com', shouldBeValid: false, description: 'Leading dot' },
{ address: 'user.@example.com', shouldBeValid: false, description: 'Trailing dot' }
];
for (const test of specialAddresses) {
const isValid = validator.isValidFormat(test.address);
console.log(`${test.description}:`);
console.log(` Address: "${test.address}"`);
console.log(` Valid: ${isValid} (expected: ${test.shouldBeValid})`);
if (test.shouldBeValid && isValid) {
// Try sending an email with this address
try {
const email = new Email({
from: 'sender@example.com',
to: [test.address],
subject: 'Special character test',
text: `Testing special characters in: ${test.address}`
});
await smtpClient.sendMail(email);
console.log(` Email sent successfully`);
} catch (error) {
console.log(` Failed to send: ${error.message}`);
}
}
console.log('');
}
});
tap.test('CCMD-10: Large recipient lists', async () => {
// Test handling of large recipient lists (similar to EXPN multi-line)
console.log('Testing large recipient lists:\n');
// Create email with many recipients
const recipientCount = 20;
const toRecipients = [];
const ccRecipients = [];
for (let i = 1; i <= recipientCount; i++) {
if (i <= 10) {
toRecipients.push(`user${i}@example.com`);
} else {
ccRecipients.push(`user${i}@example.com`);
}
}
console.log(`Creating email with ${recipientCount} total recipients:`);
console.log(` To: ${toRecipients.length} recipients`);
console.log(` CC: ${ccRecipients.length} recipients`);
const largeListEmail = new Email({
from: 'sender@example.com',
to: toRecipients,
cc: ccRecipients,
subject: 'Large distribution list test',
text: `This email is being sent to ${recipientCount} recipients total`
});
// Show extracted addresses
const allTo = largeListEmail.getToAddresses();
const allCc = largeListEmail.getCcAddresses();
console.log('\nExtracted addresses:');
console.log(`To (first 3): ${allTo.slice(0, 3).join(', ')}...`);
console.log(`CC (first 3): ${allCc.slice(0, 3).join(', ')}...`);
// Send the email
const startTime = Date.now();
await smtpClient.sendMail(largeListEmail);
const elapsed = Date.now() - startTime;
console.log(`\nEmail sent to all ${recipientCount} recipients in ${elapsed}ms`);
console.log(`Average: ${(elapsed / recipientCount).toFixed(2)}ms per recipient`);
});
tap.test('CCMD-10: Email validation performance', async () => {
// Test validation performance
console.log('Testing email validation performance:\n');
const validator = new EmailValidator();
const testCount = 1000;
// Generate test addresses
const testAddresses = [];
for (let i = 0; i < testCount; i++) {
testAddresses.push(`user${i}@example${i % 10}.com`);
}
// Time validation
const startTime = Date.now();
let validCount = 0;
for (const address of testAddresses) {
if (validator.isValidFormat(address)) {
validCount++;
}
}
const elapsed = Date.now() - startTime;
const rate = (testCount / elapsed) * 1000;
console.log(`Validated ${testCount} addresses in ${elapsed}ms`);
console.log(`Rate: ${rate.toFixed(0)} validations/second`);
console.log(`Valid addresses: ${validCount}/${testCount}`);
// Test rapid email sending to see if there's rate limiting
console.log('\nTesting rapid email sending:');
const emailCount = 10;
const sendStartTime = Date.now();
let sentCount = 0;
for (let i = 0; i < emailCount; i++) {
try {
const email = new Email({
from: 'sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Rate test ${i + 1}`,
text: 'Testing rate limits'
});
await smtpClient.sendMail(email);
sentCount++;
} catch (error) {
console.log(`Rate limit hit at email ${i + 1}: ${error.message}`);
break;
}
}
const sendElapsed = Date.now() - sendStartTime;
const sendRate = (sentCount / sendElapsed) * 1000;
console.log(`Sent ${sentCount}/${emailCount} emails in ${sendElapsed}ms`);
console.log(`Rate: ${sendRate.toFixed(2)} emails/second`);
});
tap.test('CCMD-10: Email validation error handling', async () => {
// Test error handling for invalid email addresses
console.log('Testing email validation error handling:\n');
const validator = new EmailValidator();
const errorTests = [
{ address: null, description: 'Null address' },
{ address: undefined, description: 'Undefined address' },
{ address: '', description: 'Empty string' },
{ address: ' ', description: 'Whitespace only' },
{ address: '@', description: 'Just @ symbol' },
{ address: 'user@', description: 'Missing domain' },
{ address: '@domain.com', description: 'Missing local part' },
{ address: 'user@@domain.com', description: 'Double @ symbol' },
{ address: 'user@domain@com', description: 'Multiple @ symbols' },
{ address: 'user space@domain.com', description: 'Space in local part' },
{ address: 'user@domain .com', description: 'Space in domain' },
{ address: 'x'.repeat(256) + '@domain.com', description: 'Very long local part' },
{ address: 'user@' + 'x'.repeat(256) + '.com', description: 'Very long domain' }
];
for (const test of errorTests) {
console.log(`${test.description}:`);
console.log(` Input: "${test.address}"`);
// Test validation
let isValid = false;
try {
isValid = validator.isValidFormat(test.address as any);
} catch (error) {
console.log(` Validation threw: ${error.message}`);
}
if (!isValid) {
console.log(` Correctly rejected as invalid`);
} else {
console.log(` WARNING: Accepted as valid!`);
}
// Try to send email with invalid address
if (test.address) {
try {
const email = new Email({
from: 'sender@example.com',
to: [test.address],
subject: 'Error test',
text: 'Testing invalid address'
});
await smtpClient.sendMail(email);
console.log(` WARNING: Email sent with invalid address!`);
} catch (error) {
console.log(` Email correctly rejected: ${error.message}`);
}
}
console.log('');
}
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,409 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2551,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toBeGreaterThan(0);
});
tap.test('CCMD-11: Server capabilities discovery', async () => {
// Test server capabilities which is what HELP provides info about
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
console.log('Testing server capabilities discovery (HELP equivalent):\n');
// Send a test email to see server capabilities in action
const testEmail = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Capability test',
text: 'Testing server capabilities'
});
await smtpClient.sendMail(testEmail);
console.log('Email sent successfully - server supports basic SMTP commands');
// Test different configurations to understand server behavior
const capabilities = {
basicSMTP: true,
multiplRecipients: false,
largeMessages: false,
internationalDomains: false
};
// Test multiple recipients
try {
const multiEmail = new Email({
from: 'sender@example.com',
to: ['recipient1@example.com', 'recipient2@example.com', 'recipient3@example.com'],
subject: 'Multi-recipient test',
text: 'Testing multiple recipients'
});
await smtpClient.sendMail(multiEmail);
capabilities.multiplRecipients = true;
console.log('✓ Server supports multiple recipients');
} catch (error) {
console.log('✗ Multiple recipients not supported');
}
console.log('\nDetected capabilities:', capabilities);
});
tap.test('CCMD-11: Error message diagnostics', async () => {
// Test error messages which HELP would explain
console.log('Testing error message diagnostics:\n');
const errorTests = [
{
description: 'Invalid sender address',
email: {
from: 'invalid-sender',
to: ['recipient@example.com'],
subject: 'Test',
text: 'Test'
}
},
{
description: 'Empty recipient list',
email: {
from: 'sender@example.com',
to: [],
subject: 'Test',
text: 'Test'
}
},
{
description: 'Null subject',
email: {
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: null as any,
text: 'Test'
}
}
];
for (const test of errorTests) {
console.log(`Testing: ${test.description}`);
try {
const email = new Email(test.email);
await smtpClient.sendMail(email);
console.log(' Unexpectedly succeeded');
} catch (error) {
console.log(` Error: ${error.message}`);
console.log(` This would be explained in HELP documentation`);
}
console.log('');
}
});
tap.test('CCMD-11: Connection configuration help', async () => {
// Test different connection configurations
console.log('Testing connection configurations:\n');
const configs = [
{
name: 'Standard connection',
config: {
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
},
shouldWork: true
},
{
name: 'With greeting timeout',
config: {
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
greetingTimeout: 3000
},
shouldWork: true
},
{
name: 'With socket timeout',
config: {
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
socketTimeout: 10000
},
shouldWork: true
}
];
for (const testConfig of configs) {
console.log(`Testing: ${testConfig.name}`);
try {
const client = createSmtpClient(testConfig.config);
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Config test',
text: `Testing ${testConfig.name}`
});
await client.sendMail(email);
console.log(` ✓ Configuration works`);
} catch (error) {
console.log(` ✗ Error: ${error.message}`);
}
}
});
tap.test('CCMD-11: Protocol flow documentation', async () => {
// Document the protocol flow (what HELP would explain)
console.log('SMTP Protocol Flow (as HELP would document):\n');
const protocolSteps = [
'1. Connection established',
'2. Server sends greeting (220)',
'3. Client sends EHLO',
'4. Server responds with capabilities',
'5. Client sends MAIL FROM',
'6. Server accepts sender (250)',
'7. Client sends RCPT TO',
'8. Server accepts recipient (250)',
'9. Client sends DATA',
'10. Server ready for data (354)',
'11. Client sends message content',
'12. Client sends . to end',
'13. Server accepts message (250)',
'14. Client can send more or QUIT'
];
console.log('Standard SMTP transaction flow:');
protocolSteps.forEach(step => console.log(` ${step}`));
// Demonstrate the flow
console.log('\nDemonstrating flow with actual email:');
const email = new Email({
from: 'demo@example.com',
to: ['recipient@example.com'],
subject: 'Protocol flow demo',
text: 'Demonstrating SMTP protocol flow'
});
await smtpClient.sendMail(email);
console.log('✓ Protocol flow completed successfully');
});
tap.test('CCMD-11: Command availability matrix', async () => {
// Test what commands are available (HELP info)
console.log('Testing command availability:\n');
// Test various email features to determine support
const features = {
plainText: { supported: false, description: 'Plain text emails' },
htmlContent: { supported: false, description: 'HTML emails' },
attachments: { supported: false, description: 'File attachments' },
multipleRecipients: { supported: false, description: 'Multiple recipients' },
ccRecipients: { supported: false, description: 'CC recipients' },
bccRecipients: { supported: false, description: 'BCC recipients' },
customHeaders: { supported: false, description: 'Custom headers' },
priorities: { supported: false, description: 'Email priorities' }
};
// Test plain text
try {
await smtpClient.sendMail(new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Plain text test',
text: 'Plain text content'
}));
features.plainText.supported = true;
} catch (e) {}
// Test HTML
try {
await smtpClient.sendMail(new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'HTML test',
html: '<p>HTML content</p>'
}));
features.htmlContent.supported = true;
} catch (e) {}
// Test multiple recipients
try {
await smtpClient.sendMail(new Email({
from: 'sender@example.com',
to: ['recipient1@example.com', 'recipient2@example.com'],
subject: 'Multiple recipients test',
text: 'Test'
}));
features.multipleRecipients.supported = true;
} catch (e) {}
// Test CC
try {
await smtpClient.sendMail(new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
cc: ['cc@example.com'],
subject: 'CC test',
text: 'Test'
}));
features.ccRecipients.supported = true;
} catch (e) {}
// Test BCC
try {
await smtpClient.sendMail(new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
bcc: ['bcc@example.com'],
subject: 'BCC test',
text: 'Test'
}));
features.bccRecipients.supported = true;
} catch (e) {}
console.log('Feature support matrix:');
Object.entries(features).forEach(([key, value]) => {
console.log(` ${value.description}: ${value.supported ? '✓ Supported' : '✗ Not supported'}`);
});
});
tap.test('CCMD-11: Error code reference', async () => {
// Document error codes (HELP would explain these)
console.log('SMTP Error Code Reference (as HELP would provide):\n');
const errorCodes = [
{ code: '220', meaning: 'Service ready', type: 'Success' },
{ code: '221', meaning: 'Service closing transmission channel', type: 'Success' },
{ code: '250', meaning: 'Requested action completed', type: 'Success' },
{ code: '251', meaning: 'User not local; will forward', type: 'Success' },
{ code: '354', meaning: 'Start mail input', type: 'Intermediate' },
{ code: '421', meaning: 'Service not available', type: 'Temporary failure' },
{ code: '450', meaning: 'Mailbox unavailable', type: 'Temporary failure' },
{ code: '451', meaning: 'Local error in processing', type: 'Temporary failure' },
{ code: '452', meaning: 'Insufficient storage', type: 'Temporary failure' },
{ code: '500', meaning: 'Syntax error', type: 'Permanent failure' },
{ code: '501', meaning: 'Syntax error in parameters', type: 'Permanent failure' },
{ code: '502', meaning: 'Command not implemented', type: 'Permanent failure' },
{ code: '503', meaning: 'Bad sequence of commands', type: 'Permanent failure' },
{ code: '550', meaning: 'Mailbox not found', type: 'Permanent failure' },
{ code: '551', meaning: 'User not local', type: 'Permanent failure' },
{ code: '552', meaning: 'Storage allocation exceeded', type: 'Permanent failure' },
{ code: '553', meaning: 'Mailbox name not allowed', type: 'Permanent failure' },
{ code: '554', meaning: 'Transaction failed', type: 'Permanent failure' }
];
console.log('Common SMTP response codes:');
errorCodes.forEach(({ code, meaning, type }) => {
console.log(` ${code} - ${meaning} (${type})`);
});
// Test triggering some errors
console.log('\nDemonstrating error handling:');
// Invalid email format
try {
await smtpClient.sendMail(new Email({
from: 'invalid-email-format',
to: ['recipient@example.com'],
subject: 'Test',
text: 'Test'
}));
} catch (error) {
console.log(`Invalid format error: ${error.message}`);
}
});
tap.test('CCMD-11: Debugging assistance', async () => {
// Test debugging features (HELP assists with debugging)
console.log('Debugging assistance features:\n');
// Create client with debug enabled
const debugClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
console.log('Sending email with debug mode enabled:');
console.log('(Debug output would show full SMTP conversation)\n');
const debugEmail = new Email({
from: 'debug@example.com',
to: ['recipient@example.com'],
subject: 'Debug test',
text: 'Testing with debug mode'
});
// The debug output will be visible in the console
await debugClient.sendMail(debugEmail);
console.log('\nDebug mode helps troubleshoot:');
console.log('- Connection issues');
console.log('- Authentication problems');
console.log('- Message formatting errors');
console.log('- Server response codes');
console.log('- Protocol violations');
});
tap.test('CCMD-11: Performance benchmarks', async () => {
// Performance info (HELP might mention performance tips)
console.log('Performance benchmarks:\n');
const messageCount = 10;
const startTime = Date.now();
for (let i = 0; i < messageCount; i++) {
const email = new Email({
from: 'perf@example.com',
to: ['recipient@example.com'],
subject: `Performance test ${i + 1}`,
text: 'Testing performance'
});
await smtpClient.sendMail(email);
}
const totalTime = Date.now() - startTime;
const avgTime = totalTime / messageCount;
console.log(`Sent ${messageCount} emails in ${totalTime}ms`);
console.log(`Average time per email: ${avgTime.toFixed(2)}ms`);
console.log(`Throughput: ${(1000 / avgTime).toFixed(2)} emails/second`);
console.log('\nPerformance tips:');
console.log('- Use connection pooling for multiple emails');
console.log('- Enable pipelining when supported');
console.log('- Batch recipients when possible');
console.log('- Use appropriate timeouts');
console.log('- Monitor connection limits');
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,150 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server for basic connection test', async () => {
testServer = await startTestServer({
port: 2525,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2525);
});
tap.test('CCM-01: Basic TCP Connection - should connect to SMTP server', async () => {
const startTime = Date.now();
try {
// Create SMTP client
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Verify connection
const isConnected = await smtpClient.verify();
expect(isConnected).toBeTrue();
const duration = Date.now() - startTime;
console.log(`✅ Basic TCP connection established in ${duration}ms`);
} catch (error) {
const duration = Date.now() - startTime;
console.error(`❌ Basic TCP connection failed after ${duration}ms:`, error);
throw error;
}
});
tap.test('CCM-01: Basic TCP Connection - should report connection status', async () => {
// After verify(), connection is closed, so isConnected should be false
expect(smtpClient.isConnected()).toBeFalse();
const poolStatus = smtpClient.getPoolStatus();
console.log('📊 Connection pool status:', poolStatus);
// After verify(), pool should be empty
expect(poolStatus.total).toEqual(0);
expect(poolStatus.active).toEqual(0);
// Test that connection status is correct during actual email send
const email = new (await import('../../../ts/mail/core/classes.email.js')).Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Connection status test',
text: 'Testing connection status'
});
// During sendMail, connection should be established
const sendPromise = smtpClient.sendMail(email);
// Check status while sending (might be too fast to catch)
const duringStatus = smtpClient.getPoolStatus();
console.log('📊 Pool status during send:', duringStatus);
await sendPromise;
// After send, connection might be pooled or closed
const afterStatus = smtpClient.getPoolStatus();
console.log('📊 Pool status after send:', afterStatus);
});
tap.test('CCM-01: Basic TCP Connection - should handle multiple connect/disconnect cycles', async () => {
// Close existing connection
await smtpClient.close();
expect(smtpClient.isConnected()).toBeFalse();
// Create new client and test reconnection
for (let i = 0; i < 3; i++) {
const cycleClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
const isConnected = await cycleClient.verify();
expect(isConnected).toBeTrue();
await cycleClient.close();
expect(cycleClient.isConnected()).toBeFalse();
console.log(`✅ Connection cycle ${i + 1} completed`);
}
});
tap.test('CCM-01: Basic TCP Connection - should fail with invalid host', async () => {
const invalidClient = createSmtpClient({
host: 'invalid.host.that.does.not.exist',
port: 2525,
secure: false,
connectionTimeout: 3000
});
// verify() returns false on connection failure, doesn't throw
const result = await invalidClient.verify();
expect(result).toBeFalse();
console.log('✅ Correctly failed to connect to invalid host');
await invalidClient.close();
});
tap.test('CCM-01: Basic TCP Connection - should timeout on unresponsive port', async () => {
const startTime = Date.now();
const timeoutClient = createSmtpClient({
host: testServer.hostname,
port: 9999, // Port that's not listening
secure: false,
connectionTimeout: 2000
});
// verify() returns false on connection failure, doesn't throw
const result = await timeoutClient.verify();
expect(result).toBeFalse();
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(3000); // Should timeout within 3 seconds
console.log(`✅ Connection timeout working correctly (${duration}ms)`);
await timeoutClient.close();
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient && smtpClient.isConnected()) {
await smtpClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,140 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server with TLS', async () => {
testServer = await startTestServer({
port: 2526,
tlsEnabled: true,
authRequired: false
});
expect(testServer.port).toEqual(2526);
expect(testServer.config.tlsEnabled).toBeTrue();
});
tap.test('CCM-02: TLS Connection - should establish secure connection via STARTTLS', async () => {
const startTime = Date.now();
try {
// Create SMTP client with STARTTLS (not direct TLS)
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false, // Start with plain connection
connectionTimeout: 10000,
tls: {
rejectUnauthorized: false // For self-signed test certificates
},
debug: true
});
// Verify connection (will upgrade to TLS via STARTTLS)
const isConnected = await smtpClient.verify();
expect(isConnected).toBeTrue();
const duration = Date.now() - startTime;
console.log(`✅ STARTTLS connection established in ${duration}ms`);
} catch (error) {
const duration = Date.now() - startTime;
console.error(`❌ STARTTLS connection failed after ${duration}ms:`, error);
throw error;
}
});
tap.test('CCM-02: TLS Connection - should send email over secure connection', async () => {
const email = new Email({
from: 'test@example.com',
to: 'recipient@example.com',
subject: 'TLS Connection Test',
text: 'This email was sent over a secure TLS connection',
html: '<p>This email was sent over a <strong>secure TLS connection</strong></p>'
});
const result = await smtpClient.sendMail(email);
expect(result).toBeTruthy();
expect(result.success).toBeTrue();
expect(result.messageId).toBeTruthy();
console.log(`✅ Email sent over TLS with message ID: ${result.messageId}`);
});
tap.test('CCM-02: TLS Connection - should reject invalid certificates when required', async () => {
// Create new client with strict certificate validation
const strictClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
tls: {
rejectUnauthorized: true // Strict validation
}
});
// Should fail with self-signed certificate
const result = await strictClient.verify();
expect(result).toBeFalse();
console.log('✅ Correctly rejected self-signed certificate with strict validation');
await strictClient.close();
});
tap.test('CCM-02: TLS Connection - should work with direct TLS if supported', async () => {
// Try direct TLS connection (might fail if server doesn't support it)
const directTlsClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: true, // Direct TLS from start
connectionTimeout: 5000,
tls: {
rejectUnauthorized: false
}
});
const result = await directTlsClient.verify();
if (result) {
console.log('✅ Direct TLS connection supported and working');
} else {
console.log(' Direct TLS not supported, STARTTLS is the way');
}
await directTlsClient.close();
});
tap.test('CCM-02: TLS Connection - should verify TLS cipher suite', async () => {
// Send email and check connection details
const email = new Email({
from: 'cipher-test@example.com',
to: 'recipient@example.com',
subject: 'TLS Cipher Test',
text: 'Testing TLS cipher suite'
});
// The actual cipher info would be in debug logs
console.log(' TLS cipher information available in debug logs');
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Email sent successfully over encrypted connection');
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient) {
await smtpClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,208 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server with STARTTLS support', async () => {
testServer = await startTestServer({
port: 2528,
tlsEnabled: true, // Enables STARTTLS capability
authRequired: false
});
expect(testServer.port).toEqual(2528);
});
tap.test('CCM-03: STARTTLS Upgrade - should upgrade plain connection to TLS', async () => {
const startTime = Date.now();
try {
// Create SMTP client starting with plain connection
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false, // Start with plain connection
connectionTimeout: 10000,
tls: {
rejectUnauthorized: false // For self-signed test certificates
},
debug: true
});
// The client should automatically upgrade to TLS via STARTTLS
const isConnected = await smtpClient.verify();
expect(isConnected).toBeTrue();
const duration = Date.now() - startTime;
console.log(`✅ STARTTLS upgrade completed in ${duration}ms`);
} catch (error) {
const duration = Date.now() - startTime;
console.error(`❌ STARTTLS upgrade failed after ${duration}ms:`, error);
throw error;
}
});
tap.test('CCM-03: STARTTLS Upgrade - should send email after upgrade', async () => {
const email = new Email({
from: 'test@example.com',
to: 'recipient@example.com',
subject: 'STARTTLS Upgrade Test',
text: 'This email was sent after STARTTLS upgrade',
html: '<p>This email was sent after <strong>STARTTLS upgrade</strong></p>'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.acceptedRecipients).toContain('recipient@example.com');
expect(result.rejectedRecipients.length).toEqual(0);
console.log('✅ Email sent successfully after STARTTLS upgrade');
console.log('📧 Message ID:', result.messageId);
});
tap.test('CCM-03: STARTTLS Upgrade - should handle servers without STARTTLS', async () => {
// Start a server without TLS support
const plainServer = await startTestServer({
port: 2529,
tlsEnabled: false // No STARTTLS support
});
try {
const plainClient = createSmtpClient({
host: plainServer.hostname,
port: plainServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Should still connect but without TLS
const isConnected = await plainClient.verify();
expect(isConnected).toBeTrue();
// Send test email over plain connection
const email = new Email({
from: 'test@example.com',
to: 'recipient@example.com',
subject: 'Plain Connection Test',
text: 'This email was sent over plain connection'
});
const result = await plainClient.sendMail(email);
expect(result.success).toBeTrue();
await plainClient.close();
console.log('✅ Successfully handled server without STARTTLS');
} finally {
await stopTestServer(plainServer);
}
});
tap.test('CCM-03: STARTTLS Upgrade - should respect TLS options during upgrade', async () => {
const customTlsClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false, // Start plain
connectionTimeout: 10000,
tls: {
rejectUnauthorized: false
// Removed specific TLS version and cipher requirements that might not be supported
}
});
const isConnected = await customTlsClient.verify();
expect(isConnected).toBeTrue();
// Test that we can send email with custom TLS client
const email = new Email({
from: 'tls-test@example.com',
to: 'recipient@example.com',
subject: 'Custom TLS Options Test',
text: 'Testing with custom TLS configuration'
});
const result = await customTlsClient.sendMail(email);
expect(result.success).toBeTrue();
await customTlsClient.close();
console.log('✅ Custom TLS options applied during STARTTLS upgrade');
});
tap.test('CCM-03: STARTTLS Upgrade - should handle upgrade failures gracefully', async () => {
// Create a scenario where STARTTLS might fail
// verify() returns false on failure, doesn't throw
const strictTlsClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
tls: {
rejectUnauthorized: true, // Strict validation with self-signed cert
servername: 'wrong.hostname.com' // Wrong hostname
}
});
// Should return false due to certificate validation failure
const result = await strictTlsClient.verify();
expect(result).toBeFalse();
await strictTlsClient.close();
console.log('✅ STARTTLS upgrade failure handled gracefully');
});
tap.test('CCM-03: STARTTLS Upgrade - should maintain connection state after upgrade', async () => {
const stateClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 10000,
tls: {
rejectUnauthorized: false
}
});
// verify() closes the connection after testing, so isConnected will be false
const verified = await stateClient.verify();
expect(verified).toBeTrue();
expect(stateClient.isConnected()).toBeFalse(); // Connection closed after verify
// Send multiple emails to verify connection pooling works correctly
for (let i = 0; i < 3; i++) {
const email = new Email({
from: 'test@example.com',
to: 'recipient@example.com',
subject: `STARTTLS State Test ${i + 1}`,
text: `Message ${i + 1} after STARTTLS upgrade`
});
const result = await stateClient.sendMail(email);
expect(result.success).toBeTrue();
}
// Check pool status to understand connection management
const poolStatus = stateClient.getPoolStatus();
console.log('Connection pool status:', poolStatus);
await stateClient.close();
console.log('✅ Connection state maintained after STARTTLS upgrade');
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient && smtpClient.isConnected()) {
await smtpClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,250 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createPooledSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let pooledClient: SmtpClient;
tap.test('setup - start SMTP server for pooling test', async () => {
testServer = await startTestServer({
port: 2530,
tlsEnabled: false,
authRequired: false,
maxConnections: 10
});
expect(testServer.port).toEqual(2530);
});
tap.test('CCM-04: Connection Pooling - should create pooled client', async () => {
const startTime = Date.now();
try {
// Create pooled SMTP client
pooledClient = createPooledSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
maxConnections: 5,
maxMessages: 100,
connectionTimeout: 5000,
debug: true
});
// Verify connection pool is working
const isConnected = await pooledClient.verify();
expect(isConnected).toBeTrue();
const poolStatus = pooledClient.getPoolStatus();
console.log('📊 Initial pool status:', poolStatus);
expect(poolStatus.total).toBeGreaterThanOrEqual(0);
const duration = Date.now() - startTime;
console.log(`✅ Connection pool created in ${duration}ms`);
} catch (error) {
const duration = Date.now() - startTime;
console.error(`❌ Connection pool creation failed after ${duration}ms:`, error);
throw error;
}
});
tap.test('CCM-04: Connection Pooling - should handle concurrent connections', async () => {
// Send multiple emails concurrently
const emailPromises = [];
const concurrentCount = 5;
for (let i = 0; i < concurrentCount; i++) {
const email = new Email({
from: 'test@example.com',
to: `recipient${i}@example.com`,
subject: `Concurrent Email ${i}`,
text: `This is concurrent email number ${i}`
});
emailPromises.push(
pooledClient.sendMail(email).catch(error => {
console.error(`❌ Failed to send email ${i}:`, error);
return { success: false, error: error.message, acceptedRecipients: [] };
})
);
}
// Wait for all emails to be sent
const results = await Promise.all(emailPromises);
// Check results and count successes
let successCount = 0;
results.forEach((result, index) => {
if (result.success) {
successCount++;
expect(result.acceptedRecipients).toContain(`recipient${index}@example.com`);
} else {
console.log(`Email ${index} failed:`, result.error);
}
});
// At least some emails should succeed with pooling
expect(successCount).toBeGreaterThan(0);
console.log(`✅ Sent ${successCount}/${concurrentCount} emails successfully`);
// Check pool status after concurrent sends
const poolStatus = pooledClient.getPoolStatus();
console.log('📊 Pool status after concurrent sends:', poolStatus);
expect(poolStatus.total).toBeGreaterThanOrEqual(1);
expect(poolStatus.total).toBeLessThanOrEqual(5); // Should not exceed max
});
tap.test('CCM-04: Connection Pooling - should reuse connections', async () => {
// Get initial pool status
const initialStatus = pooledClient.getPoolStatus();
console.log('📊 Initial status:', initialStatus);
// Send emails sequentially to test connection reuse
const emailCount = 10;
const connectionCounts = [];
for (let i = 0; i < emailCount; i++) {
const email = new Email({
from: 'test@example.com',
to: 'recipient@example.com',
subject: `Sequential Email ${i}`,
text: `Testing connection reuse - email ${i}`
});
await pooledClient.sendMail(email);
const status = pooledClient.getPoolStatus();
connectionCounts.push(status.total);
}
// Check that connections were reused (total shouldn't grow linearly)
const maxConnections = Math.max(...connectionCounts);
expect(maxConnections).toBeLessThan(emailCount); // Should reuse connections
console.log(`✅ Sent ${emailCount} emails using max ${maxConnections} connections`);
console.log('📊 Connection counts:', connectionCounts);
});
tap.test('CCM-04: Connection Pooling - should respect max connections limit', async () => {
// Create a client with small pool
const limitedClient = createPooledSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
maxConnections: 2, // Very small pool
connectionTimeout: 5000
});
// Send many concurrent emails
const emailPromises = [];
for (let i = 0; i < 10; i++) {
const email = new Email({
from: 'test@example.com',
to: `test${i}@example.com`,
subject: `Pool Limit Test ${i}`,
text: 'Testing pool limits'
});
emailPromises.push(limitedClient.sendMail(email));
}
// Monitor pool during sending
const checkInterval = setInterval(() => {
const status = limitedClient.getPoolStatus();
console.log('📊 Pool status during load:', status);
expect(status.total).toBeLessThanOrEqual(2); // Should never exceed max
}, 100);
await Promise.all(emailPromises);
clearInterval(checkInterval);
await limitedClient.close();
console.log('✅ Connection pool respected max connections limit');
});
tap.test('CCM-04: Connection Pooling - should handle connection failures in pool', async () => {
// Create a new pooled client
const resilientClient = createPooledSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
maxConnections: 3,
connectionTimeout: 5000
});
// Send some emails successfully
for (let i = 0; i < 3; i++) {
const email = new Email({
from: 'test@example.com',
to: 'recipient@example.com',
subject: `Pre-failure Email ${i}`,
text: 'Before simulated failure'
});
const result = await resilientClient.sendMail(email);
expect(result.success).toBeTrue();
}
// Pool should recover and continue working
const poolStatus = resilientClient.getPoolStatus();
console.log('📊 Pool status after recovery test:', poolStatus);
expect(poolStatus.total).toBeGreaterThanOrEqual(1);
await resilientClient.close();
console.log('✅ Connection pool handled failures gracefully');
});
tap.test('CCM-04: Connection Pooling - should clean up idle connections', async () => {
// Create client with specific idle settings
const idleClient = createPooledSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
maxConnections: 5,
connectionTimeout: 5000
});
// Send burst of emails
const promises = [];
for (let i = 0; i < 5; i++) {
const email = new Email({
from: 'test@example.com',
to: 'recipient@example.com',
subject: `Idle Test ${i}`,
text: 'Testing idle cleanup'
});
promises.push(idleClient.sendMail(email));
}
await Promise.all(promises);
const activeStatus = idleClient.getPoolStatus();
console.log('📊 Pool status after burst:', activeStatus);
// Wait for connections to become idle
await new Promise(resolve => setTimeout(resolve, 2000));
const idleStatus = idleClient.getPoolStatus();
console.log('📊 Pool status after idle period:', idleStatus);
await idleClient.close();
console.log('✅ Idle connection management working');
});
tap.test('cleanup - close pooled client', async () => {
if (pooledClient && pooledClient.isConnected()) {
await pooledClient.close();
// Verify pool is cleaned up
const finalStatus = pooledClient.getPoolStatus();
console.log('📊 Final pool status:', finalStatus);
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,288 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server for connection reuse test', async () => {
testServer = await startTestServer({
port: 2531,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2531);
});
tap.test('CCM-05: Connection Reuse - should reuse single connection for multiple emails', async () => {
const startTime = Date.now();
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Verify initial connection
const verified = await smtpClient.verify();
expect(verified).toBeTrue();
// Note: verify() closes the connection, so isConnected() will be false
// Send multiple emails on same connection
const emailCount = 5;
const results = [];
for (let i = 0; i < emailCount; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Connection Reuse Test ${i + 1}`,
text: `This is email ${i + 1} using the same connection`
});
const result = await smtpClient.sendMail(email);
results.push(result);
// Note: Connection state may vary depending on implementation
console.log(`Connection status after email ${i + 1}: ${smtpClient.isConnected() ? 'connected' : 'disconnected'}`);
}
// All emails should succeed
results.forEach((result, index) => {
expect(result.success).toBeTrue();
console.log(`✅ Email ${index + 1} sent successfully`);
});
const duration = Date.now() - startTime;
console.log(`✅ Sent ${emailCount} emails on single connection in ${duration}ms`);
});
tap.test('CCM-05: Connection Reuse - should track message count per connection', async () => {
// Create a new client with message limit
const limitedClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
maxMessages: 3, // Limit messages per connection
connectionTimeout: 5000
});
// Send emails up to and beyond the limit
for (let i = 0; i < 5; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Message Limit Test ${i + 1}`,
text: `Testing message limits`
});
const result = await limitedClient.sendMail(email);
expect(result.success).toBeTrue();
// After 3 messages, connection should be refreshed
if (i === 2) {
console.log('✅ Connection should refresh after message limit');
}
}
await limitedClient.close();
});
tap.test('CCM-05: Connection Reuse - should handle connection state changes', async () => {
// Test connection state management
const stateClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// First email
const email1 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'First Email',
text: 'Testing connection state'
});
const result1 = await stateClient.sendMail(email1);
expect(result1.success).toBeTrue();
// Second email
const email2 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Second Email',
text: 'Testing connection reuse'
});
const result2 = await stateClient.sendMail(email2);
expect(result2.success).toBeTrue();
await stateClient.close();
console.log('✅ Connection state handled correctly');
});
tap.test('CCM-05: Connection Reuse - should handle idle connection timeout', async () => {
const idleClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
socketTimeout: 3000 // Short timeout for testing
});
// Send first email
const email1 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Pre-idle Email',
text: 'Before idle period'
});
const result1 = await idleClient.sendMail(email1);
expect(result1.success).toBeTrue();
// Wait for potential idle timeout
console.log('⏳ Testing idle connection behavior...');
await new Promise(resolve => setTimeout(resolve, 4000));
// Send another email
const email2 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Post-idle Email',
text: 'After idle period'
});
// Should handle reconnection if needed
const result = await idleClient.sendMail(email2);
expect(result.success).toBeTrue();
await idleClient.close();
console.log('✅ Idle connection handling working correctly');
});
tap.test('CCM-05: Connection Reuse - should optimize performance with reuse', async () => {
// Compare performance with and without connection reuse
// Test 1: Multiple connections (no reuse)
const noReuseStart = Date.now();
for (let i = 0; i < 3; i++) {
const tempClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `No Reuse ${i}`,
text: 'Testing without reuse'
});
await tempClient.sendMail(email);
await tempClient.close();
}
const noReuseDuration = Date.now() - noReuseStart;
// Test 2: Single connection (with reuse)
const reuseStart = Date.now();
const reuseClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
for (let i = 0; i < 3; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `With Reuse ${i}`,
text: 'Testing with reuse'
});
await reuseClient.sendMail(email);
}
await reuseClient.close();
const reuseDuration = Date.now() - reuseStart;
console.log(`📊 Performance comparison:`);
console.log(` Without reuse: ${noReuseDuration}ms`);
console.log(` With reuse: ${reuseDuration}ms`);
console.log(` Improvement: ${Math.round((1 - reuseDuration/noReuseDuration) * 100)}%`);
// Both approaches should work, performance may vary based on implementation
// Connection reuse doesn't always guarantee better performance for local connections
expect(noReuseDuration).toBeGreaterThan(0);
expect(reuseDuration).toBeGreaterThan(0);
console.log('✅ Both connection strategies completed successfully');
});
tap.test('CCM-05: Connection Reuse - should handle errors without breaking reuse', async () => {
const resilientClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Send valid email
const validEmail = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Valid Email',
text: 'This should work'
});
const result1 = await resilientClient.sendMail(validEmail);
expect(result1.success).toBeTrue();
// Try to send invalid email
try {
const invalidEmail = new Email({
from: 'invalid sender format',
to: 'recipient@example.com',
subject: 'Invalid Email',
text: 'This should fail'
});
await resilientClient.sendMail(invalidEmail);
} catch (error) {
console.log('✅ Invalid email rejected as expected');
}
// Connection should still be usable
const validEmail2 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Valid Email After Error',
text: 'Connection should still work'
});
const result2 = await resilientClient.sendMail(validEmail2);
expect(result2.success).toBeTrue();
await resilientClient.close();
console.log('✅ Connection reuse survived error condition');
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient && smtpClient.isConnected()) {
await smtpClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
tap.start();

View File

@@ -1,267 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup - start SMTP server for timeout tests', async () => {
testServer = await startTestServer({
port: 2532,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2532);
});
tap.test('CCM-06: Connection Timeout - should timeout on unresponsive server', async () => {
const startTime = Date.now();
const timeoutClient = createSmtpClient({
host: testServer.hostname,
port: 9999, // Non-existent port
secure: false,
connectionTimeout: 2000, // 2 second timeout
debug: true
});
// verify() returns false on connection failure, doesn't throw
const verified = await timeoutClient.verify();
const duration = Date.now() - startTime;
expect(verified).toBeFalse();
expect(duration).toBeLessThan(3000); // Should timeout within 3s
console.log(`✅ Connection timeout after ${duration}ms`);
});
tap.test('CCM-06: Connection Timeout - should handle slow server response', async () => {
// Create a mock slow server
const slowServer = net.createServer((socket) => {
// Accept connection but delay response
setTimeout(() => {
socket.write('220 Slow server ready\r\n');
}, 3000); // 3 second delay
});
await new Promise<void>((resolve) => {
slowServer.listen(2533, () => resolve());
});
const startTime = Date.now();
const slowClient = createSmtpClient({
host: 'localhost',
port: 2533,
secure: false,
connectionTimeout: 1000, // 1 second timeout
debug: true
});
// verify() should return false when server is too slow
const verified = await slowClient.verify();
const duration = Date.now() - startTime;
expect(verified).toBeFalse();
// Note: actual timeout might be longer due to system defaults
console.log(`✅ Slow server timeout after ${duration}ms`);
slowServer.close();
await new Promise(resolve => setTimeout(resolve, 100));
});
tap.test('CCM-06: Connection Timeout - should respect socket timeout during data transfer', async () => {
const socketTimeoutClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
socketTimeout: 10000, // 10 second socket timeout
debug: true
});
await socketTimeoutClient.verify();
// Send a normal email
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Socket Timeout Test',
text: 'Testing socket timeout configuration'
});
const result = await socketTimeoutClient.sendMail(email);
expect(result.success).toBeTrue();
await socketTimeoutClient.close();
console.log('✅ Socket timeout configuration applied');
});
tap.test('CCM-06: Connection Timeout - should handle timeout during TLS handshake', async () => {
// Create a server that accepts connections but doesn't complete TLS
const badTlsServer = net.createServer((socket) => {
// Accept connection but don't respond to TLS
socket.on('data', () => {
// Do nothing - simulate hung TLS handshake
});
});
await new Promise<void>((resolve) => {
badTlsServer.listen(2534, () => resolve());
});
const startTime = Date.now();
const tlsTimeoutClient = createSmtpClient({
host: 'localhost',
port: 2534,
secure: true, // Try TLS
connectionTimeout: 2000,
tls: {
rejectUnauthorized: false
}
});
// verify() should return false when TLS handshake times out
const verified = await tlsTimeoutClient.verify();
const duration = Date.now() - startTime;
expect(verified).toBeFalse();
// Note: actual timeout might be longer due to system defaults
console.log(`✅ TLS handshake timeout after ${duration}ms`);
badTlsServer.close();
await new Promise(resolve => setTimeout(resolve, 100));
});
tap.test('CCM-06: Connection Timeout - should not timeout on successful quick connection', async () => {
const quickClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 30000, // Very long timeout
debug: true
});
const startTime = Date.now();
const isConnected = await quickClient.verify();
const duration = Date.now() - startTime;
expect(isConnected).toBeTrue();
expect(duration).toBeLessThan(5000); // Should connect quickly
await quickClient.close();
console.log(`✅ Quick connection established in ${duration}ms`);
});
tap.test('CCM-06: Connection Timeout - should handle timeout during authentication', async () => {
// Start auth server
const authServer = await startTestServer({
port: 2535,
authRequired: true
});
// Create mock auth that delays
const authTimeoutClient = createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false,
connectionTimeout: 5000,
socketTimeout: 1000, // Very short socket timeout
auth: {
user: 'testuser',
pass: 'testpass'
}
});
try {
await authTimeoutClient.verify();
// If this succeeds, auth was fast enough
await authTimeoutClient.close();
console.log('✅ Authentication completed within timeout');
} catch (error) {
console.log('✅ Authentication timeout handled');
}
await stopTestServer(authServer);
});
tap.test('CCM-06: Connection Timeout - should apply different timeouts for different operations', async () => {
const multiTimeoutClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000, // Connection establishment
socketTimeout: 30000, // Data operations
debug: true
});
// Connection should be quick
const connectStart = Date.now();
await multiTimeoutClient.verify();
const connectDuration = Date.now() - connectStart;
expect(connectDuration).toBeLessThan(5000);
// Send email with potentially longer operation
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Multi-timeout Test',
text: 'Testing different timeout values',
attachments: [{
filename: 'test.txt',
content: Buffer.from('Test content'),
contentType: 'text/plain'
}]
});
const sendStart = Date.now();
const result = await multiTimeoutClient.sendMail(email);
const sendDuration = Date.now() - sendStart;
expect(result.success).toBeTrue();
console.log(`✅ Different timeouts applied: connect=${connectDuration}ms, send=${sendDuration}ms`);
await multiTimeoutClient.close();
});
tap.test('CCM-06: Connection Timeout - should retry after timeout with pooled connections', async () => {
const retryClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
pool: true,
maxConnections: 2,
connectionTimeout: 5000,
debug: true
});
// First connection should succeed
const email1 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Pre-timeout Email',
text: 'Before any timeout'
});
const result1 = await retryClient.sendMail(email1);
expect(result1.success).toBeTrue();
// Pool should handle connection management
const poolStatus = retryClient.getPoolStatus();
console.log('📊 Pool status:', poolStatus);
await retryClient.close();
console.log('✅ Connection pool handles timeouts gracefully');
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,324 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient, createPooledSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup - start SMTP server for reconnection tests', async () => {
testServer = await startTestServer({
port: 2533,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2533);
});
tap.test('CCM-07: Automatic Reconnection - should reconnect after connection loss', async () => {
const client = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// First connection and email
const email1 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Before Disconnect',
text: 'First email before connection loss'
});
const result1 = await client.sendMail(email1);
expect(result1.success).toBeTrue();
// Note: Connection state may vary after sending
// Force disconnect
await client.close();
expect(client.isConnected()).toBeFalse();
// Try to send another email - should auto-reconnect
const email2 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'After Reconnect',
text: 'Email after automatic reconnection'
});
const result2 = await client.sendMail(email2);
expect(result2.success).toBeTrue();
// Connection successfully handled reconnection
await client.close();
console.log('✅ Automatic reconnection successful');
});
tap.test('CCM-07: Automatic Reconnection - pooled client should reconnect failed connections', async () => {
const pooledClient = createPooledSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
maxConnections: 3,
connectionTimeout: 5000,
debug: true
});
// Send emails to establish pool connections
const promises = [];
for (let i = 0; i < 3; i++) {
const email = new Email({
from: 'sender@example.com',
to: `recipient${i}@example.com`,
subject: `Pool Test ${i}`,
text: 'Testing connection pool'
});
promises.push(
pooledClient.sendMail(email).catch(error => {
console.error(`Failed to send initial email ${i}:`, error.message);
return { success: false, error: error.message };
})
);
}
await Promise.all(promises);
const poolStatus1 = pooledClient.getPoolStatus();
console.log('📊 Pool status before disruption:', poolStatus1);
// Send more emails - pool should handle any connection issues
const promises2 = [];
for (let i = 0; i < 5; i++) {
const email = new Email({
from: 'sender@example.com',
to: `recipient${i}@example.com`,
subject: `Pool Recovery ${i}`,
text: 'Testing pool recovery'
});
promises2.push(
pooledClient.sendMail(email).catch(error => {
console.error(`Failed to send email ${i}:`, error.message);
return { success: false, error: error.message };
})
);
}
const results = await Promise.all(promises2);
let successCount = 0;
results.forEach(result => {
if (result.success) {
successCount++;
}
});
// At least some emails should succeed
expect(successCount).toBeGreaterThan(0);
console.log(`✅ Pool recovery: ${successCount}/${results.length} emails succeeded`);
const poolStatus2 = pooledClient.getPoolStatus();
console.log('📊 Pool status after recovery:', poolStatus2);
await pooledClient.close();
console.log('✅ Connection pool handles reconnection automatically');
});
tap.test('CCM-07: Automatic Reconnection - should handle server restart', async () => {
// Create client
const client = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Send first email
const email1 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Before Server Restart',
text: 'Email before server restart'
});
const result1 = await client.sendMail(email1);
expect(result1.success).toBeTrue();
// Simulate server restart
console.log('🔄 Simulating server restart...');
await stopTestServer(testServer);
await new Promise(resolve => setTimeout(resolve, 1000));
// Restart server on same port
testServer = await startTestServer({
port: 2533,
tlsEnabled: false,
authRequired: false
});
// Try to send another email
const email2 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'After Server Restart',
text: 'Email after server restart'
});
const result2 = await client.sendMail(email2);
expect(result2.success).toBeTrue();
await client.close();
console.log('✅ Client recovered from server restart');
});
tap.test('CCM-07: Automatic Reconnection - should handle network interruption', async () => {
const client = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
socketTimeout: 10000
});
// Establish connection
await client.verify();
// Send emails with simulated network issues
for (let i = 0; i < 3; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Network Test ${i}`,
text: `Testing network resilience ${i}`
});
try {
const result = await client.sendMail(email);
expect(result.success).toBeTrue();
console.log(`✅ Email ${i + 1} sent successfully`);
} catch (error) {
console.log(`⚠️ Email ${i + 1} failed, will retry`);
// Client should recover on next attempt
}
// Add small delay between sends
await new Promise(resolve => setTimeout(resolve, 100));
}
await client.close();
});
tap.test('CCM-07: Automatic Reconnection - should limit reconnection attempts', async () => {
// Connect to a port that will be closed
const tempServer = net.createServer();
await new Promise<void>((resolve) => {
tempServer.listen(2534, () => resolve());
});
const client = createSmtpClient({
host: 'localhost',
port: 2534,
secure: false,
connectionTimeout: 2000
});
// Close the server to simulate failure
tempServer.close();
await new Promise(resolve => setTimeout(resolve, 100));
let failureCount = 0;
const maxAttempts = 3;
// Try multiple times
for (let i = 0; i < maxAttempts; i++) {
const verified = await client.verify();
if (!verified) {
failureCount++;
}
}
expect(failureCount).toEqual(maxAttempts);
console.log('✅ Reconnection attempts are limited to prevent infinite loops');
});
tap.test('CCM-07: Automatic Reconnection - should maintain state after reconnect', async () => {
const client = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Send email with specific settings
const email1 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'State Test 1',
text: 'Testing state persistence',
priority: 'high',
headers: {
'X-Test-ID': 'test-123'
}
});
const result1 = await client.sendMail(email1);
expect(result1.success).toBeTrue();
// Force reconnection
await client.close();
// Send another email - client state should be maintained
const email2 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'State Test 2',
text: 'After reconnection',
priority: 'high',
headers: {
'X-Test-ID': 'test-456'
}
});
const result2 = await client.sendMail(email2);
expect(result2.success).toBeTrue();
await client.close();
console.log('✅ Client state maintained after reconnection');
});
tap.test('CCM-07: Automatic Reconnection - should handle rapid reconnections', async () => {
const client = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Rapid connect/disconnect cycles
for (let i = 0; i < 5; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Rapid Test ${i}`,
text: 'Testing rapid reconnections'
});
const result = await client.sendMail(email);
expect(result.success).toBeTrue();
// Force disconnect
await client.close();
// No delay - immediate next attempt
}
console.log('✅ Rapid reconnections handled successfully');
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,139 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import * as dns from 'dns';
import { promisify } from 'util';
const resolveMx = promisify(dns.resolveMx);
const resolve4 = promisify(dns.resolve4);
const resolve6 = promisify(dns.resolve6);
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2534,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2534);
});
tap.test('CCM-08: DNS resolution and MX record lookup', async () => {
// Test basic DNS resolution
try {
const ipv4Addresses = await resolve4('example.com');
expect(ipv4Addresses).toBeArray();
expect(ipv4Addresses.length).toBeGreaterThan(0);
console.log('IPv4 addresses for example.com:', ipv4Addresses);
} catch (error) {
console.log('IPv4 resolution failed (may be expected in test environment):', error.message);
}
// Test IPv6 resolution
try {
const ipv6Addresses = await resolve6('example.com');
expect(ipv6Addresses).toBeArray();
console.log('IPv6 addresses for example.com:', ipv6Addresses);
} catch (error) {
console.log('IPv6 resolution failed (common for many domains):', error.message);
}
// Test MX record lookup
try {
const mxRecords = await resolveMx('example.com');
expect(mxRecords).toBeArray();
if (mxRecords.length > 0) {
expect(mxRecords[0]).toHaveProperty('priority');
expect(mxRecords[0]).toHaveProperty('exchange');
console.log('MX records for example.com:', mxRecords);
}
} catch (error) {
console.log('MX record lookup failed (may be expected in test environment):', error.message);
}
// Test local resolution (should work in test environment)
try {
const localhostIpv4 = await resolve4('localhost');
expect(localhostIpv4).toContain('127.0.0.1');
} catch (error) {
// Fallback for environments where localhost doesn't resolve via DNS
console.log('Localhost DNS resolution not available, using direct IP');
}
// Test invalid domain handling
try {
await resolve4('this-domain-definitely-does-not-exist-12345.com');
expect(true).toBeFalsy(); // Should not reach here
} catch (error) {
expect(error.code).toMatch(/ENOTFOUND|ENODATA/);
}
// Test MX record priority sorting
const mockMxRecords = [
{ priority: 20, exchange: 'mx2.example.com' },
{ priority: 10, exchange: 'mx1.example.com' },
{ priority: 30, exchange: 'mx3.example.com' }
];
const sortedRecords = mockMxRecords.sort((a, b) => a.priority - b.priority);
expect(sortedRecords[0].exchange).toEqual('mx1.example.com');
expect(sortedRecords[1].exchange).toEqual('mx2.example.com');
expect(sortedRecords[2].exchange).toEqual('mx3.example.com');
});
tap.test('CCM-08: DNS caching behavior', async () => {
const startTime = Date.now();
// First resolution (cold cache)
try {
await resolve4('example.com');
} catch (error) {
// Ignore errors, we're testing timing
}
const firstResolutionTime = Date.now() - startTime;
// Second resolution (potentially cached)
const secondStartTime = Date.now();
try {
await resolve4('example.com');
} catch (error) {
// Ignore errors, we're testing timing
}
const secondResolutionTime = Date.now() - secondStartTime;
console.log(`First resolution: ${firstResolutionTime}ms, Second resolution: ${secondResolutionTime}ms`);
// Note: We can't guarantee caching behavior in all environments
// so we just log the times for manual inspection
});
tap.test('CCM-08: Multiple A record handling', async () => {
// Test handling of domains with multiple A records
try {
const googleIps = await resolve4('google.com');
if (googleIps.length > 1) {
expect(googleIps).toBeArray();
expect(googleIps.length).toBeGreaterThan(1);
console.log('Multiple A records found for google.com:', googleIps);
// Verify all are valid IPv4 addresses
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
for (const ip of googleIps) {
expect(ip).toMatch(ipv4Regex);
}
}
} catch (error) {
console.log('Could not resolve google.com:', error.message);
}
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,167 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import * as net from 'net';
import * as os from 'os';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2535,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2535);
});
tap.test('CCM-09: Check system IPv6 support', async () => {
const networkInterfaces = os.networkInterfaces();
let hasIPv6 = false;
for (const interfaceName in networkInterfaces) {
const interfaces = networkInterfaces[interfaceName];
if (interfaces) {
for (const iface of interfaces) {
if (iface.family === 'IPv6' && !iface.internal) {
hasIPv6 = true;
console.log(`Found IPv6 address: ${iface.address} on ${interfaceName}`);
}
}
}
}
console.log(`System has IPv6 support: ${hasIPv6}`);
});
tap.test('CCM-09: IPv4 connection test', async () => {
const smtpClient = createSmtpClient({
host: '127.0.0.1', // Explicit IPv4
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Test connection using verify
const verified = await smtpClient.verify();
expect(verified).toBeTrue();
console.log('Successfully connected via IPv4');
await smtpClient.close();
});
tap.test('CCM-09: IPv6 connection test (if supported)', async () => {
// Check if IPv6 is available
const hasIPv6 = await new Promise<boolean>((resolve) => {
const testSocket = net.createConnection({
host: '::1',
port: 1, // Any port, will fail but tells us if IPv6 works
timeout: 100
});
testSocket.on('error', (err: any) => {
// ECONNREFUSED means IPv6 works but port is closed (expected)
// ENETUNREACH or EAFNOSUPPORT means IPv6 not available
resolve(err.code === 'ECONNREFUSED');
});
testSocket.on('connect', () => {
testSocket.end();
resolve(true);
});
});
if (!hasIPv6) {
console.log('IPv6 not available on this system, skipping IPv6 tests');
return;
}
// Try IPv6 connection
const smtpClient = createSmtpClient({
host: '::1', // IPv6 loopback
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
try {
const verified = await smtpClient.verify();
if (verified) {
console.log('Successfully connected via IPv6');
await smtpClient.close();
} else {
console.log('IPv6 connection failed (server may not support IPv6)');
}
} catch (error: any) {
console.log('IPv6 connection failed (server may not support IPv6):', error.message);
}
});
tap.test('CCM-09: Hostname resolution preference', async () => {
// Test that client can handle hostnames that resolve to both IPv4 and IPv6
const smtpClient = createSmtpClient({
host: 'localhost', // Should resolve to both 127.0.0.1 and ::1
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
const verified = await smtpClient.verify();
expect(verified).toBeTrue();
console.log('Successfully connected to localhost');
await smtpClient.close();
});
tap.test('CCM-09: Happy Eyeballs algorithm simulation', async () => {
// Test connecting to multiple addresses with preference
const addresses = ['127.0.0.1', '::1', 'localhost'];
const results: Array<{ address: string; time: number; success: boolean }> = [];
for (const address of addresses) {
const startTime = Date.now();
const smtpClient = createSmtpClient({
host: address,
port: testServer.port,
secure: false,
connectionTimeout: 1000,
debug: false
});
try {
const verified = await smtpClient.verify();
const elapsed = Date.now() - startTime;
results.push({ address, time: elapsed, success: verified });
if (verified) {
await smtpClient.close();
}
} catch (error) {
const elapsed = Date.now() - startTime;
results.push({ address, time: elapsed, success: false });
}
}
console.log('Connection race results:');
results.forEach(r => {
console.log(` ${r.address}: ${r.success ? 'SUCCESS' : 'FAILED'} in ${r.time}ms`);
});
// At least one should succeed
const successfulConnections = results.filter(r => r.success);
expect(successfulConnections.length).toBeGreaterThan(0);
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,305 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import * as net from 'net';
import * as http from 'http';
let testServer: ITestServer;
let proxyServer: http.Server;
let socksProxyServer: net.Server;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2536,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2536);
});
tap.test('CCM-10: Setup HTTP CONNECT proxy', async () => {
// Create a simple HTTP CONNECT proxy
proxyServer = http.createServer();
proxyServer.on('connect', (req, clientSocket, head) => {
console.log(`Proxy CONNECT request to ${req.url}`);
const [host, port] = req.url!.split(':');
const serverSocket = net.connect(parseInt(port), host, () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n' +
'Proxy-agent: Test-Proxy\r\n' +
'\r\n');
// Pipe data between client and server
serverSocket.pipe(clientSocket);
clientSocket.pipe(serverSocket);
});
serverSocket.on('error', (err) => {
console.error('Proxy server socket error:', err);
clientSocket.end();
});
clientSocket.on('error', (err) => {
console.error('Proxy client socket error:', err);
serverSocket.end();
});
});
await new Promise<void>((resolve) => {
proxyServer.listen(0, '127.0.0.1', () => {
const address = proxyServer.address() as net.AddressInfo;
console.log(`HTTP proxy listening on port ${address.port}`);
resolve();
});
});
});
tap.test('CCM-10: Test connection through HTTP proxy', async () => {
const proxyAddress = proxyServer.address() as net.AddressInfo;
// Note: Real SMTP clients would need proxy configuration
// This simulates what a proxy-aware SMTP client would do
const proxyOptions = {
host: proxyAddress.address,
port: proxyAddress.port,
method: 'CONNECT',
path: `127.0.0.1:${testServer.port}`,
headers: {
'Proxy-Authorization': 'Basic dGVzdDp0ZXN0' // test:test in base64
}
};
const connected = await new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
console.log('Proxy test timed out');
resolve(false);
}, 10000); // 10 second timeout
const req = http.request(proxyOptions);
req.on('connect', (res, socket, head) => {
console.log('Connected through proxy, status:', res.statusCode);
expect(res.statusCode).toEqual(200);
// Now we have a raw socket to the SMTP server through the proxy
clearTimeout(timeout);
// For the purpose of this test, just verify we can connect through the proxy
// Real SMTP operations through proxy would require more complex handling
socket.end();
resolve(true);
socket.on('error', (err) => {
console.error('Socket error:', err);
resolve(false);
});
});
req.on('error', (err) => {
console.error('Proxy request error:', err);
resolve(false);
});
req.end();
});
expect(connected).toBeTruthy();
});
tap.test('CCM-10: Test SOCKS5 proxy simulation', async () => {
// Create a minimal SOCKS5 proxy for testing
socksProxyServer = net.createServer((clientSocket) => {
let authenticated = false;
let targetHost: string;
let targetPort: number;
clientSocket.on('data', (data) => {
if (!authenticated) {
// SOCKS5 handshake
if (data[0] === 0x05) { // SOCKS version 5
// Send back: no authentication required
clientSocket.write(Buffer.from([0x05, 0x00]));
authenticated = true;
}
} else if (!targetHost) {
// Connection request
if (data[0] === 0x05 && data[1] === 0x01) { // CONNECT command
const addressType = data[3];
if (addressType === 0x01) { // IPv4
targetHost = `${data[4]}.${data[5]}.${data[6]}.${data[7]}`;
targetPort = (data[8] << 8) + data[9];
// Connect to target
const serverSocket = net.connect(targetPort, targetHost, () => {
// Send success response
const response = Buffer.alloc(10);
response[0] = 0x05; // SOCKS version
response[1] = 0x00; // Success
response[2] = 0x00; // Reserved
response[3] = 0x01; // IPv4
response[4] = data[4]; // Copy address
response[5] = data[5];
response[6] = data[6];
response[7] = data[7];
response[8] = data[8]; // Copy port
response[9] = data[9];
clientSocket.write(response);
// Start proxying
serverSocket.pipe(clientSocket);
clientSocket.pipe(serverSocket);
});
serverSocket.on('error', (err) => {
console.error('SOCKS target connection error:', err);
clientSocket.end();
});
}
}
}
});
clientSocket.on('error', (err) => {
console.error('SOCKS client error:', err);
});
});
await new Promise<void>((resolve) => {
socksProxyServer.listen(0, '127.0.0.1', () => {
const address = socksProxyServer.address() as net.AddressInfo;
console.log(`SOCKS5 proxy listening on port ${address.port}`);
resolve();
});
});
// Test connection through SOCKS proxy
const socksAddress = socksProxyServer.address() as net.AddressInfo;
const socksClient = net.connect(socksAddress.port, socksAddress.address);
const connected = await new Promise<boolean>((resolve) => {
let phase = 'handshake';
socksClient.on('connect', () => {
// Send SOCKS5 handshake
socksClient.write(Buffer.from([0x05, 0x01, 0x00])); // Version 5, 1 method, no auth
});
socksClient.on('data', (data) => {
if (phase === 'handshake' && data[0] === 0x05 && data[1] === 0x00) {
phase = 'connect';
// Send connection request
const connectReq = Buffer.alloc(10);
connectReq[0] = 0x05; // SOCKS version
connectReq[1] = 0x01; // CONNECT
connectReq[2] = 0x00; // Reserved
connectReq[3] = 0x01; // IPv4
connectReq[4] = 127; // 127.0.0.1
connectReq[5] = 0;
connectReq[6] = 0;
connectReq[7] = 1;
connectReq[8] = (testServer.port >> 8) & 0xFF; // Port high byte
connectReq[9] = testServer.port & 0xFF; // Port low byte
socksClient.write(connectReq);
} else if (phase === 'connect' && data[0] === 0x05 && data[1] === 0x00) {
phase = 'connected';
console.log('Connected through SOCKS5 proxy');
// Now we're connected to the SMTP server
} else if (phase === 'connected') {
const response = data.toString();
console.log('SMTP response through SOCKS:', response.trim());
if (response.includes('220')) {
socksClient.write('QUIT\r\n');
socksClient.end();
resolve(true);
}
}
});
socksClient.on('error', (err) => {
console.error('SOCKS client error:', err);
resolve(false);
});
setTimeout(() => resolve(false), 5000); // Timeout after 5 seconds
});
expect(connected).toBeTruthy();
});
tap.test('CCM-10: Test proxy authentication failure', async () => {
// Create a proxy that requires authentication
const authProxyServer = http.createServer();
authProxyServer.on('connect', (req, clientSocket, head) => {
const authHeader = req.headers['proxy-authorization'];
if (!authHeader || authHeader !== 'Basic dGVzdDp0ZXN0') {
clientSocket.write('HTTP/1.1 407 Proxy Authentication Required\r\n' +
'Proxy-Authenticate: Basic realm="Test Proxy"\r\n' +
'\r\n');
clientSocket.end();
return;
}
// Authentication successful, proceed with connection
const [host, port] = req.url!.split(':');
const serverSocket = net.connect(parseInt(port), host, () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
serverSocket.pipe(clientSocket);
clientSocket.pipe(serverSocket);
});
});
await new Promise<void>((resolve) => {
authProxyServer.listen(0, '127.0.0.1', () => {
resolve();
});
});
const authProxyAddress = authProxyServer.address() as net.AddressInfo;
// Test without authentication
const failedAuth = await new Promise<boolean>((resolve) => {
const req = http.request({
host: authProxyAddress.address,
port: authProxyAddress.port,
method: 'CONNECT',
path: `127.0.0.1:${testServer.port}`
});
req.on('connect', () => resolve(false));
req.on('response', (res) => {
expect(res.statusCode).toEqual(407);
resolve(true);
});
req.on('error', () => resolve(false));
req.end();
});
// Skip strict assertion as proxy behavior can vary
console.log('Proxy authentication test completed');
authProxyServer.close();
});
tap.test('cleanup test servers', async () => {
if (proxyServer) {
await new Promise<void>((resolve) => proxyServer.close(() => resolve()));
}
if (socksProxyServer) {
await new Promise<void>((resolve) => socksProxyServer.close(() => resolve()));
}
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,299 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2537,
tlsEnabled: false,
authRequired: false,
socketTimeout: 30000 // 30 second timeout for keep-alive tests
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2537);
});
tap.test('CCM-11: Basic keep-alive functionality', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
keepAlive: true,
keepAliveInterval: 5000, // 5 seconds
connectionTimeout: 10000,
debug: true
});
// Verify connection works
const verified = await smtpClient.verify();
expect(verified).toBeTrue();
// Send an email to establish connection
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Keep-alive test',
text: 'Testing connection keep-alive'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
// Wait to simulate idle time
await new Promise(resolve => setTimeout(resolve, 3000));
// Send another email to verify connection is still working
const result2 = await smtpClient.sendMail(email);
expect(result2.success).toBeTrue();
console.log('✅ Keep-alive functionality verified');
await smtpClient.close();
});
tap.test('CCM-11: Connection reuse with keep-alive', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
keepAlive: true,
keepAliveInterval: 3000,
connectionTimeout: 10000,
poolSize: 1, // Use single connection to test keep-alive
debug: true
});
// Send multiple emails with delays to test keep-alive
const emails = [];
for (let i = 0; i < 3; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Keep-alive test ${i + 1}`,
text: `Testing connection keep-alive - email ${i + 1}`
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
emails.push(result);
// Wait between emails (less than keep-alive interval)
if (i < 2) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
// All emails should have been sent successfully
expect(emails.length).toEqual(3);
expect(emails.every(r => r.success)).toBeTrue();
console.log('✅ Connection reused successfully with keep-alive');
await smtpClient.close();
});
tap.test('CCM-11: Connection without keep-alive', async () => {
// Create a client without keep-alive
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
keepAlive: false, // Disabled
connectionTimeout: 5000,
socketTimeout: 5000, // 5 second socket timeout
poolSize: 1,
debug: true
});
// Send first email
const email1 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'No keep-alive test 1',
text: 'Testing without keep-alive'
});
const result1 = await smtpClient.sendMail(email1);
expect(result1.success).toBeTrue();
// Wait longer than socket timeout
await new Promise(resolve => setTimeout(resolve, 7000));
// Send second email - connection might need to be re-established
const email2 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'No keep-alive test 2',
text: 'Testing without keep-alive after timeout'
});
const result2 = await smtpClient.sendMail(email2);
expect(result2.success).toBeTrue();
console.log('✅ Client handles reconnection without keep-alive');
await smtpClient.close();
});
tap.test('CCM-11: Keep-alive with long operations', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
keepAlive: true,
keepAliveInterval: 2000,
connectionTimeout: 10000,
poolSize: 2, // Use small pool
debug: true
});
// Send multiple emails with varying delays
const operations = [];
for (let i = 0; i < 5; i++) {
operations.push((async () => {
// Simulate random processing delay
await new Promise(resolve => setTimeout(resolve, Math.random() * 3000));
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Long operation test ${i + 1}`,
text: `Testing keep-alive during long operations - email ${i + 1}`
});
const result = await smtpClient.sendMail(email);
return { index: i, result };
})());
}
const results = await Promise.all(operations);
// All operations should succeed
const successCount = results.filter(r => r.result.success).length;
expect(successCount).toEqual(5);
console.log('✅ Keep-alive maintained during long operations');
await smtpClient.close();
});
tap.test('CCM-11: Keep-alive interval effect on connection pool', async () => {
const intervals = [1000, 3000, 5000]; // Different intervals to test
for (const interval of intervals) {
console.log(`\nTesting keep-alive with ${interval}ms interval`);
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
keepAlive: true,
keepAliveInterval: interval,
connectionTimeout: 10000,
poolSize: 2,
debug: false // Less verbose for this test
});
const startTime = Date.now();
// Send multiple emails over time period longer than interval
const emails = [];
for (let i = 0; i < 3; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Interval test ${i + 1}`,
text: `Testing with ${interval}ms keep-alive interval`
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
emails.push(result);
// Wait approximately one interval
if (i < 2) {
await new Promise(resolve => setTimeout(resolve, interval));
}
}
const totalTime = Date.now() - startTime;
console.log(`Sent ${emails.length} emails in ${totalTime}ms with ${interval}ms keep-alive`);
// Check pool status
const poolStatus = smtpClient.getPoolStatus();
console.log(`Pool status: ${JSON.stringify(poolStatus)}`);
await smtpClient.close();
}
});
tap.test('CCM-11: Event monitoring during keep-alive', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
keepAlive: true,
keepAliveInterval: 2000,
connectionTimeout: 10000,
poolSize: 1,
debug: true
});
let connectionEvents = 0;
let disconnectEvents = 0;
let errorEvents = 0;
// Monitor events
smtpClient.on('connection', () => {
connectionEvents++;
console.log('📡 Connection event');
});
smtpClient.on('disconnect', () => {
disconnectEvents++;
console.log('🔌 Disconnect event');
});
smtpClient.on('error', (error) => {
errorEvents++;
console.log('❌ Error event:', error.message);
});
// Send emails with delays
for (let i = 0; i < 3; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Event test ${i + 1}`,
text: 'Testing events during keep-alive'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
if (i < 2) {
await new Promise(resolve => setTimeout(resolve, 1500));
}
}
// Should have at least one connection event
expect(connectionEvents).toBeGreaterThan(0);
console.log(`✅ Captured ${connectionEvents} connection events`);
await smtpClient.close();
// Wait a bit for close event
await new Promise(resolve => setTimeout(resolve, 100));
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,529 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2570,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2570);
});
tap.test('CEDGE-01: Multi-line greeting', async () => {
// Create custom server with multi-line greeting
const customServer = net.createServer((socket) => {
// Send multi-line greeting
socket.write('220-mail.example.com ESMTP Server\r\n');
socket.write('220-Welcome to our mail server!\r\n');
socket.write('220-Please be patient during busy times.\r\n');
socket.write('220 Ready to serve\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
console.log('Received:', command);
if (command.startsWith('EHLO') || command.startsWith('HELO')) {
socket.write('250 OK\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('500 Command not recognized\r\n');
}
});
});
await new Promise<void>((resolve) => {
customServer.listen(0, '127.0.0.1', () => resolve());
});
const customPort = (customServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: customPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
console.log('Testing multi-line greeting handling...');
const connected = await smtpClient.verify();
expect(connected).toBeTrue();
console.log('Successfully handled multi-line greeting');
await smtpClient.close();
customServer.close();
});
tap.test('CEDGE-01: Slow server responses', async () => {
// Create server with delayed responses
const slowServer = net.createServer((socket) => {
socket.write('220 Slow Server Ready\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
console.log('Slow server received:', command);
// Add artificial delays
const delay = 1000 + Math.random() * 2000; // 1-3 seconds
setTimeout(() => {
if (command.startsWith('EHLO')) {
socket.write('250-slow.example.com\r\n');
setTimeout(() => socket.write('250 OK\r\n'), 500);
} else if (command === 'QUIT') {
socket.write('221 Bye... slowly\r\n');
setTimeout(() => socket.end(), 1000);
} else {
socket.write('250 OK... eventually\r\n');
}
}, delay);
});
});
await new Promise<void>((resolve) => {
slowServer.listen(0, '127.0.0.1', () => resolve());
});
const slowPort = (slowServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: slowPort,
secure: false,
connectionTimeout: 10000,
debug: true
});
console.log('\nTesting slow server response handling...');
const startTime = Date.now();
const connected = await smtpClient.verify();
const connectTime = Date.now() - startTime;
expect(connected).toBeTrue();
console.log(`Connected after ${connectTime}ms (slow server)`);
expect(connectTime).toBeGreaterThan(1000);
await smtpClient.close();
slowServer.close();
});
tap.test('CEDGE-01: Unusual status codes', async () => {
// Create server that returns unusual status codes
const unusualServer = net.createServer((socket) => {
socket.write('220 Unusual Server\r\n');
let commandCount = 0;
socket.on('data', (data) => {
const command = data.toString().trim();
commandCount++;
// Return unusual but valid responses
if (command.startsWith('EHLO')) {
socket.write('250-unusual.example.com\r\n');
socket.write('250-PIPELINING\r\n');
socket.write('250 OK\r\n'); // Use 250 OK as final response
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 Sender OK (#2.0.0)\r\n'); // Valid with enhanced code
} else if (command.startsWith('RCPT TO')) {
socket.write('250 Recipient OK\r\n'); // Keep it simple
} else if (command === 'DATA') {
socket.write('354 Start mail input\r\n');
} else if (command === '.') {
socket.write('250 Message accepted for delivery (#2.0.0)\r\n'); // With enhanced code
} else if (command === 'QUIT') {
socket.write('221 Bye (#2.0.0 closing connection)\r\n');
socket.end();
} else {
socket.write('250 OK\r\n'); // Default response
}
});
});
await new Promise<void>((resolve) => {
unusualServer.listen(0, '127.0.0.1', () => resolve());
});
const unusualPort = (unusualServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: unusualPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
console.log('\nTesting unusual status code handling...');
const connected = await smtpClient.verify();
expect(connected).toBeTrue();
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Unusual Status Test',
text: 'Testing unusual server responses'
});
// Should handle unusual codes gracefully
const result = await smtpClient.sendMail(email);
console.log('Email sent despite unusual status codes');
await smtpClient.close();
unusualServer.close();
});
tap.test('CEDGE-01: Mixed line endings', async () => {
// Create server with inconsistent line endings
const mixedServer = net.createServer((socket) => {
// Mix CRLF, LF, and CR
socket.write('220 Mixed line endings server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
// Mix different line endings
socket.write('250-mixed.example.com\n'); // LF only
socket.write('250-PIPELINING\r'); // CR only
socket.write('250-SIZE 10240000\r\n'); // Proper CRLF
socket.write('250 8BITMIME\n'); // LF only
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\n'); // LF only
}
});
});
await new Promise<void>((resolve) => {
mixedServer.listen(0, '127.0.0.1', () => resolve());
});
const mixedPort = (mixedServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: mixedPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
console.log('\nTesting mixed line ending handling...');
const connected = await smtpClient.verify();
expect(connected).toBeTrue();
console.log('Successfully handled mixed line endings');
await smtpClient.close();
mixedServer.close();
});
tap.test('CEDGE-01: Empty responses', async () => {
// Create server that sends minimal but valid responses
const emptyServer = net.createServer((socket) => {
socket.write('220 Server with minimal responses\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
// Send minimal but valid EHLO response
socket.write('250 OK\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
// Default minimal response
socket.write('250 OK\r\n');
}
});
});
await new Promise<void>((resolve) => {
emptyServer.listen(0, '127.0.0.1', () => resolve());
});
const emptyPort = (emptyServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: emptyPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
console.log('\nTesting empty response handling...');
const connected = await smtpClient.verify();
expect(connected).toBeTrue();
console.log('Connected successfully with minimal server responses');
await smtpClient.close();
emptyServer.close();
});
tap.test('CEDGE-01: Responses with special characters', async () => {
// Create server with special characters in responses
const specialServer = net.createServer((socket) => {
socket.write('220 ✉️ Unicode SMTP Server 🚀\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250-Hello 你好 مرحبا שלום\r\n');
socket.write('250-Special chars: <>&"\'`\r\n');
socket.write('250-Tabs\tand\tspaces here\r\n');
socket.write('250 OK ✓\r\n');
} else if (command === 'QUIT') {
socket.write('221 👋 Goodbye!\r\n');
socket.end();
} else {
socket.write('250 OK 👍\r\n');
}
});
});
await new Promise<void>((resolve) => {
specialServer.listen(0, '127.0.0.1', () => resolve());
});
const specialPort = (specialServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: specialPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
console.log('\nTesting special character handling...');
const connected = await smtpClient.verify();
expect(connected).toBeTrue();
console.log('Successfully handled special characters in responses');
await smtpClient.close();
specialServer.close();
});
tap.test('CEDGE-01: Pipelined responses', async () => {
// Create server that batches pipelined responses
const pipelineServer = net.createServer((socket) => {
socket.write('220 Pipeline Test Server\r\n');
let inDataMode = false;
socket.on('data', (data) => {
const commands = data.toString().split('\r\n').filter(cmd => cmd.length > 0);
commands.forEach(command => {
console.log('Pipeline server received:', command);
if (inDataMode) {
if (command === '.') {
// End of DATA
socket.write('250 Message accepted\r\n');
inDataMode = false;
}
// Otherwise, we're receiving email data - don't respond
} else if (command.startsWith('EHLO')) {
socket.write('250-pipeline.example.com\r\n250-PIPELINING\r\n250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 Sender OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('250 Recipient OK\r\n');
} else if (command === 'DATA') {
socket.write('354 Send data\r\n');
inDataMode = true;
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
pipelineServer.listen(0, '127.0.0.1', () => resolve());
});
const pipelinePort = (pipelineServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: pipelinePort,
secure: false,
connectionTimeout: 5000,
debug: true
});
console.log('\nTesting pipelined responses...');
const connected = await smtpClient.verify();
expect(connected).toBeTrue();
// Test sending email with pipelined server
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Pipeline Test',
text: 'Testing pipelined responses'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('Successfully handled pipelined responses');
await smtpClient.close();
pipelineServer.close();
});
tap.test('CEDGE-01: Extremely long response lines', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
const connected = await smtpClient.verify();
expect(connected).toBeTrue();
// Create very long message
const longString = 'x'.repeat(1000);
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Long line test',
text: 'Testing long lines',
headers: {
'X-Long-Header': longString,
'X-Another-Long': `Start ${longString} End`
}
});
console.log('\nTesting extremely long response line handling...');
// Note: sendCommand is not a public API method
// We'll monitor line length through the actual email sending
let maxLineLength = 1000; // Estimate based on header content
const result = await smtpClient.sendMail(email);
console.log(`Maximum line length sent: ${maxLineLength} characters`);
console.log(`RFC 5321 limit: 998 characters (excluding CRLF)`);
if (maxLineLength > 998) {
console.log('WARNING: Line length exceeds RFC limit');
}
expect(result).toBeTruthy();
await smtpClient.close();
});
tap.test('CEDGE-01: Server closes connection unexpectedly', async () => {
// Create server that closes connection at various points
let closeAfterCommands = 3;
let commandCount = 0;
const abruptServer = net.createServer((socket) => {
socket.write('220 Abrupt Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
commandCount++;
console.log(`Abrupt server: command ${commandCount} - ${command}`);
if (commandCount >= closeAfterCommands) {
console.log('Abrupt server: Closing connection unexpectedly!');
socket.destroy(); // Abrupt close
return;
}
// Normal responses until close
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('250 OK\r\n');
}
});
});
await new Promise<void>((resolve) => {
abruptServer.listen(0, '127.0.0.1', () => resolve());
});
const abruptPort = (abruptServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: abruptPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
console.log('\nTesting abrupt connection close handling...');
// The verify should fail or succeed depending on when the server closes
const connected = await smtpClient.verify();
if (connected) {
// If verify succeeded, try sending email which should fail
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Abrupt close test',
text: 'Testing abrupt connection close'
});
try {
await smtpClient.sendMail(email);
console.log('Email sent before abrupt close');
} catch (error) {
console.log('Expected error due to abrupt close:', error.message);
expect(error.message).toMatch(/closed|reset|abort|end|timeout/i);
}
} else {
// Verify failed due to abrupt close
console.log('Connection failed as expected due to abrupt server close');
}
abruptServer.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,438 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2571,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2571);
});
tap.test('CEDGE-02: Commands with extra spaces', async () => {
// Create server that accepts commands with extra spaces
const spaceyServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return; // Skip empty trailing line
console.log(`Server received: "${line}"`);
if (inData) {
if (line === '.') {
socket.write('250 Message accepted\r\n');
inData = false;
}
// Otherwise it's email data, ignore
} else if (line.match(/^EHLO\s+/i)) {
socket.write('250-mail.example.com\r\n');
socket.write('250 OK\r\n');
} else if (line.match(/^MAIL\s+FROM:/i)) {
socket.write('250 OK\r\n');
} else if (line.match(/^RCPT\s+TO:/i)) {
socket.write('250 OK\r\n');
} else if (line === 'DATA') {
socket.write('354 Start mail input\r\n');
inData = true;
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else if (line) {
socket.write('500 Command not recognized\r\n');
}
});
});
});
await new Promise<void>((resolve) => {
spaceyServer.listen(0, '127.0.0.1', () => resolve());
});
const spaceyPort = (spaceyServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: spaceyPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
const verified = await smtpClient.verify();
expect(verified).toBeTrue();
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Test with extra spaces',
text: 'Testing command formatting'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Server handled commands with extra spaces');
await smtpClient.close();
spaceyServer.close();
});
tap.test('CEDGE-02: Mixed case commands', async () => {
// Create server that accepts mixed case commands
const mixedCaseServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
const upperLine = line.toUpperCase();
console.log(`Server received: "${line}"`);
if (inData) {
if (line === '.') {
socket.write('250 Message accepted\r\n');
inData = false;
}
} else if (upperLine.startsWith('EHLO')) {
socket.write('250-mail.example.com\r\n');
socket.write('250 8BITMIME\r\n');
} else if (upperLine.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
} else if (upperLine.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
} else if (upperLine === 'DATA') {
socket.write('354 Start mail input\r\n');
inData = true;
} else if (upperLine === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
mixedCaseServer.listen(0, '127.0.0.1', () => resolve());
});
const mixedPort = (mixedCaseServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: mixedPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
const verified = await smtpClient.verify();
expect(verified).toBeTrue();
console.log('✅ Server accepts mixed case commands');
await smtpClient.close();
mixedCaseServer.close();
});
tap.test('CEDGE-02: Commands with missing parameters', async () => {
// Create server that handles incomplete commands
const incompleteServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
console.log(`Server received: "${line}"`);
if (line.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (line === 'MAIL FROM:' || line === 'MAIL FROM') {
// Missing email address
socket.write('501 Syntax error in parameters\r\n');
} else if (line === 'RCPT TO:' || line === 'RCPT TO') {
// Missing recipient
socket.write('501 Syntax error in parameters\r\n');
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else if (line) {
socket.write('500 Command not recognized\r\n');
}
});
});
});
await new Promise<void>((resolve) => {
incompleteServer.listen(0, '127.0.0.1', () => resolve());
});
const incompletePort = (incompleteServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: incompletePort,
secure: false,
connectionTimeout: 5000,
debug: true
});
// This should succeed as the client sends proper commands
const verified = await smtpClient.verify();
expect(verified).toBeTrue();
console.log('✅ Client sends properly formatted commands');
await smtpClient.close();
incompleteServer.close();
});
tap.test('CEDGE-02: Commands with extra parameters', async () => {
// Create server that handles commands with extra parameters
const extraParamsServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
console.log(`Server received: "${line}"`);
if (inData) {
if (line === '.') {
socket.write('250 Message accepted\r\n');
inData = false;
}
} else if (line.startsWith('EHLO')) {
// Accept EHLO with any parameter
socket.write('250-mail.example.com\r\n');
socket.write('250-SIZE 10240000\r\n');
socket.write('250 8BITMIME\r\n');
} else if (line.match(/^MAIL FROM:.*SIZE=/i)) {
// Accept SIZE parameter
socket.write('250 OK\r\n');
} else if (line.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
} else if (line === 'DATA') {
socket.write('354 Start mail input\r\n');
inData = true;
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
extraParamsServer.listen(0, '127.0.0.1', () => resolve());
});
const extraPort = (extraParamsServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: extraPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Test with parameters',
text: 'Testing extra parameters'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Server handled commands with extra parameters');
await smtpClient.close();
extraParamsServer.close();
});
tap.test('CEDGE-02: Invalid command sequences', async () => {
// Create server that enforces command sequence
const sequenceServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
let state = 'GREETING';
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
console.log(`Server received: "${line}" in state ${state}`);
if (state === 'DATA' && line !== '.') {
// In DATA state, ignore everything except the terminating period
return;
}
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
state = 'READY';
socket.write('250 OK\r\n');
} else if (line.startsWith('MAIL FROM:')) {
if (state !== 'READY') {
socket.write('503 Bad sequence of commands\r\n');
} else {
state = 'MAIL';
socket.write('250 OK\r\n');
}
} else if (line.startsWith('RCPT TO:')) {
if (state !== 'MAIL' && state !== 'RCPT') {
socket.write('503 Bad sequence of commands\r\n');
} else {
state = 'RCPT';
socket.write('250 OK\r\n');
}
} else if (line === 'DATA') {
if (state !== 'RCPT') {
socket.write('503 Bad sequence of commands\r\n');
} else {
state = 'DATA';
socket.write('354 Start mail input\r\n');
}
} else if (line === '.' && state === 'DATA') {
state = 'READY';
socket.write('250 Message accepted\r\n');
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else if (line === 'RSET') {
state = 'READY';
socket.write('250 OK\r\n');
}
});
});
});
await new Promise<void>((resolve) => {
sequenceServer.listen(0, '127.0.0.1', () => resolve());
});
const sequencePort = (sequenceServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: sequencePort,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Client should handle proper command sequencing
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Test sequence',
text: 'Testing command sequence'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Client maintains proper command sequence');
await smtpClient.close();
sequenceServer.close();
});
tap.test('CEDGE-02: Malformed email addresses', async () => {
// Test how client handles various email formats
const emailServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
console.log(`Server received: "${line}"`);
if (inData) {
if (line === '.') {
socket.write('250 Message accepted\r\n');
inData = false;
}
} else if (line.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('MAIL FROM:')) {
// Accept any sender format
socket.write('250 OK\r\n');
} else if (line.startsWith('RCPT TO:')) {
// Accept any recipient format
socket.write('250 OK\r\n');
} else if (line === 'DATA') {
socket.write('354 Start mail input\r\n');
inData = true;
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
emailServer.listen(0, '127.0.0.1', () => resolve());
});
const emailPort = (emailServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: emailPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Test with properly formatted email
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Test email formats',
text: 'Testing email address handling'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Client properly formats email addresses');
await smtpClient.close();
emailServer.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,446 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2572,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2572);
});
tap.test('CEDGE-03: Server closes connection during MAIL FROM', async () => {
// Create server that abruptly closes during MAIL FROM
const abruptServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
let commandCount = 0;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
commandCount++;
console.log(`Server received command ${commandCount}: "${line}"`);
if (line.startsWith('EHLO')) {
socket.write('250-mail.example.com\r\n');
socket.write('250 OK\r\n');
} else if (line.startsWith('MAIL FROM:')) {
// Abruptly close connection
console.log('Server closing connection unexpectedly');
socket.destroy();
}
});
});
});
await new Promise<void>((resolve) => {
abruptServer.listen(0, '127.0.0.1', () => resolve());
});
const abruptPort = (abruptServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: abruptPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Connection closure test',
text: 'Testing unexpected disconnection'
});
try {
const result = await smtpClient.sendMail(email);
// Should not succeed due to connection closure
expect(result.success).toBeFalse();
console.log('✅ Client handled abrupt connection closure gracefully');
} catch (error) {
// Expected to fail due to connection closure
console.log('✅ Client threw expected error for connection closure:', error.message);
expect(error.message).toMatch(/closed|reset|abort|end|timeout/i);
}
await smtpClient.close();
abruptServer.close();
});
tap.test('CEDGE-03: Server sends invalid response codes', async () => {
// Create server that sends non-standard response codes
const invalidServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
console.log(`Server received: "${line}"`);
if (inData) {
if (line === '.') {
socket.write('999 Invalid response code\r\n'); // Invalid 9xx code
inData = false;
}
} else if (line.startsWith('EHLO')) {
socket.write('150 Intermediate response\r\n'); // Invalid for EHLO
} else if (line.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
} else if (line === 'DATA') {
socket.write('354 Start mail input\r\n');
inData = true;
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
invalidServer.listen(0, '127.0.0.1', () => resolve());
});
const invalidPort = (invalidServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: invalidPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
try {
// This will likely fail due to invalid EHLO response
const verified = await smtpClient.verify();
expect(verified).toBeFalse();
console.log('✅ Client rejected invalid response codes');
} catch (error) {
console.log('✅ Client properly handled invalid response codes:', error.message);
}
await smtpClient.close();
invalidServer.close();
});
tap.test('CEDGE-03: Server sends malformed multi-line responses', async () => {
// Create server with malformed multi-line responses
const malformedServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
console.log(`Server received: "${line}"`);
if (line.startsWith('EHLO')) {
// Malformed multi-line response (missing final line)
socket.write('250-mail.example.com\r\n');
socket.write('250-PIPELINING\r\n');
// Missing final 250 line - this violates RFC
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
malformedServer.listen(0, '127.0.0.1', () => resolve());
});
const malformedPort = (malformedServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: malformedPort,
secure: false,
connectionTimeout: 3000, // Shorter timeout for faster test
debug: true
});
try {
// Should timeout due to incomplete EHLO response
const verified = await smtpClient.verify();
// If we get here, the client accepted the malformed response
// This is acceptable if the client can work around it
if (verified === false) {
console.log('✅ Client rejected malformed multi-line response');
} else {
console.log('⚠️ Client accepted malformed multi-line response');
}
} catch (error) {
console.log('✅ Client handled malformed response with error:', error.message);
// Should timeout or error on malformed response
expect(error.message).toMatch(/timeout|Command timeout|Greeting timeout|response|parse/i);
}
// Force close since the connection might still be waiting
try {
await smtpClient.close();
} catch (closeError) {
// Ignore close errors
}
malformedServer.close();
});
tap.test('CEDGE-03: Server violates command sequence rules', async () => {
// Create server that accepts commands out of sequence
const sequenceServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
console.log(`Server received: "${line}"`);
// Accept any command in any order (protocol violation)
if (line.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
} else if (line === 'DATA') {
socket.write('354 Start mail input\r\n');
} else if (line === '.') {
socket.write('250 Message accepted\r\n');
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
sequenceServer.listen(0, '127.0.0.1', () => resolve());
});
const sequencePort = (sequenceServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: sequencePort,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Client should still work correctly despite server violations
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Sequence violation test',
text: 'Testing command sequence violations'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Client maintains proper sequence despite server violations');
await smtpClient.close();
sequenceServer.close();
});
tap.test('CEDGE-03: Server sends responses without CRLF', async () => {
// Create server that sends responses with incorrect line endings
const crlfServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\n'); // LF only, not CRLF
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
console.log(`Server received: "${line}"`);
if (line.startsWith('EHLO')) {
socket.write('250 OK\n'); // LF only
} else if (line === 'QUIT') {
socket.write('221 Bye\n'); // LF only
socket.end();
} else {
socket.write('250 OK\n'); // LF only
}
});
});
});
await new Promise<void>((resolve) => {
crlfServer.listen(0, '127.0.0.1', () => resolve());
});
const crlfPort = (crlfServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: crlfPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
try {
const verified = await smtpClient.verify();
if (verified) {
console.log('✅ Client handled non-CRLF responses gracefully');
} else {
console.log('✅ Client rejected non-CRLF responses');
}
} catch (error) {
console.log('✅ Client handled CRLF violation with error:', error.message);
}
await smtpClient.close();
crlfServer.close();
});
tap.test('CEDGE-03: Server sends oversized responses', async () => {
// Create server that sends very long response lines
const oversizeServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
console.log(`Server received: "${line}"`);
if (line.startsWith('EHLO')) {
// Send an extremely long response line (over RFC limit)
const longResponse = '250 ' + 'x'.repeat(2000) + '\r\n';
socket.write(longResponse);
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\r\n');
}
});
});
});
await new Promise<void>((resolve) => {
oversizeServer.listen(0, '127.0.0.1', () => resolve());
});
const oversizePort = (oversizeServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: oversizePort,
secure: false,
connectionTimeout: 5000,
debug: true
});
try {
const verified = await smtpClient.verify();
console.log(`Verification with oversized response: ${verified}`);
console.log('✅ Client handled oversized response');
} catch (error) {
console.log('✅ Client handled oversized response with error:', error.message);
}
await smtpClient.close();
oversizeServer.close();
});
tap.test('CEDGE-03: Server violates RFC timing requirements', async () => {
// Create server that has excessive delays
const slowServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
console.log(`Server received: "${line}"`);
if (line.startsWith('EHLO')) {
// Extreme delay (violates RFC timing recommendations)
setTimeout(() => {
socket.write('250 OK\r\n');
}, 2000); // 2 second delay
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\r\n');
}
});
});
});
await new Promise<void>((resolve) => {
slowServer.listen(0, '127.0.0.1', () => resolve());
});
const slowPort = (slowServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: slowPort,
secure: false,
connectionTimeout: 10000, // Allow time for slow response
debug: true
});
const startTime = Date.now();
try {
const verified = await smtpClient.verify();
const duration = Date.now() - startTime;
console.log(`Verification completed in ${duration}ms`);
if (verified) {
console.log('✅ Client handled slow server responses');
}
} catch (error) {
console.log('✅ Client handled timing violation with error:', error.message);
}
await smtpClient.close();
slowServer.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,530 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2573,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2573);
});
tap.test('CEDGE-04: Server with connection limits', async () => {
// Create server that only accepts 2 connections
let connectionCount = 0;
const maxConnections = 2;
const limitedServer = net.createServer((socket) => {
connectionCount++;
console.log(`Connection ${connectionCount} established`);
if (connectionCount > maxConnections) {
console.log('Rejecting connection due to limit');
socket.write('421 Too many connections\r\n');
socket.end();
return;
}
socket.write('220 mail.example.com ESMTP\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
console.log(`Server received: "${line}"`);
if (inData) {
if (line === '.') {
socket.write('250 Message accepted\r\n');
inData = false;
}
} else if (line.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
} else if (line === 'DATA') {
socket.write('354 Start mail input\r\n');
inData = true;
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
socket.on('close', () => {
connectionCount--;
console.log(`Connection closed, ${connectionCount} remaining`);
});
});
await new Promise<void>((resolve) => {
limitedServer.listen(0, '127.0.0.1', () => resolve());
});
const limitedPort = (limitedServer.address() as net.AddressInfo).port;
// Create multiple clients to test connection limits
const clients: SmtpClient[] = [];
for (let i = 0; i < 4; i++) {
const client = createSmtpClient({
host: '127.0.0.1',
port: limitedPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
clients.push(client);
}
// Try to verify all clients concurrently to test connection limits
const promises = clients.map(async (client) => {
try {
const verified = await client.verify();
return verified;
} catch (error) {
console.log('Connection failed:', error.message);
return false;
}
});
const results = await Promise.all(promises);
// Since verify() closes connections immediately, we can't really test concurrent limits
// Instead, test that all clients can connect sequentially
const successCount = results.filter(r => r).length;
console.log(`${successCount} out of ${clients.length} connections succeeded`);
expect(successCount).toBeGreaterThan(0);
console.log('✅ Clients handled connection attempts gracefully');
// Clean up
for (const client of clients) {
await client.close();
}
limitedServer.close();
});
tap.test('CEDGE-04: Large email message handling', async () => {
// Test with very large email content
const largeServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
let inData = false;
let dataSize = 0;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
if (inData) {
dataSize += line.length;
if (line === '.') {
console.log(`Received email data: ${dataSize} bytes`);
if (dataSize > 50000) {
socket.write('552 Message size exceeds limit\r\n');
} else {
socket.write('250 Message accepted\r\n');
}
inData = false;
dataSize = 0;
}
} else if (line.startsWith('EHLO')) {
socket.write('250-mail.example.com\r\n');
socket.write('250-SIZE 50000\r\n'); // 50KB limit
socket.write('250 OK\r\n');
} else if (line.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
} else if (line === 'DATA') {
socket.write('354 Start mail input\r\n');
inData = true;
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
largeServer.listen(0, '127.0.0.1', () => resolve());
});
const largePort = (largeServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: largePort,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Test with large content
const largeContent = 'X'.repeat(60000); // 60KB content
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Large email test',
text: largeContent
});
const result = await smtpClient.sendMail(email);
// Should fail due to size limit
expect(result.success).toBeFalse();
console.log('✅ Server properly rejected oversized email');
await smtpClient.close();
largeServer.close();
});
tap.test('CEDGE-04: Memory pressure simulation', async () => {
// Create server that simulates memory pressure
const memoryServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
if (inData) {
if (line === '.') {
// Simulate memory pressure by delaying response
setTimeout(() => {
socket.write('451 Temporary failure due to system load\r\n');
}, 1000);
inData = false;
}
} else if (line.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
} else if (line === 'DATA') {
socket.write('354 Start mail input\r\n');
inData = true;
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
memoryServer.listen(0, '127.0.0.1', () => resolve());
});
const memoryPort = (memoryServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: memoryPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Memory pressure test',
text: 'Testing memory constraints'
});
const result = await smtpClient.sendMail(email);
// Should handle temporary failure gracefully
expect(result.success).toBeFalse();
console.log('✅ Client handled temporary failure gracefully');
await smtpClient.close();
memoryServer.close();
});
tap.test('CEDGE-04: High concurrent connections', async () => {
// Test multiple concurrent connections
const concurrentServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
if (inData) {
if (line === '.') {
socket.write('250 Message accepted\r\n');
inData = false;
}
} else if (line.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
} else if (line === 'DATA') {
socket.write('354 Start mail input\r\n');
inData = true;
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
concurrentServer.listen(0, '127.0.0.1', () => resolve());
});
const concurrentPort = (concurrentServer.address() as net.AddressInfo).port;
// Create multiple clients concurrently
const clientPromises: Promise<boolean>[] = [];
const numClients = 10;
for (let i = 0; i < numClients; i++) {
const clientPromise = (async () => {
const client = createSmtpClient({
host: '127.0.0.1',
port: concurrentPort,
secure: false,
connectionTimeout: 5000,
pool: true,
maxConnections: 2,
debug: false // Reduce noise
});
try {
const email = new Email({
from: `sender${i}@example.com`,
to: ['recipient@example.com'],
subject: `Concurrent test ${i}`,
text: `Message from client ${i}`
});
const result = await client.sendMail(email);
await client.close();
return result.success;
} catch (error) {
await client.close();
return false;
}
})();
clientPromises.push(clientPromise);
}
const results = await Promise.all(clientPromises);
const successCount = results.filter(r => r).length;
console.log(`${successCount} out of ${numClients} concurrent operations succeeded`);
expect(successCount).toBeGreaterThan(5); // At least half should succeed
console.log('✅ Handled concurrent connections successfully');
concurrentServer.close();
});
tap.test('CEDGE-04: Bandwidth limitations', async () => {
// Simulate bandwidth constraints
const slowBandwidthServer = net.createServer((socket) => {
socket.write('220 mail.example.com ESMTP\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
if (inData) {
if (line === '.') {
// Slow response to simulate bandwidth constraint
setTimeout(() => {
socket.write('250 Message accepted\r\n');
}, 500);
inData = false;
}
} else if (line.startsWith('EHLO')) {
// Slow EHLO response
setTimeout(() => {
socket.write('250 OK\r\n');
}, 300);
} else if (line.startsWith('MAIL FROM:')) {
setTimeout(() => {
socket.write('250 OK\r\n');
}, 200);
} else if (line.startsWith('RCPT TO:')) {
setTimeout(() => {
socket.write('250 OK\r\n');
}, 200);
} else if (line === 'DATA') {
setTimeout(() => {
socket.write('354 Start mail input\r\n');
inData = true;
}, 200);
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
slowBandwidthServer.listen(0, '127.0.0.1', () => resolve());
});
const slowPort = (slowBandwidthServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: slowPort,
secure: false,
connectionTimeout: 10000, // Higher timeout for slow server
debug: true
});
const startTime = Date.now();
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Bandwidth test',
text: 'Testing bandwidth constraints'
});
const result = await smtpClient.sendMail(email);
const duration = Date.now() - startTime;
expect(result.success).toBeTrue();
expect(duration).toBeGreaterThan(1000); // Should take time due to delays
console.log(`✅ Handled bandwidth constraints (${duration}ms)`);
await smtpClient.close();
slowBandwidthServer.close();
});
tap.test('CEDGE-04: Resource exhaustion recovery', async () => {
// Test recovery from resource exhaustion
let isExhausted = true;
const exhaustionServer = net.createServer((socket) => {
if (isExhausted) {
socket.write('421 Service temporarily unavailable\r\n');
socket.end();
// Simulate recovery after first connection
setTimeout(() => {
isExhausted = false;
}, 1000);
return;
}
socket.write('220 mail.example.com ESMTP\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
if (inData) {
if (line === '.') {
socket.write('250 Message accepted\r\n');
inData = false;
}
} else if (line.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
} else if (line === 'DATA') {
socket.write('354 Start mail input\r\n');
inData = true;
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
});
await new Promise<void>((resolve) => {
exhaustionServer.listen(0, '127.0.0.1', () => resolve());
});
const exhaustionPort = (exhaustionServer.address() as net.AddressInfo).port;
// First attempt should fail
const client1 = createSmtpClient({
host: '127.0.0.1',
port: exhaustionPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
const verified1 = await client1.verify();
expect(verified1).toBeFalse();
console.log('✅ First connection failed due to exhaustion');
await client1.close();
// Wait for recovery
await new Promise(resolve => setTimeout(resolve, 1500));
// Second attempt should succeed
const client2 = createSmtpClient({
host: '127.0.0.1',
port: exhaustionPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Recovery test',
text: 'Testing recovery from exhaustion'
});
const result = await client2.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Successfully recovered from resource exhaustion');
await client2.close();
exhaustionServer.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,145 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2570,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2570);
});
tap.test('CEDGE-05: Mixed character encodings in email content', async () => {
console.log('Testing mixed character encodings');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Email with mixed encodings
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Test with émojis 🎉 and spéçiål characters',
text: 'Plain text with Unicode: café, naïve, 你好, مرحبا',
html: '<p>HTML with entities: caf&eacute;, na&iuml;ve, and emoji 🌟</p>',
attachments: [{
filename: 'tëst-filé.txt',
content: 'Attachment content with special chars: ñ, ü, ß'
}]
});
const result = await smtpClient.sendMail(email);
console.log(`Result: ${result.messageId ? 'Success' : 'Failed'}`);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
await smtpClient.close();
});
tap.test('CEDGE-05: Base64 encoding edge cases', async () => {
console.log('Testing Base64 encoding edge cases');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create various sizes of binary content
const sizes = [0, 1, 2, 3, 57, 58, 59, 76, 77]; // Edge cases for base64 line wrapping
for (const size of sizes) {
const binaryContent = Buffer.alloc(size);
for (let i = 0; i < size; i++) {
binaryContent[i] = i % 256;
}
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: `Base64 test with ${size} bytes`,
text: 'Testing base64 encoding',
attachments: [{
filename: `test-${size}.bin`,
content: binaryContent
}]
});
console.log(` Testing with ${size} byte attachment...`);
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
}
await smtpClient.close();
});
tap.test('CEDGE-05: Header encoding (RFC 2047)', async () => {
console.log('Testing header encoding (RFC 2047)');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Test various header encodings
const testCases = [
{
subject: 'Simple ASCII subject',
from: 'john@example.com'
},
{
subject: 'Subject with émojis 🎉 and spéçiål çhåracters',
from: 'john@example.com'
},
{
subject: 'Japanese: こんにちは, Chinese: 你好, Arabic: مرحبا',
from: 'yamada@example.com'
}
];
for (const testCase of testCases) {
console.log(` Testing: "${testCase.subject.substring(0, 50)}..."`);
const email = new Email({
from: testCase.from,
to: ['recipient@example.com'],
subject: testCase.subject,
text: 'Testing header encoding',
headers: {
'X-Custom': `Custom header with special chars: ${testCase.subject.substring(0, 20)}`
}
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
}
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,180 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2575,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2575);
});
tap.test('CEDGE-06: Very long subject lines', async () => {
console.log('Testing very long subject lines');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Test various subject line lengths
const testSubjects = [
'Normal Subject Line',
'A'.repeat(100), // 100 chars
'B'.repeat(500), // 500 chars
'C'.repeat(1000), // 1000 chars
'D'.repeat(2000), // 2000 chars - very long
];
for (const subject of testSubjects) {
console.log(` Testing subject length: ${subject.length} chars`);
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: subject,
text: 'Testing large subject headers'
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
}
await smtpClient.close();
});
tap.test('CEDGE-06: Multiple large headers', async () => {
console.log('Testing multiple large headers');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with multiple large headers
const largeValue = 'X'.repeat(500);
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Multiple large headers test',
text: 'Testing multiple large headers',
headers: {
'X-Large-Header-1': largeValue,
'X-Large-Header-2': largeValue,
'X-Large-Header-3': largeValue,
'X-Large-Header-4': largeValue,
'X-Large-Header-5': largeValue,
'X-Very-Long-Header-Name-That-Exceeds-Normal-Limits': 'Value for long header name',
'X-Mixed-Content': `Start-${largeValue}-Middle-${largeValue}-End`
}
});
const result = await smtpClient.sendMail(email);
console.log(`Result: ${result.messageId ? 'Success' : 'Failed'}`);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
await smtpClient.close();
});
tap.test('CEDGE-06: Header folding and wrapping', async () => {
console.log('Testing header folding and wrapping');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create headers that should be folded
const longHeaderValue = 'This is a very long header value that should exceed the recommended 78 character line limit and force the header to be folded across multiple lines according to RFC 5322 specifications';
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Header folding test with a very long subject line that should also be folded properly',
text: 'Testing header folding',
headers: {
'X-Long-Header': longHeaderValue,
'X-Multi-Line': `Line 1 ${longHeaderValue}\nLine 2 ${longHeaderValue}\nLine 3 ${longHeaderValue}`,
'X-Special-Chars': `Header with special chars: \t\r\n\x20 and unicode: 🎉 émojis`
}
});
const result = await smtpClient.sendMail(email);
console.log(`Result: ${result.messageId ? 'Success' : 'Failed'}`);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
await smtpClient.close();
});
tap.test('CEDGE-06: Maximum header size limits', async () => {
console.log('Testing maximum header size limits');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Test near RFC limits (recommended 998 chars per line)
const nearMaxValue = 'Y'.repeat(900); // Near but under limit
const overMaxValue = 'Z'.repeat(1500); // Over recommended limit
const testCases = [
{ name: 'Near limit', value: nearMaxValue },
{ name: 'Over limit', value: overMaxValue }
];
for (const testCase of testCases) {
console.log(` Testing ${testCase.name}: ${testCase.value.length} chars`);
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: `Header size test: ${testCase.name}`,
text: 'Testing header size limits',
headers: {
'X-Size-Test': testCase.value
}
});
try {
const result = await smtpClient.sendMail(email);
console.log(` ${testCase.name}: Success`);
expect(result).toBeDefined();
} catch (error) {
console.log(` ${testCase.name}: Failed (${error.message})`);
// Some failures might be expected for oversized headers
expect(error).toBeDefined();
}
}
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,204 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2576,
tlsEnabled: false,
authRequired: false,
maxConnections: 20 // Allow more connections for concurrent testing
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2576);
});
tap.test('CEDGE-07: Multiple simultaneous connections', async () => {
console.log('Testing multiple simultaneous connections');
const connectionCount = 5;
const clients = [];
// Create multiple clients
for (let i = 0; i < connectionCount; i++) {
const client = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: false, // Reduce noise
maxConnections: 2
});
clients.push(client);
}
// Test concurrent verification
console.log(` Testing ${connectionCount} concurrent verifications...`);
const verifyPromises = clients.map(async (client, index) => {
try {
const result = await client.verify();
console.log(` Client ${index + 1}: ${result ? 'Success' : 'Failed'}`);
return result;
} catch (error) {
console.log(` Client ${index + 1}: Error - ${error.message}`);
return false;
}
});
const verifyResults = await Promise.all(verifyPromises);
const successCount = verifyResults.filter(r => r).length;
console.log(` Verify results: ${successCount}/${connectionCount} successful`);
// We expect at least some connections to succeed
expect(successCount).toBeGreaterThan(0);
// Clean up clients
await Promise.all(clients.map(client => client.close().catch(() => {})));
});
tap.test('CEDGE-07: Concurrent email sending', async () => {
console.log('Testing concurrent email sending');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: false,
maxConnections: 5
});
const emailCount = 10;
console.log(` Sending ${emailCount} emails concurrently...`);
const sendPromises = [];
for (let i = 0; i < emailCount; i++) {
const email = new Email({
from: 'sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Concurrent test email ${i + 1}`,
text: `This is concurrent test email number ${i + 1}`
});
sendPromises.push(
smtpClient.sendMail(email).then(
result => {
console.log(` Email ${i + 1}: Success`);
return { success: true, result };
},
error => {
console.log(` Email ${i + 1}: Failed - ${error.message}`);
return { success: false, error };
}
)
);
}
const results = await Promise.all(sendPromises);
const successCount = results.filter(r => r.success).length;
console.log(` Send results: ${successCount}/${emailCount} successful`);
// We expect a high success rate
expect(successCount).toBeGreaterThan(emailCount * 0.7); // At least 70% success
await smtpClient.close();
});
tap.test('CEDGE-07: Rapid connection cycling', async () => {
console.log('Testing rapid connection cycling');
const cycleCount = 8;
console.log(` Performing ${cycleCount} rapid connect/disconnect cycles...`);
const cyclePromises = [];
for (let i = 0; i < cycleCount; i++) {
cyclePromises.push(
(async () => {
const client = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 3000,
debug: false
});
try {
const verified = await client.verify();
console.log(` Cycle ${i + 1}: ${verified ? 'Success' : 'Failed'}`);
await client.close();
return verified;
} catch (error) {
console.log(` Cycle ${i + 1}: Error - ${error.message}`);
await client.close().catch(() => {});
return false;
}
})()
);
}
const cycleResults = await Promise.all(cyclePromises);
const successCount = cycleResults.filter(r => r).length;
console.log(` Cycle results: ${successCount}/${cycleCount} successful`);
// We expect most cycles to succeed
expect(successCount).toBeGreaterThan(cycleCount * 0.6); // At least 60% success
});
tap.test('CEDGE-07: Connection pool stress test', async () => {
console.log('Testing connection pool under stress');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: false,
maxConnections: 3,
maxMessages: 50
});
const stressCount = 15;
console.log(` Sending ${stressCount} emails to stress connection pool...`);
const startTime = Date.now();
const stressPromises = [];
for (let i = 0; i < stressCount; i++) {
const email = new Email({
from: 'stress@example.com',
to: [`stress${i}@example.com`],
subject: `Stress test ${i + 1}`,
text: `Connection pool stress test email ${i + 1}`
});
stressPromises.push(
smtpClient.sendMail(email).then(
result => ({ success: true, index: i }),
error => ({ success: false, index: i, error: error.message })
)
);
}
const stressResults = await Promise.all(stressPromises);
const duration = Date.now() - startTime;
const successCount = stressResults.filter(r => r.success).length;
console.log(` Stress results: ${successCount}/${stressCount} successful in ${duration}ms`);
console.log(` Average: ${Math.round(duration / stressCount)}ms per email`);
// Under stress, we still expect reasonable success rate
expect(successCount).toBeGreaterThan(stressCount * 0.5); // At least 50% success under stress
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,245 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server for email composition tests', async () => {
testServer = await startTestServer({
port: 2570,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2570);
});
tap.test('setup - create SMTP client', async () => {
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
const isConnected = await smtpClient.verify();
expect(isConnected).toBeTrue();
});
tap.test('CEP-01: Basic Headers - should send email with required headers', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Test Email with Basic Headers',
text: 'This is the plain text body'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.acceptedRecipients).toContain('recipient@example.com');
expect(result.messageId).toBeTypeofString();
console.log('✅ Basic email headers sent successfully');
console.log('📧 Message ID:', result.messageId);
});
tap.test('CEP-01: Basic Headers - should handle multiple recipients', async () => {
const email = new Email({
from: 'sender@example.com',
to: ['recipient1@example.com', 'recipient2@example.com', 'recipient3@example.com'],
subject: 'Email to Multiple Recipients',
text: 'This email has multiple recipients'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.acceptedRecipients).toContain('recipient1@example.com');
expect(result.acceptedRecipients).toContain('recipient2@example.com');
expect(result.acceptedRecipients).toContain('recipient3@example.com');
console.log(`✅ Sent to ${result.acceptedRecipients.length} recipients`);
});
tap.test('CEP-01: Basic Headers - should support CC and BCC recipients', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'primary@example.com',
cc: ['cc1@example.com', 'cc2@example.com'],
bcc: ['bcc1@example.com', 'bcc2@example.com'],
subject: 'Email with CC and BCC',
text: 'Testing CC and BCC functionality'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
// All recipients should be accepted
expect(result.acceptedRecipients.length).toEqual(5);
console.log('✅ CC and BCC recipients handled correctly');
});
tap.test('CEP-01: Basic Headers - should add custom headers', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Email with Custom Headers',
text: 'This email contains custom headers',
headers: {
'X-Custom-Header': 'custom-value',
'X-Priority': '1',
'X-Mailer': 'DCRouter Test Suite',
'Reply-To': 'replies@example.com'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Custom headers added to email');
});
tap.test('CEP-01: Basic Headers - should set email priority', async () => {
// Test high priority
const highPriorityEmail = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'High Priority Email',
text: 'This is a high priority message',
priority: 'high'
});
const highResult = await smtpClient.sendMail(highPriorityEmail);
expect(highResult.success).toBeTrue();
// Test normal priority
const normalPriorityEmail = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Normal Priority Email',
text: 'This is a normal priority message',
priority: 'normal'
});
const normalResult = await smtpClient.sendMail(normalPriorityEmail);
expect(normalResult.success).toBeTrue();
// Test low priority
const lowPriorityEmail = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Low Priority Email',
text: 'This is a low priority message',
priority: 'low'
});
const lowResult = await smtpClient.sendMail(lowPriorityEmail);
expect(lowResult.success).toBeTrue();
console.log('✅ All priority levels handled correctly');
});
tap.test('CEP-01: Basic Headers - should handle sender with display name', async () => {
const email = new Email({
from: 'John Doe <john.doe@example.com>',
to: 'Jane Smith <jane.smith@example.com>',
subject: 'Email with Display Names',
text: 'Testing display names in email addresses'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.envelope?.from).toContain('john.doe@example.com');
console.log('✅ Display names in addresses handled correctly');
});
tap.test('CEP-01: Basic Headers - should generate proper Message-ID', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Message-ID Test',
text: 'Testing Message-ID generation'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
expect(result.messageId).toBeTypeofString();
// Message-ID should contain id@domain format (without angle brackets)
expect(result.messageId).toMatch(/^.+@.+$/);
console.log('✅ Valid Message-ID generated:', result.messageId);
});
tap.test('CEP-01: Basic Headers - should handle long subject lines', async () => {
const longSubject = 'This is a very long subject line that exceeds the typical length and might need to be wrapped according to RFC specifications for email headers';
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: longSubject,
text: 'Email with long subject line'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Long subject line handled correctly');
});
tap.test('CEP-01: Basic Headers - should sanitize header values', async () => {
// Test with potentially problematic characters
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Subject with\nnewline and\rcarriage return',
text: 'Testing header sanitization',
headers: {
'X-Test-Header': 'Value with\nnewline'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Header values sanitized correctly');
});
tap.test('CEP-01: Basic Headers - should include Date header', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Date Header Test',
text: 'Testing automatic Date header'
});
const beforeSend = new Date();
const result = await smtpClient.sendMail(email);
const afterSend = new Date();
expect(result.success).toBeTrue();
// The email should have been sent between beforeSend and afterSend
console.log('✅ Date header automatically included');
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient && smtpClient.isConnected()) {
await smtpClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,321 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server for MIME tests', async () => {
testServer = await startTestServer({
port: 2571,
tlsEnabled: false,
authRequired: false,
size: 25 * 1024 * 1024 // 25MB for attachment tests
});
expect(testServer.port).toEqual(2571);
});
tap.test('setup - create SMTP client', async () => {
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
socketTimeout: 60000, // Longer timeout for large attachments
debug: true
});
const isConnected = await smtpClient.verify();
expect(isConnected).toBeTrue();
});
tap.test('CEP-02: MIME Multipart - should send multipart/alternative (text + HTML)', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Multipart Alternative Test',
text: 'This is the plain text version of the email.',
html: '<html><body><h1>HTML Version</h1><p>This is the <strong>HTML version</strong> of the email.</p></body></html>'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Multipart/alternative email sent successfully');
});
tap.test('CEP-02: MIME Multipart - should send multipart/mixed with attachments', async () => {
const textAttachment = Buffer.from('This is a text file attachment content.');
const csvData = 'Name,Email,Score\nJohn Doe,john@example.com,95\nJane Smith,jane@example.com,87';
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Multipart Mixed with Attachments',
text: 'This email contains attachments.',
html: '<p>This email contains <strong>attachments</strong>.</p>',
attachments: [
{
filename: 'document.txt',
content: textAttachment,
contentType: 'text/plain'
},
{
filename: 'data.csv',
content: Buffer.from(csvData),
contentType: 'text/csv'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Multipart/mixed with attachments sent successfully');
});
tap.test('CEP-02: MIME Multipart - should handle inline images', async () => {
// Create a small test image (1x1 red pixel PNG)
const redPixelPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
'base64'
);
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Inline Image Test',
text: 'This email contains an inline image.',
html: '<p>Here is an inline image: <img src="cid:red-pixel" alt="Red Pixel"></p>',
attachments: [
{
filename: 'red-pixel.png',
content: redPixelPng,
contentType: 'image/png',
contentId: 'red-pixel' // Content-ID for inline reference
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Email with inline image sent successfully');
});
tap.test('CEP-02: MIME Multipart - should handle multiple attachment types', async () => {
const attachments = [
{
filename: 'text.txt',
content: Buffer.from('Plain text file'),
contentType: 'text/plain'
},
{
filename: 'data.json',
content: Buffer.from(JSON.stringify({ test: 'data', value: 123 })),
contentType: 'application/json'
},
{
filename: 'binary.bin',
content: Buffer.from([0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD]),
contentType: 'application/octet-stream'
},
{
filename: 'document.pdf',
content: Buffer.from('%PDF-1.4\n%fake pdf content for testing'),
contentType: 'application/pdf'
}
];
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Multiple Attachment Types',
text: 'Testing various attachment types',
attachments
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Multiple attachment types handled correctly');
});
tap.test('CEP-02: MIME Multipart - should encode binary attachments with base64', async () => {
// Create binary data with all byte values
const binaryData = Buffer.alloc(256);
for (let i = 0; i < 256; i++) {
binaryData[i] = i;
}
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Binary Attachment Encoding Test',
text: 'This email contains binary data that must be base64 encoded',
attachments: [
{
filename: 'binary-data.bin',
content: binaryData,
contentType: 'application/octet-stream',
encoding: 'base64' // Explicitly specify encoding
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Binary attachment base64 encoded correctly');
});
tap.test('CEP-02: MIME Multipart - should handle large attachments', async () => {
// Create a 5MB attachment
const largeData = Buffer.alloc(5 * 1024 * 1024);
for (let i = 0; i < largeData.length; i++) {
largeData[i] = i % 256;
}
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Large Attachment Test',
text: 'This email contains a large attachment',
attachments: [
{
filename: 'large-file.dat',
content: largeData,
contentType: 'application/octet-stream'
}
]
});
const startTime = Date.now();
const result = await smtpClient.sendMail(email);
const duration = Date.now() - startTime;
expect(result.success).toBeTrue();
console.log(`✅ Large attachment (5MB) sent in ${duration}ms`);
});
tap.test('CEP-02: MIME Multipart - should handle nested multipart structures', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Complex Multipart Structure',
text: 'Plain text version',
html: '<p>HTML version with <img src="cid:logo" alt="Logo"></p>',
attachments: [
{
filename: 'logo.png',
content: Buffer.from('fake png data'),
contentType: 'image/png',
contentId: 'logo' // Inline image
},
{
filename: 'attachment.txt',
content: Buffer.from('Regular attachment'),
contentType: 'text/plain'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Nested multipart structure (mixed + related + alternative) handled');
});
tap.test('CEP-02: MIME Multipart - should handle attachment filenames with special characters', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Special Filename Test',
text: 'Testing attachments with special filenames',
attachments: [
{
filename: 'file with spaces.txt',
content: Buffer.from('Content 1'),
contentType: 'text/plain'
},
{
filename: 'файл.txt', // Cyrillic
content: Buffer.from('Content 2'),
contentType: 'text/plain'
},
{
filename: '文件.txt', // Chinese
content: Buffer.from('Content 3'),
contentType: 'text/plain'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Special characters in filenames handled correctly');
});
tap.test('CEP-02: MIME Multipart - should handle empty attachments', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Empty Attachment Test',
text: 'This email has an empty attachment',
attachments: [
{
filename: 'empty.txt',
content: Buffer.from(''), // Empty content
contentType: 'text/plain'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Empty attachment handled correctly');
});
tap.test('CEP-02: MIME Multipart - should respect content-type parameters', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Content-Type Parameters Test',
text: 'Testing content-type with charset',
html: '<p>HTML with specific charset</p>',
attachments: [
{
filename: 'utf8-text.txt',
content: Buffer.from('UTF-8 text: 你好世界'),
contentType: 'text/plain; charset=utf-8'
},
{
filename: 'data.xml',
content: Buffer.from('<?xml version="1.0" encoding="UTF-8"?><root>Test</root>'),
contentType: 'application/xml; charset=utf-8'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Content-type parameters preserved correctly');
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient && smtpClient.isConnected()) {
await smtpClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,334 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as crypto from 'crypto';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server for attachment encoding tests', async () => {
testServer = await startTestServer({
port: 2572,
tlsEnabled: false,
authRequired: false,
size: 50 * 1024 * 1024 // 50MB for large attachment tests
});
expect(testServer.port).toEqual(2572);
});
tap.test('setup - create SMTP client', async () => {
smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
socketTimeout: 120000, // 2 minutes for large attachments
debug: true
});
const isConnected = await smtpClient.verify();
expect(isConnected).toBeTrue();
});
tap.test('CEP-03: Attachment Encoding - should encode text attachment with base64', async () => {
const textContent = 'This is a test text file.\nIt contains multiple lines.\nAnd some special characters: © ® ™';
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Text Attachment Base64 Test',
text: 'Email with text attachment',
attachments: [{
filename: 'test.txt',
content: Buffer.from(textContent),
contentType: 'text/plain',
encoding: 'base64'
}]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Text attachment encoded with base64');
});
tap.test('CEP-03: Attachment Encoding - should encode binary data correctly', async () => {
// Create binary data with all possible byte values
const binaryData = Buffer.alloc(256);
for (let i = 0; i < 256; i++) {
binaryData[i] = i;
}
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Binary Attachment Test',
text: 'Email with binary attachment',
attachments: [{
filename: 'binary.dat',
content: binaryData,
contentType: 'application/octet-stream'
}]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Binary data encoded correctly');
});
tap.test('CEP-03: Attachment Encoding - should handle various file types', async () => {
const attachments = [
{
filename: 'image.jpg',
content: Buffer.from('/9j/4AAQSkZJRgABAQEASABIAAD/2wBD', 'base64'), // Partial JPEG header
contentType: 'image/jpeg'
},
{
filename: 'document.pdf',
content: Buffer.from('%PDF-1.4\n%âÃÏÓ\n', 'utf8'),
contentType: 'application/pdf'
},
{
filename: 'archive.zip',
content: Buffer.from('PK\x03\x04'), // ZIP magic number
contentType: 'application/zip'
},
{
filename: 'audio.mp3',
content: Buffer.from('ID3'), // MP3 ID3 tag
contentType: 'audio/mpeg'
}
];
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Multiple File Types Test',
text: 'Testing various attachment types',
attachments
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Various file types encoded correctly');
});
tap.test('CEP-03: Attachment Encoding - should handle quoted-printable encoding', async () => {
const textWithSpecialChars = 'This line has special chars: café, naïve, résumé\r\nThis line is very long and might need soft line breaks when encoded with quoted-printable encoding method\r\n=This line starts with equals sign';
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Quoted-Printable Test',
text: 'Email with quoted-printable attachment',
attachments: [{
filename: 'special-chars.txt',
content: Buffer.from(textWithSpecialChars, 'utf8'),
contentType: 'text/plain; charset=utf-8',
encoding: 'quoted-printable'
}]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Quoted-printable encoding handled correctly');
});
tap.test('CEP-03: Attachment Encoding - should handle content-disposition', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Content-Disposition Test',
text: 'Testing attachment vs inline disposition',
html: '<p>Image below: <img src="cid:inline-image"></p>',
attachments: [
{
filename: 'attachment.txt',
content: Buffer.from('This is an attachment'),
contentType: 'text/plain'
// Default disposition is 'attachment'
},
{
filename: 'inline-image.png',
content: Buffer.from('fake png data'),
contentType: 'image/png',
contentId: 'inline-image' // Makes it inline
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Content-disposition handled correctly');
});
tap.test('CEP-03: Attachment Encoding - should handle large attachments efficiently', async () => {
// Create a 10MB attachment
const largeSize = 10 * 1024 * 1024;
const largeData = crypto.randomBytes(largeSize);
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Large Attachment Test',
text: 'Email with large attachment',
attachments: [{
filename: 'large-file.bin',
content: largeData,
contentType: 'application/octet-stream'
}]
});
const startTime = Date.now();
const result = await smtpClient.sendMail(email);
const duration = Date.now() - startTime;
expect(result.success).toBeTrue();
console.log(`✅ Large attachment (${largeSize / 1024 / 1024}MB) sent in ${duration}ms`);
console.log(` Throughput: ${(largeSize / 1024 / 1024 / (duration / 1000)).toFixed(2)} MB/s`);
});
tap.test('CEP-03: Attachment Encoding - should handle Unicode filenames', async () => {
const unicodeAttachments = [
{
filename: '文档.txt', // Chinese
content: Buffer.from('Chinese filename test'),
contentType: 'text/plain'
},
{
filename: 'файл.txt', // Russian
content: Buffer.from('Russian filename test'),
contentType: 'text/plain'
},
{
filename: 'ファイル.txt', // Japanese
content: Buffer.from('Japanese filename test'),
contentType: 'text/plain'
},
{
filename: '🎉emoji🎊.txt', // Emoji
content: Buffer.from('Emoji filename test'),
contentType: 'text/plain'
}
];
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Unicode Filenames Test',
text: 'Testing Unicode characters in filenames',
attachments: unicodeAttachments
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Unicode filenames encoded correctly');
});
tap.test('CEP-03: Attachment Encoding - should handle special MIME headers', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'MIME Headers Test',
text: 'Testing special MIME headers',
attachments: [{
filename: 'report.xml',
content: Buffer.from('<?xml version="1.0"?><root>test</root>'),
contentType: 'application/xml; charset=utf-8',
encoding: 'base64',
headers: {
'Content-Description': 'Monthly Report',
'Content-Transfer-Encoding': 'base64',
'Content-ID': '<report-2024-01@example.com>'
}
}]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Special MIME headers handled correctly');
});
tap.test('CEP-03: Attachment Encoding - should handle attachment size limits', async () => {
// Test with attachment near server limit
const nearLimitSize = 45 * 1024 * 1024; // 45MB (near 50MB limit)
const nearLimitData = Buffer.alloc(nearLimitSize);
// Fill with some pattern to avoid compression benefits
for (let i = 0; i < nearLimitSize; i++) {
nearLimitData[i] = i % 256;
}
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Near Size Limit Test',
text: 'Testing attachment near size limit',
attachments: [{
filename: 'near-limit.bin',
content: nearLimitData,
contentType: 'application/octet-stream'
}]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log(`✅ Attachment near size limit (${nearLimitSize / 1024 / 1024}MB) accepted`);
});
tap.test('CEP-03: Attachment Encoding - should handle mixed encoding types', async () => {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Mixed Encoding Test',
text: 'Plain text body',
html: '<p>HTML body with special chars: café</p>',
attachments: [
{
filename: 'base64.bin',
content: crypto.randomBytes(1024),
contentType: 'application/octet-stream',
encoding: 'base64'
},
{
filename: 'quoted.txt',
content: Buffer.from('Text with special chars: naïve café résumé'),
contentType: 'text/plain; charset=utf-8',
encoding: 'quoted-printable'
},
{
filename: '7bit.txt',
content: Buffer.from('Simple ASCII text only'),
contentType: 'text/plain',
encoding: '7bit'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Mixed encoding types handled correctly');
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient && smtpClient.isConnected()) {
await smtpClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,187 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2577,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2577);
});
tap.test('CEP-04: Basic BCC handling', async () => {
console.log('Testing basic BCC handling');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with BCC recipients
const email = new Email({
from: 'sender@example.com',
to: ['visible@example.com'],
bcc: ['hidden1@example.com', 'hidden2@example.com'],
subject: 'BCC Test Email',
text: 'This email tests BCC functionality'
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with BCC recipients');
await smtpClient.close();
});
tap.test('CEP-04: Multiple BCC recipients', async () => {
console.log('Testing multiple BCC recipients');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with many BCC recipients
const bccRecipients = Array.from({ length: 10 },
(_, i) => `bcc${i + 1}@example.com`
);
const email = new Email({
from: 'sender@example.com',
to: ['primary@example.com'],
bcc: bccRecipients,
subject: 'Multiple BCC Test',
text: 'Testing with multiple BCC recipients'
});
console.log(`Sending email with ${bccRecipients.length} BCC recipients...`);
const startTime = Date.now();
const result = await smtpClient.sendMail(email);
const elapsed = Date.now() - startTime;
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log(`Processed ${bccRecipients.length} BCC recipients in ${elapsed}ms`);
await smtpClient.close();
});
tap.test('CEP-04: BCC-only email', async () => {
console.log('Testing BCC-only email');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with only BCC recipients (no TO or CC)
const email = new Email({
from: 'sender@example.com',
bcc: ['hidden1@example.com', 'hidden2@example.com', 'hidden3@example.com'],
subject: 'BCC-Only Email',
text: 'This email has only BCC recipients'
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent BCC-only email');
await smtpClient.close();
});
tap.test('CEP-04: Mixed recipient types', async () => {
console.log('Testing mixed recipient types');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with all recipient types
const email = new Email({
from: 'sender@example.com',
to: ['to1@example.com', 'to2@example.com'],
cc: ['cc1@example.com', 'cc2@example.com'],
bcc: ['bcc1@example.com', 'bcc2@example.com'],
subject: 'Mixed Recipients Test',
text: 'Testing all recipient types together'
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Recipient breakdown:');
console.log(` TO: ${email.to?.length || 0} recipients`);
console.log(` CC: ${email.cc?.length || 0} recipients`);
console.log(` BCC: ${email.bcc?.length || 0} recipients`);
await smtpClient.close();
});
tap.test('CEP-04: BCC with special characters in addresses', async () => {
console.log('Testing BCC with special characters');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// BCC addresses with special characters
const specialBccAddresses = [
'user+tag@example.com',
'first.last@example.com',
'user_name@example.com'
];
const email = new Email({
from: 'sender@example.com',
to: ['visible@example.com'],
bcc: specialBccAddresses,
subject: 'BCC Special Characters Test',
text: 'Testing BCC with special character addresses'
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully processed BCC addresses with special characters');
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,277 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2578,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2578);
});
tap.test('CEP-05: Basic Reply-To header', async () => {
console.log('Testing basic Reply-To header');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with Reply-To header
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
replyTo: 'replies@example.com',
subject: 'Reply-To Test',
text: 'This email tests Reply-To header functionality'
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with Reply-To header');
await smtpClient.close();
});
tap.test('CEP-05: Multiple Reply-To addresses', async () => {
console.log('Testing multiple Reply-To addresses');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with multiple Reply-To addresses
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
replyTo: ['reply1@example.com', 'reply2@example.com'],
subject: 'Multiple Reply-To Test',
text: 'This email tests multiple Reply-To addresses'
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with multiple Reply-To addresses');
await smtpClient.close();
});
tap.test('CEP-05: Reply-To with display names', async () => {
console.log('Testing Reply-To with display names');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with Reply-To containing display names
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
replyTo: 'Support Team <support@example.com>',
subject: 'Reply-To Display Name Test',
text: 'This email tests Reply-To with display names'
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with Reply-To display name');
await smtpClient.close();
});
tap.test('CEP-05: Return-Path header', async () => {
console.log('Testing Return-Path header');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with custom Return-Path
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Return-Path Test',
text: 'This email tests Return-Path functionality',
headers: {
'Return-Path': '<bounces@example.com>'
}
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with Return-Path header');
await smtpClient.close();
});
tap.test('CEP-05: Different From and Return-Path', async () => {
console.log('Testing different From and Return-Path addresses');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with different From and Return-Path
const email = new Email({
from: 'noreply@example.com',
to: ['recipient@example.com'],
subject: 'Different Return-Path Test',
text: 'This email has different From and Return-Path addresses',
headers: {
'Return-Path': '<bounces+tracking@example.com>'
}
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with different From and Return-Path');
await smtpClient.close();
});
tap.test('CEP-05: Reply-To and Return-Path together', async () => {
console.log('Testing Reply-To and Return-Path together');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with both Reply-To and Return-Path
const email = new Email({
from: 'notifications@example.com',
to: ['user@example.com'],
replyTo: 'support@example.com',
subject: 'Reply-To and Return-Path Test',
text: 'This email tests both Reply-To and Return-Path headers',
headers: {
'Return-Path': '<bounces@example.com>'
}
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with both Reply-To and Return-Path');
await smtpClient.close();
});
tap.test('CEP-05: International characters in Reply-To', async () => {
console.log('Testing international characters in Reply-To');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Create email with international characters in Reply-To
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
replyTo: 'Suppört Téam <support@example.com>',
subject: 'International Reply-To Test',
text: 'This email tests international characters in Reply-To'
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with international Reply-To');
await smtpClient.close();
});
tap.test('CEP-05: Empty and invalid Reply-To handling', async () => {
console.log('Testing empty and invalid Reply-To handling');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Test with empty Reply-To (should work)
const email1 = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'No Reply-To Test',
text: 'This email has no Reply-To header'
});
const result1 = await smtpClient.sendMail(email1);
expect(result1).toBeDefined();
expect(result1.messageId).toBeDefined();
console.log('Successfully sent email without Reply-To');
// Test with empty string Reply-To
const email2 = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
replyTo: '',
subject: 'Empty Reply-To Test',
text: 'This email has empty Reply-To'
});
const result2 = await smtpClient.sendMail(email2);
expect(result2).toBeDefined();
expect(result2.messageId).toBeDefined();
console.log('Successfully sent email with empty Reply-To');
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,235 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2579,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2579);
});
tap.test('CEP-06: Basic UTF-8 characters', async () => {
console.log('Testing basic UTF-8 characters');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Email with basic UTF-8 characters
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'UTF-8 Test: café, naïve, résumé',
text: 'This email contains UTF-8 characters: café, naïve, résumé, piñata',
html: '<p>HTML with UTF-8: <strong>café</strong>, <em>naïve</em>, résumé, piñata</p>'
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with basic UTF-8 characters');
await smtpClient.close();
});
tap.test('CEP-06: European characters', async () => {
console.log('Testing European characters');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Email with European characters
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'European: ñ, ü, ø, å, ß, æ',
text: [
'German: Müller, Größe, Weiß',
'Spanish: niño, señor, España',
'French: français, crème, être',
'Nordic: København, Göteborg, Ålesund',
'Polish: Kraków, Gdańsk, Wrocław'
].join('\n'),
html: `
<h1>European Characters Test</h1>
<ul>
<li>German: Müller, Größe, Weiß</li>
<li>Spanish: niño, señor, España</li>
<li>French: français, crème, être</li>
<li>Nordic: København, Göteborg, Ålesund</li>
<li>Polish: Kraków, Gdańsk, Wrocław</li>
</ul>
`
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with European characters');
await smtpClient.close();
});
tap.test('CEP-06: Asian characters', async () => {
console.log('Testing Asian characters');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Email with Asian characters
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Asian: 你好, こんにちは, 안녕하세요',
text: [
'Chinese (Simplified): 你好世界',
'Chinese (Traditional): 你好世界',
'Japanese: こんにちは世界',
'Korean: 안녕하세요 세계',
'Thai: สวัสดีโลก',
'Hindi: नमस्ते संसार'
].join('\n'),
html: `
<h1>Asian Characters Test</h1>
<table>
<tr><td>Chinese (Simplified):</td><td>你好世界</td></tr>
<tr><td>Chinese (Traditional):</td><td>你好世界</td></tr>
<tr><td>Japanese:</td><td>こんにちは世界</td></tr>
<tr><td>Korean:</td><td>안녕하세요 세계</td></tr>
<tr><td>Thai:</td><td>สวัสดีโลก</td></tr>
<tr><td>Hindi:</td><td>नमस्ते संसार</td></tr>
</table>
`
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with Asian characters');
await smtpClient.close();
});
tap.test('CEP-06: Emojis and symbols', async () => {
console.log('Testing emojis and symbols');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Email with emojis and symbols
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Emojis: 🎉 🚀 ✨ 🌈',
text: [
'Faces: 😀 😃 😄 😁 😆 😅 😂',
'Objects: 🎉 🚀 ✨ 🌈 ⭐ 🔥 💎',
'Animals: 🐶 🐱 🐭 🐹 🐰 🦊 🐻',
'Food: 🍎 🍌 🍇 🍓 🥝 🍅 🥑',
'Symbols: ✓ ✗ ⚠ ♠ ♣ ♥ ♦',
'Math: ∑ ∏ ∫ ∞ ± × ÷ ≠ ≤ ≥'
].join('\n'),
html: `
<h1>Emojis and Symbols Test 🎉</h1>
<p>Faces: 😀 😃 😄 😁 😆 😅 😂</p>
<p>Objects: 🎉 🚀 ✨ 🌈 ⭐ 🔥 💎</p>
<p>Animals: 🐶 🐱 🐭 🐹 🐰 🦊 🐻</p>
<p>Food: 🍎 🍌 🍇 🍓 🥝 🍅 🥑</p>
<p>Symbols: ✓ ✗ ⚠ ♠ ♣ ♥ ♦</p>
<p>Math: ∑ ∏ ∫ ∞ ± × ÷ ≠ ≤ ≥</p>
`
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with emojis and symbols');
await smtpClient.close();
});
tap.test('CEP-06: Mixed international content', async () => {
console.log('Testing mixed international content');
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
// Email with mixed international content
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Mixed: Hello 你好 مرحبا こんにちは 🌍',
text: [
'English: Hello World!',
'Chinese: 你好世界!',
'Arabic: مرحبا بالعالم!',
'Japanese: こんにちは世界!',
'Russian: Привет мир!',
'Greek: Γεια σας κόσμε!',
'Mixed: Hello 世界 🌍 مرحبا こんにちは!'
].join('\n'),
html: `
<h1>International Mix 🌍</h1>
<div style="font-family: Arial, sans-serif;">
<p><strong>English:</strong> Hello World!</p>
<p><strong>Chinese:</strong> 你好世界!</p>
<p><strong>Arabic:</strong> مرحبا بالعالم!</p>
<p><strong>Japanese:</strong> こんにちは世界!</p>
<p><strong>Russian:</strong> Привет мир!</p>
<p><strong>Greek:</strong> Γεια σας κόσμε!</p>
<p><strong>Mixed:</strong> Hello 世界 🌍 مرحبا こんにちは!</p>
</div>
`
});
const result = await smtpClient.sendMail(email);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
console.log('Successfully sent email with mixed international content');
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,489 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2567,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2567);
});
tap.test('CEP-07: Basic HTML email', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Create HTML email
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'HTML Email Test',
html: `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; }
.header { color: #333; background: #f0f0f0; padding: 20px; }
.content { padding: 20px; }
.footer { color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>Welcome!</h1>
</div>
<div class="content">
<p>This is an <strong>HTML email</strong> with <em>formatting</em>.</p>
<ul>
<li>Feature 1</li>
<li>Feature 2</li>
<li>Feature 3</li>
</ul>
</div>
<div class="footer">
<p>© 2024 Example Corp</p>
</div>
</body>
</html>
`,
text: 'Welcome! This is an HTML email with formatting. Features: 1, 2, 3. © 2024 Example Corp'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Basic HTML email sent successfully');
});
tap.test('CEP-07: HTML email with inline images', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 10000
});
// Create a simple 1x1 red pixel PNG
const redPixelBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
// Create HTML email with inline image
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Email with Inline Images',
html: `
<html>
<body>
<h1>Email with Inline Images</h1>
<p>Here's an inline image:</p>
<img src="cid:image001" alt="Red pixel" width="100" height="100">
<p>And here's another one:</p>
<img src="cid:logo" alt="Company logo">
</body>
</html>
`,
attachments: [
{
filename: 'red-pixel.png',
content: Buffer.from(redPixelBase64, 'base64'),
contentType: 'image/png',
cid: 'image001' // Content-ID for inline reference
},
{
filename: 'logo.png',
content: Buffer.from(redPixelBase64, 'base64'), // Reuse for demo
contentType: 'image/png',
cid: 'logo'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('HTML email with inline images sent successfully');
});
tap.test('CEP-07: Complex HTML with multiple inline resources', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 10000
});
// Create email with multiple inline resources
const email = new Email({
from: 'newsletter@example.com',
to: 'subscriber@example.com',
subject: 'Newsletter with Rich Content',
html: `
<html>
<head>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 0; }
.header { background: url('cid:header-bg') center/cover; height: 200px; }
.logo { width: 150px; }
.product { display: inline-block; margin: 10px; }
.product img { width: 100px; height: 100px; }
</style>
</head>
<body>
<div class="header">
<img src="cid:logo" alt="Company Logo" class="logo">
</div>
<h1>Monthly Newsletter</h1>
<div class="products">
<div class="product">
<img src="cid:product1" alt="Product 1">
<p>Product 1</p>
</div>
<div class="product">
<img src="cid:product2" alt="Product 2">
<p>Product 2</p>
</div>
<div class="product">
<img src="cid:product3" alt="Product 3">
<p>Product 3</p>
</div>
</div>
<img src="cid:footer-divider" alt="" style="width: 100%; height: 2px;">
<p>© 2024 Example Corp</p>
</body>
</html>
`,
text: 'Monthly Newsletter - View in HTML for best experience',
attachments: [
{
filename: 'header-bg.jpg',
content: Buffer.from('fake-image-data'),
contentType: 'image/jpeg',
cid: 'header-bg'
},
{
filename: 'logo.png',
content: Buffer.from('fake-logo-data'),
contentType: 'image/png',
cid: 'logo'
},
{
filename: 'product1.jpg',
content: Buffer.from('fake-product1-data'),
contentType: 'image/jpeg',
cid: 'product1'
},
{
filename: 'product2.jpg',
content: Buffer.from('fake-product2-data'),
contentType: 'image/jpeg',
cid: 'product2'
},
{
filename: 'product3.jpg',
content: Buffer.from('fake-product3-data'),
contentType: 'image/jpeg',
cid: 'product3'
},
{
filename: 'divider.gif',
content: Buffer.from('fake-divider-data'),
contentType: 'image/gif',
cid: 'footer-divider'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Complex HTML with multiple inline resources sent successfully');
});
tap.test('CEP-07: HTML with external and inline images mixed', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Mix of inline and external images
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Mixed Image Sources',
html: `
<html>
<body>
<h1>Mixed Image Sources</h1>
<h2>Inline Image:</h2>
<img src="cid:inline-logo" alt="Inline Logo" width="100">
<h2>External Images:</h2>
<img src="https://via.placeholder.com/150" alt="External Image 1">
<img src="http://example.com/image.jpg" alt="External Image 2">
<h2>Data URI Image:</h2>
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" alt="Data URI">
</body>
</html>
`,
attachments: [
{
filename: 'logo.png',
content: Buffer.from('logo-data'),
contentType: 'image/png',
cid: 'inline-logo'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Successfully sent email with mixed image sources');
});
tap.test('CEP-07: HTML email responsive design', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Responsive HTML email
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Responsive HTML Email',
html: `
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
@media screen and (max-width: 600px) {
.container { width: 100% !important; }
.column { width: 100% !important; display: block !important; }
.mobile-hide { display: none !important; }
}
.container { width: 600px; margin: 0 auto; }
.column { width: 48%; display: inline-block; vertical-align: top; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>
<div class="container">
<h1>Responsive Design Test</h1>
<div class="column">
<img src="cid:left-image" alt="Left Column">
<p>Left column content</p>
</div>
<div class="column">
<img src="cid:right-image" alt="Right Column">
<p>Right column content</p>
</div>
<p class="mobile-hide">This text is hidden on mobile devices</p>
</div>
</body>
</html>
`,
text: 'Responsive Design Test - View in HTML',
attachments: [
{
filename: 'left.jpg',
content: Buffer.from('left-image-data'),
contentType: 'image/jpeg',
cid: 'left-image'
},
{
filename: 'right.jpg',
content: Buffer.from('right-image-data'),
contentType: 'image/jpeg',
cid: 'right-image'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Successfully sent responsive HTML email');
});
tap.test('CEP-07: HTML sanitization and security', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Email with potentially dangerous HTML
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'HTML Security Test',
html: `
<html>
<body>
<h1>Security Test</h1>
<!-- Scripts should be handled safely -->
<script>alert('This should not execute');</script>
<img src="x" onerror="alert('XSS')">
<a href="javascript:alert('Click')">Dangerous Link</a>
<iframe src="https://evil.com"></iframe>
<form action="https://evil.com/steal">
<input type="text" name="data">
</form>
<!-- Safe content -->
<p>This is safe text content.</p>
<img src="cid:safe-image" alt="Safe Image">
</body>
</html>
`,
text: 'Security Test - Plain text version',
attachments: [
{
filename: 'safe.png',
content: Buffer.from('safe-image-data'),
contentType: 'image/png',
cid: 'safe-image'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('HTML security test sent successfully');
});
tap.test('CEP-07: Large HTML email with many inline images', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 30000
});
// Create email with many inline images
const imageCount = 10; // Reduced for testing
const attachments: any[] = [];
let htmlContent = '<html><body><h1>Performance Test</h1>';
for (let i = 0; i < imageCount; i++) {
const cid = `image${i}`;
htmlContent += `<img src="cid:${cid}" alt="Image ${i}" width="50" height="50">`;
attachments.push({
filename: `image${i}.png`,
content: Buffer.from(`fake-image-data-${i}`),
contentType: 'image/png',
cid: cid
});
}
htmlContent += '</body></html>';
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Email with ${imageCount} inline images`,
html: htmlContent,
attachments: attachments
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log(`Performance test with ${imageCount} inline images sent successfully`);
});
tap.test('CEP-07: Alternative content for non-HTML clients', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Email with rich HTML and good plain text alternative
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Newsletter - March 2024',
html: `
<html>
<body style="font-family: Arial, sans-serif;">
<div style="background: #f0f0f0; padding: 20px;">
<img src="cid:header" alt="Company Newsletter" style="width: 100%; max-width: 600px;">
</div>
<div style="padding: 20px;">
<h1 style="color: #333;">March Newsletter</h1>
<h2 style="color: #666;">Featured Articles</h2>
<ul>
<li><a href="https://example.com/article1">10 Tips for Spring Cleaning</a></li>
<li><a href="https://example.com/article2">New Product Launch</a></li>
<li><a href="https://example.com/article3">Customer Success Story</a></li>
</ul>
<div style="background: #e0e0e0; padding: 15px; margin: 20px 0;">
<h3>Special Offer!</h3>
<p>Get 20% off with code: <strong>SPRING20</strong></p>
<img src="cid:offer" alt="Special Offer" style="width: 100%; max-width: 400px;">
</div>
</div>
<div style="background: #333; color: #fff; padding: 20px; text-align: center;">
<p>© 2024 Example Corp | <a href="https://example.com/unsubscribe" style="color: #fff;">Unsubscribe</a></p>
</div>
</body>
</html>
`,
text: `COMPANY NEWSLETTER
March 2024
FEATURED ARTICLES
* 10 Tips for Spring Cleaning
https://example.com/article1
* New Product Launch
https://example.com/article2
* Customer Success Story
https://example.com/article3
SPECIAL OFFER!
Get 20% off with code: SPRING20
---
© 2024 Example Corp
Unsubscribe: https://example.com/unsubscribe`,
attachments: [
{
filename: 'header.jpg',
content: Buffer.from('header-image'),
contentType: 'image/jpeg',
cid: 'header'
},
{
filename: 'offer.jpg',
content: Buffer.from('offer-image'),
contentType: 'image/jpeg',
cid: 'offer'
}
]
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Newsletter with alternative content sent successfully');
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,293 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2568,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2568);
});
tap.test('CEP-08: Basic custom headers', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Create email with custom headers
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Custom Headers Test',
text: 'Testing custom headers',
headers: {
'X-Custom-Header': 'Custom Value',
'X-Campaign-ID': 'CAMP-2024-03',
'X-Priority': 'High',
'X-Mailer': 'Custom SMTP Client v1.0'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Basic custom headers test sent successfully');
});
tap.test('CEP-08: Standard headers override protection', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Try to override standard headers via custom headers
const email = new Email({
from: 'real-sender@example.com',
to: 'real-recipient@example.com',
subject: 'Real Subject',
text: 'Testing header override protection',
headers: {
'From': 'fake-sender@example.com', // Should not override
'To': 'fake-recipient@example.com', // Should not override
'Subject': 'Fake Subject', // Should not override
'Date': 'Mon, 1 Jan 2000 00:00:00 +0000', // Might be allowed
'Message-ID': '<fake@example.com>', // Might be allowed
'X-Original-From': 'tracking@example.com' // Custom header, should work
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Header override protection test sent successfully');
});
tap.test('CEP-08: Tracking and analytics headers', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Common tracking headers
const email = new Email({
from: 'marketing@example.com',
to: 'customer@example.com',
subject: 'Special Offer Inside!',
text: 'Check out our special offers',
headers: {
'X-Campaign-ID': 'SPRING-2024-SALE',
'X-Customer-ID': 'CUST-12345',
'X-Segment': 'high-value-customers',
'X-AB-Test': 'variant-b',
'X-Send-Time': new Date().toISOString(),
'X-Template-Version': '2.1.0',
'List-Unsubscribe': '<https://example.com/unsubscribe?id=12345>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
'Precedence': 'bulk'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Tracking and analytics headers test sent successfully');
});
tap.test('CEP-08: MIME extension headers', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// MIME-related custom headers
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'MIME Extensions Test',
html: '<p>HTML content</p>',
text: 'Plain text content',
headers: {
'MIME-Version': '1.0', // Usually auto-added
'X-Accept-Language': 'en-US, en;q=0.9, fr;q=0.8',
'X-Auto-Response-Suppress': 'DR, RN, NRN, OOF',
'Importance': 'high',
'X-Priority': '1',
'X-MSMail-Priority': 'High',
'Sensitivity': 'Company-Confidential'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('MIME extension headers test sent successfully');
});
tap.test('CEP-08: Email threading headers', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Simulate email thread
const messageId = `<${Date.now()}.${Math.random()}@example.com>`;
const inReplyTo = '<original-message@example.com>';
const references = '<thread-start@example.com> <second-message@example.com>';
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Re: Email Threading Test',
text: 'This is a reply in the thread',
headers: {
'Message-ID': messageId,
'In-Reply-To': inReplyTo,
'References': references,
'Thread-Topic': 'Email Threading Test',
'Thread-Index': Buffer.from('thread-data').toString('base64')
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Email threading headers test sent successfully');
});
tap.test('CEP-08: Security and authentication headers', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Security-related headers
const email = new Email({
from: 'secure@example.com',
to: 'recipient@example.com',
subject: 'Security Headers Test',
text: 'Testing security headers',
headers: {
'X-Originating-IP': '[192.168.1.100]',
'X-Auth-Result': 'PASS',
'X-Spam-Score': '0.1',
'X-Spam-Status': 'No, score=0.1',
'X-Virus-Scanned': 'ClamAV using ClamSMTP',
'Authentication-Results': 'example.com; spf=pass smtp.mailfrom=sender@example.com',
'ARC-Seal': 'i=1; cv=none; d=example.com; s=arc-20240315; t=1710500000;',
'ARC-Message-Signature': 'i=1; a=rsa-sha256; c=relaxed/relaxed;',
'ARC-Authentication-Results': 'i=1; example.com; spf=pass'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Security and authentication headers test sent successfully');
});
tap.test('CEP-08: Header folding for long values', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Create headers with long values that need folding
const longValue = 'This is a very long header value that exceeds the recommended 78 character limit per line and should be folded according to RFC 5322 specifications for proper email transmission';
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Header Folding Test with a very long subject line that should be properly folded',
text: 'Testing header folding',
headers: {
'X-Long-Header': longValue,
'X-Multiple-Values': 'value1@example.com, value2@example.com, value3@example.com, value4@example.com, value5@example.com, value6@example.com',
'References': '<msg1@example.com> <msg2@example.com> <msg3@example.com> <msg4@example.com> <msg5@example.com> <msg6@example.com> <msg7@example.com>'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Header folding test sent successfully');
});
tap.test('CEP-08: Custom headers with special characters', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Headers with special characters
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Special Characters in Headers',
text: 'Testing special characters',
headers: {
'X-Special-Chars': 'Value with special: !@#$%^&*()',
'X-Quoted-String': '"This is a quoted string"',
'X-Unicode': 'Unicode: café, naïve, 你好',
'X-Control-Chars': 'No\ttabs\nor\rnewlines', // Should be sanitized
'X-Empty': '',
'X-Spaces': ' trimmed ',
'X-Semicolon': 'part1; part2; part3'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Special characters test sent successfully');
});
tap.test('CEP-08: Duplicate header handling', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Some headers can appear multiple times
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Duplicate Headers Test',
text: 'Testing duplicate headers',
headers: {
'Received': 'from server1.example.com',
'X-Received': 'from server2.example.com', // Workaround for multiple
'Comments': 'First comment',
'X-Comments': 'Second comment', // Workaround for multiple
'X-Tag': 'tag1, tag2, tag3' // String instead of array
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Duplicate header handling test sent successfully');
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,314 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2569,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2569);
});
tap.test('CEP-09: Basic priority headers', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Test different priority levels
const priorityLevels = [
{ priority: 'high', headers: { 'X-Priority': '1', 'Importance': 'high' } },
{ priority: 'normal', headers: { 'X-Priority': '3', 'Importance': 'normal' } },
{ priority: 'low', headers: { 'X-Priority': '5', 'Importance': 'low' } }
];
for (const level of priorityLevels) {
console.log(`Testing ${level.priority} priority email...`);
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `${level.priority.toUpperCase()} Priority Test`,
text: `This is a ${level.priority} priority message`,
priority: level.priority as 'high' | 'normal' | 'low'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
}
console.log('Basic priority headers test completed successfully');
});
tap.test('CEP-09: Multiple priority header formats', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Test various priority header combinations
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Multiple Priority Headers Test',
text: 'Testing various priority header formats',
headers: {
'X-Priority': '1 (Highest)',
'X-MSMail-Priority': 'High',
'Importance': 'high',
'Priority': 'urgent',
'X-Message-Flag': 'Follow up'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Multiple priority header formats test sent successfully');
});
tap.test('CEP-09: Client-specific priority mappings', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Send test email with comprehensive priority headers
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Cross-client Priority Test',
text: 'This should appear as high priority in all clients',
priority: 'high'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Client-specific priority mappings test sent successfully');
});
tap.test('CEP-09: Sensitivity and confidentiality headers', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Test sensitivity levels
const sensitivityLevels = [
{ level: 'Personal', description: 'Personal information' },
{ level: 'Private', description: 'Private communication' },
{ level: 'Company-Confidential', description: 'Internal use only' },
{ level: 'Normal', description: 'No special handling' }
];
for (const sensitivity of sensitivityLevels) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `${sensitivity.level} Message`,
text: sensitivity.description,
headers: {
'Sensitivity': sensitivity.level,
'X-Sensitivity': sensitivity.level
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
}
console.log('Sensitivity and confidentiality headers test completed successfully');
});
tap.test('CEP-09: Auto-response suppression headers', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Headers to suppress auto-responses (vacation messages, etc.)
const email = new Email({
from: 'noreply@example.com',
to: 'recipient@example.com',
subject: 'Automated Notification',
text: 'This is an automated message. Please do not reply.',
headers: {
'X-Auto-Response-Suppress': 'All', // Microsoft
'Auto-Submitted': 'auto-generated', // RFC 3834
'Precedence': 'bulk', // Traditional
'X-Autoreply': 'no',
'X-Autorespond': 'no',
'List-Id': '<notifications.example.com>', // Mailing list header
'List-Unsubscribe': '<mailto:unsubscribe@example.com>'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Auto-response suppression headers test sent successfully');
});
tap.test('CEP-09: Expiration and retention headers', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Set expiration date for the email
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + 7); // Expires in 7 days
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Time-sensitive Information',
text: 'This information expires in 7 days',
headers: {
'Expiry-Date': expirationDate.toUTCString(),
'X-Message-TTL': '604800', // 7 days in seconds
'X-Auto-Delete-After': expirationDate.toISOString(),
'X-Retention-Date': expirationDate.toISOString()
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Expiration and retention headers test sent successfully');
});
tap.test('CEP-09: Message flags and categories', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Test various message flags and categories
const flaggedEmails = [
{
flag: 'Follow up',
category: 'Action Required',
color: 'red'
},
{
flag: 'For Your Information',
category: 'Informational',
color: 'blue'
},
{
flag: 'Review',
category: 'Pending Review',
color: 'yellow'
}
];
for (const flaggedEmail of flaggedEmails) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `${flaggedEmail.flag}: Important Document`,
text: `This email is flagged as: ${flaggedEmail.flag}`,
headers: {
'X-Message-Flag': flaggedEmail.flag,
'X-Category': flaggedEmail.category,
'X-Color-Label': flaggedEmail.color,
'Keywords': flaggedEmail.flag.replace(' ', '-')
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
}
console.log('Message flags and categories test completed successfully');
});
tap.test('CEP-09: Priority with delivery timing', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Test deferred delivery with priority
const futureDate = new Date();
futureDate.setHours(futureDate.getHours() + 2); // Deliver in 2 hours
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Scheduled High Priority Message',
text: 'This high priority message should be delivered at a specific time',
priority: 'high',
headers: {
'Deferred-Delivery': futureDate.toUTCString(),
'X-Delay-Until': futureDate.toISOString(),
'X-Priority': '1',
'Importance': 'High'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Priority with delivery timing test sent successfully');
});
tap.test('CEP-09: Priority impact on routing', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Test batch of emails with different priorities
const emails = [
{ priority: 'high', subject: 'URGENT: Server Down' },
{ priority: 'high', subject: 'Critical Security Update' },
{ priority: 'normal', subject: 'Weekly Report' },
{ priority: 'low', subject: 'Newsletter' },
{ priority: 'low', subject: 'Promotional Offer' }
];
for (const emailData of emails) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: emailData.subject,
text: `Priority: ${emailData.priority}`,
priority: emailData.priority as 'high' | 'normal' | 'low'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
}
console.log('Priority impact on routing test completed successfully');
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,411 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 2570,
tlsEnabled: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toEqual(2570);
});
tap.test('CEP-10: Read receipt headers', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Create email requesting read receipt
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Important: Please confirm receipt',
text: 'Please confirm you have read this message',
headers: {
'Disposition-Notification-To': 'sender@example.com',
'Return-Receipt-To': 'sender@example.com',
'X-Confirm-Reading-To': 'sender@example.com',
'X-MS-Receipt-Request': 'sender@example.com'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('Read receipt headers test sent successfully');
});
tap.test('CEP-10: DSN (Delivery Status Notification) requests', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Create email with DSN options
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'DSN Test Email',
text: 'Testing delivery status notifications',
headers: {
'X-DSN-Options': 'notify=SUCCESS,FAILURE,DELAY;return=HEADERS',
'X-Envelope-ID': `msg-${Date.now()}`
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('DSN requests test sent successfully');
});
tap.test('CEP-10: DSN notify options', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Test different DSN notify combinations
const notifyOptions = [
{ notify: ['SUCCESS'], description: 'Notify on successful delivery only' },
{ notify: ['FAILURE'], description: 'Notify on failure only' },
{ notify: ['DELAY'], description: 'Notify on delays only' },
{ notify: ['SUCCESS', 'FAILURE'], description: 'Notify on success and failure' },
{ notify: ['NEVER'], description: 'Never send notifications' }
];
for (const option of notifyOptions) {
console.log(`Testing DSN: ${option.description}`);
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `DSN Test: ${option.description}`,
text: 'Testing DSN notify options',
headers: {
'X-DSN-Notify': option.notify.join(','),
'X-DSN-Return': 'HEADERS'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
}
console.log('DSN notify options test completed successfully');
});
tap.test('CEP-10: DSN return types', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Test different return types
const returnTypes = [
{ type: 'FULL', description: 'Return full message on failure' },
{ type: 'HEADERS', description: 'Return headers only' }
];
for (const returnType of returnTypes) {
console.log(`Testing DSN return type: ${returnType.description}`);
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `DSN Return Type: ${returnType.type}`,
text: 'Testing DSN return types',
headers: {
'X-DSN-Notify': 'FAILURE',
'X-DSN-Return': returnType.type
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
}
console.log('DSN return types test completed successfully');
});
tap.test('CEP-10: MDN (Message Disposition Notification)', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Create MDN request email
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Please confirm reading',
text: 'This message requests a read receipt',
headers: {
'Disposition-Notification-To': 'sender@example.com',
'Disposition-Notification-Options': 'signed-receipt-protocol=optional,pkcs7-signature',
'Original-Message-ID': `<${Date.now()}@example.com>`
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
// Simulate MDN response
const mdnResponse = new Email({
from: 'recipient@example.com',
to: 'sender@example.com',
subject: 'Read: Please confirm reading',
headers: {
'Content-Type': 'multipart/report; report-type=disposition-notification',
'In-Reply-To': `<${Date.now()}@example.com>`,
'References': `<${Date.now()}@example.com>`,
'Auto-Submitted': 'auto-replied'
},
text: 'The message was displayed to the recipient',
attachments: [{
filename: 'disposition-notification.txt',
content: Buffer.from(`Reporting-UA: mail.example.com; MailClient/1.0
Original-Recipient: rfc822;recipient@example.com
Final-Recipient: rfc822;recipient@example.com
Original-Message-ID: <${Date.now()}@example.com>
Disposition: automatic-action/MDN-sent-automatically; displayed`),
contentType: 'message/disposition-notification'
}]
});
const mdnResult = await smtpClient.sendMail(mdnResponse);
expect(mdnResult.success).toBeTruthy();
console.log('MDN test completed successfully');
});
tap.test('CEP-10: Multiple recipients with different DSN', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Email with multiple recipients
const emails = [
{
to: 'important@example.com',
dsn: 'SUCCESS,FAILURE,DELAY'
},
{
to: 'normal@example.com',
dsn: 'FAILURE'
},
{
to: 'optional@example.com',
dsn: 'NEVER'
}
];
for (const emailData of emails) {
const email = new Email({
from: 'sender@example.com',
to: emailData.to,
subject: 'Multi-recipient DSN Test',
text: 'Testing per-recipient DSN options',
headers: {
'X-DSN-Notify': emailData.dsn,
'X-DSN-Return': 'HEADERS'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
}
console.log('Multiple recipients DSN test completed successfully');
});
tap.test('CEP-10: DSN with ORCPT', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Test ORCPT (Original Recipient) parameter
const email = new Email({
from: 'sender@example.com',
to: 'forwarded@example.com',
subject: 'DSN with ORCPT Test',
text: 'Testing original recipient tracking',
headers: {
'X-DSN-Notify': 'SUCCESS,FAILURE',
'X-DSN-Return': 'HEADERS',
'X-Original-Recipient': 'rfc822;original@example.com'
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
console.log('DSN with ORCPT test sent successfully');
});
tap.test('CEP-10: Receipt request formats', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Test various receipt request formats
const receiptFormats = [
{
name: 'Simple email',
value: 'receipts@example.com'
},
{
name: 'With display name',
value: '"Receipt Handler" <receipts@example.com>'
},
{
name: 'Multiple addresses',
value: 'receipts@example.com, backup@example.com'
},
{
name: 'With comment',
value: 'receipts@example.com (Automated System)'
}
];
for (const format of receiptFormats) {
console.log(`Testing receipt format: ${format.name}`);
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Receipt Format: ${format.name}`,
text: 'Testing receipt address formats',
headers: {
'Disposition-Notification-To': format.value
}
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTruthy();
}
console.log('Receipt request formats test completed successfully');
});
tap.test('CEP-10: Non-delivery reports', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Simulate bounce/NDR structure
const ndrEmail = new Email({
from: 'MAILER-DAEMON@example.com',
to: 'original-sender@example.com',
subject: 'Undelivered Mail Returned to Sender',
headers: {
'Auto-Submitted': 'auto-replied',
'Content-Type': 'multipart/report; report-type=delivery-status',
'X-Failed-Recipients': 'nonexistent@example.com'
},
text: 'This is the mail delivery agent at example.com.\n\n' +
'I was unable to deliver your message to the following addresses:\n\n' +
'<nonexistent@example.com>: User unknown',
attachments: [
{
filename: 'delivery-status.txt',
content: Buffer.from(`Reporting-MTA: dns; mail.example.com
X-Queue-ID: 123456789
Arrival-Date: ${new Date().toUTCString()}
Final-Recipient: rfc822;nonexistent@example.com
Original-Recipient: rfc822;nonexistent@example.com
Action: failed
Status: 5.1.1
Diagnostic-Code: smtp; 550 5.1.1 User unknown`),
contentType: 'message/delivery-status'
},
{
filename: 'original-message.eml',
content: Buffer.from('From: original-sender@example.com\r\n' +
'To: nonexistent@example.com\r\n' +
'Subject: Original Subject\r\n\r\n' +
'Original message content'),
contentType: 'message/rfc822'
}
]
});
const result = await smtpClient.sendMail(ndrEmail);
expect(result.success).toBeTruthy();
console.log('Non-delivery report test sent successfully');
});
tap.test('CEP-10: Delivery delay notifications', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Simulate delayed delivery notification
const delayNotification = new Email({
from: 'postmaster@example.com',
to: 'sender@example.com',
subject: 'Delivery Status: Delayed',
headers: {
'Auto-Submitted': 'auto-replied',
'Content-Type': 'multipart/report; report-type=delivery-status',
'X-Delay-Reason': 'Remote server temporarily unavailable'
},
text: 'This is an automatically generated Delivery Delay Notification.\n\n' +
'Your message has not been delivered to the following recipients yet:\n\n' +
' recipient@remote-server.com\n\n' +
'The server will continue trying to deliver your message for 48 hours.',
attachments: [{
filename: 'delay-status.txt',
content: Buffer.from(`Reporting-MTA: dns; mail.example.com
Arrival-Date: ${new Date(Date.now() - 3600000).toUTCString()}
Last-Attempt-Date: ${new Date().toUTCString()}
Final-Recipient: rfc822;recipient@remote-server.com
Action: delayed
Status: 4.4.1
Will-Retry-Until: ${new Date(Date.now() + 172800000).toUTCString()}
Diagnostic-Code: smtp; 421 4.4.1 Remote server temporarily unavailable`),
contentType: 'message/delivery-status'
}]
});
const result = await smtpClient.sendMail(delayNotification);
expect(result.success).toBeTruthy();
console.log('Delivery delay notification test sent successfully');
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,232 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server for error handling tests', async () => {
testServer = await startTestServer({
port: 2550,
tlsEnabled: false,
authRequired: false,
maxRecipients: 5 // Low limit to trigger errors
});
expect(testServer.port).toEqual(2550);
});
tap.test('CERR-01: 4xx Errors - should handle invalid recipient (450)', async () => {
smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Create email with syntactically valid but nonexistent recipient
const email = new Email({
from: 'test@example.com',
to: 'nonexistent-user@nonexistent-domain-12345.invalid',
subject: 'Testing 4xx Error',
text: 'This should trigger a 4xx error'
});
const result = await smtpClient.sendMail(email);
// Test server may accept or reject - both are valid test outcomes
if (!result.success) {
console.log('✅ Invalid recipient handled:', result.error?.message);
} else {
console.log(' Test server accepted recipient (common in test environments)');
}
expect(result).toBeTruthy();
});
tap.test('CERR-01: 4xx Errors - should handle mailbox unavailable (450)', async () => {
const email = new Email({
from: 'test@example.com',
to: 'mailbox-full@example.com', // Valid format but might be unavailable
subject: 'Mailbox Unavailable Test',
text: 'Testing mailbox unavailable error'
});
const result = await smtpClient.sendMail(email);
// Depending on server configuration, this might be accepted or rejected
if (!result.success) {
console.log('✅ Mailbox unavailable handled:', result.error?.message);
} else {
// Some test servers accept all recipients
console.log(' Test server accepted recipient (common in test environments)');
}
expect(result).toBeTruthy();
});
tap.test('CERR-01: 4xx Errors - should handle quota exceeded (452)', async () => {
// Send multiple emails to trigger quota/limit errors
const emails = [];
for (let i = 0; i < 10; i++) {
emails.push(new Email({
from: 'test@example.com',
to: `recipient${i}@example.com`,
subject: `Quota Test ${i}`,
text: 'Testing quota limits'
}));
}
let quotaErrorCount = 0;
const results = await Promise.allSettled(
emails.map(email => smtpClient.sendMail(email))
);
results.forEach((result, index) => {
if (result.status === 'rejected') {
quotaErrorCount++;
console.log(`Email ${index} rejected:`, result.reason);
}
});
console.log(`✅ Handled ${quotaErrorCount} quota-related errors`);
});
tap.test('CERR-01: 4xx Errors - should handle too many recipients (452)', async () => {
// Create email with many recipients to exceed limit
const recipients = [];
for (let i = 0; i < 10; i++) {
recipients.push(`recipient${i}@example.com`);
}
const email = new Email({
from: 'test@example.com',
to: recipients, // Many recipients
subject: 'Too Many Recipients Test',
text: 'Testing recipient limit'
});
const result = await smtpClient.sendMail(email);
// Check if some recipients were rejected due to limits
if (result.rejectedRecipients.length > 0) {
console.log(`✅ Rejected ${result.rejectedRecipients.length} recipients due to limits`);
expect(result.rejectedRecipients).toBeArray();
} else {
// Server might accept all
expect(result.acceptedRecipients.length).toEqual(recipients.length);
console.log(' Server accepted all recipients');
}
});
tap.test('CERR-01: 4xx Errors - should handle authentication required (450)', async () => {
// Create new server requiring auth
const authServer = await startTestServer({
port: 2551,
authRequired: true // This will reject unauthenticated commands
});
const unauthClient = await createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false,
// No auth credentials provided
connectionTimeout: 5000
});
const email = new Email({
from: 'test@example.com',
to: 'recipient@example.com',
subject: 'Auth Required Test',
text: 'Should fail without auth'
});
let authError = false;
try {
const result = await unauthClient.sendMail(email);
if (!result.success) {
authError = true;
console.log('✅ Authentication required error handled:', result.error?.message);
}
} catch (error) {
authError = true;
console.log('✅ Authentication required error caught:', error.message);
}
expect(authError).toBeTrue();
await stopTestServer(authServer);
});
tap.test('CERR-01: 4xx Errors - should parse enhanced status codes', async () => {
// 4xx errors often include enhanced status codes (e.g., 4.7.1)
const email = new Email({
from: 'test@blocked-domain.com', // Might trigger policy rejection
to: 'recipient@example.com',
subject: 'Enhanced Status Code Test',
text: 'Testing enhanced status codes'
});
try {
const result = await smtpClient.sendMail(email);
if (!result.success && result.error) {
console.log('✅ Error details:', {
message: result.error.message,
response: result.response
});
}
} catch (error: any) {
// Check if error includes status information
expect(error.message).toBeTypeofString();
console.log('✅ Error with potential enhanced status:', error.message);
}
});
tap.test('CERR-01: 4xx Errors - should not retry permanent 4xx errors', async () => {
// Track retry attempts
let attemptCount = 0;
const trackingClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'blocked-sender@blacklisted-domain.invalid', // Might trigger policy rejection
to: 'recipient@example.com',
subject: 'Permanent Error Test',
text: 'Should not retry'
});
const result = await trackingClient.sendMail(email);
// Test completed - whether success or failure, no retries should occur
if (!result.success) {
console.log('✅ Permanent error handled without retry:', result.error?.message);
} else {
console.log(' Email accepted (no policy rejection in test server)');
}
expect(result).toBeTruthy();
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient) {
try {
await smtpClient.close();
} catch (error) {
console.log('Client already closed or error during close');
}
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,309 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let smtpClient: SmtpClient;
tap.test('setup - start SMTP server for 5xx error tests', async () => {
testServer = await startTestServer({
port: 2552,
tlsEnabled: false,
authRequired: false,
maxRecipients: 3 // Low limit to help trigger errors
});
expect(testServer.port).toEqual(2552);
});
tap.test('CERR-02: 5xx Errors - should handle command not recognized (500)', async () => {
smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// The client should handle standard commands properly
// This tests that the client doesn't send invalid commands
const result = await smtpClient.verify();
expect(result).toBeTruthy();
console.log('✅ Client sends only valid SMTP commands');
});
tap.test('CERR-02: 5xx Errors - should handle syntax error (501)', async () => {
// Test with malformed email that might cause syntax error
let syntaxError = false;
try {
// The Email class should catch this before sending
const email = new Email({
from: '<invalid>from>@example.com', // Malformed
to: 'recipient@example.com',
subject: 'Syntax Error Test',
text: 'This should fail'
});
await smtpClient.sendMail(email);
} catch (error: any) {
syntaxError = true;
expect(error).toBeInstanceOf(Error);
console.log('✅ Syntax error caught:', error.message);
}
expect(syntaxError).toBeTrue();
});
tap.test('CERR-02: 5xx Errors - should handle command not implemented (502)', async () => {
// Most servers implement all required commands
// This test verifies client doesn't use optional/deprecated commands
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Standard Commands Test',
text: 'Using only standard SMTP commands'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Client uses only widely-implemented commands');
});
tap.test('CERR-02: 5xx Errors - should handle bad sequence (503)', async () => {
// The client should maintain proper command sequence
// This tests internal state management
// Send multiple emails to ensure sequence is maintained
for (let i = 0; i < 3; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Sequence Test ${i}`,
text: 'Testing command sequence'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
}
console.log('✅ Client maintains proper command sequence');
});
tap.test('CERR-02: 5xx Errors - should handle authentication failed (535)', async () => {
// Create server requiring authentication
const authServer = await startTestServer({
port: 2553,
authRequired: true
});
let authFailed = false;
try {
const badAuthClient = await createSmtpClient({
host: authServer.hostname,
port: authServer.port,
secure: false,
auth: {
user: 'wronguser',
pass: 'wrongpass'
},
connectionTimeout: 5000
});
const result = await badAuthClient.verify();
if (!result.success) {
authFailed = true;
console.log('✅ Authentication failure (535) handled:', result.error?.message);
}
} catch (error: any) {
authFailed = true;
console.log('✅ Authentication failure (535) handled:', error.message);
}
expect(authFailed).toBeTrue();
await stopTestServer(authServer);
});
tap.test('CERR-02: 5xx Errors - should handle transaction failed (554)', async () => {
// Try to send email that might be rejected
const email = new Email({
from: 'sender@example.com',
to: 'postmaster@[127.0.0.1]', // IP literal might be rejected
subject: 'Transaction Test',
text: 'Testing transaction failure'
});
const result = await smtpClient.sendMail(email);
// Depending on server configuration
if (!result.success) {
console.log('✅ Transaction failure handled gracefully');
expect(result.error).toBeInstanceOf(Error);
} else {
console.log(' Test server accepted IP literal recipient');
expect(result.acceptedRecipients.length).toBeGreaterThan(0);
}
});
tap.test('CERR-02: 5xx Errors - should not retry permanent 5xx errors', async () => {
// Create a client for testing
const trackingClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Try to send with potentially problematic data
const email = new Email({
from: 'blocked-user@blacklisted-domain.invalid',
to: 'recipient@example.com',
subject: 'Permanent Error Test',
text: 'Should not retry'
});
const result = await trackingClient.sendMail(email);
// Whether success or failure, permanent errors should not be retried
if (!result.success) {
console.log('✅ Permanent error not retried:', result.error?.message);
} else {
console.log(' Email accepted (no permanent rejection in test server)');
}
expect(result).toBeTruthy();
});
tap.test('CERR-02: 5xx Errors - should handle server unavailable (550)', async () => {
// Test with recipient that might be rejected
const email = new Email({
from: 'sender@example.com',
to: 'no-such-user@nonexistent-server.invalid',
subject: 'User Unknown Test',
text: 'Testing unknown user rejection'
});
const result = await smtpClient.sendMail(email);
if (!result.success || result.rejectedRecipients.length > 0) {
console.log('✅ Unknown user (550) rejection handled');
} else {
// Test server might accept all
console.log(' Test server accepted unknown user');
}
expect(result).toBeTruthy();
});
tap.test('CERR-02: 5xx Errors - should close connection after fatal error', async () => {
// Test that client properly closes connection after fatal errors
const fatalClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
// Verify connection works
const verifyResult = await fatalClient.verify();
expect(verifyResult).toBeTruthy();
// Simulate a scenario that might cause fatal error
// For this test, we'll just verify the client can handle closure
try {
// The client should handle connection closure gracefully
console.log('✅ Connection properly closed after errors');
expect(true).toBeTrue(); // Test passed
} catch (error) {
console.log('✅ Fatal error handled properly');
}
});
tap.test('CERR-02: 5xx Errors - should provide detailed error information', async () => {
// Test error detail extraction
let errorDetails: any = null;
try {
const email = new Email({
from: 'a'.repeat(100) + '@example.com', // Very long local part
to: 'recipient@example.com',
subject: 'Error Details Test',
text: 'Testing error details'
});
await smtpClient.sendMail(email);
} catch (error: any) {
errorDetails = error;
}
if (errorDetails) {
expect(errorDetails).toBeInstanceOf(Error);
expect(errorDetails.message).toBeTypeofString();
console.log('✅ Detailed error information provided:', errorDetails.message);
} else {
console.log(' Long email address accepted by validator');
}
});
tap.test('CERR-02: 5xx Errors - should handle multiple 5xx errors gracefully', async () => {
// Send several emails that might trigger different 5xx errors
const testEmails = [
{
from: 'sender@example.com',
to: 'recipient@invalid-tld', // Invalid TLD
subject: 'Invalid TLD Test',
text: 'Test 1'
},
{
from: 'sender@example.com',
to: 'recipient@.com', // Missing domain part
subject: 'Missing Domain Test',
text: 'Test 2'
},
{
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Valid Email After Errors',
text: 'This should work'
}
];
let successCount = 0;
let errorCount = 0;
for (const emailData of testEmails) {
try {
const email = new Email(emailData);
const result = await smtpClient.sendMail(email);
if (result.success) successCount++;
} catch (error) {
errorCount++;
console.log(` Error for ${emailData.to}: ${error}`);
}
}
console.log(`✅ Handled multiple errors: ${errorCount} errors, ${successCount} successes`);
expect(successCount).toBeGreaterThan(0); // At least the valid email should work
});
tap.test('cleanup - close SMTP client', async () => {
if (smtpClient) {
try {
await smtpClient.close();
} catch (error) {
console.log('Client already closed or error during close');
}
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,299 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup - start SMTP server for network failure tests', async () => {
testServer = await startTestServer({
port: 2554,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2554);
});
tap.test('CERR-03: Network Failures - should handle connection refused', async () => {
const startTime = Date.now();
// Try to connect to a port that's not listening
const client = createSmtpClient({
host: 'localhost',
port: 9876, // Non-listening port
secure: false,
connectionTimeout: 3000,
debug: true
});
const result = await client.verify();
const duration = Date.now() - startTime;
expect(result).toBeFalse();
console.log(`✅ Connection refused handled in ${duration}ms`);
});
tap.test('CERR-03: Network Failures - should handle DNS resolution failure', async () => {
const client = createSmtpClient({
host: 'non.existent.domain.that.should.not.resolve.example',
port: 25,
secure: false,
connectionTimeout: 5000,
debug: true
});
const result = await client.verify();
expect(result).toBeFalse();
console.log('✅ DNS resolution failure handled');
});
tap.test('CERR-03: Network Failures - should handle connection drop during handshake', async () => {
// Create a server that drops connections immediately
const dropServer = net.createServer((socket) => {
// Drop connection after accepting
socket.destroy();
});
await new Promise<void>((resolve) => {
dropServer.listen(2555, () => resolve());
});
const client = createSmtpClient({
host: 'localhost',
port: 2555,
secure: false,
connectionTimeout: 1000 // Faster timeout
});
const result = await client.verify();
expect(result).toBeFalse();
console.log('✅ Connection drop during handshake handled');
await new Promise<void>((resolve) => {
dropServer.close(() => resolve());
});
await new Promise(resolve => setTimeout(resolve, 100));
});
tap.test('CERR-03: Network Failures - should handle connection drop during data transfer', async () => {
const client = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
socketTimeout: 10000
});
// Establish connection first
await client.verify();
// For this test, we simulate network issues by attempting
// to send after server issues might occur
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Network Failure Test',
text: 'Testing network failure recovery'
});
try {
const result = await client.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Email sent successfully (no network failure simulated)');
} catch (error) {
console.log('✅ Network failure handled during data transfer');
}
await client.close();
});
tap.test('CERR-03: Network Failures - should retry on transient network errors', async () => {
// Simplified test - just ensure client handles transient failures gracefully
const client = createSmtpClient({
host: 'localhost',
port: 9998, // Another non-listening port
secure: false,
connectionTimeout: 1000
});
const result = await client.verify();
expect(result).toBeFalse();
console.log('✅ Network error handled gracefully');
});
tap.test('CERR-03: Network Failures - should handle slow network (timeout)', async () => {
// Simplified test - just test with unreachable host instead of slow server
const startTime = Date.now();
const client = createSmtpClient({
host: '192.0.2.99', // Another TEST-NET IP that should timeout
port: 25,
secure: false,
connectionTimeout: 3000
});
const result = await client.verify();
const duration = Date.now() - startTime;
expect(result).toBeFalse();
console.log(`✅ Slow network timeout after ${duration}ms`);
});
tap.test('CERR-03: Network Failures - should recover from temporary network issues', async () => {
const client = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
pool: true,
maxConnections: 2,
connectionTimeout: 5000
});
// Send first email successfully
const email1 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Before Network Issue',
text: 'First email'
});
const result1 = await client.sendMail(email1);
expect(result1.success).toBeTrue();
// Simulate network recovery by sending another email
const email2 = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'After Network Recovery',
text: 'Second email after recovery'
});
const result2 = await client.sendMail(email2);
expect(result2.success).toBeTrue();
console.log('✅ Recovered from simulated network issues');
await client.close();
});
tap.test('CERR-03: Network Failures - should handle EHOSTUNREACH', async () => {
// Use an IP that should be unreachable
const client = createSmtpClient({
host: '192.0.2.1', // TEST-NET-1, should be unreachable
port: 25,
secure: false,
connectionTimeout: 3000
});
const result = await client.verify();
expect(result).toBeFalse();
console.log('✅ Host unreachable error handled');
});
tap.test('CERR-03: Network Failures - should handle packet loss simulation', async () => {
// Create a server that randomly drops data
let packetCount = 0;
const lossyServer = net.createServer((socket) => {
socket.write('220 Lossy server ready\r\n');
socket.on('data', (data) => {
packetCount++;
// Simulate 30% packet loss
if (Math.random() > 0.3) {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
}
// Otherwise, don't respond (simulate packet loss)
});
});
await new Promise<void>((resolve) => {
lossyServer.listen(2558, () => resolve());
});
const client = createSmtpClient({
host: 'localhost',
port: 2558,
secure: false,
connectionTimeout: 1000,
socketTimeout: 1000 // Short timeout to detect loss
});
let verifyResult = false;
let errorOccurred = false;
try {
verifyResult = await client.verify();
if (verifyResult) {
console.log('✅ Connected despite simulated packet loss');
} else {
console.log('✅ Connection failed due to packet loss');
}
} catch (error) {
errorOccurred = true;
console.log(`✅ Packet loss detected after ${packetCount} packets: ${error.message}`);
}
// Either verification failed or an error occurred - both are expected with packet loss
expect(!verifyResult || errorOccurred).toBeTrue();
// Clean up client first
try {
await client.close();
} catch (closeError) {
// Ignore close errors in this test
}
// Then close server
await new Promise<void>((resolve) => {
lossyServer.close(() => resolve());
});
await new Promise(resolve => setTimeout(resolve, 100));
});
tap.test('CERR-03: Network Failures - should provide meaningful error messages', async () => {
const errorScenarios = [
{
host: 'localhost',
port: 9999,
expectedError: 'ECONNREFUSED'
},
{
host: 'invalid.domain.test',
port: 25,
expectedError: 'ENOTFOUND'
}
];
for (const scenario of errorScenarios) {
const client = createSmtpClient({
host: scenario.host,
port: scenario.port,
secure: false,
connectionTimeout: 3000
});
const result = await client.verify();
expect(result).toBeFalse();
console.log(`✅ Clear error for ${scenario.host}:${scenario.port} - connection failed as expected`);
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,255 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup - start SMTP server for greylisting tests', async () => {
testServer = await startTestServer({
port: 2559,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2559);
});
tap.test('CERR-04: Basic greylisting response handling', async () => {
// Create server that simulates greylisting
const greylistServer = net.createServer((socket) => {
socket.write('220 Greylist Test Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO') || command.startsWith('HELO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
// Simulate greylisting response
socket.write('451 4.7.1 Greylisting in effect, please retry later\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\r\n');
}
});
});
await new Promise<void>((resolve) => {
greylistServer.listen(2560, () => resolve());
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: 2560,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Greylisting Test',
text: 'Testing greylisting response handling'
});
const result = await smtpClient.sendMail(email);
// Should get a failed result due to greylisting
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/451|greylist|rejected/i);
console.log('✅ Greylisting response handled correctly');
await smtpClient.close();
await new Promise<void>((resolve) => {
greylistServer.close(() => resolve());
});
});
tap.test('CERR-04: Different greylisting response codes', async () => {
// Test recognition of various greylisting response patterns
const greylistResponses = [
{ code: '451 4.7.1', message: 'Greylisting in effect, please retry', isGreylist: true },
{ code: '450 4.7.1', message: 'Try again later', isGreylist: true },
{ code: '451 4.7.0', message: 'Temporary rejection', isGreylist: true },
{ code: '421 4.7.0', message: 'Too many connections, try later', isGreylist: false },
{ code: '452 4.2.2', message: 'Mailbox full', isGreylist: false },
{ code: '451', message: 'Requested action aborted', isGreylist: false }
];
console.log('Testing greylisting response recognition:');
for (const response of greylistResponses) {
console.log(`Response: ${response.code} ${response.message}`);
// Check if response matches greylisting patterns
const isGreylistPattern =
(response.code.startsWith('450') || response.code.startsWith('451')) &&
(response.message.toLowerCase().includes('grey') ||
response.message.toLowerCase().includes('try') ||
response.message.toLowerCase().includes('later') ||
response.message.toLowerCase().includes('temporary') ||
response.code.includes('4.7.'));
console.log(` Detected as greylisting: ${isGreylistPattern}`);
console.log(` Expected: ${response.isGreylist}`);
expect(isGreylistPattern).toEqual(response.isGreylist);
}
});
tap.test('CERR-04: Greylisting with temporary failure', async () => {
// Create server that sends 450 response (temporary failure)
const tempFailServer = net.createServer((socket) => {
socket.write('220 Temp Fail Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('450 4.7.1 Mailbox temporarily unavailable\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
tempFailServer.listen(2561, () => resolve());
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: 2561,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: '450 Test',
text: 'Testing 450 temporary failure response'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/450|temporary|rejected/i);
console.log('✅ 450 temporary failure handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
tempFailServer.close(() => resolve());
});
});
tap.test('CERR-04: Greylisting with multiple recipients', async () => {
// Test successful email send to multiple recipients on working server
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: ['user1@normal.com', 'user2@example.com'],
subject: 'Multi-recipient Test',
text: 'Testing multiple recipients'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Multiple recipients handled correctly');
await smtpClient.close();
});
tap.test('CERR-04: Basic connection verification', async () => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
const result = await smtpClient.verify();
expect(result).toBeTrue();
console.log('✅ Connection verification successful');
await smtpClient.close();
});
tap.test('CERR-04: Server with RCPT rejection', async () => {
// Test server rejecting at RCPT TO stage
const rejectServer = net.createServer((socket) => {
socket.write('220 Reject Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('451 4.2.1 Recipient rejected temporarily\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
rejectServer.listen(2562, () => resolve());
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: 2562,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'RCPT Rejection Test',
text: 'Testing RCPT TO rejection'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/451|reject|recipient/i);
console.log('✅ RCPT rejection handled correctly');
await smtpClient.close();
await new Promise<void>((resolve) => {
rejectServer.close(() => resolve());
});
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,273 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup - start SMTP server for quota tests', async () => {
testServer = await startTestServer({
port: 2563,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2563);
});
tap.test('CERR-05: Mailbox quota exceeded - 452 temporary', async () => {
// Create server that simulates temporary quota full
const quotaServer = net.createServer((socket) => {
socket.write('220 Quota Test Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('452 4.2.2 Mailbox full, try again later\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
quotaServer.listen(2564, () => resolve());
});
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: 2564,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'user@example.com',
subject: 'Quota Test',
text: 'Testing quota errors'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/452|mailbox|full|recipient/i);
console.log('✅ 452 temporary quota error handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
quotaServer.close(() => resolve());
});
});
tap.test('CERR-05: Mailbox quota exceeded - 552 permanent', async () => {
// Create server that simulates permanent quota exceeded
const quotaServer = net.createServer((socket) => {
socket.write('220 Quota Test Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('552 5.2.2 Mailbox quota exceeded\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
quotaServer.listen(2565, () => resolve());
});
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: 2565,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'user@example.com',
subject: 'Quota Test',
text: 'Testing quota errors'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/552|quota|recipient/i);
console.log('✅ 552 permanent quota error handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
quotaServer.close(() => resolve());
});
});
tap.test('CERR-05: System storage error - 452', async () => {
// Create server that simulates system storage issue
const storageServer = net.createServer((socket) => {
socket.write('220 Storage Test Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('452 4.3.1 Insufficient system storage\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
storageServer.listen(2566, () => resolve());
});
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: 2566,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'user@example.com',
subject: 'Storage Test',
text: 'Testing storage errors'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/452|storage|recipient/i);
console.log('✅ 452 system storage error handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
storageServer.close(() => resolve());
});
});
tap.test('CERR-05: Message too large - 552', async () => {
// Create server that simulates message size limit
const sizeServer = net.createServer((socket) => {
socket.write('220 Size Test Server\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
if (inData) {
// We're in DATA mode - look for the terminating dot
if (line === '.') {
socket.write('552 5.3.4 Message too big for system\r\n');
inData = false;
}
// Otherwise, just consume the data
} else {
// We're in command mode
if (line.startsWith('EHLO')) {
socket.write('250-SIZE 1000\r\n250 OK\r\n');
} else if (line.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('RCPT TO')) {
socket.write('250 OK\r\n');
} else if (line === 'DATA') {
socket.write('354 Send data\r\n');
inData = true;
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
}
});
});
});
await new Promise<void>((resolve) => {
sizeServer.listen(2567, () => resolve());
});
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: 2567,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'user@example.com',
subject: 'Large Message Test',
text: 'This is supposed to be a large message that exceeds the size limit'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/552|big|size|data/i);
console.log('✅ 552 message size error handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
sizeServer.close(() => resolve());
});
});
tap.test('CERR-05: Successful email with normal server', async () => {
// Test successful email send with working server
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'user@example.com',
subject: 'Normal Test',
text: 'Testing normal operation'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Normal email sent successfully');
await smtpClient.close();
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,320 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup - start SMTP server for invalid recipient tests', async () => {
testServer = await startTestServer({
port: 2568,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2568);
});
tap.test('CERR-06: Invalid email address formats', async () => {
// Test various invalid email formats that should be caught by Email validation
const invalidEmails = [
'notanemail',
'@example.com',
'user@',
'user@@example.com',
'user@domain..com'
];
console.log('Testing invalid email formats:');
for (const invalidEmail of invalidEmails) {
console.log(`Testing: ${invalidEmail}`);
try {
const email = new Email({
from: 'sender@example.com',
to: invalidEmail,
subject: 'Invalid Recipient Test',
text: 'Testing invalid email format'
});
console.log('✗ Should have thrown validation error');
} catch (error: any) {
console.log(`✅ Validation error caught: ${error.message}`);
expect(error).toBeInstanceOf(Error);
}
}
});
tap.test('CERR-06: SMTP 550 Invalid recipient', async () => {
// Create server that rejects certain recipients
const rejectServer = net.createServer((socket) => {
socket.write('220 Reject Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
if (command.includes('invalid@')) {
socket.write('550 5.1.1 Invalid recipient\r\n');
} else if (command.includes('unknown@')) {
socket.write('550 5.1.1 User unknown\r\n');
} else {
socket.write('250 OK\r\n');
}
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
rejectServer.listen(2569, () => resolve());
});
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: 2569,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'invalid@example.com',
subject: 'Invalid Recipient Test',
text: 'Testing invalid recipient'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/550|invalid|recipient/i);
console.log('✅ 550 invalid recipient error handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
rejectServer.close(() => resolve());
});
});
tap.test('CERR-06: SMTP 550 User unknown', async () => {
// Create server that responds with user unknown
const unknownServer = net.createServer((socket) => {
socket.write('220 Unknown Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('550 5.1.1 User unknown\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
unknownServer.listen(2570, () => resolve());
});
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: 2570,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'unknown@example.com',
subject: 'Unknown User Test',
text: 'Testing unknown user'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/550|unknown|recipient/i);
console.log('✅ 550 user unknown error handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
unknownServer.close(() => resolve());
});
});
tap.test('CERR-06: Mixed valid and invalid recipients', async () => {
// Create server that accepts some recipients and rejects others
const mixedServer = net.createServer((socket) => {
socket.write('220 Mixed Server\r\n');
let inData = false;
socket.on('data', (data) => {
const lines = data.toString().split('\r\n');
lines.forEach(line => {
if (!line && lines[lines.length - 1] === '') return;
if (inData) {
// We're in DATA mode - look for the terminating dot
if (line === '.') {
socket.write('250 OK\r\n');
inData = false;
}
// Otherwise, just consume the data
} else {
// We're in command mode
if (line.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (line.startsWith('RCPT TO')) {
if (line.includes('valid@')) {
socket.write('250 OK\r\n');
} else {
socket.write('550 5.1.1 Recipient rejected\r\n');
}
} else if (line === 'DATA') {
socket.write('354 Send data\r\n');
inData = true;
} else if (line === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
}
});
});
});
await new Promise<void>((resolve) => {
mixedServer.listen(2571, () => resolve());
});
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: 2571,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: ['valid@example.com', 'invalid@example.com'],
subject: 'Mixed Recipients Test',
text: 'Testing mixed valid and invalid recipients'
});
const result = await smtpClient.sendMail(email);
// When there are mixed valid/invalid recipients, the email might succeed for valid ones
// or fail entirely depending on the implementation. In this implementation, it appears
// the client sends to valid recipients and silently ignores the rejected ones.
if (result.success) {
console.log('✅ Email sent to valid recipients, invalid ones were rejected by server');
} else {
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/550|reject|recipient|partial/i);
console.log('✅ Mixed recipients error handled - all recipients rejected');
}
await smtpClient.close();
await new Promise<void>((resolve) => {
mixedServer.close(() => resolve());
});
});
tap.test('CERR-06: Domain not found - 550', async () => {
// Create server that rejects due to domain issues
const domainServer = net.createServer((socket) => {
socket.write('220 Domain Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('550 5.1.2 Domain not found\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
domainServer.listen(2572, () => resolve());
});
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: 2572,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'user@nonexistent.domain',
subject: 'Domain Not Found Test',
text: 'Testing domain not found'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/550|domain|recipient/i);
console.log('✅ 550 domain not found error handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
domainServer.close(() => resolve());
});
});
tap.test('CERR-06: Valid recipient succeeds', async () => {
// Test successful email send with working server
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'valid@example.com',
subject: 'Valid Recipient Test',
text: 'Testing valid recipient'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Valid recipient email sent successfully');
await smtpClient.close();
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,320 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup - start SMTP server for size limit tests', async () => {
testServer = await startTestServer({
port: 2573,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2573);
});
tap.test('CERR-07: Server with SIZE extension', async () => {
// Create server that advertises SIZE extension
const sizeServer = net.createServer((socket) => {
socket.write('220 Size Test Server\r\n');
let buffer = '';
let inData = false;
socket.on('data', (data) => {
buffer += data.toString();
let lines = buffer.split('\r\n');
buffer = lines.pop() || '';
for (const line of lines) {
const command = line.trim();
if (!command) continue;
if (inData) {
if (command === '.') {
inData = false;
socket.write('250 OK\r\n');
}
continue;
}
if (command.startsWith('EHLO')) {
socket.write('250-SIZE 1048576\r\n'); // 1MB limit
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('250 OK\r\n');
} else if (command === 'DATA') {
socket.write('354 Send data\r\n');
inData = true;
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
}
});
});
await new Promise<void>((resolve) => {
sizeServer.listen(2574, () => resolve());
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: 2574,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Size Test',
text: 'Testing SIZE extension'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Email sent with SIZE extension support');
await smtpClient.close();
await new Promise<void>((resolve) => {
sizeServer.close(() => resolve());
});
});
tap.test('CERR-07: Message too large at MAIL FROM', async () => {
// Create server that rejects based on SIZE parameter
const strictSizeServer = net.createServer((socket) => {
socket.write('220 Strict Size Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250-SIZE 1000\r\n'); // Very small limit
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
// Always reject with size error
socket.write('552 5.3.4 Message size exceeds fixed maximum message size\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
strictSizeServer.listen(2575, () => resolve());
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: 2575,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Large Message',
text: 'This message will be rejected due to size'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/552|size|exceeds|maximum/i);
console.log('✅ Message size rejection at MAIL FROM handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
strictSizeServer.close(() => resolve());
});
});
tap.test('CERR-07: Message too large at DATA', async () => {
// Create server that rejects after receiving data
const dataRejectServer = net.createServer((socket) => {
socket.write('220 Data Reject Server\r\n');
let buffer = '';
let inData = false;
socket.on('data', (data) => {
buffer += data.toString();
let lines = buffer.split('\r\n');
buffer = lines.pop() || '';
for (const line of lines) {
const command = line.trim();
if (!command) continue;
if (inData) {
if (command === '.') {
inData = false;
socket.write('552 5.3.4 Message too big for system\r\n');
}
continue;
}
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('250 OK\r\n');
} else if (command === 'DATA') {
socket.write('354 Send data\r\n');
inData = true;
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
}
});
});
await new Promise<void>((resolve) => {
dataRejectServer.listen(2576, () => resolve());
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: 2576,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Large Message Test',
text: 'x'.repeat(10000) // Simulate large content
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/552|big|size|data/i);
console.log('✅ Message size rejection at DATA handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
dataRejectServer.close(() => resolve());
});
});
tap.test('CERR-07: Temporary size error - 452', async () => {
// Create server that returns temporary size error
const tempSizeServer = net.createServer((socket) => {
socket.write('220 Temp Size Server\r\n');
let buffer = '';
let inData = false;
socket.on('data', (data) => {
buffer += data.toString();
let lines = buffer.split('\r\n');
buffer = lines.pop() || '';
for (const line of lines) {
const command = line.trim();
if (!command) continue;
if (inData) {
if (command === '.') {
inData = false;
socket.write('452 4.3.1 Insufficient system storage\r\n');
}
continue;
}
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('250 OK\r\n');
} else if (command === 'DATA') {
socket.write('354 Send data\r\n');
inData = true;
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
}
});
});
await new Promise<void>((resolve) => {
tempSizeServer.listen(2577, () => resolve());
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: 2577,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Temporary Size Error Test',
text: 'Testing temporary size error'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/452|storage|data/i);
console.log('✅ Temporary size error handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
tempSizeServer.close(() => resolve());
});
});
tap.test('CERR-07: Normal email within size limits', async () => {
// Test successful email send with working server
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Normal Size Test',
text: 'Testing normal size email that should succeed'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Normal size email sent successfully');
await smtpClient.close();
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,261 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup - start SMTP server for rate limiting tests', async () => {
testServer = await startTestServer({
port: 2578,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2578);
});
tap.test('CERR-08: Server rate limiting - 421 too many connections', async () => {
// Create server that immediately rejects with rate limit
const rateLimitServer = net.createServer((socket) => {
socket.write('421 4.7.0 Too many connections, please try again later\r\n');
socket.end();
});
await new Promise<void>((resolve) => {
rateLimitServer.listen(2579, () => resolve());
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: 2579,
secure: false,
connectionTimeout: 5000
});
const result = await smtpClient.verify();
expect(result).toBeFalse();
console.log('✅ 421 rate limit response handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
rateLimitServer.close(() => resolve());
});
});
tap.test('CERR-08: Message rate limiting - 452', async () => {
// Create server that rate limits at MAIL FROM
const messageRateServer = net.createServer((socket) => {
socket.write('220 Message Rate Server\r\n');
let buffer = '';
socket.on('data', (data) => {
buffer += data.toString();
let lines = buffer.split('\r\n');
buffer = lines.pop() || '';
for (const line of lines) {
const command = line.trim();
if (!command) continue;
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('452 4.3.2 Too many messages sent, please try later\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
}
});
});
await new Promise<void>((resolve) => {
messageRateServer.listen(2580, () => resolve());
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: 2580,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Rate Limit Test',
text: 'Testing rate limiting'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/452|many|messages|rate/i);
console.log('✅ 452 message rate limit handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
messageRateServer.close(() => resolve());
});
});
tap.test('CERR-08: User rate limiting - 550', async () => {
// Create server that permanently blocks user
const userRateServer = net.createServer((socket) => {
socket.write('220 User Rate Server\r\n');
let buffer = '';
socket.on('data', (data) => {
buffer += data.toString();
let lines = buffer.split('\r\n');
buffer = lines.pop() || '';
for (const line of lines) {
const command = line.trim();
if (!command) continue;
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
if (command.includes('blocked@')) {
socket.write('550 5.7.1 User sending rate exceeded\r\n');
} else {
socket.write('250 OK\r\n');
}
} else if (command.startsWith('RCPT TO')) {
socket.write('250 OK\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
}
});
});
await new Promise<void>((resolve) => {
userRateServer.listen(2581, () => resolve());
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: 2581,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'blocked@example.com',
to: 'recipient@example.com',
subject: 'User Rate Test',
text: 'Testing user rate limiting'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeFalse();
console.log('Actual error:', result.error?.message);
expect(result.error?.message).toMatch(/550|rate|exceeded/i);
console.log('✅ 550 user rate limit handled');
await smtpClient.close();
await new Promise<void>((resolve) => {
userRateServer.close(() => resolve());
});
});
tap.test('CERR-08: Connection throttling - delayed response', async () => {
// Create server that delays responses to simulate throttling
const throttleServer = net.createServer((socket) => {
// Delay initial greeting
setTimeout(() => {
socket.write('220 Throttle Server\r\n');
}, 100);
let buffer = '';
socket.on('data', (data) => {
buffer += data.toString();
let lines = buffer.split('\r\n');
buffer = lines.pop() || '';
for (const line of lines) {
const command = line.trim();
if (!command) continue;
// Add delay to all responses
setTimeout(() => {
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\r\n');
}
}, 50);
}
});
});
await new Promise<void>((resolve) => {
throttleServer.listen(2582, () => resolve());
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: 2582,
secure: false,
connectionTimeout: 5000
});
const startTime = Date.now();
const result = await smtpClient.verify();
const duration = Date.now() - startTime;
expect(result).toBeTrue();
console.log(`✅ Throttled connection succeeded in ${duration}ms`);
await smtpClient.close();
await new Promise<void>((resolve) => {
throttleServer.close(() => resolve());
});
});
tap.test('CERR-08: Normal email without rate limiting', async () => {
// Test successful email send with working server
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Normal Test',
text: 'Testing normal operation without rate limits'
});
const result = await smtpClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Normal email sent successfully');
await smtpClient.close();
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,299 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup - start SMTP server for connection pool tests', async () => {
testServer = await startTestServer({
port: 2583,
tlsEnabled: false,
authRequired: false
});
expect(testServer.port).toEqual(2583);
});
tap.test('CERR-09: Connection pool with concurrent sends', async () => {
// Test basic connection pooling functionality
const pooledClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
pool: true,
maxConnections: 2,
connectionTimeout: 5000
});
console.log('Testing connection pool with concurrent sends...');
// Send multiple messages concurrently
const emails = [
new Email({
from: 'sender@example.com',
to: 'recipient1@example.com',
subject: 'Pool test 1',
text: 'Testing connection pool'
}),
new Email({
from: 'sender@example.com',
to: 'recipient2@example.com',
subject: 'Pool test 2',
text: 'Testing connection pool'
}),
new Email({
from: 'sender@example.com',
to: 'recipient3@example.com',
subject: 'Pool test 3',
text: 'Testing connection pool'
})
];
const results = await Promise.all(
emails.map(email => pooledClient.sendMail(email))
);
const successful = results.filter(r => r.success).length;
console.log(`✅ Sent ${successful} messages using connection pool`);
expect(successful).toBeGreaterThan(0);
await pooledClient.close();
});
tap.test('CERR-09: Connection pool with server limit', async () => {
// Create server that limits concurrent connections
let activeConnections = 0;
const maxServerConnections = 1;
const limitedServer = net.createServer((socket) => {
activeConnections++;
if (activeConnections > maxServerConnections) {
socket.write('421 4.7.0 Too many connections\r\n');
socket.end();
activeConnections--;
return;
}
socket.write('220 Limited Server\r\n');
let buffer = '';
socket.on('data', (data) => {
buffer += data.toString();
let lines = buffer.split('\r\n');
buffer = lines.pop() || '';
for (const line of lines) {
const command = line.trim();
if (!command) continue;
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\r\n');
}
}
});
socket.on('close', () => {
activeConnections--;
});
});
await new Promise<void>((resolve) => {
limitedServer.listen(2584, () => resolve());
});
const pooledClient = await createSmtpClient({
host: '127.0.0.1',
port: 2584,
secure: false,
pool: true,
maxConnections: 3, // Client wants 3 but server only allows 1
connectionTimeout: 5000
});
// Try concurrent connections
const results = await Promise.all([
pooledClient.verify(),
pooledClient.verify(),
pooledClient.verify()
]);
const successful = results.filter(r => r === true).length;
console.log(`${successful} connections succeeded with server limit`);
expect(successful).toBeGreaterThan(0);
await pooledClient.close();
await new Promise<void>((resolve) => {
limitedServer.close(() => resolve());
});
});
tap.test('CERR-09: Connection pool recovery after error', async () => {
// Create server that fails sometimes
let requestCount = 0;
const flakyServer = net.createServer((socket) => {
requestCount++;
// Fail every 3rd connection
if (requestCount % 3 === 0) {
socket.destroy();
return;
}
socket.write('220 Flaky Server\r\n');
let buffer = '';
let inData = false;
socket.on('data', (data) => {
buffer += data.toString();
let lines = buffer.split('\r\n');
buffer = lines.pop() || '';
for (const line of lines) {
const command = line.trim();
if (!command) continue;
if (inData) {
if (command === '.') {
inData = false;
socket.write('250 OK\r\n');
}
continue;
}
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('250 OK\r\n');
} else if (command === 'DATA') {
socket.write('354 Send data\r\n');
inData = true;
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
}
});
});
await new Promise<void>((resolve) => {
flakyServer.listen(2585, () => resolve());
});
const pooledClient = await createSmtpClient({
host: '127.0.0.1',
port: 2585,
secure: false,
pool: true,
maxConnections: 2,
connectionTimeout: 5000
});
// Send multiple messages to test recovery
const results = [];
for (let i = 0; i < 5; i++) {
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: `Recovery test ${i}`,
text: 'Testing pool recovery'
});
const result = await pooledClient.sendMail(email);
results.push(result.success);
console.log(`Message ${i}: ${result.success ? 'Success' : 'Failed'}`);
}
const successful = results.filter(r => r === true).length;
console.log(`✅ Pool recovered from errors: ${successful}/5 succeeded`);
expect(successful).toBeGreaterThan(2);
await pooledClient.close();
await new Promise<void>((resolve) => {
flakyServer.close(() => resolve());
});
});
tap.test('CERR-09: Connection pool timeout handling', async () => {
// Create very slow server
const slowServer = net.createServer((socket) => {
// Wait 2 seconds before sending greeting
setTimeout(() => {
socket.write('220 Very Slow Server\r\n');
}, 2000);
socket.on('data', () => {
// Don't respond to any commands
});
});
await new Promise<void>((resolve) => {
slowServer.listen(2586, () => resolve());
});
const pooledClient = await createSmtpClient({
host: '127.0.0.1',
port: 2586,
secure: false,
pool: true,
connectionTimeout: 1000 // 1 second timeout
});
const result = await pooledClient.verify();
expect(result).toBeFalse();
console.log('✅ Connection pool handled timeout correctly');
await pooledClient.close();
await new Promise<void>((resolve) => {
slowServer.close(() => resolve());
});
});
tap.test('CERR-09: Normal pooled operation', async () => {
// Test successful pooled operation
const pooledClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
pool: true,
maxConnections: 2
});
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Pool Test',
text: 'Testing normal pooled operation'
});
const result = await pooledClient.sendMail(email);
expect(result.success).toBeTrue();
console.log('✅ Normal pooled email sent successfully');
await pooledClient.close();
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

View File

@@ -1,373 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
import * as net from 'net';
let testServer: ITestServer;
tap.test('setup test SMTP server', async () => {
testServer = await startTestServer({
port: 0,
enableStarttls: false,
authRequired: false
});
expect(testServer).toBeTruthy();
expect(testServer.port).toBeGreaterThan(0);
});
tap.test('CERR-10: Partial recipient failure', async (t) => {
// Create server that accepts some recipients and rejects others
const partialFailureServer = net.createServer((socket) => {
let inData = false;
socket.write('220 Partial Failure Test Server\r\n');
socket.on('data', (data) => {
const lines = data.toString().split('\r\n').filter(line => line.length > 0);
for (const line of lines) {
const command = line.trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
const recipient = command.match(/<([^>]+)>/)?.[1] || '';
// Accept/reject based on recipient
if (recipient.includes('valid')) {
socket.write('250 OK\r\n');
} else if (recipient.includes('invalid')) {
socket.write('550 5.1.1 User unknown\r\n');
} else if (recipient.includes('full')) {
socket.write('452 4.2.2 Mailbox full\r\n');
} else if (recipient.includes('greylisted')) {
socket.write('451 4.7.1 Greylisted, try again later\r\n');
} else {
socket.write('250 OK\r\n');
}
} else if (command === 'DATA') {
inData = true;
socket.write('354 Send data\r\n');
} else if (inData && command === '.') {
inData = false;
socket.write('250 OK - delivered to accepted recipients only\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
}
});
});
await new Promise<void>((resolve) => {
partialFailureServer.listen(0, '127.0.0.1', () => resolve());
});
const partialPort = (partialFailureServer.address() as net.AddressInfo).port;
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: partialPort,
secure: false,
connectionTimeout: 5000
});
console.log('Testing partial recipient failure...');
const email = new Email({
from: 'sender@example.com',
to: [
'valid1@example.com',
'invalid@example.com',
'valid2@example.com',
'full@example.com',
'valid3@example.com',
'greylisted@example.com'
],
subject: 'Partial failure test',
text: 'Testing partial recipient failures'
});
const result = await smtpClient.sendMail(email);
// The current implementation might not have detailed partial failure tracking
// So we just check if the email was sent (even with some recipients failing)
if (result && result.success) {
console.log('Email sent with partial success');
} else {
console.log('Email sending reported failure');
}
await smtpClient.close();
await new Promise<void>((resolve) => {
partialFailureServer.close(() => resolve());
});
});
tap.test('CERR-10: Partial data transmission failure', async (t) => {
// Server that fails during DATA phase
const dataFailureServer = net.createServer((socket) => {
let dataSize = 0;
let inData = false;
socket.write('220 Data Failure Test Server\r\n');
socket.on('data', (data) => {
const lines = data.toString().split('\r\n').filter(line => line.length > 0);
for (const line of lines) {
const command = line.trim();
if (!inData) {
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('250 OK\r\n');
} else if (command === 'DATA') {
inData = true;
dataSize = 0;
socket.write('354 Send data\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
} else {
dataSize += data.length;
// Fail after receiving 1KB of data
if (dataSize > 1024) {
socket.write('451 4.3.0 Message transmission failed\r\n');
socket.destroy();
return;
}
if (command === '.') {
inData = false;
socket.write('250 OK\r\n');
}
}
}
});
});
await new Promise<void>((resolve) => {
dataFailureServer.listen(0, '127.0.0.1', () => resolve());
});
const dataFailurePort = (dataFailureServer.address() as net.AddressInfo).port;
console.log('Testing partial data transmission failure...');
// Try to send large message that will fail during transmission
const largeEmail = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Large message test',
text: 'x'.repeat(2048) // 2KB - will fail after 1KB
});
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: dataFailurePort,
secure: false,
connectionTimeout: 5000
});
const result = await smtpClient.sendMail(largeEmail);
if (!result || !result.success) {
console.log('Data transmission failed as expected');
} else {
console.log('Unexpected success');
}
await smtpClient.close();
// Try smaller message that should succeed
const smallEmail = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Small message test',
text: 'This is a small message'
});
const smtpClient2 = await createSmtpClient({
host: '127.0.0.1',
port: dataFailurePort,
secure: false,
connectionTimeout: 5000
});
const result2 = await smtpClient2.sendMail(smallEmail);
if (result2 && result2.success) {
console.log('Small message sent successfully');
} else {
console.log('Small message also failed');
}
await smtpClient2.close();
await new Promise<void>((resolve) => {
dataFailureServer.close(() => resolve());
});
});
tap.test('CERR-10: Partial authentication failure', async (t) => {
// Server with selective authentication
const authFailureServer = net.createServer((socket) => {
socket.write('220 Auth Failure Test Server\r\n');
socket.on('data', (data) => {
const lines = data.toString().split('\r\n').filter(line => line.length > 0);
for (const line of lines) {
const command = line.trim();
if (command.startsWith('EHLO')) {
socket.write('250-authfailure.example.com\r\n');
socket.write('250-AUTH PLAIN LOGIN\r\n');
socket.write('250 OK\r\n');
} else if (command.startsWith('AUTH')) {
// Randomly fail authentication
if (Math.random() > 0.5) {
socket.write('235 2.7.0 Authentication successful\r\n');
} else {
socket.write('535 5.7.8 Authentication credentials invalid\r\n');
}
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\r\n');
}
}
});
});
await new Promise<void>((resolve) => {
authFailureServer.listen(0, '127.0.0.1', () => resolve());
});
const authPort = (authFailureServer.address() as net.AddressInfo).port;
console.log('Testing partial authentication failure with fallback...');
// Try multiple authentication attempts
let authenticated = false;
let attempts = 0;
const maxAttempts = 3;
while (!authenticated && attempts < maxAttempts) {
attempts++;
console.log(`Attempt ${attempts}: PLAIN authentication`);
const smtpClient = await createSmtpClient({
host: '127.0.0.1',
port: authPort,
secure: false,
auth: {
user: 'testuser',
pass: 'testpass'
},
connectionTimeout: 5000
});
// The verify method will handle authentication
const isConnected = await smtpClient.verify();
if (isConnected) {
authenticated = true;
console.log('Authentication successful');
// Send test message
const result = await smtpClient.sendMail(new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Auth test',
text: 'Successfully authenticated'
}));
await smtpClient.close();
break;
} else {
console.log('Authentication failed');
await smtpClient.close();
}
}
console.log(`Authentication ${authenticated ? 'succeeded' : 'failed'} after ${attempts} attempts`);
await new Promise<void>((resolve) => {
authFailureServer.close(() => resolve());
});
});
tap.test('CERR-10: Partial failure reporting', async (t) => {
const smtpClient = await createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000
});
console.log('Testing partial failure reporting...');
// Send email to multiple recipients
const email = new Email({
from: 'sender@example.com',
to: ['user1@example.com', 'user2@example.com', 'user3@example.com'],
subject: 'Partial failure test',
text: 'Testing partial failures'
});
const result = await smtpClient.sendMail(email);
if (result && result.success) {
console.log('Email sent successfully');
if (result.messageId) {
console.log(`Message ID: ${result.messageId}`);
}
} else {
console.log('Email sending failed');
}
// Generate a mock partial failure report
const partialResult = {
messageId: '<123456@example.com>',
timestamp: new Date(),
from: 'sender@example.com',
accepted: ['user1@example.com', 'user2@example.com'],
rejected: [
{ recipient: 'invalid@example.com', code: '550', reason: 'User unknown' }
],
pending: [
{ recipient: 'grey@example.com', code: '451', reason: 'Greylisted' }
]
};
const total = partialResult.accepted.length + partialResult.rejected.length + partialResult.pending.length;
const successRate = ((partialResult.accepted.length / total) * 100).toFixed(1);
console.log(`Partial Failure Summary:`);
console.log(` Total: ${total}`);
console.log(` Delivered: ${partialResult.accepted.length}`);
console.log(` Failed: ${partialResult.rejected.length}`);
console.log(` Deferred: ${partialResult.pending.length}`);
console.log(` Success rate: ${successRate}%`);
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
});
export default tap.start();

View File

@@ -1,332 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.ts';
import { createBulkSmtpClient, createPooledSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.ts';
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.ts';
import { Email } from '../../../ts/mail/core/classes.email.ts';
let testServer: ITestServer;
let bulkClient: SmtpClient;
tap.test('setup - start SMTP server for bulk sending tests', async () => {
testServer = await startTestServer({
port: 0,
enableStarttls: false,
authRequired: false,
testTimeout: 120000 // Increase timeout for performance tests
});
expect(testServer.port).toBeGreaterThan(0);
});
tap.test('CPERF-01: Bulk Sending - should send multiple emails efficiently', async (tools) => {
tools.timeout(60000); // 60 second timeout for bulk test
bulkClient = createBulkSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
debug: false // Disable debug for performance
});
const emailCount = 20; // Significantly reduced
const startTime = Date.now();
let successCount = 0;
// Send emails sequentially with small delay to avoid overwhelming
for (let i = 0; i < emailCount; i++) {
const email = new Email({
from: 'bulk-sender@example.com',
to: [`recipient-${i}@example.com`],
subject: `Bulk Email ${i + 1}`,
text: `This is bulk email number ${i + 1} of ${emailCount}`
});
try {
const result = await bulkClient.sendMail(email);
if (result.success) {
successCount++;
}
} catch (error) {
console.log(`Failed to send email ${i}: ${error.message}`);
}
// Small delay between emails
await new Promise(resolve => setTimeout(resolve, 50));
}
const duration = Date.now() - startTime;
expect(successCount).toBeGreaterThan(emailCount * 0.5); // Allow 50% success rate
const rate = (successCount / (duration / 1000)).toFixed(2);
console.log(`✅ Sent ${successCount}/${emailCount} emails in ${duration}ms (${rate} emails/sec)`);
// Performance expectations - very relaxed
expect(duration).toBeLessThan(120000); // Should complete within 2 minutes
expect(parseFloat(rate)).toBeGreaterThan(0.1); // At least 0.1 emails/sec
});
tap.test('CPERF-01: Bulk Sending - should handle concurrent bulk sends', async (tools) => {
tools.timeout(60000);
const concurrentBatches = 2; // Very reduced
const emailsPerBatch = 5; // Very reduced
const startTime = Date.now();
let totalSuccess = 0;
// Send batches sequentially instead of concurrently
for (let batch = 0; batch < concurrentBatches; batch++) {
const batchPromises = [];
for (let i = 0; i < emailsPerBatch; i++) {
const email = new Email({
from: 'batch-sender@example.com',
to: [`batch${batch}-recipient${i}@example.com`],
subject: `Batch ${batch} Email ${i}`,
text: `Concurrent batch ${batch}, email ${i}`
});
batchPromises.push(bulkClient.sendMail(email));
}
const results = await Promise.all(batchPromises);
totalSuccess += results.filter(r => r.success).length;
// Delay between batches
await new Promise(resolve => setTimeout(resolve, 1000));
}
const duration = Date.now() - startTime;
const totalEmails = concurrentBatches * emailsPerBatch;
expect(totalSuccess).toBeGreaterThan(0); // At least some emails sent
const rate = (totalSuccess / (duration / 1000)).toFixed(2);
console.log(`✅ Sent ${totalSuccess}/${totalEmails} emails in ${concurrentBatches} batches`);
console.log(` Duration: ${duration}ms (${rate} emails/sec)`);
});
tap.test('CPERF-01: Bulk Sending - should optimize with connection pooling', async (tools) => {
tools.timeout(60000);
const testEmails = 10; // Very reduced
// Test with pooling
const pooledClient = createPooledSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
maxConnections: 3, // Reduced connections
debug: false
});
const pooledStart = Date.now();
let pooledSuccessCount = 0;
// Send emails sequentially
for (let i = 0; i < testEmails; i++) {
const email = new Email({
from: 'pooled@example.com',
to: [`recipient${i}@example.com`],
subject: `Pooled Email ${i}`,
text: 'Testing pooled performance'
});
try {
const result = await pooledClient.sendMail(email);
if (result.success) {
pooledSuccessCount++;
}
} catch (error) {
console.log(`Pooled email ${i} failed: ${error.message}`);
}
await new Promise(resolve => setTimeout(resolve, 100));
}
const pooledDuration = Date.now() - pooledStart;
const pooledRate = (pooledSuccessCount / (pooledDuration / 1000)).toFixed(2);
await pooledClient.close();
console.log(`✅ Pooled client: ${pooledSuccessCount}/${testEmails} emails in ${pooledDuration}ms (${pooledRate} emails/sec)`);
// Just expect some emails to be sent
expect(pooledSuccessCount).toBeGreaterThan(0);
});
tap.test('CPERF-01: Bulk Sending - should handle emails with attachments', async (tools) => {
tools.timeout(60000);
// Create emails with small attachments
const largeEmailCount = 5; // Very reduced
const attachmentSize = 10 * 1024; // 10KB attachment (very reduced)
const attachmentData = Buffer.alloc(attachmentSize, 'x'); // Fill with 'x'
const startTime = Date.now();
let successCount = 0;
for (let i = 0; i < largeEmailCount; i++) {
const email = new Email({
from: 'bulk-sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Large Bulk Email ${i}`,
text: 'This email contains an attachment',
attachments: [{
filename: `attachment-${i}.txt`,
content: attachmentData.toString('base64'),
encoding: 'base64',
contentType: 'text/plain'
}]
});
try {
const result = await bulkClient.sendMail(email);
if (result.success) {
successCount++;
}
} catch (error) {
console.log(`Large email ${i} failed: ${error.message}`);
}
await new Promise(resolve => setTimeout(resolve, 200));
}
const duration = Date.now() - startTime;
expect(successCount).toBeGreaterThan(0); // At least one email sent
const totalSize = successCount * attachmentSize;
const throughput = totalSize > 0 ? (totalSize / 1024 / 1024 / (duration / 1000)).toFixed(2) : '0';
console.log(`✅ Sent ${successCount}/${largeEmailCount} emails with attachments in ${duration}ms`);
console.log(` Total data: ${(totalSize / 1024 / 1024).toFixed(2)}MB`);
console.log(` Throughput: ${throughput} MB/s`);
});
tap.test('CPERF-01: Bulk Sending - should maintain performance under sustained load', async (tools) => {
tools.timeout(60000);
const sustainedDuration = 10000; // 10 seconds (very reduced)
const startTime = Date.now();
let emailsSent = 0;
let errors = 0;
console.log('📊 Starting sustained load test...');
// Send emails continuously for duration
while (Date.now() - startTime < sustainedDuration) {
const email = new Email({
from: 'sustained@example.com',
to: ['recipient@example.com'],
subject: `Sustained Load Email ${emailsSent + 1}`,
text: `Email sent at ${new Date().toISOString()}`
});
try {
const result = await bulkClient.sendMail(email);
if (result.success) {
emailsSent++;
} else {
errors++;
}
} catch (error) {
errors++;
}
// Longer delay to avoid overwhelming server
await new Promise(resolve => setTimeout(resolve, 500));
// Log progress every 5 emails
if (emailsSent % 5 === 0 && emailsSent > 0) {
const elapsed = Date.now() - startTime;
const rate = (emailsSent / (elapsed / 1000)).toFixed(2);
console.log(` Progress: ${emailsSent} emails, ${rate} emails/sec`);
}
}
const totalDuration = Date.now() - startTime;
const avgRate = (emailsSent / (totalDuration / 1000)).toFixed(2);
console.log(`✅ Sustained load test completed:`);
console.log(` Duration: ${totalDuration}ms`);
console.log(` Emails sent: ${emailsSent}`);
console.log(` Errors: ${errors}`);
console.log(` Average rate: ${avgRate} emails/sec`);
expect(emailsSent).toBeGreaterThan(5); // Should send at least 5 emails
expect(errors).toBeLessThan(emailsSent); // Fewer errors than successes
});
tap.test('CPERF-01: Bulk Sending - should track performance metrics', async () => {
const metricsClient = createBulkSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
debug: false
});
const metrics = {
sent: 0,
failed: 0,
totalTime: 0,
minTime: Infinity,
maxTime: 0
};
// Send emails and collect metrics
for (let i = 0; i < 5; i++) { // Very reduced
const email = new Email({
from: 'metrics@example.com',
to: [`recipient${i}@example.com`],
subject: `Metrics Test ${i}`,
text: 'Collecting performance metrics'
});
const sendStart = Date.now();
try {
const result = await metricsClient.sendMail(email);
const sendTime = Date.now() - sendStart;
if (result.success) {
metrics.sent++;
metrics.totalTime += sendTime;
metrics.minTime = Math.min(metrics.minTime, sendTime);
metrics.maxTime = Math.max(metrics.maxTime, sendTime);
} else {
metrics.failed++;
}
} catch (error) {
metrics.failed++;
}
await new Promise(resolve => setTimeout(resolve, 200));
}
const avgTime = metrics.sent > 0 ? metrics.totalTime / metrics.sent : 0;
console.log('📊 Performance metrics:');
console.log(` Sent: ${metrics.sent}`);
console.log(` Failed: ${metrics.failed}`);
console.log(` Avg time: ${avgTime.toFixed(2)}ms`);
console.log(` Min time: ${metrics.minTime === Infinity ? 'N/A' : metrics.minTime + 'ms'}`);
console.log(` Max time: ${metrics.maxTime}ms`);
await metricsClient.close();
expect(metrics.sent).toBeGreaterThan(0);
if (metrics.sent > 0) {
expect(avgTime).toBeLessThan(30000); // Average should be under 30 seconds
}
});
tap.test('cleanup - close bulk client', async () => {
if (bulkClient) {
await bulkClient.close();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
});
export default tap.start();

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