Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96b4ccb7d3 | |||
| 7c0c327913 | |||
| 9e722874b4 | |||
| 873af43ef2 | |||
| 76d898b648 | |||
| b422639c34 | |||
| c45ba2a7b4 | |||
| b10597fd5e | |||
| 7908cbaefa | |||
| 526dcb4dac | |||
| cf8fcb6efa | |||
| 2088c9f76e | |||
| 7853ef67b6 | |||
| f7af8c4534 | |||
| a7ea1d86cb | |||
| 27bab5f345 | |||
| fc4877e06b | |||
| 36006191fc | |||
| d43fc15d8e | |||
| 248bfcfe78 | |||
| 1e7c9f6822 | |||
| f3a74a7660 |
97
changelog.md
97
changelog.md
@@ -1,5 +1,102 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-11 - 5.1.3 - fix(docs)
|
||||
clarify sendEmail default behavior and document automatic MX discovery and delivery modes
|
||||
|
||||
- Updated README to describe automatic MX record discovery and grouping behavior when using sendEmail() (MTA mode)
|
||||
- Added a Delivery Modes section and API signature for sendEmail(mode) describing mta, forward, and process options
|
||||
- Expanded examples to show multi-recipient delivery, explicit mode usage, and retained low-level sendOutboundEmail example
|
||||
|
||||
## 2026-02-11 - 5.1.2 - fix(readme)
|
||||
adjust ASCII architecture diagram alignment in README
|
||||
|
||||
- Whitespace and alignment tweaks to the ASCII architecture diagram in readme.md
|
||||
- No code or behavior changes; documentation-only edit
|
||||
|
||||
## 2026-02-11 - 5.1.1 - fix(release)
|
||||
no changes
|
||||
|
||||
- No files changed in this commit.
|
||||
- Current package version remains 5.1.0 (from package.json).
|
||||
|
||||
## 2026-02-11 - 5.1.0 - feat(mailer-smtp)
|
||||
add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements
|
||||
|
||||
- Add server-side SCRAM-SHA-256 implementation in Rust (scram.rs) and wire up SCRAM credential request/response between Rust and TypeScript bridge (ScramCredentialRequest / scramCredentialResult).
|
||||
- Support SCRAM-SHA-256 auth mechanism in SMTP command parsing and advertise AUTH PLAIN LOGIN SCRAM-SHA-256 capability.
|
||||
- Add opportunistic TLS mode for MTA-to-MTA delivery: configurable tls_opportunistic flag, an OpportunisticVerifier that skips cert verification per RFC 7435, and plumbing into connect/upgrade TLS paths.
|
||||
- Add pipelined envelope support for MAIL FROM + multiple RCPT TO (send_pipelined_envelope) and use pipelining when server advertises PIPELINING to improve outbound performance.
|
||||
- Add Ed25519 DKIM signing support and auto-dispatch: sign_dkim_ed25519, sign_dkim_auto, dkim_dns_record_value_typed, and TS changes to detect key type and call the auto signing API.
|
||||
- Expose additional per-domain TLS certs (additionalTlsCerts) and implement SNI-based certificate resolver on the server to select certs by hostname; parsing helpers and fallback default cert handling included.
|
||||
- Install ring crypto provider early in mailer-bin main for rustls operations and add related rust dependencies (sha2, hmac, pbkdf2) and workspace entries.
|
||||
- TypeScript delivery and server bridge changes: group recipients by domain, MX resolution fallback to A record, MTA delivery loop over MX hosts, DKIM options propagation, TLS opportunistic option passed to outbound client, SCRAM credential computation in TS using PBKDF2/HMAC/SHA256 and sending results back to Rust.
|
||||
- Add new tests and utilities: IPv6 DNSBL support and tests, SCRAM unit tests, DKIM Ed25519 tests, node-level MTA delivery integration test, and various test updates.
|
||||
- Public API additions on the Rust <-> TS bridge: signDkim accepts keyType, new scram credential result command, onScramCredentialRequest/onScramCredentialResult helpers and sendScramCredentialResult.
|
||||
- Various refactors and safety/feature improvements across mailer-core/smtp/security: envelope handling, stream buffering detection, and error handling for auth flows.
|
||||
|
||||
## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mail)
|
||||
remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests
|
||||
|
||||
- Removed ts/mail/security/classes.dmarcverifier.ts and ts/mail/security/classes.dkimverifier.ts — DMARC and DKIM verifier implementations deleted
|
||||
- Removed ts/errors/index.ts — MTA-specific error classes removed
|
||||
- Added ts/mail/routing/classes.dkim.manager.ts — new DKIM key management and rotation logic
|
||||
- Added ts/mail/routing/classes.email.action.executor.ts — centralized email action execution (forward/process/deliver/reject)
|
||||
- Updated ts/mail/security/classes.spfverifier.ts to retain SPF parsing but removed verify/verifyAndApply logic delegating to Rust bridge
|
||||
- Updated ts/mail/routing/index.ts to export new routing classes and adjusted import paths (e.g. delivery queue import updated)
|
||||
- Tests trimmed: DMARC tests and rate limiter tests removed; SPF parsing test retained and simplified
|
||||
- This set of changes alters public exports and removes previously available verifier APIs — major version bump recommended
|
||||
|
||||
## 2026-02-11 - 4.1.1 - fix(readme)
|
||||
clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README
|
||||
|
||||
- Changed IPC description to JSON-over-stdin/stdout (clarifies communication format between Rust and TypeScript)
|
||||
- Added Rust SMTP client entry and documented outbound mail data flow (TypeScript -> Rust signing/delivery -> result back)
|
||||
- Expanded testing instructions with commands for building Rust binary and running unit/E2E tests
|
||||
- Updated architecture diagram labels and Rust crate/module descriptions (mailer-smtp now includes client; test counts noted)
|
||||
- Documentation-only changes; no source code behavior modified
|
||||
|
||||
## 2026-02-11 - 4.1.0 - feat(e2e-tests)
|
||||
add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions
|
||||
|
||||
- Adds four end-to-end test files: test.e2e.server-lifecycle.node.ts, test.e2e.inbound-smtp.node.ts, test.e2e.outbound-delivery.node.ts, test.e2e.routing-actions.node.ts
|
||||
- Tests exercise UnifiedEmailServer start/stop, SMTP handshake and transactions, outbound delivery via a mock SMTP server, routing actions (process, deliver, reject, forward), concurrency, and RSET handling mid-session
|
||||
- Introduces a minimal mock SMTP server to avoid IPC deadlock with the Rust SMTP client during outbound delivery tests
|
||||
- Tests will skip when the Rust bridge or server cannot start (binary build required)
|
||||
|
||||
## 2026-02-11 - 4.0.0 - BREAKING CHANGE(smtp-client)
|
||||
Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery
|
||||
|
||||
- Introduce a Rust SMTP client crate with connection handling, TLS, protocol engine, and connection pooling (new modules: connection, pool, protocol, error, config).
|
||||
- Add IPC handlers and management commands in the Rust binary: sendEmail, sendRawEmail, verifySmtpConnection, closeSmtpPool, getSmtpPoolStatus and integrate a SmtpClientManager into the runtime.
|
||||
- Update TypeScript bridge (RustSecurityBridge) with new types and methods (ISmtpSendOptions, ISmtpSendResult, verifySmtpConnection, sendOutboundEmail, sendRawEmail, getSmtpPoolStatus, closeSmtpPool) and rework UnifiedEmailServer to use the Rust bridge for outbound delivery and DKIM signing.
|
||||
- Remove the previous TypeScript SMTP client implementation and associated tests/utilities (many ts/mail/delivery/smtpclient modules and tests deleted) in favor of the Rust implementation.
|
||||
- Bump dependencies and cargo config: @push.rocks/smartrust to ^1.2.0 in package.json and add/require crates (uuid, base64, webpki-roots) in Rust Cargo files.
|
||||
|
||||
## 2026-02-10 - 3.0.0 - BREAKING CHANGE(security)
|
||||
implement resilience and lifecycle management for RustSecurityBridge (auto-restart, health checks, state machine and eventing); remove legacy TS SMTP test helper and DNSManager; remove deliverability IP-warmup/sender-reputation integrations and related types; drop unused dependencies
|
||||
|
||||
- RustSecurityBridge now extends EventEmitter and includes a BridgeState state machine, IBridgeResilienceConfig with DEFAULT_RESILIENCE_CONFIG, auto-restart with exponential backoff, periodic health checks, restart/restore logic, and descriptive ensureRunning() guards on command methods.
|
||||
- Added static methods: resetInstance() (test-friendly) and configure(...) to tweak resilience settings at runtime.
|
||||
- Added stateChange events and logging for lifecycle transitions; new tests added for resilience: test/test.rustsecuritybridge.resilience.node.ts.
|
||||
- Removed the TypeScript SMTP test helper (test/helpers/server.loader.ts), the DNSManager (ts/mail/routing/classes.dnsmanager.ts), and many deliverability-related interfaces/implementations (IP warmup manager and sender reputation monitor) from unified email server.
|
||||
- Removed public types ISmtpServerOptions and ISmtpTransactionResult from ts/mail/delivery/interfaces.ts, which is a breaking API change for consumers relying on those types.
|
||||
- Removed unused dependencies from package.json: ip and mailauth.
|
||||
|
||||
## 2026-02-10 - 2.4.0 - feat(docs)
|
||||
document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts
|
||||
|
||||
- Clarifies that the Rust SMTP server accepts the full SMTP protocol and runs the security pipeline in-process (DKIM/SPF/DMARC verification, content scanning, IP reputation/DNSBL) to avoid IPC round-trips
|
||||
- Notes that Rust now emits an emailReceived IPC event with pre-computed security results attached for TypeScript to use in routing/delivery decisions
|
||||
- Updates mailer-smtp crate description to include the in-process security pipeline and increments its test count from 72 to 77
|
||||
- Adjusts TypeScript directory comments to reflect removal/relocation of the legacy TS SMTP server and the smtpclient path
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartmta",
|
||||
"version": "2.3.1",
|
||||
"version": "5.1.3",
|
||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||
"keywords": [
|
||||
"mta",
|
||||
@@ -44,36 +44,14 @@
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.2.5",
|
||||
"@api.global/typedserver": "^8.3.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@push.rocks/projectinfo": "^5.0.1",
|
||||
"@push.rocks/qenv": "^6.1.0",
|
||||
"@push.rocks/smartacme": "^8.0.0",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartdns": "^7.5.0",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartfs": "^1.3.1",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.1.8",
|
||||
"@push.rocks/smartmail": "^2.2.0",
|
||||
"@push.rocks/smartmetrics": "^2.0.10",
|
||||
"@push.rocks/smartnetwork": "^4.0.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.0.3",
|
||||
"@push.rocks/smartproxy": "^23.1.0",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrule": "^2.0.1",
|
||||
"@push.rocks/smartrust": "^1.1.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@serve.zone/interfaces": "^5.0.4",
|
||||
"@push.rocks/smartrust": "^1.2.0",
|
||||
"@tsclass/tsclass": "^9.2.0",
|
||||
"ip": "^2.0.1",
|
||||
"lru-cache": "^11.2.5",
|
||||
"mailauth": "^4.13.0",
|
||||
"mailparser": "^3.9.3",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
||||
1887
pnpm-lock.yaml
generated
1887
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
388
readme.md
388
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartmta
|
||||
|
||||
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. 🚀
|
||||
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. Automatic MX record discovery means you just call `sendEmail()` and smartmta figures out where to deliver. 🚀
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -18,14 +18,14 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
||||
|
||||
## Overview
|
||||
|
||||
`@push.rocks/smartmta` is a **complete mail server solution** — SMTP server, SMTP client, email security, content scanning, and delivery management — all built with a custom SMTP implementation. The SMTP server itself runs as a Rust binary for maximum performance, communicating with the TypeScript orchestration layer via IPC.
|
||||
`@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 engine runs as a Rust binary for maximum performance, communicating with the TypeScript orchestration layer via JSON-over-stdin/stdout IPC.
|
||||
|
||||
### ⚡ What's Inside
|
||||
|
||||
| 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 |
|
||||
| **Rust SMTP Server** | High-performance SMTP engine in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
|
||||
| **Rust SMTP Client** | Outbound delivery with connection pooling, retry logic, TLS negotiation, DKIM signing — all in Rust |
|
||||
| **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
|
||||
| **SPF** | Full SPF record validation via Rust |
|
||||
| **DMARC** | Policy enforcement and verification |
|
||||
@@ -37,44 +37,51 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
||||
| **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 |
|
||||
| **DNS Manager** | Automatic DNS record management (MX, SPF, DKIM, DMARC) |
|
||||
|
||||
### 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ UnifiedEmailServer │
|
||||
│ (orchestrates all components, emits events) │
|
||||
│ UnifiedEmailServer │
|
||||
│ (orchestrates all components, emits events) │
|
||||
├───────────┬───────────┬──────────────┬───────────────────────┤
|
||||
│ Email │ Security │ Delivery │ Configuration │
|
||||
│ Router │ Stack │ System │ │
|
||||
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
|
||||
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
|
||||
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
|
||||
│ │ Act │ │ │ DMARC │ │ │ SMTP Cli │ │ │ DKIMCreator │ │
|
||||
│ └──────┘ │ │ IPRep │ │ │ Retry │ │ │ Templates │ │
|
||||
│ │ │ Scan │ │ └──────────┘ │ └────────────────┘ │
|
||||
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
|
||||
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
|
||||
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
|
||||
│ │ Act │ │ │ DMARC │ │ │ Retry │ │ │ DKIMCreator │ │
|
||||
│ └──────┘ │ │ IPRep │ │ └──────────┘ │ │ Templates │ │
|
||||
│ │ │ Scan │ │ │ └────────────────┘ │
|
||||
│ │ └───────┘ │ │ │
|
||||
├───────────┴───────────┴──────────────┴───────────────────────┤
|
||||
│ Rust Security Bridge (smartrust IPC) │
|
||||
│ Rust Security Bridge (smartrust IPC) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Rust Acceleration Layer │
|
||||
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
|
||||
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
|
||||
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
|
||||
│ │ TLS/AUTH │ │IP Rep/Content │ │ MIME/Bounce │ │
|
||||
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
|
||||
│ Rust Acceleration Layer │
|
||||
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
|
||||
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
|
||||
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
|
||||
│ │ SMTP Client │ │IP Rep/Content │ │ MIME/Bounce │ │
|
||||
│ │ TLS/AUTH │ │ Scanning │ │ Detection │ │
|
||||
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Data flow for inbound mail:**
|
||||
|
||||
1. Rust SMTP server accepts the connection and handles the SMTP protocol
|
||||
2. On `DATA` completion, Rust emits an `emailReceived` event via IPC
|
||||
3. TypeScript processes the email (routing, scanning, delivery decisions)
|
||||
4. TypeScript sends the processing result back to Rust via IPC
|
||||
5. Rust sends the final SMTP response to the client
|
||||
1. 📨 Rust SMTP server accepts the connection and handles the full SMTP protocol
|
||||
2. 🔒 On `DATA` completion, Rust runs the security pipeline **in-process** (DKIM/SPF/DMARC verification, content scanning, IP reputation check) — zero IPC round-trips
|
||||
3. 📤 Rust emits an `emailReceived` event via IPC with pre-computed security results attached
|
||||
4. 🔀 TypeScript processes the email (routing decisions using the pre-computed results, delivery)
|
||||
5. ✅ Rust sends the final SMTP response to the client
|
||||
|
||||
**Data flow for outbound mail:**
|
||||
|
||||
1. 📝 TypeScript constructs the email and calls `sendEmail()` (defaults to MTA mode)
|
||||
2. 🔍 MTA mode automatically resolves MX records for each recipient domain, sorts by priority, and groups recipients for efficient delivery
|
||||
3. 🦀 Sends to Rust via IPC — Rust builds the RFC 2822 message, signs with DKIM, and delivers via its SMTP client with connection pooling
|
||||
4. 📬 Result (accepted/rejected recipients, server response) returned to TypeScript
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -163,32 +170,18 @@ await emailServer.start();
|
||||
|
||||
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
|
||||
|
||||
### 📧 Sending Emails with the SMTP Client
|
||||
### 📧 Sending Emails (Automatic MX Discovery)
|
||||
|
||||
Create and send emails using the built-in SMTP client with connection pooling:
|
||||
The recommended way to send email is `sendEmail()`. It defaults to **MTA mode**, which automatically resolves MX records for each recipient domain via DNS — you don't need to know the destination mail server:
|
||||
|
||||
```typescript
|
||||
import { Email, Delivery } from '@push.rocks/smartmta';
|
||||
|
||||
// Create a client with connection pooling
|
||||
const client = Delivery.smtpClientMod.createSmtpClient({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false, // will upgrade via STARTTLS
|
||||
pool: true,
|
||||
maxConnections: 5,
|
||||
auth: {
|
||||
user: 'sender@example.com',
|
||||
pass: 'your-password',
|
||||
},
|
||||
});
|
||||
import { Email, UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
|
||||
// Build an email
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
cc: ['cc@example.com'],
|
||||
subject: 'Hello from smartmta!',
|
||||
to: ['alice@gmail.com', 'bob@company.org'],
|
||||
subject: 'Hello from smartmta! 🚀',
|
||||
text: 'Plain text body',
|
||||
html: '<h1>Hello!</h1><p>HTML body with <strong>formatting</strong></p>',
|
||||
priority: 'high',
|
||||
@@ -201,32 +194,75 @@ const email = new Email({
|
||||
],
|
||||
});
|
||||
|
||||
// Send it
|
||||
const result = await client.sendMail(email);
|
||||
console.log(`Message sent: ${result.messageId}`);
|
||||
// Send — MTA mode auto-discovers MX servers for gmail.com and company.org
|
||||
const emailId = await emailServer.sendEmail(email);
|
||||
|
||||
// Optionally specify a delivery mode explicitly
|
||||
const emailId2 = await emailServer.sendEmail(email, 'mta');
|
||||
```
|
||||
|
||||
Additional client factories are available:
|
||||
In MTA mode, smartmta:
|
||||
- 🔍 Resolves MX records for each recipient domain (e.g. `gmail.com`, `company.org`)
|
||||
- 📊 Sorts MX hosts by priority (lowest = highest priority per RFC 5321)
|
||||
- 🔄 Tries each MX host in order until delivery succeeds
|
||||
- 🌐 Falls back to the domain's A record if no MX records exist
|
||||
- 📦 Groups recipients by domain for efficient batch delivery
|
||||
- 🔑 Signs outbound mail with DKIM automatically
|
||||
|
||||
### 📮 Delivery Modes
|
||||
|
||||
`sendEmail()` accepts a mode parameter that controls how the email is delivered:
|
||||
|
||||
```typescript
|
||||
// Pooled client for high-throughput scenarios
|
||||
const pooled = Delivery.smtpClientMod.createPooledSmtpClient({ /* ... */ });
|
||||
|
||||
// Optimized for bulk sending
|
||||
const bulk = Delivery.smtpClientMod.createBulkSmtpClient({ /* ... */ });
|
||||
|
||||
// Optimized for transactional emails
|
||||
const transactional = Delivery.smtpClientMod.createTransactionalSmtpClient({ /* ... */ });
|
||||
public async sendEmail(
|
||||
email: Email,
|
||||
mode: EmailProcessingMode = 'mta', // 'mta' | 'forward' | 'process'
|
||||
route?: IEmailRoute,
|
||||
options?: {
|
||||
skipSuppressionCheck?: boolean;
|
||||
ipAddress?: string;
|
||||
isTransactional?: boolean;
|
||||
}
|
||||
): Promise<string>
|
||||
```
|
||||
|
||||
### 🔑 DKIM Signing
|
||||
| Mode | Description |
|
||||
|---|---|
|
||||
| `mta` (default) | **Auto MX discovery** — resolves MX records via DNS, delivers directly to the recipient's mail server. No relay configuration needed. |
|
||||
| `forward` | **Relay delivery** — forwards the email to a configured SMTP host (e.g. an internal mail gateway or third-party relay). |
|
||||
| `process` | **Scan + deliver** — runs the content scanning / security pipeline first, then delivers via auto MX resolution. |
|
||||
|
||||
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by `UnifiedEmailServer` during outbound delivery:
|
||||
### 📬 Direct SMTP Delivery (Low-Level)
|
||||
|
||||
For cases where you know the exact target SMTP server (e.g. relaying to a specific host), use the lower-level `sendOutboundEmail()`:
|
||||
|
||||
```typescript
|
||||
// Send directly to a known SMTP server (bypasses MX resolution)
|
||||
const result = await emailServer.sendOutboundEmail('smtp.example.com', 587, email, {
|
||||
auth: { user: 'sender@example.com', pass: 'your-password' },
|
||||
dkimDomain: 'example.com',
|
||||
dkimSelector: 'default',
|
||||
});
|
||||
|
||||
console.log(`Accepted: ${result.accepted.join(', ')}`);
|
||||
console.log(`Response: ${result.response}`);
|
||||
// -> Accepted: recipient@example.com
|
||||
// -> Response: 2.0.0 Ok: queued
|
||||
```
|
||||
|
||||
The `sendOutboundEmail` method:
|
||||
- 🔑 Automatically resolves DKIM keys from the `DKIMCreator` for the specified domain
|
||||
- 🔗 Uses connection pooling in Rust — reuses TCP/TLS connections across sends
|
||||
- ⏱️ Configurable connection and socket timeouts via `outbound` options on the server
|
||||
|
||||
### 🔑 DKIM Signing & Key Management
|
||||
|
||||
DKIM key management is handled by `DKIMCreator`, which generates, stores, and rotates keys per domain. Signing is performed automatically by the Rust SMTP client during outbound delivery:
|
||||
|
||||
```typescript
|
||||
import { DKIMCreator } from '@push.rocks/smartmta';
|
||||
|
||||
const dkimCreator = new DKIMCreator('/path/to/keys');
|
||||
const dkimCreator = new DKIMCreator('/path/to/keys', storageManager);
|
||||
|
||||
// Auto-generate keys if they don't exist
|
||||
await dkimCreator.handleDKIMKeysForDomain('example.com');
|
||||
@@ -244,30 +280,34 @@ if (needsRotation) {
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
When `UnifiedEmailServer.start()` is called:
|
||||
- DKIM keys are generated or loaded for every configured domain
|
||||
- Signing is applied to all outbound mail via the Rust security bridge
|
||||
- Key rotation is checked automatically based on your `rotationInterval` config
|
||||
|
||||
### 🛡️ Email Authentication (SPF, DKIM, DMARC)
|
||||
|
||||
Verify incoming emails against all three authentication standards. All verification is powered by the Rust binary:
|
||||
All verification is powered by the Rust binary. For inbound mail, `UnifiedEmailServer` runs the full security pipeline **automatically** — DKIM, SPF, DMARC, content scanning, and IP reputation in a single Rust pass. Results are attached as headers (`Received-SPF`, `X-DKIM-Result`, `X-DMARC-Result`).
|
||||
|
||||
You can also use the individual verifiers directly:
|
||||
|
||||
```typescript
|
||||
import { DKIMVerifier, SpfVerifier, DmarcVerifier } from '@push.rocks/smartmta';
|
||||
|
||||
// SPF verification — first arg is an Email object
|
||||
// SPF verification
|
||||
const spfVerifier = new SpfVerifier();
|
||||
const spfResult = await spfVerifier.verify(email, senderIP, heloDomain);
|
||||
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror',
|
||||
// domain: string, ip: string }
|
||||
// -> { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none', domain, ip }
|
||||
|
||||
// DKIM verification — takes raw email content
|
||||
// DKIM verification
|
||||
const dkimVerifier = new DKIMVerifier();
|
||||
const dkimResult = await dkimVerifier.verify(rawEmailContent);
|
||||
// -> [{ is_valid: true, domain: 'example.com', selector: 'default', status: 'pass' }]
|
||||
|
||||
// DMARC verification — first arg is an Email object
|
||||
// DMARC verification
|
||||
const dmarcVerifier = new DmarcVerifier();
|
||||
const dmarcResult = await dmarcVerifier.verify(email, spfResult, dkimResult);
|
||||
// -> { action: 'pass' | 'quarantine' | 'reject', hasDmarc: boolean,
|
||||
// spfDomainAligned: boolean, dkimDomainAligned: boolean, ... }
|
||||
// -> { action: 'pass' | 'quarantine' | 'reject', policy, spfDomainAligned, dkimDomainAligned }
|
||||
```
|
||||
|
||||
### 🔀 Email Routing
|
||||
@@ -294,7 +334,7 @@ const router = new EmailRouter([
|
||||
priority: 50,
|
||||
match: {
|
||||
recipients: '*@example.com',
|
||||
sizeRange: { max: 10 * 1024 * 1024 }, // under 10MB
|
||||
sizeRange: { max: 10 * 1024 * 1024 }, // under 10MB
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
@@ -326,7 +366,16 @@ const router = new EmailRouter([
|
||||
const matchedRoute = await router.evaluateRoutes(emailContext);
|
||||
```
|
||||
|
||||
**Match criteria available:**
|
||||
#### Route Action Types
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| `forward` | Forward the email to another SMTP server via the Rust SMTP client |
|
||||
| `deliver` | Queue for local MTA delivery |
|
||||
| `process` | Queue for processing (with optional content scanning and DKIM signing) |
|
||||
| `reject` | Reject with a configurable SMTP error code and message |
|
||||
|
||||
#### Match Criteria
|
||||
|
||||
| Criterion | Description |
|
||||
|---|---|
|
||||
@@ -339,52 +388,6 @@ const matchedRoute = await router.evaluateRoutes(emailContext);
|
||||
| `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: [...] }
|
||||
```
|
||||
|
||||
### 🌐 IP Reputation Checking
|
||||
|
||||
Check sender IP addresses against DNSBL blacklists and classify IP types. DNSBL lookups run in Rust:
|
||||
|
||||
```typescript
|
||||
import { IPReputationChecker } from '@push.rocks/smartmta';
|
||||
|
||||
const ipChecker = 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: [] }
|
||||
```
|
||||
|
||||
### ⏱️ Rate Limiting
|
||||
|
||||
Hierarchical rate limiting to protect your server and maintain deliverability:
|
||||
@@ -445,7 +448,7 @@ const bounce = await bounceManager.processSmtpFailure(
|
||||
// Check if an address is suppressed due to bounces
|
||||
const suppressed = bounceManager.isEmailSuppressed('recipient@example.com');
|
||||
|
||||
// Manually manage the suppression list
|
||||
// Manage the suppression list
|
||||
bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
|
||||
bounceManager.removeFromSuppressionList('recovered@example.com');
|
||||
```
|
||||
@@ -484,7 +487,7 @@ const email = await templates.createEmail('welcome', {
|
||||
|
||||
### 🌍 DNS Management
|
||||
|
||||
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:
|
||||
When `UnifiedEmailServer.start()` is called, it automatically ensures MX, SPF, DKIM, and DMARC records are in place for all configured domains:
|
||||
|
||||
```typescript
|
||||
const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
@@ -492,7 +495,7 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'external-dns', // managed via Cloudflare API
|
||||
dnsMode: 'external-dns', // managed via Cloudflare API
|
||||
},
|
||||
],
|
||||
// ... other config
|
||||
@@ -506,100 +509,43 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
|
||||
await emailServer.start();
|
||||
```
|
||||
|
||||
### 🦀 RustSecurityBridge
|
||||
|
||||
The `RustSecurityBridge` is the singleton that manages the Rust binary process. It handles security verification, content scanning, bounce detection, and the SMTP server lifecycle — all via `@push.rocks/smartrust` IPC:
|
||||
|
||||
```typescript
|
||||
import { RustSecurityBridge } from '@push.rocks/smartmta';
|
||||
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
await bridge.start();
|
||||
|
||||
// Compound verification: DKIM + SPF + DMARC in a single IPC call
|
||||
const securityResult = await bridge.verifyEmail({
|
||||
rawMessage: rawEmailString,
|
||||
ip: '203.0.113.10',
|
||||
heloDomain: 'sender.example.com',
|
||||
mailFrom: 'user@example.com',
|
||||
});
|
||||
// -> { dkim: [...], spf: { result, explanation }, dmarc: { result, policy } }
|
||||
|
||||
// Individual security operations
|
||||
const dkimResults = await bridge.verifyDkim(rawEmailString);
|
||||
const spfResult = await bridge.checkSpf({
|
||||
ip: '203.0.113.10',
|
||||
heloDomain: 'sender.example.com',
|
||||
mailFrom: 'user@example.com',
|
||||
});
|
||||
const reputationResult = await bridge.checkIpReputation('203.0.113.10');
|
||||
|
||||
// DKIM signing
|
||||
const signed = await bridge.signDkim({
|
||||
email: rawEmailString,
|
||||
domain: 'example.com',
|
||||
selector: 'default',
|
||||
privateKeyPem: privateKey,
|
||||
});
|
||||
|
||||
// Content scanning
|
||||
const scanResult = await bridge.scanContent({
|
||||
subject: 'Win a free iPhone!!!',
|
||||
body: '<a href="http://phishing.example.com">Click here</a>',
|
||||
from: 'scammer@evil.com',
|
||||
});
|
||||
|
||||
// Bounce detection
|
||||
const bounceResult = await bridge.detectBounce({
|
||||
subject: 'Delivery Status Notification (Failure)',
|
||||
body: '550 5.1.1 User unknown',
|
||||
from: 'mailer-daemon@example.com',
|
||||
});
|
||||
|
||||
await bridge.stop();
|
||||
```
|
||||
|
||||
> ⚠️ **Important:** The Rust bridge is **mandatory**. There are no TypeScript fallbacks. If the Rust binary is unavailable, `UnifiedEmailServer.start()` will throw an error.
|
||||
|
||||
## 🦀 Rust Acceleration Layer
|
||||
|
||||
Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC). The Rust workspace lives at `rust/` with five crates:
|
||||
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 four 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) |
|
||||
| `mailer-smtp` | ✅ Complete (106 tests) | Full SMTP protocol engine — TCP/TLS server + client, STARTTLS, AUTH, pipelining, connection pooling, in-process security pipeline |
|
||||
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — wires everything together |
|
||||
|
||||
### What Runs in Rust
|
||||
### What Runs Where
|
||||
|
||||
| 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 |
|
||||
| SMTP server (port listening, protocol, TLS) | 🦀 Rust | Performance, memory safety, zero-copy parsing |
|
||||
| SMTP client (outbound delivery, connection pooling) | 🦀 Rust | Connection management, TLS negotiation |
|
||||
| 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 |
|
||||
| Email routing & orchestration | 🟦 TypeScript | Business logic, flexibility |
|
||||
| Delivery queue & retry | 🟦 TypeScript | State management, persistence |
|
||||
| Template rendering | 🟦 TypeScript | String interpolation |
|
||||
| Domain & DNS management | 🟦 TypeScript | API integrations |
|
||||
|
||||
## Project Structure
|
||||
## 📁 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)
|
||||
│ │ ├── delivery/ # DeliveryQueue, DeliverySystem, RateLimiter
|
||||
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
|
||||
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
|
||||
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
|
||||
@@ -607,14 +553,56 @@ smartmta/
|
||||
│ └── 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
|
||||
│ ├── mailer-smtp/ # Full SMTP server + client (TCP/TLS, rate limiting, pooling)
|
||||
│ └── mailer-bin/ # CLI + smartrust IPC bridge
|
||||
├── test/ # Test suite (116 TypeScript + 154 Rust tests)
|
||||
├── dist_ts/ # Compiled TypeScript output
|
||||
└── dist_rust/ # Compiled Rust binaries
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
The project has comprehensive test coverage with both unit and end-to-end tests:
|
||||
|
||||
```bash
|
||||
# Build Rust binary first
|
||||
pnpm build
|
||||
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run specific test files
|
||||
tstest test/test.e2e.server-lifecycle.node.ts --verbose --timeout 60
|
||||
tstest test/test.e2e.inbound-smtp.node.ts --verbose --timeout 60
|
||||
tstest test/test.e2e.routing-actions.node.ts --verbose --timeout 60
|
||||
tstest test/test.e2e.outbound-delivery.node.ts --verbose --timeout 60
|
||||
```
|
||||
|
||||
**E2E tests** exercise the full pipeline — starting `UnifiedEmailServer`, connecting via raw TCP sockets, sending SMTP transactions, verifying routing actions, and testing outbound delivery through a mock SMTP receiver.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Exported Classes (top-level)
|
||||
|
||||
| Class | Description |
|
||||
|---|---|
|
||||
| `UnifiedEmailServer` | 🎯 Main entry point — orchestrates SMTP server, routing, security, and delivery |
|
||||
| `Email` | Email message class with validation, attachments, headers, and RFC 822 serialization |
|
||||
| `EmailRouter` | Pattern-based route matching and evaluation engine |
|
||||
| `DomainRegistry` | Multi-domain configuration manager |
|
||||
| `DnsManager` | Automatic DNS record management |
|
||||
| `DKIMCreator` | DKIM key generation, storage, rotation |
|
||||
| `DKIMVerifier` | DKIM signature verification (delegates to Rust) |
|
||||
| `SpfVerifier` | SPF record validation (delegates to Rust) |
|
||||
| `DmarcVerifier` | DMARC policy enforcement (delegates to Rust) |
|
||||
|
||||
### Namespaced Exports
|
||||
|
||||
| Namespace | Classes |
|
||||
|---|---|
|
||||
| `Core` | `Email`, `EmailValidator`, `TemplateManager`, `BounceManager` |
|
||||
| `Delivery` | `UnifiedDeliveryQueue`, `MultiModeDeliverySystem`, `DeliveryStatus`, `UnifiedRateLimiter` |
|
||||
|
||||
## 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.
|
||||
|
||||
147
rust/Cargo.lock
generated
147
rust/Cargo.lock
generated
@@ -274,15 +274,6 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -356,16 +347,6 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
@@ -913,16 +894,6 @@ version = "0.2.181"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -1004,16 +975,19 @@ dependencies = [
|
||||
name = "mailer-bin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"clap",
|
||||
"dashmap",
|
||||
"hickory-resolver 0.25.2",
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"mailer-smtp",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1021,48 +995,28 @@ name = "mailer-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"mailparse",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mailer-napi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"mailer-smtp",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mailer-security"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hickory-resolver 0.25.2",
|
||||
"ipnet",
|
||||
"mail-auth",
|
||||
"mailer-core",
|
||||
"psl",
|
||||
"regex",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1070,23 +1024,26 @@ name = "mailer-smtp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"dashmap",
|
||||
"hickory-resolver 0.25.2",
|
||||
"hmac",
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"mailparse",
|
||||
"pbkdf2",
|
||||
"regex",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1144,66 +1101,6 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "2.16.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ctor",
|
||||
"napi-derive",
|
||||
"napi-sys",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "2.16.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"convert_case",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "1.0.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"semver",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -1543,12 +1440,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -1859,12 +1750,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -1972,6 +1857,24 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -4,7 +4,6 @@ members = [
|
||||
"crates/mailer-core",
|
||||
"crates/mailer-smtp",
|
||||
"crates/mailer-security",
|
||||
"crates/mailer-napi",
|
||||
"crates/mailer-bin",
|
||||
]
|
||||
|
||||
@@ -19,19 +18,17 @@ 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"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
pbkdf2 = { version = "0.12", default-features = false }
|
||||
|
||||
@@ -19,3 +19,6 @@ serde_json.workspace = true
|
||||
clap.workspace = true
|
||||
hickory-resolver.workspace = true
|
||||
dashmap.workspace = true
|
||||
base64.workspace = true
|
||||
uuid.workspace = true
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use mailer_smtp::connection::{
|
||||
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult,
|
||||
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult, ScramCredentialResult,
|
||||
};
|
||||
|
||||
/// mailer-bin: Rust-powered email security tools
|
||||
@@ -114,10 +114,11 @@ struct IpcEvent {
|
||||
|
||||
// --- Pending callbacks for correlation-ID based reverse calls ---
|
||||
|
||||
/// Stores oneshot senders for pending email processing and auth callbacks.
|
||||
/// Stores oneshot senders for pending email processing, auth, and SCRAM callbacks.
|
||||
struct PendingCallbacks {
|
||||
email: DashMap<String, oneshot::Sender<EmailProcessingResult>>,
|
||||
auth: DashMap<String, oneshot::Sender<AuthResult>>,
|
||||
scram: DashMap<String, oneshot::Sender<ScramCredentialResult>>,
|
||||
}
|
||||
|
||||
impl PendingCallbacks {
|
||||
@@ -125,6 +126,7 @@ impl PendingCallbacks {
|
||||
Self {
|
||||
email: DashMap::new(),
|
||||
auth: DashMap::new(),
|
||||
scram: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,9 +149,22 @@ impl CallbackRegistry for PendingCallbacks {
|
||||
self.auth.insert(correlation_id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
|
||||
fn register_scram_callback(
|
||||
&self,
|
||||
correlation_id: &str,
|
||||
) -> oneshot::Receiver<ScramCredentialResult> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.scram.insert(correlation_id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Install the ring CryptoProvider for rustls TLS operations (STARTTLS, implicit TLS).
|
||||
// This must happen before any TLS connection is attempted.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.management {
|
||||
@@ -327,6 +342,7 @@ struct ManagementState {
|
||||
callbacks: Arc<PendingCallbacks>,
|
||||
smtp_handle: Option<mailer_smtp::server::SmtpServerHandle>,
|
||||
smtp_event_rx: Option<tokio::sync::mpsc::Receiver<ConnectionEvent>>,
|
||||
smtp_client_manager: Arc<mailer_smtp::client::SmtpClientManager>,
|
||||
}
|
||||
|
||||
/// Run in management/IPC mode for smartrust bridge.
|
||||
@@ -349,10 +365,12 @@ fn run_management_mode() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
let callbacks = Arc::new(PendingCallbacks::new());
|
||||
let smtp_client_manager = Arc::new(mailer_smtp::client::SmtpClientManager::new());
|
||||
let mut state = ManagementState {
|
||||
callbacks: callbacks.clone(),
|
||||
smtp_handle: None,
|
||||
smtp_event_rx: None,
|
||||
smtp_client_manager: smtp_client_manager.clone(),
|
||||
};
|
||||
|
||||
// We need to read stdin in a separate thread (blocking I/O)
|
||||
@@ -491,6 +509,22 @@ fn handle_smtp_event(event: ConnectionEvent) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
ConnectionEvent::ScramCredentialRequest {
|
||||
correlation_id,
|
||||
session_id,
|
||||
username,
|
||||
remote_addr,
|
||||
} => {
|
||||
emit_event(
|
||||
"scramCredentialRequest",
|
||||
serde_json::json!({
|
||||
"correlationId": correlation_id,
|
||||
"sessionId": session_id,
|
||||
"username": username,
|
||||
"remoteAddr": remote_addr,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,8 +673,13 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
|
||||
.get("privateKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let key_type = req
|
||||
.params
|
||||
.get("keyType")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("rsa");
|
||||
|
||||
match mailer_security::sign_dkim(raw_message.as_bytes(), domain, selector, private_key) {
|
||||
match mailer_security::sign_dkim_auto(raw_message.as_bytes(), domain, selector, private_key, key_type) {
|
||||
Ok(header) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
@@ -822,6 +861,10 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
|
||||
handle_auth_result(req, state)
|
||||
}
|
||||
|
||||
"scramCredentialResult" => {
|
||||
handle_scram_credential_result(req, state)
|
||||
}
|
||||
|
||||
"configureRateLimits" => {
|
||||
// Rate limit configuration is set at startSmtpServer time.
|
||||
// This command allows runtime updates, but for now we acknowledge it.
|
||||
@@ -833,6 +876,28 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
|
||||
}
|
||||
}
|
||||
|
||||
// --- SMTP Client commands ---
|
||||
|
||||
"sendEmail" => {
|
||||
handle_send_email(req, state).await
|
||||
}
|
||||
|
||||
"sendRawEmail" => {
|
||||
handle_send_raw_email(req, state).await
|
||||
}
|
||||
|
||||
"verifySmtpConnection" => {
|
||||
handle_verify_smtp_connection(req, state).await
|
||||
}
|
||||
|
||||
"closeSmtpPool" => {
|
||||
handle_close_smtp_pool(req, state).await
|
||||
}
|
||||
|
||||
"getSmtpPoolStatus" => {
|
||||
handle_get_smtp_pool_status(req, state)
|
||||
}
|
||||
|
||||
_ => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
@@ -985,6 +1050,56 @@ fn handle_auth_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle scramCredentialResult IPC command — resolves a pending SCRAM credential callback.
|
||||
fn handle_scram_credential_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
let correlation_id = req
|
||||
.params
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let found = req.params.get("found").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let result = ScramCredentialResult {
|
||||
found,
|
||||
salt: req.params.get("salt")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
|
||||
iterations: req.params.get("iterations")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as u32),
|
||||
stored_key: req.params.get("storedKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
|
||||
server_key: req.params.get("serverKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
|
||||
};
|
||||
|
||||
if let Some((_, tx)) = state.callbacks.scram.remove(correlation_id) {
|
||||
let _ = tx.send(result);
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"resolved": true})),
|
||||
error: None,
|
||||
}
|
||||
} else {
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!(
|
||||
"No pending SCRAM credential callback for correlationId: {}",
|
||||
correlation_id
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse SmtpServerConfig from IPC params JSON.
|
||||
fn parse_smtp_config(
|
||||
params: &serde_json::Value,
|
||||
@@ -1050,5 +1165,321 @@ fn parse_smtp_config(
|
||||
config.processing_timeout_secs = timeout;
|
||||
}
|
||||
|
||||
// Parse additional TLS certs for SNI
|
||||
if let Some(certs_arr) = params.get("additionalTlsCerts").and_then(|v| v.as_array()) {
|
||||
for cert_val in certs_arr {
|
||||
if let (Some(domains_arr), Some(cert_pem), Some(key_pem)) = (
|
||||
cert_val.get("domains").and_then(|v| v.as_array()),
|
||||
cert_val.get("certPem").and_then(|v| v.as_str()),
|
||||
cert_val.get("keyPem").and_then(|v| v.as_str()),
|
||||
) {
|
||||
let domains: Vec<String> = domains_arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect();
|
||||
config.additional_tls_certs.push(mailer_smtp::config::TlsDomainCert {
|
||||
domains,
|
||||
cert_pem: cert_pem.to_string(),
|
||||
key_pem: key_pem.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SMTP Client IPC handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Structured email to build a MIME message from.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OutboundEmail {
|
||||
from: String,
|
||||
to: Vec<String>,
|
||||
#[serde(default)]
|
||||
cc: Vec<String>,
|
||||
#[serde(default)]
|
||||
bcc: Vec<String>,
|
||||
#[serde(default)]
|
||||
subject: String,
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
html: Option<String>,
|
||||
#[serde(default)]
|
||||
headers: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl OutboundEmail {
|
||||
/// Convert to `mailer_core::Email` for proper RFC 5322 MIME building.
|
||||
fn to_core_email(&self) -> mailer_core::Email {
|
||||
let mut email = mailer_core::Email::new(&self.from, &self.subject, &self.text);
|
||||
for addr in &self.to {
|
||||
email.add_to(addr);
|
||||
}
|
||||
for addr in &self.cc {
|
||||
email.add_cc(addr);
|
||||
}
|
||||
for addr in &self.bcc {
|
||||
email.add_bcc(addr);
|
||||
}
|
||||
if let Some(html) = &self.html {
|
||||
email.set_html(html);
|
||||
}
|
||||
for (key, value) in &self.headers {
|
||||
email.add_header(key, value);
|
||||
}
|
||||
email
|
||||
}
|
||||
|
||||
/// Build an RFC 5322 compliant message using `mailer_core::build_rfc822`.
|
||||
fn to_rfc822(&self) -> Vec<u8> {
|
||||
let email = self.to_core_email();
|
||||
match mailer_core::build_rfc822(&email) {
|
||||
Ok(msg) => msg.into_bytes(),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to build RFC 822 message: {e}");
|
||||
// Fallback: minimal message
|
||||
format!(
|
||||
"From: {}\r\nTo: {}\r\nSubject: {}\r\n\r\n{}",
|
||||
self.from,
|
||||
self.to.join(", "),
|
||||
self.subject,
|
||||
self.text
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all recipients (to + cc + bcc).
|
||||
fn all_recipients(&self) -> Vec<String> {
|
||||
let mut all = self.to.clone();
|
||||
all.extend(self.cc.clone());
|
||||
all.extend(self.bcc.clone());
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle sendEmail IPC command — build MIME, optional DKIM sign, send via pool.
|
||||
async fn handle_send_email(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
// Parse client config from params
|
||||
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid config: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Parse the email
|
||||
let email: OutboundEmail = match req.params.get("email").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some("Missing or invalid 'email' field".into()),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Build raw message
|
||||
let mut raw_message = email.to_rfc822();
|
||||
|
||||
// Optional DKIM signing
|
||||
if let Some(dkim_val) = req.params.get("dkim") {
|
||||
if let Ok(dkim_config) = serde_json::from_value::<mailer_smtp::client::DkimSignConfig>(dkim_val.clone()) {
|
||||
match mailer_security::sign_dkim_auto(
|
||||
&raw_message,
|
||||
&dkim_config.domain,
|
||||
&dkim_config.selector,
|
||||
&dkim_config.private_key,
|
||||
&dkim_config.key_type,
|
||||
) {
|
||||
Ok(header) => {
|
||||
// Prepend DKIM header to the message
|
||||
let mut signed = header.into_bytes();
|
||||
signed.extend_from_slice(&raw_message);
|
||||
raw_message = signed;
|
||||
}
|
||||
Err(e) => {
|
||||
// Log but don't fail — send unsigned
|
||||
eprintln!("DKIM signing failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let all_recipients = email.all_recipients();
|
||||
let sender = &email.from;
|
||||
|
||||
match state
|
||||
.smtp_client_manager
|
||||
.send_message(&config, sender, &all_recipients, &raw_message)
|
||||
.await
|
||||
{
|
||||
Ok(result) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::to_value(&result).unwrap()),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(serde_json::to_string(&serde_json::json!({
|
||||
"message": e.to_string(),
|
||||
"errorType": e.error_type(),
|
||||
"retryable": e.is_retryable(),
|
||||
"smtpCode": e.smtp_code(),
|
||||
}))
|
||||
.unwrap()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle sendRawEmail IPC command — send a pre-formatted message.
|
||||
async fn handle_send_raw_email(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
// Parse client config from params
|
||||
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid config: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let envelope_from = req
|
||||
.params
|
||||
.get("envelopeFrom")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let envelope_to: Vec<String> = req
|
||||
.params
|
||||
.get("envelopeTo")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let raw_b64 = req
|
||||
.params
|
||||
.get("rawMessageBase64")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Decode base64 message
|
||||
use base64::Engine;
|
||||
let raw_message = match base64::engine::general_purpose::STANDARD.decode(raw_b64) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid base64 message: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match state
|
||||
.smtp_client_manager
|
||||
.send_message(&config, envelope_from, &envelope_to, &raw_message)
|
||||
.await
|
||||
{
|
||||
Ok(result) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::to_value(&result).unwrap()),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(serde_json::to_string(&serde_json::json!({
|
||||
"message": e.to_string(),
|
||||
"errorType": e.error_type(),
|
||||
"retryable": e.is_retryable(),
|
||||
"smtpCode": e.smtp_code(),
|
||||
}))
|
||||
.unwrap()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle verifySmtpConnection IPC command.
|
||||
async fn handle_verify_smtp_connection(
|
||||
req: &IpcRequest,
|
||||
state: &ManagementState,
|
||||
) -> IpcResponse {
|
||||
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid config: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match state.smtp_client_manager.verify_connection(&config).await {
|
||||
Ok(result) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::to_value(&result).unwrap()),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle closeSmtpPool IPC command.
|
||||
async fn handle_close_smtp_pool(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
if let Some(pool_key) = req.params.get("poolKey").and_then(|v| v.as_str()) {
|
||||
state.smtp_client_manager.close_pool(pool_key).await;
|
||||
} else {
|
||||
state.smtp_client_manager.close_all_pools().await;
|
||||
}
|
||||
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"closed": true})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle getSmtpPoolStatus IPC command.
|
||||
fn handle_get_smtp_pool_status(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
let pools = state.smtp_client_manager.pool_status();
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"pools": pools})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ license.workspace = true
|
||||
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
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
[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"
|
||||
@@ -1,5 +0,0 @@
|
||||
extern crate napi_build;
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//! 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(),
|
||||
)
|
||||
}
|
||||
@@ -7,14 +7,11 @@ 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
|
||||
|
||||
@@ -111,16 +111,18 @@ static MACRO_DOCUMENT_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(||
|
||||
// HTML helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Regexes for HTML text extraction (compiled once via LazyLock).
|
||||
static HTML_STYLE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap());
|
||||
static HTML_SCRIPT_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap());
|
||||
static HTML_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
|
||||
|
||||
/// 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, " ");
|
||||
let text = HTML_STYLE_RE.replace_all(html, " ");
|
||||
let text = HTML_SCRIPT_RE.replace_all(&text, " ");
|
||||
let text = HTML_TAG_RE.replace_all(&text, " ");
|
||||
|
||||
text.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use mail_auth::common::crypto::{RsaKey, Sha256};
|
||||
use mail_auth::common::crypto::{Ed25519Key, RsaKey, Sha256};
|
||||
use mail_auth::common::headers::HeaderWriter;
|
||||
use mail_auth::dkim::{Canonicalization, DkimSigner};
|
||||
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
|
||||
@@ -118,9 +118,62 @@ pub fn sign_dkim(
|
||||
Ok(signature.to_header())
|
||||
}
|
||||
|
||||
/// Sign a raw email message with DKIM using Ed25519-SHA256 (RFC 8463).
|
||||
///
|
||||
/// * `raw_message` - The raw RFC 5322 message bytes
|
||||
/// * `domain` - The signing domain (d= tag)
|
||||
/// * `selector` - The DKIM selector (s= tag)
|
||||
/// * `private_key_pkcs8_der` - Ed25519 private key in PKCS#8 DER format
|
||||
///
|
||||
/// Returns the DKIM-Signature header string to prepend to the message.
|
||||
pub fn sign_dkim_ed25519(
|
||||
raw_message: &[u8],
|
||||
domain: &str,
|
||||
selector: &str,
|
||||
private_key_pkcs8_der: &[u8],
|
||||
) -> Result<String> {
|
||||
let ed_key = Ed25519Key::from_pkcs8_maybe_unchecked_der(private_key_pkcs8_der)
|
||||
.map_err(|e| SecurityError::Key(format!("Failed to load Ed25519 key: {}", e)))?;
|
||||
|
||||
let signature = DkimSigner::from_key(ed_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!("Ed25519 DKIM signing failed: {}", e)))?;
|
||||
|
||||
Ok(signature.to_header())
|
||||
}
|
||||
|
||||
/// Sign a raw email message with DKIM, auto-selecting RSA or Ed25519 based on `key_type`.
|
||||
///
|
||||
/// * `key_type` - `"rsa"` (default) or `"ed25519"`
|
||||
/// * For RSA: `private_key_pem` is a PEM-encoded RSA key
|
||||
/// * For Ed25519: `private_key_pem` is a PEM-encoded PKCS#8 Ed25519 key
|
||||
pub fn sign_dkim_auto(
|
||||
raw_message: &[u8],
|
||||
domain: &str,
|
||||
selector: &str,
|
||||
private_key_pem: &str,
|
||||
key_type: &str,
|
||||
) -> Result<String> {
|
||||
match key_type.to_lowercase().as_str() {
|
||||
"ed25519" => {
|
||||
// Parse PEM to DER for Ed25519
|
||||
let der = rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes())
|
||||
.map_err(|e| SecurityError::Key(format!("Failed to parse Ed25519 PEM: {}", e)))?;
|
||||
sign_dkim_ed25519(raw_message, domain, selector, der.secret_pkcs8_der())
|
||||
}
|
||||
_ => sign_dkim(raw_message, domain, selector, private_key_pem),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a DKIM DNS TXT record value for a given public key.
|
||||
///
|
||||
/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`.
|
||||
/// `key_type` should be `"rsa"` or `"ed25519"`.
|
||||
pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
|
||||
// Extract the base64 content from PEM
|
||||
let key_b64: String = public_key_pem
|
||||
@@ -132,6 +185,24 @@ pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
|
||||
format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64)
|
||||
}
|
||||
|
||||
/// Generate a DKIM DNS TXT record value with explicit key type.
|
||||
///
|
||||
/// * `key_type` - `"rsa"` or `"ed25519"`
|
||||
pub fn dkim_dns_record_value_typed(public_key_pem: &str, key_type: &str) -> String {
|
||||
let key_b64: String = public_key_pem
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("-----"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
let k = match key_type.to_lowercase().as_str() {
|
||||
"ed25519" => "ed25519",
|
||||
_ => "rsa",
|
||||
};
|
||||
|
||||
format!("v=DKIM1; h=sha256; k={}; p={}", k, key_b64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -149,4 +220,42 @@ mod tests {
|
||||
let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_dkim_ed25519() {
|
||||
// Generate an Ed25519 key pair using mail-auth
|
||||
let pkcs8_der = Ed25519Key::generate_pkcs8().expect("generate ed25519 key");
|
||||
let ed_key = Ed25519Key::from_pkcs8_der(&pkcs8_der).expect("parse ed25519 key");
|
||||
let _pub_key = ed_key.public_key();
|
||||
|
||||
let msg = b"From: test@example.com\r\nTo: rcpt@example.com\r\nSubject: Test\r\n\r\nBody";
|
||||
let result = sign_dkim_ed25519(msg, "example.com", "ed25519sel", &pkcs8_der);
|
||||
assert!(result.is_ok());
|
||||
let header = result.unwrap();
|
||||
assert!(header.contains("a=ed25519-sha256"));
|
||||
assert!(header.contains("d=example.com"));
|
||||
assert!(header.contains("s=ed25519sel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_dkim_auto_dispatches() {
|
||||
// RSA with invalid key should error
|
||||
let msg = b"From: test@example.com\r\n\r\nBody";
|
||||
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "rsa");
|
||||
assert!(result.is_err());
|
||||
|
||||
// Ed25519 with invalid PEM should error
|
||||
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "ed25519");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dkim_dns_record_value_typed() {
|
||||
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----";
|
||||
let rsa_record = dkim_dns_record_value_typed(pem, "rsa");
|
||||
assert!(rsa_record.contains("k=rsa"));
|
||||
|
||||
let ed_record = dkim_dns_record_value_typed(pem, "ed25519");
|
||||
assert!(ed_record.contains("k=ed25519"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use hickory_resolver::TokioResolver;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn risk_level(score: u8) -> RiskLevel {
|
||||
|
||||
/// Check an IP against DNSBL servers.
|
||||
///
|
||||
/// * `ip` - The IP address to check (must be IPv4)
|
||||
/// * `ip` - The IP address to check (IPv4 or IPv6)
|
||||
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
|
||||
/// * `resolver` - DNS resolver to use
|
||||
pub async fn check_dnsbl(
|
||||
@@ -91,20 +91,10 @@ pub async fn check_dnsbl(
|
||||
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 = match ip {
|
||||
IpAddr::V4(v4) => reverse_ipv4(v4),
|
||||
IpAddr::V6(v6) => reverse_ipv6(v6),
|
||||
};
|
||||
|
||||
let reversed = reverse_ipv4(ipv4);
|
||||
let total = dnsbl_servers.len();
|
||||
|
||||
// Query all DNSBL servers in parallel
|
||||
@@ -178,6 +168,21 @@ fn reverse_ipv4(ip: Ipv4Addr) -> String {
|
||||
format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0])
|
||||
}
|
||||
|
||||
/// Reverse IPv6 address to nibble format for DNSBL queries.
|
||||
///
|
||||
/// Expands to full 32-nibble hex, reverses, and dot-separates each nibble.
|
||||
/// E.g. `2001:db8::1` -> `1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2`
|
||||
fn reverse_ipv6(ip: Ipv6Addr) -> String {
|
||||
let segments = ip.segments();
|
||||
let full_hex: String = segments.iter().map(|s| format!("{:04x}", s)).collect();
|
||||
full_hex
|
||||
.chars()
|
||||
.rev()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
/// Heuristic IP type classification based on well-known prefix ranges.
|
||||
/// Same heuristics as the TypeScript IPReputationChecker.
|
||||
fn classify_ip(ip: IpAddr) -> IpType {
|
||||
@@ -272,6 +277,38 @@ mod tests {
|
||||
assert!(!is_valid_ipv4("not-an-ip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_ipv6() {
|
||||
let ip: Ipv6Addr = "2001:0db8:0000:0000:0000:0000:0000:0001".parse().unwrap();
|
||||
assert_eq!(
|
||||
reverse_ipv6(ip),
|
||||
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_ipv6_loopback() {
|
||||
let ip: Ipv6Addr = "::1".parse().unwrap();
|
||||
assert_eq!(
|
||||
reverse_ipv6(ip),
|
||||
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_dnsbl_ipv6_runs() {
|
||||
// Verify IPv6 actually goes through DNSBL queries (not skipped)
|
||||
let resolver = hickory_resolver::TokioResolver::builder_tokio()
|
||||
.map(|b| b.build())
|
||||
.unwrap();
|
||||
let ip: IpAddr = "::1".parse().unwrap();
|
||||
let result = check_dnsbl(ip, DEFAULT_DNSBL_SERVERS, &resolver).await.unwrap();
|
||||
// Loopback should not be listed on any DNSBL
|
||||
assert_eq!(result.listed_count, 0);
|
||||
// But total_checked should be > 0 — proving IPv6 was actually queried
|
||||
assert_eq!(result.total_checked, DEFAULT_DNSBL_SERVERS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_dnsbl_servers() {
|
||||
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 dkim::{dkim_dns_record_value, dkim_dns_record_value_typed, dkim_outputs_to_results, sign_dkim, sign_dkim_auto, sign_dkim_ed25519, verify_dkim, DkimVerificationResult};
|
||||
pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
|
||||
pub use verify::{verify_email_security, EmailSecurityResult};
|
||||
pub use error::{Result, SecurityError};
|
||||
|
||||
@@ -13,13 +13,16 @@ 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"] }
|
||||
serde_json.workspace = true
|
||||
regex.workspace = true
|
||||
uuid.workspace = true
|
||||
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
|
||||
webpki-roots = "0.26"
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
pbkdf2.workspace = true
|
||||
|
||||
170
rust/crates/mailer-smtp/src/client/config.rs
Normal file
170
rust/crates/mailer-smtp/src/client/config.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
//! SMTP client configuration types.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Configuration for connecting to an SMTP server.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SmtpClientConfig {
|
||||
/// Target SMTP server hostname.
|
||||
pub host: String,
|
||||
|
||||
/// Target port (25 = SMTP, 465 = implicit TLS, 587 = submission).
|
||||
pub port: u16,
|
||||
|
||||
/// Use implicit TLS (port 465). If false, STARTTLS is attempted.
|
||||
#[serde(default)]
|
||||
pub secure: bool,
|
||||
|
||||
/// Domain to use in EHLO command. Defaults to "localhost".
|
||||
#[serde(default = "default_domain")]
|
||||
pub domain: String,
|
||||
|
||||
/// Authentication credentials (optional).
|
||||
pub auth: Option<SmtpAuthConfig>,
|
||||
|
||||
/// Connection timeout in seconds. Default: 30.
|
||||
#[serde(default = "default_connection_timeout")]
|
||||
pub connection_timeout_secs: u64,
|
||||
|
||||
/// Socket read/write timeout in seconds. Default: 120.
|
||||
#[serde(default = "default_socket_timeout")]
|
||||
pub socket_timeout_secs: u64,
|
||||
|
||||
/// Pool key override. Defaults to "host:port".
|
||||
pub pool_key: Option<String>,
|
||||
|
||||
/// Maximum connections per pool. Default: 10.
|
||||
#[serde(default = "default_max_pool_connections")]
|
||||
pub max_pool_connections: usize,
|
||||
|
||||
/// Accept invalid TLS certificates (expired, self-signed, wrong hostname).
|
||||
/// Standard for MTA-to-MTA opportunistic TLS per RFC 7435.
|
||||
/// Default: false.
|
||||
#[serde(default)]
|
||||
pub tls_opportunistic: bool,
|
||||
}
|
||||
|
||||
/// Authentication configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SmtpAuthConfig {
|
||||
/// Username.
|
||||
pub user: String,
|
||||
/// Password.
|
||||
pub pass: String,
|
||||
/// Method: "PLAIN" or "LOGIN". Default: "PLAIN".
|
||||
#[serde(default = "default_auth_method")]
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
/// DKIM signing configuration (applied before sending).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DkimSignConfig {
|
||||
/// Signing domain (e.g. "example.com").
|
||||
pub domain: String,
|
||||
/// DKIM selector (e.g. "default" or "mta").
|
||||
pub selector: String,
|
||||
/// PEM-encoded private key (RSA or Ed25519 PKCS#8).
|
||||
pub private_key: String,
|
||||
/// Key type: "rsa" (default) or "ed25519".
|
||||
#[serde(default = "default_key_type")]
|
||||
pub key_type: String,
|
||||
}
|
||||
|
||||
fn default_key_type() -> String {
|
||||
"rsa".to_string()
|
||||
}
|
||||
|
||||
impl SmtpClientConfig {
|
||||
/// Get the effective pool key for this config.
|
||||
pub fn effective_pool_key(&self) -> String {
|
||||
self.pool_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}:{}", self.host, self.port))
|
||||
}
|
||||
}
|
||||
|
||||
fn default_domain() -> String {
|
||||
"localhost".to_string()
|
||||
}
|
||||
|
||||
fn default_connection_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_socket_timeout() -> u64 {
|
||||
120
|
||||
}
|
||||
|
||||
fn default_max_pool_connections() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_auth_method() -> String {
|
||||
"PLAIN".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_minimal_config() {
|
||||
let json = r#"{"host":"mail.example.com","port":25}"#;
|
||||
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.host, "mail.example.com");
|
||||
assert_eq!(config.port, 25);
|
||||
assert!(!config.secure);
|
||||
assert_eq!(config.domain, "localhost");
|
||||
assert!(config.auth.is_none());
|
||||
assert_eq!(config.connection_timeout_secs, 30);
|
||||
assert_eq!(config.socket_timeout_secs, 120);
|
||||
assert_eq!(config.max_pool_connections, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_full_config() {
|
||||
let json = r#"{
|
||||
"host": "smtp.gmail.com",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"domain": "myserver.com",
|
||||
"auth": { "user": "u", "pass": "p", "method": "LOGIN" },
|
||||
"connectionTimeoutSecs": 60,
|
||||
"socketTimeoutSecs": 300,
|
||||
"poolKey": "gmail",
|
||||
"maxPoolConnections": 5
|
||||
}"#;
|
||||
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.host, "smtp.gmail.com");
|
||||
assert_eq!(config.port, 465);
|
||||
assert!(config.secure);
|
||||
assert_eq!(config.domain, "myserver.com");
|
||||
assert_eq!(config.connection_timeout_secs, 60);
|
||||
assert_eq!(config.socket_timeout_secs, 300);
|
||||
assert_eq!(config.effective_pool_key(), "gmail");
|
||||
assert_eq!(config.max_pool_connections, 5);
|
||||
let auth = config.auth.unwrap();
|
||||
assert_eq!(auth.user, "u");
|
||||
assert_eq!(auth.pass, "p");
|
||||
assert_eq!(auth.method, "LOGIN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_pool_key_default() {
|
||||
let json = r#"{"host":"mx.example.com","port":587}"#;
|
||||
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.effective_pool_key(), "mx.example.com:587");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dkim_config_deserialize() {
|
||||
let json = r#"{"domain":"example.com","selector":"mta","privateKey":"-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----"}"#;
|
||||
let dkim: DkimSignConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(dkim.domain, "example.com");
|
||||
assert_eq!(dkim.selector, "mta");
|
||||
assert!(dkim.private_key.contains("RSA PRIVATE KEY"));
|
||||
}
|
||||
}
|
||||
260
rust/crates/mailer-smtp/src/client/connection.rs
Normal file
260
rust/crates/mailer-smtp/src/client/connection.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
//! TCP/TLS connection management for the SMTP client.
|
||||
|
||||
use super::error::SmtpClientError;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio_rustls::client::TlsStream;
|
||||
use tracing::debug;
|
||||
|
||||
/// A client-side SMTP stream that may be plain or TLS.
|
||||
pub enum ClientSmtpStream {
|
||||
Plain(BufReader<TcpStream>),
|
||||
Tls(BufReader<TlsStream<TcpStream>>),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ClientSmtpStream {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ClientSmtpStream::Plain(_) => write!(f, "ClientSmtpStream::Plain"),
|
||||
ClientSmtpStream::Tls(_) => write!(f, "ClientSmtpStream::Tls"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientSmtpStream {
|
||||
/// Read a line from the stream (CRLF-terminated).
|
||||
pub async fn read_line(&mut self, buf: &mut String) -> Result<usize, SmtpClientError> {
|
||||
match self {
|
||||
ClientSmtpStream::Plain(reader) => reader.read_line(buf).await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("Read error: {e}"),
|
||||
}
|
||||
}),
|
||||
ClientSmtpStream::Tls(reader) => reader.read_line(buf).await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("TLS read error: {e}"),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write bytes to the stream.
|
||||
pub async fn write_all(&mut self, data: &[u8]) -> Result<(), SmtpClientError> {
|
||||
match self {
|
||||
ClientSmtpStream::Plain(reader) => {
|
||||
reader.get_mut().write_all(data).await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("Write error: {e}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
ClientSmtpStream::Tls(reader) => {
|
||||
reader.get_mut().write_all(data).await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("TLS write error: {e}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the stream.
|
||||
pub async fn flush(&mut self) -> Result<(), SmtpClientError> {
|
||||
match self {
|
||||
ClientSmtpStream::Plain(reader) => {
|
||||
reader.get_mut().flush().await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("Flush error: {e}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
ClientSmtpStream::Tls(reader) => {
|
||||
reader.get_mut().flush().await.map_err(|e| {
|
||||
SmtpClientError::ConnectionError {
|
||||
message: format!("TLS flush error: {e}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume this stream and return the inner TcpStream (for STARTTLS upgrade).
|
||||
/// Only works on Plain streams; returns an error on TLS streams.
|
||||
pub fn into_tcp_stream(self) -> Result<TcpStream, SmtpClientError> {
|
||||
match self {
|
||||
ClientSmtpStream::Plain(reader) => Ok(reader.into_inner()),
|
||||
ClientSmtpStream::Tls(_) => Err(SmtpClientError::TlsError {
|
||||
message: "Cannot extract TcpStream from an already-TLS stream".into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to an SMTP server via plain TCP.
|
||||
pub async fn connect_plain(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout_secs: u64,
|
||||
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||
debug!("Connecting to {}:{} (plain)", host, port);
|
||||
let addr = format!("{host}:{port}");
|
||||
let stream = timeout(Duration::from_secs(timeout_secs), TcpStream::connect(&addr))
|
||||
.await
|
||||
.map_err(|_| SmtpClientError::TimeoutError {
|
||||
message: format!("Connection to {addr} timed out after {timeout_secs}s"),
|
||||
})?
|
||||
.map_err(|e| SmtpClientError::ConnectionError {
|
||||
message: format!("Failed to connect to {addr}: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(ClientSmtpStream::Plain(BufReader::new(stream)))
|
||||
}
|
||||
|
||||
/// Connect to an SMTP server via implicit TLS (port 465).
|
||||
pub async fn connect_tls(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout_secs: u64,
|
||||
tls_opportunistic: bool,
|
||||
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||
debug!("Connecting to {}:{} (implicit TLS)", host, port);
|
||||
let addr = format!("{host}:{port}");
|
||||
|
||||
let tcp_stream = timeout(Duration::from_secs(timeout_secs), TcpStream::connect(&addr))
|
||||
.await
|
||||
.map_err(|_| SmtpClientError::TimeoutError {
|
||||
message: format!("Connection to {addr} timed out after {timeout_secs}s"),
|
||||
})?
|
||||
.map_err(|e| SmtpClientError::ConnectionError {
|
||||
message: format!("Failed to connect to {addr}: {e}"),
|
||||
})?;
|
||||
|
||||
let tls_stream = perform_tls_handshake(tcp_stream, host, tls_opportunistic).await?;
|
||||
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
|
||||
}
|
||||
|
||||
/// Upgrade a plain TCP connection to TLS (STARTTLS).
|
||||
pub async fn upgrade_to_tls(
|
||||
stream: ClientSmtpStream,
|
||||
hostname: &str,
|
||||
tls_opportunistic: bool,
|
||||
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||
debug!("Upgrading connection to TLS (STARTTLS) for {}", hostname);
|
||||
let tcp_stream = stream.into_tcp_stream()?;
|
||||
let tls_stream = perform_tls_handshake(tcp_stream, hostname, tls_opportunistic).await?;
|
||||
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
|
||||
}
|
||||
|
||||
/// A TLS certificate verifier that accepts any certificate.
|
||||
/// Used for MTA-to-MTA opportunistic TLS per RFC 7435.
|
||||
#[derive(Debug)]
|
||||
struct OpportunisticVerifier;
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for OpportunisticVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls_pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls_pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls_pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls_pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls_pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls_pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform the TLS handshake on a TCP stream using webpki-roots.
|
||||
/// When `tls_opportunistic` is true, certificate verification is skipped
|
||||
/// (standard for MTA-to-MTA delivery per RFC 7435).
|
||||
async fn perform_tls_handshake(
|
||||
tcp_stream: TcpStream,
|
||||
hostname: &str,
|
||||
tls_opportunistic: bool,
|
||||
) -> Result<TlsStream<TcpStream>, SmtpClientError> {
|
||||
let tls_config = if tls_opportunistic {
|
||||
debug!("Using opportunistic TLS (no cert verification) for {}", hostname);
|
||||
rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(OpportunisticVerifier))
|
||||
.with_no_client_auth()
|
||||
} else {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth()
|
||||
};
|
||||
|
||||
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
|
||||
let server_name = rustls_pki_types::ServerName::try_from(hostname.to_string()).map_err(|e| {
|
||||
SmtpClientError::TlsError {
|
||||
message: format!("Invalid server name '{hostname}': {e}"),
|
||||
}
|
||||
})?;
|
||||
|
||||
let tls_stream = connector
|
||||
.connect(server_name, tcp_stream)
|
||||
.await
|
||||
.map_err(|e| SmtpClientError::TlsError {
|
||||
message: format!("TLS handshake with {hostname} failed: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(tls_stream)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connect_plain_refused() {
|
||||
// Connecting to a port that's not listening should fail
|
||||
let result = connect_plain("127.0.0.1", 19999, 2).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, SmtpClientError::ConnectionError { .. }));
|
||||
assert!(err.is_retryable());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connect_tls_refused() {
|
||||
let result = connect_tls("127.0.0.1", 19998, 2, false).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connect_timeout() {
|
||||
// 192.0.2.1 is TEST-NET, should time out
|
||||
let result = connect_plain("192.0.2.1", 25, 1).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
// May be timeout or connection error depending on network
|
||||
assert!(err.is_retryable());
|
||||
}
|
||||
}
|
||||
160
rust/crates/mailer-smtp/src/client/error.rs
Normal file
160
rust/crates/mailer-smtp/src/client/error.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
//! SMTP client error types.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Errors that can occur during SMTP client operations.
|
||||
#[derive(Debug, thiserror::Error, Serialize)]
|
||||
pub enum SmtpClientError {
|
||||
#[error("Connection error: {message}")]
|
||||
ConnectionError { message: String },
|
||||
|
||||
#[error("Timeout: {message}")]
|
||||
TimeoutError { message: String },
|
||||
|
||||
#[error("TLS error: {message}")]
|
||||
TlsError { message: String },
|
||||
|
||||
#[error("Authentication failed: {message}")]
|
||||
AuthenticationError { message: String },
|
||||
|
||||
#[error("Protocol error ({code}): {message}")]
|
||||
ProtocolError { code: u16, message: String },
|
||||
|
||||
#[error("Pool exhausted: {message}")]
|
||||
PoolExhausted { message: String },
|
||||
|
||||
#[error("Invalid configuration: {message}")]
|
||||
ConfigError { message: String },
|
||||
}
|
||||
|
||||
impl SmtpClientError {
|
||||
/// Whether this error is retryable (temporary failure).
|
||||
/// Permanent failures (5xx, auth failures) are not retryable.
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
SmtpClientError::ConnectionError { .. } => true,
|
||||
SmtpClientError::TimeoutError { .. } => true,
|
||||
SmtpClientError::TlsError { .. } => false,
|
||||
SmtpClientError::AuthenticationError { .. } => false,
|
||||
SmtpClientError::ProtocolError { code, .. } => *code >= 400 && *code < 500,
|
||||
SmtpClientError::PoolExhausted { .. } => true,
|
||||
SmtpClientError::ConfigError { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The error type as a string for IPC serialization.
|
||||
pub fn error_type(&self) -> &'static str {
|
||||
match self {
|
||||
SmtpClientError::ConnectionError { .. } => "connection",
|
||||
SmtpClientError::TimeoutError { .. } => "timeout",
|
||||
SmtpClientError::TlsError { .. } => "tls",
|
||||
SmtpClientError::AuthenticationError { .. } => "authentication",
|
||||
SmtpClientError::ProtocolError { .. } => "protocol",
|
||||
SmtpClientError::PoolExhausted { .. } => "pool_exhausted",
|
||||
SmtpClientError::ConfigError { .. } => "config",
|
||||
}
|
||||
}
|
||||
|
||||
/// The SMTP code if this is a protocol error.
|
||||
pub fn smtp_code(&self) -> Option<u16> {
|
||||
match self {
|
||||
SmtpClientError::ProtocolError { code, .. } => Some(*code),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_retryable_errors() {
|
||||
assert!(SmtpClientError::ConnectionError {
|
||||
message: "refused".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(SmtpClientError::TimeoutError {
|
||||
message: "timed out".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(SmtpClientError::PoolExhausted {
|
||||
message: "full".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(SmtpClientError::ProtocolError {
|
||||
code: 421,
|
||||
message: "try later".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(SmtpClientError::ProtocolError {
|
||||
code: 450,
|
||||
message: "mailbox busy".into()
|
||||
}
|
||||
.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_retryable_errors() {
|
||||
assert!(!SmtpClientError::AuthenticationError {
|
||||
message: "bad creds".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(!SmtpClientError::TlsError {
|
||||
message: "cert invalid".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(!SmtpClientError::ProtocolError {
|
||||
code: 550,
|
||||
message: "no such user".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(!SmtpClientError::ProtocolError {
|
||||
code: 554,
|
||||
message: "rejected".into()
|
||||
}
|
||||
.is_retryable());
|
||||
assert!(!SmtpClientError::ConfigError {
|
||||
message: "bad config".into()
|
||||
}
|
||||
.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_type_strings() {
|
||||
assert_eq!(
|
||||
SmtpClientError::ConnectionError {
|
||||
message: "x".into()
|
||||
}
|
||||
.error_type(),
|
||||
"connection"
|
||||
);
|
||||
assert_eq!(
|
||||
SmtpClientError::ProtocolError {
|
||||
code: 550,
|
||||
message: "x".into()
|
||||
}
|
||||
.error_type(),
|
||||
"protocol"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smtp_code() {
|
||||
assert_eq!(
|
||||
SmtpClientError::ProtocolError {
|
||||
code: 550,
|
||||
message: "x".into()
|
||||
}
|
||||
.smtp_code(),
|
||||
Some(550)
|
||||
);
|
||||
assert_eq!(
|
||||
SmtpClientError::ConnectionError {
|
||||
message: "x".into()
|
||||
}
|
||||
.smtp_code(),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
16
rust/crates/mailer-smtp/src/client/mod.rs
Normal file
16
rust/crates/mailer-smtp/src/client/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! SMTP client module for outbound email delivery.
|
||||
//!
|
||||
//! Provides connection pooling, SMTP protocol, TLS, and authentication
|
||||
//! for sending outbound emails through remote SMTP servers.
|
||||
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod error;
|
||||
pub mod pool;
|
||||
pub mod protocol;
|
||||
|
||||
// Re-export key types for convenience.
|
||||
pub use config::{DkimSignConfig, SmtpAuthConfig, SmtpClientConfig};
|
||||
pub use error::SmtpClientError;
|
||||
pub use pool::{SmtpClientManager, SmtpSendResult, SmtpVerifyResult};
|
||||
pub use protocol::{dot_stuff, EhloCapabilities, SmtpClientResponse};
|
||||
515
rust/crates/mailer-smtp/src/client/pool.rs
Normal file
515
rust/crates/mailer-smtp/src/client/pool.rs
Normal file
@@ -0,0 +1,515 @@
|
||||
//! Connection pooling for the SMTP client.
|
||||
//!
|
||||
//! Manages reusable connections per destination `host:port`.
|
||||
|
||||
use super::config::SmtpClientConfig;
|
||||
use super::connection::{connect_plain, connect_tls, ClientSmtpStream};
|
||||
use super::error::SmtpClientError;
|
||||
use super::protocol::{self, EhloCapabilities};
|
||||
use dashmap::DashMap;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Maximum age of a pooled connection (5 minutes).
|
||||
const MAX_CONNECTION_AGE_SECS: u64 = 300;
|
||||
|
||||
/// Maximum idle time before a connection is reaped (30 seconds).
|
||||
const MAX_IDLE_SECS: u64 = 30;
|
||||
|
||||
/// Maximum messages per pooled connection before it's recycled.
|
||||
const MAX_MESSAGES_PER_CONNECTION: u32 = 100;
|
||||
|
||||
/// A pooled SMTP connection.
|
||||
pub struct PooledConnection {
|
||||
pub stream: ClientSmtpStream,
|
||||
pub capabilities: EhloCapabilities,
|
||||
pub created_at: Instant,
|
||||
pub last_used: Instant,
|
||||
pub message_count: u32,
|
||||
pub idle: bool,
|
||||
}
|
||||
|
||||
/// Check if a pooled connection is stale (too old, too many messages, or idle too long).
|
||||
fn is_connection_stale(conn: &PooledConnection) -> bool {
|
||||
conn.created_at.elapsed().as_secs() > MAX_CONNECTION_AGE_SECS
|
||||
|| conn.message_count >= MAX_MESSAGES_PER_CONNECTION
|
||||
|| (conn.idle && conn.last_used.elapsed().as_secs() > MAX_IDLE_SECS)
|
||||
}
|
||||
|
||||
/// Per-destination connection pool.
|
||||
pub struct ConnectionPool {
|
||||
connections: Vec<PooledConnection>,
|
||||
max_connections: usize,
|
||||
config: SmtpClientConfig,
|
||||
}
|
||||
|
||||
impl ConnectionPool {
|
||||
fn new(config: SmtpClientConfig) -> Self {
|
||||
let max_connections = config.max_pool_connections;
|
||||
Self {
|
||||
connections: Vec::new(),
|
||||
max_connections,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an idle connection or create a new one.
|
||||
async fn acquire(&mut self) -> Result<PooledConnection, SmtpClientError> {
|
||||
// Remove stale connections first
|
||||
self.cleanup_stale();
|
||||
|
||||
// Find an idle connection
|
||||
if let Some(idx) = self
|
||||
.connections
|
||||
.iter()
|
||||
.position(|c| c.idle && !is_connection_stale(c))
|
||||
{
|
||||
let mut conn = self.connections.remove(idx);
|
||||
conn.idle = false;
|
||||
conn.last_used = Instant::now();
|
||||
debug!(
|
||||
"Reusing pooled connection (age={}s, msgs={})",
|
||||
conn.created_at.elapsed().as_secs(),
|
||||
conn.message_count
|
||||
);
|
||||
return Ok(conn);
|
||||
}
|
||||
|
||||
// Check if we can create a new connection
|
||||
if self.connections.len() >= self.max_connections {
|
||||
return Err(SmtpClientError::PoolExhausted {
|
||||
message: format!(
|
||||
"Pool for {} is at max capacity ({})",
|
||||
self.config.effective_pool_key(),
|
||||
self.max_connections
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new connection
|
||||
self.create_connection().await
|
||||
}
|
||||
|
||||
/// Return a connection to the pool (or close it if it's expired).
|
||||
fn release(&mut self, mut conn: PooledConnection) {
|
||||
conn.message_count += 1;
|
||||
conn.last_used = Instant::now();
|
||||
conn.idle = true;
|
||||
|
||||
// Don't return if it's stale
|
||||
if is_connection_stale(&conn) || self.connections.len() >= self.max_connections {
|
||||
debug!("Discarding stale/excess pooled connection");
|
||||
// Drop the connection (stream will be closed)
|
||||
return;
|
||||
}
|
||||
|
||||
self.connections.push(conn);
|
||||
}
|
||||
|
||||
/// Create a fresh SMTP connection and complete the handshake.
|
||||
async fn create_connection(&self) -> Result<PooledConnection, SmtpClientError> {
|
||||
let mut stream = if self.config.secure {
|
||||
connect_tls(
|
||||
&self.config.host,
|
||||
self.config.port,
|
||||
self.config.connection_timeout_secs,
|
||||
self.config.tls_opportunistic,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
connect_plain(
|
||||
&self.config.host,
|
||||
self.config.port,
|
||||
self.config.connection_timeout_secs,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Read greeting
|
||||
protocol::read_greeting(&mut stream, self.config.socket_timeout_secs).await?;
|
||||
|
||||
// Send EHLO
|
||||
let mut capabilities =
|
||||
protocol::send_ehlo(&mut stream, &self.config.domain, self.config.socket_timeout_secs)
|
||||
.await?;
|
||||
|
||||
// STARTTLS if available and not already secure
|
||||
if !self.config.secure && capabilities.starttls {
|
||||
protocol::send_starttls(&mut stream, self.config.socket_timeout_secs).await?;
|
||||
stream =
|
||||
super::connection::upgrade_to_tls(stream, &self.config.host, self.config.tls_opportunistic).await?;
|
||||
|
||||
// Re-EHLO after STARTTLS — use updated capabilities for auth
|
||||
capabilities = protocol::send_ehlo(
|
||||
&mut stream,
|
||||
&self.config.domain,
|
||||
self.config.socket_timeout_secs,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Authenticate if credentials provided
|
||||
if let Some(auth) = &self.config.auth {
|
||||
protocol::authenticate(
|
||||
&mut stream,
|
||||
auth,
|
||||
&capabilities,
|
||||
self.config.socket_timeout_secs,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"New SMTP connection to {} established",
|
||||
self.config.effective_pool_key()
|
||||
);
|
||||
|
||||
Ok(PooledConnection {
|
||||
stream,
|
||||
capabilities,
|
||||
created_at: Instant::now(),
|
||||
last_used: Instant::now(),
|
||||
message_count: 0,
|
||||
idle: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn cleanup_stale(&mut self) {
|
||||
self.connections.retain(|c| !is_connection_stale(c));
|
||||
}
|
||||
|
||||
/// Number of connections in the pool.
|
||||
fn total(&self) -> usize {
|
||||
self.connections.len()
|
||||
}
|
||||
|
||||
/// Number of idle connections.
|
||||
fn idle_count(&self) -> usize {
|
||||
self.connections.iter().filter(|c| c.idle).count()
|
||||
}
|
||||
|
||||
/// Close all connections.
|
||||
fn close_all(&mut self) {
|
||||
self.connections.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Status report for a single pool.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PoolStatus {
|
||||
pub total: usize,
|
||||
pub active: usize,
|
||||
pub idle: usize,
|
||||
}
|
||||
|
||||
/// Manages connection pools for multiple SMTP destinations.
|
||||
pub struct SmtpClientManager {
|
||||
pools: DashMap<String, Arc<Mutex<ConnectionPool>>>,
|
||||
}
|
||||
|
||||
impl SmtpClientManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pools: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create a pool for the given config.
|
||||
fn get_pool(&self, config: &SmtpClientConfig) -> Arc<Mutex<ConnectionPool>> {
|
||||
let key = config.effective_pool_key();
|
||||
self.pools
|
||||
.entry(key)
|
||||
.or_insert_with(|| Arc::new(Mutex::new(ConnectionPool::new(config.clone()))))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Acquire a connection from the pool, send a message, and release it.
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
config: &SmtpClientConfig,
|
||||
sender: &str,
|
||||
recipients: &[String],
|
||||
message: &[u8],
|
||||
) -> Result<SmtpSendResult, SmtpClientError> {
|
||||
let pool_arc = self.get_pool(config);
|
||||
let mut pool = pool_arc.lock().await;
|
||||
|
||||
let mut conn = pool.acquire().await?;
|
||||
drop(pool); // Release the pool lock while we do network I/O
|
||||
|
||||
// Reset server state if reusing a connection that has already sent messages
|
||||
if conn.message_count > 0 {
|
||||
protocol::send_rset(&mut conn.stream, config.socket_timeout_secs).await?;
|
||||
}
|
||||
|
||||
// Perform the SMTP transaction (use pipelining if server supports it)
|
||||
let pipelining = conn.capabilities.pipelining;
|
||||
let result =
|
||||
Self::perform_send(&mut conn.stream, sender, recipients, message, config, pipelining).await;
|
||||
|
||||
// Re-acquire the pool lock and release the connection
|
||||
let mut pool = pool_arc.lock().await;
|
||||
match &result {
|
||||
Ok(_) => pool.release(conn),
|
||||
Err(_) => {
|
||||
// Don't return failed connections to the pool
|
||||
debug!("Discarding connection after send failure");
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Perform the SMTP send transaction on a connected stream.
|
||||
async fn perform_send(
|
||||
stream: &mut ClientSmtpStream,
|
||||
sender: &str,
|
||||
recipients: &[String],
|
||||
message: &[u8],
|
||||
config: &SmtpClientConfig,
|
||||
pipelining: bool,
|
||||
) -> Result<SmtpSendResult, SmtpClientError> {
|
||||
let timeout_secs = config.socket_timeout_secs;
|
||||
|
||||
let (accepted, rejected) = if pipelining {
|
||||
// Use pipelined envelope: MAIL FROM + all RCPT TO in one batch
|
||||
let (_mail_ok, acc, rej) = protocol::send_pipelined_envelope(
|
||||
stream, sender, recipients, timeout_secs,
|
||||
).await?;
|
||||
(acc, rej)
|
||||
} else {
|
||||
// Sequential: MAIL FROM, then each RCPT TO
|
||||
protocol::send_mail_from(stream, sender, timeout_secs).await?;
|
||||
|
||||
let mut accepted = Vec::new();
|
||||
let mut rejected = Vec::new();
|
||||
|
||||
for rcpt in recipients {
|
||||
match protocol::send_rcpt_to(stream, rcpt, timeout_secs).await {
|
||||
Ok(resp) => {
|
||||
if resp.is_success() {
|
||||
accepted.push(rcpt.clone());
|
||||
} else {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(accepted, rejected)
|
||||
};
|
||||
|
||||
// If no recipients were accepted, fail
|
||||
if accepted.is_empty() {
|
||||
return Err(SmtpClientError::ProtocolError {
|
||||
code: 550,
|
||||
message: "All recipients were rejected".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// DATA
|
||||
let data_resp = protocol::send_data(stream, message, timeout_secs).await?;
|
||||
|
||||
// Extract message ID from the response if present
|
||||
let message_id = data_resp
|
||||
.lines
|
||||
.iter()
|
||||
.find_map(|line| {
|
||||
// Look for a pattern like "queued as XXXX" or message-id
|
||||
if line.contains("queued") || line.contains("id=") {
|
||||
Some(line.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(SmtpSendResult {
|
||||
accepted,
|
||||
rejected,
|
||||
message_id,
|
||||
response: data_resp.full_message(),
|
||||
envelope: SmtpEnvelope {
|
||||
from: sender.to_string(),
|
||||
to: recipients.to_vec(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify connectivity to an SMTP server (connect, EHLO, QUIT).
|
||||
pub async fn verify_connection(
|
||||
&self,
|
||||
config: &SmtpClientConfig,
|
||||
) -> Result<SmtpVerifyResult, SmtpClientError> {
|
||||
let mut stream = if config.secure {
|
||||
connect_tls(
|
||||
&config.host,
|
||||
config.port,
|
||||
config.connection_timeout_secs,
|
||||
config.tls_opportunistic,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
connect_plain(
|
||||
&config.host,
|
||||
config.port,
|
||||
config.connection_timeout_secs,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
let greeting = protocol::read_greeting(&mut stream, config.socket_timeout_secs).await?;
|
||||
let caps =
|
||||
protocol::send_ehlo(&mut stream, &config.domain, config.socket_timeout_secs).await?;
|
||||
let _ = protocol::send_quit(&mut stream, config.socket_timeout_secs).await;
|
||||
|
||||
Ok(SmtpVerifyResult {
|
||||
reachable: true,
|
||||
greeting: Some(greeting.full_message()),
|
||||
capabilities: Some(caps.extensions),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get status of all pools.
|
||||
pub fn pool_status(&self) -> std::collections::HashMap<String, PoolStatus> {
|
||||
let mut result = std::collections::HashMap::new();
|
||||
|
||||
for entry in self.pools.iter() {
|
||||
let key = entry.key().clone();
|
||||
// Try to get the lock without blocking — if locked, report as active
|
||||
match entry.value().try_lock() {
|
||||
Ok(pool) => {
|
||||
let total = pool.total();
|
||||
let idle = pool.idle_count();
|
||||
result.insert(
|
||||
key,
|
||||
PoolStatus {
|
||||
total,
|
||||
active: total - idle,
|
||||
idle,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
// Pool is in use; report as busy
|
||||
result.insert(
|
||||
key,
|
||||
PoolStatus {
|
||||
total: 0,
|
||||
active: 1,
|
||||
idle: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Close a specific pool.
|
||||
pub async fn close_pool(&self, key: &str) {
|
||||
if let Some(pool_ref) = self.pools.get(key) {
|
||||
let mut pool = pool_ref.lock().await;
|
||||
pool.close_all();
|
||||
}
|
||||
self.pools.remove(key);
|
||||
}
|
||||
|
||||
/// Close all pools.
|
||||
pub async fn close_all_pools(&self) {
|
||||
let keys: Vec<String> = self.pools.iter().map(|e| e.key().clone()).collect();
|
||||
for key in keys {
|
||||
self.close_pool(&key).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of sending an email via SMTP.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SmtpSendResult {
|
||||
pub accepted: Vec<String>,
|
||||
pub rejected: Vec<String>,
|
||||
#[serde(rename = "messageId")]
|
||||
pub message_id: Option<String>,
|
||||
pub response: String,
|
||||
pub envelope: SmtpEnvelope,
|
||||
}
|
||||
|
||||
/// SMTP envelope (sender + recipients).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SmtpEnvelope {
|
||||
pub from: String,
|
||||
pub to: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of verifying an SMTP connection.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SmtpVerifyResult {
|
||||
pub reachable: bool,
|
||||
pub greeting: Option<String>,
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pool_status_serialization() {
|
||||
let status = PoolStatus {
|
||||
total: 5,
|
||||
active: 2,
|
||||
idle: 3,
|
||||
};
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert!(json.contains("\"total\":5"));
|
||||
assert!(json.contains("\"active\":2"));
|
||||
assert!(json.contains("\"idle\":3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_result_serialization() {
|
||||
let result = SmtpSendResult {
|
||||
accepted: vec!["a@b.com".into()],
|
||||
rejected: vec![],
|
||||
message_id: Some("abc123".into()),
|
||||
response: "250 OK".into(),
|
||||
envelope: SmtpEnvelope {
|
||||
from: "from@test.com".into(),
|
||||
to: vec!["a@b.com".into()],
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"messageId\":\"abc123\""));
|
||||
assert!(json.contains("\"accepted\":[\"a@b.com\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_result_serialization() {
|
||||
let result = SmtpVerifyResult {
|
||||
reachable: true,
|
||||
greeting: Some("220 mail.example.com".into()),
|
||||
capabilities: Some(vec!["SIZE 10485760".into(), "STARTTLS".into()]),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"reachable\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smtp_client_manager_new() {
|
||||
let mgr = SmtpClientManager::new();
|
||||
assert!(mgr.pool_status().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_close_all_empty() {
|
||||
let mgr = SmtpClientManager::new();
|
||||
mgr.close_all_pools().await;
|
||||
assert!(mgr.pool_status().is_empty());
|
||||
}
|
||||
}
|
||||
568
rust/crates/mailer-smtp/src/client/protocol.rs
Normal file
568
rust/crates/mailer-smtp/src/client/protocol.rs
Normal file
@@ -0,0 +1,568 @@
|
||||
//! SMTP client protocol engine.
|
||||
//!
|
||||
//! Implements the SMTP command/response flow for sending outbound email.
|
||||
|
||||
use super::config::SmtpAuthConfig;
|
||||
use super::connection::ClientSmtpStream;
|
||||
use super::error::SmtpClientError;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tracing::debug;
|
||||
|
||||
/// Parsed SMTP response (from the remote server).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SmtpClientResponse {
|
||||
pub code: u16,
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl SmtpClientResponse {
|
||||
pub fn is_success(&self) -> bool {
|
||||
self.code >= 200 && self.code < 300
|
||||
}
|
||||
|
||||
pub fn is_positive_intermediate(&self) -> bool {
|
||||
self.code >= 300 && self.code < 400
|
||||
}
|
||||
|
||||
pub fn is_temp_error(&self) -> bool {
|
||||
self.code >= 400 && self.code < 500
|
||||
}
|
||||
|
||||
pub fn is_perm_error(&self) -> bool {
|
||||
self.code >= 500
|
||||
}
|
||||
|
||||
/// Full response text (all lines joined).
|
||||
pub fn full_message(&self) -> String {
|
||||
self.lines.join(" ")
|
||||
}
|
||||
|
||||
/// Convert to a protocol error if this is an error response.
|
||||
pub fn to_error(&self) -> SmtpClientError {
|
||||
SmtpClientError::ProtocolError {
|
||||
code: self.code,
|
||||
message: self.full_message(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Server capabilities parsed from EHLO response.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EhloCapabilities {
|
||||
pub extensions: Vec<String>,
|
||||
pub max_size: Option<u64>,
|
||||
pub starttls: bool,
|
||||
pub auth_methods: Vec<String>,
|
||||
pub pipelining: bool,
|
||||
pub eight_bit_mime: bool,
|
||||
}
|
||||
|
||||
/// Read a multi-line SMTP response from the server.
|
||||
pub async fn read_response(
|
||||
stream: &mut ClientSmtpStream,
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
let mut lines = Vec::new();
|
||||
let mut code: u16;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = timeout(
|
||||
Duration::from_secs(timeout_secs),
|
||||
stream.read_line(&mut line),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| SmtpClientError::TimeoutError {
|
||||
message: format!("Timeout reading SMTP response after {timeout_secs}s"),
|
||||
})??;
|
||||
|
||||
if n == 0 {
|
||||
return Err(SmtpClientError::ConnectionError {
|
||||
message: "Connection closed while reading response".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// Guard against unbounded lines from malicious servers (RFC 5321 §4.5.3.1.4 says 512 max)
|
||||
if line.len() > 4096 {
|
||||
return Err(SmtpClientError::ProtocolError {
|
||||
code: 0,
|
||||
message: format!("Response line too long ({} bytes, max 4096)", line.len()),
|
||||
});
|
||||
}
|
||||
|
||||
let line = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||
|
||||
if line.len() < 3 {
|
||||
return Err(SmtpClientError::ProtocolError {
|
||||
code: 0,
|
||||
message: format!("Invalid response line: {line}"),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse the 3-digit code
|
||||
let parsed_code: u16 = line[..3].parse().map_err(|_| SmtpClientError::ProtocolError {
|
||||
code: 0,
|
||||
message: format!("Invalid response code in: {line}"),
|
||||
})?;
|
||||
code = parsed_code;
|
||||
|
||||
// Text after the code (skip the separator character)
|
||||
let text = if line.len() > 4 { &line[4..] } else { "" };
|
||||
lines.push(text.to_string());
|
||||
|
||||
// Check for continuation: "250-" means more lines, "250 " means last line
|
||||
if line.len() >= 4 && line.as_bytes()[3] == b'-' {
|
||||
continue;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
debug!("SMTP response: {} {}", code, lines.join(" | "));
|
||||
Ok(SmtpClientResponse { code, lines })
|
||||
}
|
||||
|
||||
/// Read the server greeting (first response after connect).
|
||||
pub async fn read_greeting(
|
||||
stream: &mut ClientSmtpStream,
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
let resp = read_response(stream, timeout_secs).await?;
|
||||
if resp.code == 220 {
|
||||
Ok(resp)
|
||||
} else {
|
||||
Err(SmtpClientError::ProtocolError {
|
||||
code: resp.code,
|
||||
message: format!("Unexpected greeting: {}", resp.full_message()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a raw command and read the response.
|
||||
async fn send_command(
|
||||
stream: &mut ClientSmtpStream,
|
||||
command: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
debug!("SMTP C: {}", command);
|
||||
stream
|
||||
.write_all(format!("{command}\r\n").as_bytes())
|
||||
.await?;
|
||||
stream.flush().await?;
|
||||
read_response(stream, timeout_secs).await
|
||||
}
|
||||
|
||||
/// Send EHLO and parse capabilities.
|
||||
pub async fn send_ehlo(
|
||||
stream: &mut ClientSmtpStream,
|
||||
domain: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<EhloCapabilities, SmtpClientError> {
|
||||
let resp = send_command(stream, &format!("EHLO {domain}"), timeout_secs).await?;
|
||||
|
||||
if !resp.is_success() {
|
||||
// Fall back to HELO
|
||||
let helo_resp = send_command(stream, &format!("HELO {domain}"), timeout_secs).await?;
|
||||
if !helo_resp.is_success() {
|
||||
return Err(helo_resp.to_error());
|
||||
}
|
||||
return Ok(EhloCapabilities::default());
|
||||
}
|
||||
|
||||
let mut caps = EhloCapabilities::default();
|
||||
|
||||
// First line is the greeting, remaining lines are capabilities
|
||||
for line in resp.lines.iter().skip(1) {
|
||||
let upper = line.to_uppercase();
|
||||
if upper.starts_with("SIZE ") {
|
||||
caps.max_size = upper[5..].trim().parse().ok();
|
||||
} else if upper == "STARTTLS" {
|
||||
caps.starttls = true;
|
||||
} else if upper.starts_with("AUTH ") {
|
||||
caps.auth_methods = upper[5..]
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
} else if upper == "PIPELINING" {
|
||||
caps.pipelining = true;
|
||||
} else if upper == "8BITMIME" {
|
||||
caps.eight_bit_mime = true;
|
||||
}
|
||||
caps.extensions.push(line.clone());
|
||||
}
|
||||
|
||||
Ok(caps)
|
||||
}
|
||||
|
||||
/// Send STARTTLS command (does not perform the TLS handshake itself).
|
||||
pub async fn send_starttls(
|
||||
stream: &mut ClientSmtpStream,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
let resp = send_command(stream, "STARTTLS", timeout_secs).await?;
|
||||
if resp.code != 220 {
|
||||
return Err(SmtpClientError::ProtocolError {
|
||||
code: resp.code,
|
||||
message: format!("STARTTLS rejected: {}", resp.full_message()),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authenticate using AUTH PLAIN.
|
||||
pub async fn send_auth_plain(
|
||||
stream: &mut ClientSmtpStream,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
// AUTH PLAIN sends \0user\0pass in base64
|
||||
let credentials = format!("\x00{user}\x00{pass}");
|
||||
let encoded = BASE64.encode(credentials.as_bytes());
|
||||
let resp = send_command(stream, &format!("AUTH PLAIN {encoded}"), timeout_secs).await?;
|
||||
|
||||
if resp.code != 235 {
|
||||
return Err(SmtpClientError::AuthenticationError {
|
||||
message: format!("AUTH PLAIN failed ({}): {}", resp.code, resp.full_message()),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authenticate using AUTH LOGIN.
|
||||
pub async fn send_auth_login(
|
||||
stream: &mut ClientSmtpStream,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
// Step 1: Send AUTH LOGIN
|
||||
let resp = send_command(stream, "AUTH LOGIN", timeout_secs).await?;
|
||||
if resp.code != 334 {
|
||||
return Err(SmtpClientError::AuthenticationError {
|
||||
message: format!(
|
||||
"AUTH LOGIN challenge failed ({}): {}",
|
||||
resp.code,
|
||||
resp.full_message()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Send base64 username
|
||||
let user_b64 = BASE64.encode(user.as_bytes());
|
||||
let resp = send_command(stream, &user_b64, timeout_secs).await?;
|
||||
if resp.code != 334 {
|
||||
return Err(SmtpClientError::AuthenticationError {
|
||||
message: format!(
|
||||
"AUTH LOGIN username rejected ({}): {}",
|
||||
resp.code,
|
||||
resp.full_message()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Send base64 password
|
||||
let pass_b64 = BASE64.encode(pass.as_bytes());
|
||||
let resp = send_command(stream, &pass_b64, timeout_secs).await?;
|
||||
if resp.code != 235 {
|
||||
return Err(SmtpClientError::AuthenticationError {
|
||||
message: format!(
|
||||
"AUTH LOGIN password rejected ({}): {}",
|
||||
resp.code,
|
||||
resp.full_message()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authenticate using the configured method.
|
||||
pub async fn authenticate(
|
||||
stream: &mut ClientSmtpStream,
|
||||
auth: &SmtpAuthConfig,
|
||||
_caps: &EhloCapabilities,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
match auth.method.to_uppercase().as_str() {
|
||||
"LOGIN" => send_auth_login(stream, &auth.user, &auth.pass, timeout_secs).await,
|
||||
_ => send_auth_plain(stream, &auth.user, &auth.pass, timeout_secs).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send MAIL FROM.
|
||||
pub async fn send_mail_from(
|
||||
stream: &mut ClientSmtpStream,
|
||||
sender: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
let resp = send_command(stream, &format!("MAIL FROM:<{sender}>"), timeout_secs).await?;
|
||||
if !resp.is_success() {
|
||||
return Err(resp.to_error());
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Send RCPT TO. Returns per-recipient success/failure.
|
||||
pub async fn send_rcpt_to(
|
||||
stream: &mut ClientSmtpStream,
|
||||
recipient: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
let resp = send_command(stream, &format!("RCPT TO:<{recipient}>"), timeout_secs).await?;
|
||||
// We don't fail the entire send on per-recipient errors;
|
||||
// the caller decides based on the response code.
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Send MAIL FROM + RCPT TO commands in a single pipelined batch.
|
||||
///
|
||||
/// Writes all envelope commands at once, then reads responses in order.
|
||||
/// Returns `(mail_from_ok, accepted_recipients, rejected_recipients)`.
|
||||
pub async fn send_pipelined_envelope(
|
||||
stream: &mut ClientSmtpStream,
|
||||
sender: &str,
|
||||
recipients: &[String],
|
||||
timeout_secs: u64,
|
||||
) -> Result<(bool, Vec<String>, Vec<String>), SmtpClientError> {
|
||||
// Build the full pipelined command batch
|
||||
let mut batch = format!("MAIL FROM:<{sender}>\r\n");
|
||||
for rcpt in recipients {
|
||||
batch.push_str(&format!("RCPT TO:<{rcpt}>\r\n"));
|
||||
}
|
||||
|
||||
// Send all commands at once
|
||||
debug!("SMTP C (pipelined): MAIL FROM + {} RCPT TO", recipients.len());
|
||||
stream.write_all(batch.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
// Read MAIL FROM response
|
||||
let mail_resp = read_response(stream, timeout_secs).await?;
|
||||
if !mail_resp.is_success() {
|
||||
return Err(mail_resp.to_error());
|
||||
}
|
||||
|
||||
// Read RCPT TO responses
|
||||
let mut accepted = Vec::new();
|
||||
let mut rejected = Vec::new();
|
||||
for rcpt in recipients {
|
||||
match read_response(stream, timeout_secs).await {
|
||||
Ok(resp) => {
|
||||
if resp.is_success() {
|
||||
accepted.push(rcpt.clone());
|
||||
} else {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((true, accepted, rejected))
|
||||
}
|
||||
|
||||
/// Send DATA command, followed by the message body with dot-stuffing.
|
||||
pub async fn send_data(
|
||||
stream: &mut ClientSmtpStream,
|
||||
message: &[u8],
|
||||
timeout_secs: u64,
|
||||
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||
// Send DATA command
|
||||
let resp = send_command(stream, "DATA", timeout_secs).await?;
|
||||
if !resp.is_positive_intermediate() {
|
||||
return Err(resp.to_error());
|
||||
}
|
||||
|
||||
// Send the message body with dot-stuffing
|
||||
let stuffed = dot_stuff(message);
|
||||
stream.write_all(&stuffed).await?;
|
||||
|
||||
// Send terminator: CRLF.CRLF
|
||||
// If the message doesn't end with CRLF, add one
|
||||
if !stuffed.ends_with(b"\r\n") {
|
||||
stream.write_all(b"\r\n").await?;
|
||||
}
|
||||
stream.write_all(b".\r\n").await?;
|
||||
stream.flush().await?;
|
||||
|
||||
// Read final response
|
||||
let final_resp = read_response(stream, timeout_secs).await?;
|
||||
if !final_resp.is_success() {
|
||||
return Err(final_resp.to_error());
|
||||
}
|
||||
|
||||
Ok(final_resp)
|
||||
}
|
||||
|
||||
/// Send RSET command to reset the server state between messages on a reused connection.
|
||||
pub async fn send_rset(
|
||||
stream: &mut ClientSmtpStream,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
let resp = send_command(stream, "RSET", timeout_secs).await?;
|
||||
if !resp.is_success() {
|
||||
return Err(resp.to_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send QUIT command.
|
||||
pub async fn send_quit(
|
||||
stream: &mut ClientSmtpStream,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), SmtpClientError> {
|
||||
// Best-effort QUIT — ignore errors since we're closing anyway
|
||||
let _ = send_command(stream, "QUIT", timeout_secs).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply SMTP dot-stuffing to a message body.
|
||||
///
|
||||
/// Any line starting with a period gets an extra period prepended.
|
||||
/// Also normalizes bare LF to CRLF.
|
||||
pub fn dot_stuff(data: &[u8]) -> Vec<u8> {
|
||||
let mut result = Vec::with_capacity(data.len() + data.len() / 40);
|
||||
let mut at_line_start = true;
|
||||
|
||||
for i in 0..data.len() {
|
||||
let byte = data[i];
|
||||
|
||||
// Normalize bare LF to CRLF
|
||||
if byte == b'\n' && (i == 0 || data[i - 1] != b'\r') {
|
||||
result.push(b'\r');
|
||||
result.push(b'\n');
|
||||
at_line_start = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dot-stuff: add extra dot at start of line
|
||||
if at_line_start && byte == b'.' {
|
||||
result.push(b'.');
|
||||
}
|
||||
|
||||
result.push(byte);
|
||||
at_line_start = byte == b'\n';
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_basic() {
|
||||
assert_eq!(
|
||||
dot_stuff(b"Hello\r\n.World\r\n"),
|
||||
b"Hello\r\n..World\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_leading_dot() {
|
||||
assert_eq!(dot_stuff(b".starts with dot\r\n"), b"..starts with dot\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_multiple_dots() {
|
||||
assert_eq!(
|
||||
dot_stuff(b"ok\r\n.line1\r\n..line2\r\n"),
|
||||
b"ok\r\n..line1\r\n...line2\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_bare_lf() {
|
||||
assert_eq!(
|
||||
dot_stuff(b"line1\nline2\n"),
|
||||
b"line1\r\nline2\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_bare_lf_with_dot() {
|
||||
assert_eq!(
|
||||
dot_stuff(b"ok\n.dotline\n"),
|
||||
b"ok\r\n..dotline\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_no_change() {
|
||||
assert_eq!(
|
||||
dot_stuff(b"Hello World\r\nNo dots here\r\n"),
|
||||
b"Hello World\r\nNo dots here\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_stuffing_empty() {
|
||||
assert_eq!(dot_stuff(b""), b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_is_success() {
|
||||
let resp = SmtpClientResponse {
|
||||
code: 250,
|
||||
lines: vec!["OK".into()],
|
||||
};
|
||||
assert!(resp.is_success());
|
||||
assert!(!resp.is_temp_error());
|
||||
assert!(!resp.is_perm_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_temp_error() {
|
||||
let resp = SmtpClientResponse {
|
||||
code: 450,
|
||||
lines: vec!["Mailbox busy".into()],
|
||||
};
|
||||
assert!(!resp.is_success());
|
||||
assert!(resp.is_temp_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_perm_error() {
|
||||
let resp = SmtpClientResponse {
|
||||
code: 550,
|
||||
lines: vec!["No such user".into()],
|
||||
};
|
||||
assert!(!resp.is_success());
|
||||
assert!(resp.is_perm_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_positive_intermediate() {
|
||||
let resp = SmtpClientResponse {
|
||||
code: 354,
|
||||
lines: vec!["Start mail input".into()],
|
||||
};
|
||||
assert!(resp.is_positive_intermediate());
|
||||
assert!(!resp.is_success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_full_message() {
|
||||
let resp = SmtpClientResponse {
|
||||
code: 250,
|
||||
lines: vec!["OK".into(), "SIZE 10485760".into()],
|
||||
};
|
||||
assert_eq!(resp.full_message(), "OK SIZE 10485760");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ehlo_capabilities_default() {
|
||||
let caps = EhloCapabilities::default();
|
||||
assert!(!caps.starttls);
|
||||
assert!(!caps.pipelining);
|
||||
assert!(!caps.eight_bit_mime);
|
||||
assert!(caps.auth_methods.is_empty());
|
||||
assert!(caps.max_size.is_none());
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ pub enum SmtpCommand {
|
||||
pub enum AuthMechanism {
|
||||
Plain,
|
||||
Login,
|
||||
ScramSha256,
|
||||
}
|
||||
|
||||
/// Errors that can occur during command parsing.
|
||||
@@ -218,6 +219,7 @@ fn parse_auth(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||
let mechanism = match mech_str.to_ascii_uppercase().as_str() {
|
||||
"PLAIN" => AuthMechanism::Plain,
|
||||
"LOGIN" => AuthMechanism::Login,
|
||||
"SCRAM-SHA-256" => AuthMechanism::ScramSha256,
|
||||
other => {
|
||||
return Err(ParseError::SyntaxError(format!(
|
||||
"unsupported AUTH mechanism: {other}"
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Per-domain TLS certificate for SNI-based cert selection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsDomainCert {
|
||||
/// Domain names this certificate covers (matched against SNI hostname).
|
||||
pub domains: Vec<String>,
|
||||
/// Certificate chain in PEM format.
|
||||
pub cert_pem: String,
|
||||
/// Private key in PEM format.
|
||||
pub key_pem: String,
|
||||
}
|
||||
|
||||
/// Configuration for an SMTP server instance.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SmtpServerConfig {
|
||||
@@ -11,10 +22,13 @@ pub struct SmtpServerConfig {
|
||||
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.
|
||||
/// TLS certificate chain in PEM format (default cert).
|
||||
pub tls_cert_pem: Option<String>,
|
||||
/// TLS private key in PEM format.
|
||||
/// TLS private key in PEM format (default key).
|
||||
pub tls_key_pem: Option<String>,
|
||||
/// Additional per-domain TLS certificates for SNI-based selection.
|
||||
#[serde(default)]
|
||||
pub additional_tls_certs: Vec<TlsDomainCert>,
|
||||
/// Maximum message size in bytes.
|
||||
pub max_message_size: u64,
|
||||
/// Maximum number of concurrent connections.
|
||||
@@ -43,6 +57,7 @@ impl Default for SmtpServerConfig {
|
||||
secure_port: None,
|
||||
tls_cert_pem: None,
|
||||
tls_key_pem: None,
|
||||
additional_tls_certs: Vec::new(),
|
||||
max_message_size: 10 * 1024 * 1024, // 10 MB
|
||||
max_connections: 100,
|
||||
max_recipients: 100,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::config::SmtpServerConfig;
|
||||
use crate::data::{DataAccumulator, DataAction};
|
||||
use crate::rate_limiter::RateLimiter;
|
||||
use crate::response::{build_capabilities, SmtpResponse};
|
||||
use crate::scram::{ScramCredentials, ScramServer};
|
||||
use crate::session::{AuthState, SmtpSession};
|
||||
use crate::validation;
|
||||
|
||||
@@ -52,6 +53,13 @@ pub enum ConnectionEvent {
|
||||
password: String,
|
||||
remote_addr: String,
|
||||
},
|
||||
/// A SCRAM credential request — Rust needs stored credentials from TS.
|
||||
ScramCredentialRequest {
|
||||
correlation_id: String,
|
||||
session_id: String,
|
||||
username: String,
|
||||
remote_addr: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// How email data is transported from Rust to TS.
|
||||
@@ -81,6 +89,16 @@ pub struct AuthResult {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of TS returning SCRAM credentials for a user.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScramCredentialResult {
|
||||
pub found: bool,
|
||||
pub salt: Option<Vec<u8>>,
|
||||
pub iterations: Option<u32>,
|
||||
pub stored_key: Option<Vec<u8>>,
|
||||
pub server_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Abstraction over plain and TLS streams.
|
||||
pub enum SmtpStream {
|
||||
Plain(BufReader<TcpStream>),
|
||||
@@ -133,6 +151,14 @@ impl SmtpStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the internal buffer has unread data (pipelined commands).
|
||||
pub fn has_buffered_data(&self) -> bool {
|
||||
match self {
|
||||
SmtpStream::Plain(reader) => !reader.buffer().is_empty(),
|
||||
SmtpStream::Tls(reader) => !reader.buffer().is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwrap to get the raw TcpStream for STARTTLS upgrade.
|
||||
/// Only works on Plain streams.
|
||||
pub fn into_tcp_stream(self) -> Option<TcpStream> {
|
||||
@@ -212,7 +238,7 @@ pub async fn handle_connection(
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => {
|
||||
// Process command
|
||||
// Process the first command
|
||||
let response = process_line(
|
||||
&line,
|
||||
&mut session,
|
||||
@@ -227,59 +253,123 @@ pub async fn handle_connection(
|
||||
)
|
||||
.await;
|
||||
|
||||
// Check for pipelined commands in the buffer.
|
||||
// Collect pipelinable responses into a batch for single write.
|
||||
let mut response_batch: Vec<u8> = Vec::new();
|
||||
let mut should_break = false;
|
||||
let mut starttls_signal = false;
|
||||
|
||||
match response {
|
||||
LineResult::Response(resp) => {
|
||||
if stream.write_all(&resp.to_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
response_batch.extend_from_slice(&resp.to_bytes());
|
||||
}
|
||||
LineResult::Quit(resp) => {
|
||||
let _ = stream.write_all(&resp.to_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
break;
|
||||
should_break = true;
|
||||
}
|
||||
LineResult::StartTlsSignal => {
|
||||
// Send 220 Ready response
|
||||
let resp = SmtpResponse::new(220, "Ready to start TLS");
|
||||
if stream.write_all(&resp.to_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
// Extract TCP stream and upgrade
|
||||
if let Some(tcp_stream) = stream.into_tcp_stream() {
|
||||
if let Some(acceptor) = &tls_acceptor {
|
||||
match acceptor.accept(tcp_stream).await {
|
||||
Ok(tls_stream) => {
|
||||
stream = SmtpStream::Tls(BufReader::new(tls_stream));
|
||||
session.secure = true;
|
||||
// Client must re-EHLO after STARTTLS
|
||||
session.state = crate::state::SmtpState::Connected;
|
||||
session.client_hostname = None;
|
||||
session.esmtp = false;
|
||||
session.auth_state = AuthState::None;
|
||||
session.envelope = Default::default();
|
||||
debug!(session_id = %session.id, "TLS upgrade successful");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(session_id = %session.id, error = %e, "TLS handshake failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Already TLS — shouldn't happen
|
||||
break;
|
||||
}
|
||||
starttls_signal = true;
|
||||
}
|
||||
LineResult::NoResponse => {}
|
||||
LineResult::Disconnect => {
|
||||
should_break = true;
|
||||
}
|
||||
}
|
||||
|
||||
if should_break {
|
||||
break;
|
||||
}
|
||||
|
||||
// Process additional pipelined commands from the buffer
|
||||
if !starttls_signal {
|
||||
while stream.has_buffered_data() {
|
||||
let mut next_line = String::new();
|
||||
match stream.read_line(&mut next_line, 4096).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {
|
||||
let next_response = process_line(
|
||||
&next_line,
|
||||
&mut session,
|
||||
&mut stream,
|
||||
&config,
|
||||
&rate_limiter,
|
||||
&event_tx,
|
||||
callback_register.as_ref(),
|
||||
&tls_acceptor,
|
||||
&authenticator,
|
||||
&resolver,
|
||||
)
|
||||
.await;
|
||||
|
||||
match next_response {
|
||||
LineResult::Response(resp) => {
|
||||
response_batch.extend_from_slice(&resp.to_bytes());
|
||||
}
|
||||
LineResult::Quit(resp) => {
|
||||
response_batch.extend_from_slice(&resp.to_bytes());
|
||||
should_break = true;
|
||||
break;
|
||||
}
|
||||
LineResult::StartTlsSignal | LineResult::Disconnect => {
|
||||
// Non-pipelinable: flush batch and handle
|
||||
starttls_signal = matches!(next_response, LineResult::StartTlsSignal);
|
||||
should_break = matches!(next_response, LineResult::Disconnect);
|
||||
break;
|
||||
}
|
||||
LineResult::NoResponse => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush the accumulated response batch in one write
|
||||
if !response_batch.is_empty() {
|
||||
if stream.write_all(&response_batch).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if should_break {
|
||||
break;
|
||||
}
|
||||
|
||||
if starttls_signal {
|
||||
// Send 220 Ready response
|
||||
let resp = SmtpResponse::new(220, "Ready to start TLS");
|
||||
if stream.write_all(&resp.to_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
// Extract TCP stream and upgrade
|
||||
if let Some(tcp_stream) = stream.into_tcp_stream() {
|
||||
if let Some(acceptor) = &tls_acceptor {
|
||||
match acceptor.accept(tcp_stream).await {
|
||||
Ok(tls_stream) => {
|
||||
stream = SmtpStream::Tls(BufReader::new(tls_stream));
|
||||
session.secure = true;
|
||||
session.state = crate::state::SmtpState::Connected;
|
||||
session.client_hostname = None;
|
||||
session.esmtp = false;
|
||||
session.auth_state = AuthState::None;
|
||||
session.envelope = Default::default();
|
||||
debug!(session_id = %session.id, "TLS upgrade successful");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(session_id = %session.id, error = %e, "TLS handshake failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -322,6 +412,12 @@ pub trait CallbackRegistry: Send + Sync {
|
||||
&self,
|
||||
correlation_id: &str,
|
||||
) -> oneshot::Receiver<AuthResult>;
|
||||
|
||||
/// Register a callback for SCRAM credential lookup and return a receiver.
|
||||
fn register_scram_callback(
|
||||
&self,
|
||||
correlation_id: &str,
|
||||
) -> oneshot::Receiver<ScramCredentialResult>;
|
||||
}
|
||||
|
||||
/// Process a single input line from the client.
|
||||
@@ -406,16 +502,29 @@ async fn process_line(
|
||||
mechanism,
|
||||
initial_response,
|
||||
} => {
|
||||
handle_auth(
|
||||
mechanism,
|
||||
initial_response,
|
||||
session,
|
||||
config,
|
||||
rate_limiter,
|
||||
event_tx,
|
||||
callback_registry,
|
||||
)
|
||||
.await
|
||||
if matches!(mechanism, AuthMechanism::ScramSha256) {
|
||||
handle_auth_scram(
|
||||
initial_response,
|
||||
session,
|
||||
stream,
|
||||
config,
|
||||
rate_limiter,
|
||||
event_tx,
|
||||
callback_registry,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
handle_auth(
|
||||
mechanism,
|
||||
initial_response,
|
||||
session,
|
||||
config,
|
||||
rate_limiter,
|
||||
event_tx,
|
||||
callback_registry,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
SmtpCommand::Help(_) => {
|
||||
@@ -832,6 +941,217 @@ async fn handle_auth(
|
||||
))
|
||||
}
|
||||
}
|
||||
AuthMechanism::ScramSha256 => {
|
||||
// SCRAM is handled separately in process_line; this should not be reached.
|
||||
LineResult::Response(SmtpResponse::not_implemented())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle AUTH SCRAM-SHA-256 — full exchange in a single async function.
|
||||
///
|
||||
/// SCRAM is a multi-step challenge-response protocol:
|
||||
/// 1. Client sends client-first-message (in initial_response or after 334)
|
||||
/// 2. Server requests SCRAM credentials from TS
|
||||
/// 3. Server sends server-first-message (334 challenge)
|
||||
/// 4. Client sends client-final-message (proof)
|
||||
/// 5. Server verifies proof and responds with 235 or 535
|
||||
async fn handle_auth_scram(
|
||||
initial_response: Option<String>,
|
||||
session: &mut SmtpSession,
|
||||
stream: &mut SmtpStream,
|
||||
config: &SmtpServerConfig,
|
||||
rate_limiter: &RateLimiter,
|
||||
event_tx: &mpsc::Sender<ConnectionEvent>,
|
||||
callback_registry: &dyn CallbackRegistry,
|
||||
) -> LineResult {
|
||||
if !config.auth_enabled {
|
||||
return LineResult::Response(SmtpResponse::not_implemented());
|
||||
}
|
||||
|
||||
if session.is_authenticated() {
|
||||
return LineResult::Response(SmtpResponse::bad_sequence("Already authenticated"));
|
||||
}
|
||||
|
||||
if !session.state.can_auth() {
|
||||
return LineResult::Response(SmtpResponse::bad_sequence("Send EHLO first"));
|
||||
}
|
||||
|
||||
// Step 1: Get client-first-message
|
||||
let client_first_b64 = match initial_response {
|
||||
Some(s) if !s.is_empty() => s,
|
||||
_ => {
|
||||
// No initial response — send empty 334 challenge
|
||||
let resp = SmtpResponse::auth_challenge("");
|
||||
if stream.write_all(&resp.to_bytes()).await.is_err() {
|
||||
return LineResult::Disconnect;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
return LineResult::Disconnect;
|
||||
}
|
||||
// Read client-first-message
|
||||
let mut line = String::new();
|
||||
let socket_timeout = Duration::from_secs(config.socket_timeout_secs);
|
||||
match timeout(socket_timeout, stream.read_line(&mut line, 4096)).await {
|
||||
Err(_) | Ok(Err(_)) | Ok(Ok(0)) => return LineResult::Disconnect,
|
||||
Ok(Ok(_)) => {}
|
||||
}
|
||||
let trimmed = line.trim().to_string();
|
||||
if trimmed == "*" {
|
||||
return LineResult::Response(SmtpResponse::new(501, "Authentication cancelled"));
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
};
|
||||
|
||||
// Decode base64 client-first-message
|
||||
let client_first_bytes = match BASE64.decode(client_first_b64.as_bytes()) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return LineResult::Response(SmtpResponse::param_error("Invalid base64 encoding"));
|
||||
}
|
||||
};
|
||||
let client_first = match String::from_utf8(client_first_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return LineResult::Response(SmtpResponse::param_error("Invalid UTF-8 in SCRAM message"));
|
||||
}
|
||||
};
|
||||
|
||||
// Parse client-first-message
|
||||
let mut scram = match ScramServer::from_client_first(&client_first) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
debug!(error = %e, "SCRAM client-first-message parse error");
|
||||
return LineResult::Response(SmtpResponse::param_error(
|
||||
"Invalid SCRAM client-first-message",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Request SCRAM credentials from TS
|
||||
let correlation_id = uuid::Uuid::new_v4().to_string();
|
||||
let rx = callback_registry.register_scram_callback(&correlation_id);
|
||||
|
||||
let event = ConnectionEvent::ScramCredentialRequest {
|
||||
correlation_id: correlation_id.clone(),
|
||||
session_id: session.id.clone(),
|
||||
username: scram.username.clone(),
|
||||
remote_addr: session.remote_addr.clone(),
|
||||
};
|
||||
|
||||
if event_tx.send(event).await.is_err() {
|
||||
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
|
||||
}
|
||||
|
||||
// Wait for credentials from TS
|
||||
let cred_timeout = Duration::from_secs(5);
|
||||
let cred_result = match timeout(cred_timeout, rx).await {
|
||||
Ok(Ok(result)) => result,
|
||||
Ok(Err(_)) => {
|
||||
warn!(correlation_id = %correlation_id, "SCRAM credential callback dropped");
|
||||
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(correlation_id = %correlation_id, "SCRAM credential request timed out");
|
||||
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
|
||||
}
|
||||
};
|
||||
|
||||
if !cred_result.found {
|
||||
// User not found — fail auth (don't reveal that user doesn't exist)
|
||||
session.auth_state = AuthState::None;
|
||||
let exceeded = session.record_auth_failure(config.max_auth_failures);
|
||||
if exceeded {
|
||||
return LineResult::Quit(SmtpResponse::service_unavailable(
|
||||
&config.hostname,
|
||||
"Too many authentication failures",
|
||||
));
|
||||
}
|
||||
return LineResult::Response(SmtpResponse::auth_failed());
|
||||
}
|
||||
|
||||
let creds = ScramCredentials {
|
||||
salt: cred_result.salt.unwrap_or_default(),
|
||||
iterations: cred_result.iterations.unwrap_or(4096),
|
||||
stored_key: cred_result.stored_key.unwrap_or_default(),
|
||||
server_key: cred_result.server_key.unwrap_or_default(),
|
||||
};
|
||||
|
||||
// Step 3: Generate and send server-first-message
|
||||
let server_first = scram.server_first_message(creds);
|
||||
let server_first_b64 = BASE64.encode(server_first.as_bytes());
|
||||
|
||||
let challenge = SmtpResponse::auth_challenge(&server_first_b64);
|
||||
if stream.write_all(&challenge.to_bytes()).await.is_err() {
|
||||
return LineResult::Disconnect;
|
||||
}
|
||||
if stream.flush().await.is_err() {
|
||||
return LineResult::Disconnect;
|
||||
}
|
||||
|
||||
// Step 4: Read client-final-message
|
||||
let mut client_final_line = String::new();
|
||||
let socket_timeout = Duration::from_secs(config.socket_timeout_secs);
|
||||
match timeout(socket_timeout, stream.read_line(&mut client_final_line, 4096)).await {
|
||||
Err(_) | Ok(Err(_)) | Ok(Ok(0)) => return LineResult::Disconnect,
|
||||
Ok(Ok(_)) => {}
|
||||
}
|
||||
|
||||
let client_final_b64 = client_final_line.trim();
|
||||
|
||||
// Cancel if *
|
||||
if client_final_b64 == "*" {
|
||||
session.auth_state = AuthState::None;
|
||||
return LineResult::Response(SmtpResponse::new(501, "Authentication cancelled"));
|
||||
}
|
||||
|
||||
// Decode base64 client-final-message
|
||||
let client_final_bytes = match BASE64.decode(client_final_b64.as_bytes()) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
session.auth_state = AuthState::None;
|
||||
return LineResult::Response(SmtpResponse::param_error("Invalid base64 encoding"));
|
||||
}
|
||||
};
|
||||
let client_final = match String::from_utf8(client_final_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
session.auth_state = AuthState::None;
|
||||
return LineResult::Response(SmtpResponse::param_error("Invalid UTF-8 in SCRAM message"));
|
||||
}
|
||||
};
|
||||
|
||||
// Step 5: Verify proof
|
||||
match scram.process_client_final(&client_final) {
|
||||
Ok(server_final) => {
|
||||
let server_final_b64 = BASE64.encode(server_final.as_bytes());
|
||||
session.auth_state = AuthState::Authenticated {
|
||||
username: scram.username.clone(),
|
||||
};
|
||||
LineResult::Response(SmtpResponse::new(
|
||||
235,
|
||||
format!("2.7.0 Authentication successful {}", server_final_b64),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, "SCRAM proof verification failed");
|
||||
session.auth_state = AuthState::None;
|
||||
let exceeded = session.record_auth_failure(config.max_auth_failures);
|
||||
if exceeded {
|
||||
if !rate_limiter.check_auth_failure(&session.remote_addr) {
|
||||
return LineResult::Quit(SmtpResponse::service_unavailable(
|
||||
&config.hostname,
|
||||
"Too many authentication failures",
|
||||
));
|
||||
}
|
||||
return LineResult::Quit(SmtpResponse::service_unavailable(
|
||||
&config.hostname,
|
||||
"Too many authentication failures",
|
||||
));
|
||||
}
|
||||
LineResult::Response(SmtpResponse::auth_failed())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
//! - TCP/TLS server (`server`)
|
||||
//! - Connection handling (`connection`)
|
||||
|
||||
pub mod client;
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod data;
|
||||
pub mod rate_limiter;
|
||||
pub mod response;
|
||||
pub mod scram;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod state;
|
||||
|
||||
@@ -196,7 +196,7 @@ pub fn build_capabilities(
|
||||
caps.push("STARTTLS".to_string());
|
||||
}
|
||||
if auth_available {
|
||||
caps.push("AUTH PLAIN LOGIN".to_string());
|
||||
caps.push("AUTH PLAIN LOGIN SCRAM-SHA-256".to_string());
|
||||
}
|
||||
caps
|
||||
}
|
||||
@@ -253,7 +253,7 @@ mod tests {
|
||||
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(&"AUTH PLAIN LOGIN SCRAM-SHA-256".to_string()));
|
||||
assert!(caps.contains(&"PIPELINING".to_string()));
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ mod tests {
|
||||
// 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()));
|
||||
assert!(!caps.contains(&"AUTH PLAIN LOGIN SCRAM-SHA-256".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
342
rust/crates/mailer-smtp/src/scram.rs
Normal file
342
rust/crates/mailer-smtp/src/scram.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
//! SCRAM-SHA-256 server-side implementation (RFC 5802 + RFC 7677).
|
||||
//!
|
||||
//! Implements the server side of the SCRAM-SHA-256 SASL mechanism,
|
||||
//! a challenge-response protocol that avoids transmitting cleartext passwords.
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Pre-computed SCRAM credentials for a user (derived from password).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScramCredentials {
|
||||
pub salt: Vec<u8>,
|
||||
pub iterations: u32,
|
||||
pub stored_key: Vec<u8>,
|
||||
pub server_key: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Server-side SCRAM state machine.
|
||||
pub struct ScramServer {
|
||||
/// Username extracted from client-first-message.
|
||||
pub username: String,
|
||||
/// Full combined nonce (client + server).
|
||||
combined_nonce: String,
|
||||
/// Server nonce portion (used in tests for verification).
|
||||
#[allow(dead_code)]
|
||||
server_nonce: String,
|
||||
/// Stored credentials (set after TS responds).
|
||||
credentials: Option<ScramCredentials>,
|
||||
/// The server-first-message (for auth message construction).
|
||||
server_first: String,
|
||||
/// The client-first-message-bare (for auth message construction).
|
||||
client_first_bare: String,
|
||||
}
|
||||
|
||||
impl ScramServer {
|
||||
/// Process the client-first-message.
|
||||
///
|
||||
/// Parses the client nonce and username, generates a server nonce,
|
||||
/// and returns a partial state that needs credentials to produce the
|
||||
/// server-first-message.
|
||||
pub fn from_client_first(client_first: &str) -> Result<Self, String> {
|
||||
// client-first-message = gs2-header client-first-message-bare
|
||||
// gs2-header = "n,," (no channel binding)
|
||||
// client-first-message-bare = "n=username,r=nonce"
|
||||
let bare = if let Some(rest) = client_first.strip_prefix("n,,") {
|
||||
rest
|
||||
} else if let Some(rest) = client_first.strip_prefix("y,,") {
|
||||
rest
|
||||
} else {
|
||||
return Err("Invalid SCRAM gs2-header".into());
|
||||
};
|
||||
|
||||
let mut username = String::new();
|
||||
let mut client_nonce = String::new();
|
||||
|
||||
for part in bare.split(',') {
|
||||
if let Some(val) = part.strip_prefix("n=") {
|
||||
username = val.to_string();
|
||||
} else if let Some(val) = part.strip_prefix("r=") {
|
||||
client_nonce = val.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if username.is_empty() || client_nonce.is_empty() {
|
||||
return Err("Missing username or nonce in client-first-message".into());
|
||||
}
|
||||
|
||||
// Generate server nonce
|
||||
let server_nonce: String = (0..24)
|
||||
.map(|_| {
|
||||
let idx = (rand_byte() as usize) % 62;
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx] as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
let combined_nonce = format!("{}{}", client_nonce, server_nonce);
|
||||
|
||||
Ok(ScramServer {
|
||||
username,
|
||||
combined_nonce,
|
||||
server_nonce,
|
||||
credentials: None,
|
||||
server_first: String::new(),
|
||||
client_first_bare: bare.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the credentials and produce the server-first-message.
|
||||
pub fn server_first_message(&mut self, creds: ScramCredentials) -> String {
|
||||
let salt_b64 = BASE64.encode(&creds.salt);
|
||||
let server_first = format!(
|
||||
"r={},s={},i={}",
|
||||
self.combined_nonce, salt_b64, creds.iterations
|
||||
);
|
||||
|
||||
self.server_first = server_first.clone();
|
||||
self.credentials = Some(creds);
|
||||
server_first
|
||||
}
|
||||
|
||||
/// Process the client-final-message and verify the proof.
|
||||
///
|
||||
/// Returns the server-final-message (containing ServerSignature) on success,
|
||||
/// or an error string on failure.
|
||||
pub fn process_client_final(&mut self, client_final: &str) -> Result<String, String> {
|
||||
let creds = self.credentials.as_ref().ok_or("No credentials set")?;
|
||||
|
||||
// Parse client-final-message
|
||||
// Format: c=biws,r=<combined_nonce>,p=<client_proof>
|
||||
let mut channel_binding = String::new();
|
||||
let mut nonce = String::new();
|
||||
let mut proof_b64 = String::new();
|
||||
|
||||
for part in client_final.split(',') {
|
||||
if let Some(val) = part.strip_prefix("c=") {
|
||||
channel_binding = val.to_string();
|
||||
} else if let Some(val) = part.strip_prefix("r=") {
|
||||
nonce = val.to_string();
|
||||
} else if let Some(val) = part.strip_prefix("p=") {
|
||||
proof_b64 = val.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Verify nonce matches
|
||||
if nonce != self.combined_nonce {
|
||||
return Err("Nonce mismatch".into());
|
||||
}
|
||||
|
||||
// Build the client-final-message-without-proof
|
||||
let client_final_without_proof = format!("c={},r={}", channel_binding, nonce);
|
||||
|
||||
// Complete the auth message
|
||||
let auth_message = format!(
|
||||
"{},{},{}",
|
||||
self.client_first_bare, self.server_first, client_final_without_proof
|
||||
);
|
||||
|
||||
// Verify client proof
|
||||
let client_proof = BASE64.decode(proof_b64.as_bytes())
|
||||
.map_err(|_| "Invalid base64 in client proof")?;
|
||||
|
||||
// ClientSignature = HMAC(StoredKey, AuthMessage)
|
||||
let client_signature = hmac_sha256(&creds.stored_key, auth_message.as_bytes());
|
||||
|
||||
// ClientKey = ClientProof XOR ClientSignature
|
||||
if client_proof.len() != client_signature.len() {
|
||||
return Err("Client proof length mismatch".into());
|
||||
}
|
||||
let client_key: Vec<u8> = client_proof
|
||||
.iter()
|
||||
.zip(client_signature.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
|
||||
// StoredKey = H(ClientKey)
|
||||
let computed_stored_key = sha256(&client_key);
|
||||
|
||||
// Verify: computed StoredKey must match the stored StoredKey
|
||||
if computed_stored_key != creds.stored_key {
|
||||
return Err("Authentication failed".into());
|
||||
}
|
||||
|
||||
// Generate ServerSignature for mutual authentication
|
||||
let server_signature = hmac_sha256(&creds.server_key, auth_message.as_bytes());
|
||||
let server_sig_b64 = BASE64.encode(&server_signature);
|
||||
|
||||
Ok(format!("v={}", server_sig_b64))
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute SCRAM credentials from a plaintext password (for TS to pre-compute).
|
||||
pub fn compute_scram_credentials(password: &str, salt: &[u8], iterations: u32) -> ScramCredentials {
|
||||
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations)
|
||||
let mut salted_password = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
password.as_bytes(),
|
||||
salt,
|
||||
iterations,
|
||||
&mut salted_password,
|
||||
);
|
||||
|
||||
// ClientKey = HMAC(SaltedPassword, "Client Key")
|
||||
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||
|
||||
// StoredKey = H(ClientKey)
|
||||
let stored_key = sha256(&client_key);
|
||||
|
||||
// ServerKey = HMAC(SaltedPassword, "Server Key")
|
||||
let server_key = hmac_sha256(&salted_password, b"Server Key");
|
||||
|
||||
ScramCredentials {
|
||||
salt: salt.to_vec(),
|
||||
iterations,
|
||||
stored_key,
|
||||
server_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn sha256(data: &[u8]) -> Vec<u8> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Simple random byte using system randomness.
|
||||
fn rand_byte() -> u8 {
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let state = RandomState::new();
|
||||
let mut hasher = state.build_hasher();
|
||||
hasher.write_u64(std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64);
|
||||
hasher.finish() as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scram_full_exchange() {
|
||||
let password = "pencil";
|
||||
let salt = b"test-salt-1234";
|
||||
let iterations = 4096;
|
||||
|
||||
// Pre-compute server-side credentials from password
|
||||
let creds = compute_scram_credentials(password, salt, iterations);
|
||||
|
||||
// 1. Client sends client-first-message
|
||||
let client_first = "n,,n=user,r=rOprNGfwEbeRWgbNEkqO";
|
||||
let mut server = ScramServer::from_client_first(client_first).unwrap();
|
||||
assert_eq!(server.username, "user");
|
||||
|
||||
// 2. Server responds with server-first-message
|
||||
let server_first = server.server_first_message(creds.clone());
|
||||
assert!(server_first.starts_with(&format!("r=rOprNGfwEbeRWgbNEkqO{}", server.server_nonce)));
|
||||
assert!(server_first.contains("s="));
|
||||
assert!(server_first.contains("i=4096"));
|
||||
|
||||
// 3. Client computes proof
|
||||
// SaltedPassword
|
||||
let mut salted_password = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
password.as_bytes(),
|
||||
salt,
|
||||
iterations,
|
||||
&mut salted_password,
|
||||
);
|
||||
|
||||
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||
let stored_key = sha256(&client_key);
|
||||
|
||||
let client_first_bare = "n=user,r=rOprNGfwEbeRWgbNEkqO";
|
||||
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
|
||||
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
|
||||
|
||||
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
|
||||
let client_proof: Vec<u8> = client_key
|
||||
.iter()
|
||||
.zip(client_signature.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
let proof_b64 = BASE64.encode(&client_proof);
|
||||
|
||||
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
|
||||
|
||||
// 4. Server verifies proof
|
||||
let result = server.process_client_final(&client_final);
|
||||
assert!(result.is_ok(), "SCRAM verification failed: {:?}", result.err());
|
||||
let server_final = result.unwrap();
|
||||
assert!(server_final.starts_with("v="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scram_wrong_password() {
|
||||
let password = "pencil";
|
||||
let wrong_password = "wrong";
|
||||
let salt = b"test-salt";
|
||||
let iterations = 4096;
|
||||
|
||||
let creds = compute_scram_credentials(password, salt, iterations);
|
||||
|
||||
let client_first = "n,,n=user,r=clientnonce123";
|
||||
let mut server = ScramServer::from_client_first(client_first).unwrap();
|
||||
let server_first = server.server_first_message(creds);
|
||||
|
||||
// Client computes proof with wrong password
|
||||
let mut salted_password = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
wrong_password.as_bytes(),
|
||||
salt,
|
||||
iterations,
|
||||
&mut salted_password,
|
||||
);
|
||||
|
||||
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||
let stored_key = sha256(&client_key);
|
||||
|
||||
let client_first_bare = "n=user,r=clientnonce123";
|
||||
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
|
||||
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
|
||||
|
||||
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
|
||||
let client_proof: Vec<u8> = client_key
|
||||
.iter()
|
||||
.zip(client_signature.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
let proof_b64 = BASE64.encode(&client_proof);
|
||||
|
||||
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
|
||||
let result = server.process_client_final(&client_final);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_scram_credentials() {
|
||||
let creds = compute_scram_credentials("password", b"salt", 4096);
|
||||
assert_eq!(creds.salt, b"salt");
|
||||
assert_eq!(creds.iterations, 4096);
|
||||
assert_eq!(creds.stored_key.len(), 32);
|
||||
assert_eq!(creds.server_key.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_client_first() {
|
||||
assert!(ScramServer::from_client_first("invalid").is_err());
|
||||
assert!(ScramServer::from_client_first("n,,").is_err());
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use crate::rate_limiter::{RateLimitConfig, RateLimiter};
|
||||
use hickory_resolver::TokioResolver;
|
||||
use mailer_security::MessageAuthenticator;
|
||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufReader;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -263,6 +264,69 @@ async fn accept_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// SNI-based certificate resolver that selects the appropriate TLS certificate
|
||||
/// based on the client's requested hostname.
|
||||
struct SniCertResolver {
|
||||
/// Domain -> certified key mapping.
|
||||
certs: HashMap<String, Arc<rustls::sign::CertifiedKey>>,
|
||||
/// Default certificate for non-matching SNI or missing SNI.
|
||||
default: Arc<rustls::sign::CertifiedKey>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SniCertResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SniCertResolver")
|
||||
.field("domains", &self.certs.keys().collect::<Vec<_>>())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl rustls::server::ResolvesServerCert for SniCertResolver {
|
||||
fn resolve(
|
||||
&self,
|
||||
client_hello: rustls::server::ClientHello<'_>,
|
||||
) -> Option<Arc<rustls::sign::CertifiedKey>> {
|
||||
if let Some(sni) = client_hello.server_name() {
|
||||
let sni_lower = sni.to_lowercase();
|
||||
if let Some(key) = self.certs.get(&sni_lower) {
|
||||
return Some(key.clone());
|
||||
}
|
||||
}
|
||||
Some(self.default.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PEM cert+key pair into a `CertifiedKey`.
|
||||
fn parse_certified_key(
|
||||
cert_pem: &str,
|
||||
key_pem: &str,
|
||||
) -> Result<rustls::sign::CertifiedKey, Box<dyn std::error::Error + Send + Sync>> {
|
||||
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());
|
||||
}
|
||||
|
||||
let key: PrivateKeyDer<'static> = {
|
||||
let mut reader = BufReader::new(key_pem.as_bytes());
|
||||
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 signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?;
|
||||
Ok(rustls::sign::CertifiedKey::new(certs, signing_key))
|
||||
}
|
||||
|
||||
/// Build a TLS acceptor from PEM cert/key strings.
|
||||
fn build_tls_acceptor(
|
||||
config: &SmtpServerConfig,
|
||||
@@ -311,9 +375,42 @@ fn build_tls_acceptor(
|
||||
.ok_or("No private key found in PEM")?
|
||||
};
|
||||
|
||||
let tls_config = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?;
|
||||
// If additional TLS certs are configured, use SNI-based resolution
|
||||
let tls_config = if config.additional_tls_certs.is_empty() {
|
||||
rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?
|
||||
} else {
|
||||
// Build default certified key
|
||||
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?;
|
||||
let default_ck = Arc::new(rustls::sign::CertifiedKey::new(certs, signing_key));
|
||||
|
||||
// Build per-domain certs
|
||||
let mut domain_certs = HashMap::new();
|
||||
for domain_cert in &config.additional_tls_certs {
|
||||
match parse_certified_key(&domain_cert.cert_pem, &domain_cert.key_pem) {
|
||||
Ok(ck) => {
|
||||
let ck = Arc::new(ck);
|
||||
for domain in &domain_cert.domains {
|
||||
domain_certs.insert(domain.to_lowercase(), ck.clone());
|
||||
}
|
||||
info!("SNI cert loaded for domains: {:?}", domain_cert.domains);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to load SNI cert for domains {:?}: {}", domain_cert.domains, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resolver = SniCertResolver {
|
||||
certs: domain_certs,
|
||||
default: default_ck,
|
||||
};
|
||||
|
||||
rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_cert_resolver(Arc::new(resolver))
|
||||
};
|
||||
|
||||
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)))
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Get version from deno.json
|
||||
VERSION=$(cat deno.json | grep -o '"version": *"[^"]*"' | cut -d'"' -f4)
|
||||
BINARY_DIR="dist/binaries"
|
||||
|
||||
echo "================================================"
|
||||
echo " MAILER Compilation Script"
|
||||
echo " Version: ${VERSION}"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Compiling for all supported platforms..."
|
||||
echo ""
|
||||
|
||||
# Clean up old binaries and create fresh directory
|
||||
rm -rf "$BINARY_DIR"
|
||||
mkdir -p "$BINARY_DIR"
|
||||
echo "→ Cleaned old binaries from $BINARY_DIR"
|
||||
echo ""
|
||||
|
||||
# Linux x86_64
|
||||
echo "→ Compiling for Linux x86_64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-linux-x64" \
|
||||
--target x86_64-unknown-linux-gnu mod.ts
|
||||
echo " ✓ Linux x86_64 complete"
|
||||
echo ""
|
||||
|
||||
# Linux ARM64
|
||||
echo "→ Compiling for Linux ARM64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-linux-arm64" \
|
||||
--target aarch64-unknown-linux-gnu mod.ts
|
||||
echo " ✓ Linux ARM64 complete"
|
||||
echo ""
|
||||
|
||||
# macOS x86_64
|
||||
echo "→ Compiling for macOS x86_64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-macos-x64" \
|
||||
--target x86_64-apple-darwin mod.ts
|
||||
echo " ✓ macOS x86_64 complete"
|
||||
echo ""
|
||||
|
||||
# macOS ARM64
|
||||
echo "→ Compiling for macOS ARM64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-macos-arm64" \
|
||||
--target aarch64-apple-darwin mod.ts
|
||||
echo " ✓ macOS ARM64 complete"
|
||||
echo ""
|
||||
|
||||
# Windows x86_64
|
||||
echo "→ Compiling for Windows x86_64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-windows-x64.exe" \
|
||||
--target x86_64-pc-windows-msvc mod.ts
|
||||
echo " ✓ Windows x86_64 complete"
|
||||
echo ""
|
||||
|
||||
echo "================================================"
|
||||
echo " Compilation Summary"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
ls -lh "$BINARY_DIR/" | tail -n +2
|
||||
echo ""
|
||||
echo "✓ All binaries compiled successfully!"
|
||||
echo ""
|
||||
echo "Binary location: $BINARY_DIR/"
|
||||
echo ""
|
||||
@@ -1,148 +0,0 @@
|
||||
import * as plugins from '../../ts/plugins.js';
|
||||
|
||||
export interface ITestServerConfig {
|
||||
port: number;
|
||||
hostname?: string;
|
||||
tlsEnabled?: boolean;
|
||||
authRequired?: boolean;
|
||||
timeout?: number;
|
||||
testCertPath?: string;
|
||||
testKeyPath?: string;
|
||||
maxConnections?: number;
|
||||
size?: number;
|
||||
maxRecipients?: number;
|
||||
}
|
||||
|
||||
export interface ITestServer {
|
||||
server: any;
|
||||
smtpServer: any;
|
||||
port: number;
|
||||
hostname: string;
|
||||
config: ITestServerConfig;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
throw new Error(
|
||||
'startTestServer is no longer available — the TS SMTP server was removed in Phase 7B. ' +
|
||||
'Use the Rust SMTP server (via UnifiedEmailServer) for integration testing.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a test SMTP server
|
||||
*/
|
||||
export async function stopTestServer(testServer: ITestServer): Promise<void> {
|
||||
if (!testServer || !testServer.smtpServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
|
||||
await testServer.smtpServer.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping test server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is free
|
||||
*/
|
||||
async function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = plugins.net.createServer();
|
||||
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
|
||||
server.on('error', () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test email data
|
||||
*/
|
||||
export function createTestEmail(options: {
|
||||
from?: string;
|
||||
to?: string | string[];
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
attachments?: any[];
|
||||
} = {}): any {
|
||||
return {
|
||||
from: options.from || 'test@example.com',
|
||||
to: options.to || 'recipient@example.com',
|
||||
subject: options.subject || 'Test Email',
|
||||
text: options.text || 'This is a test email',
|
||||
html: options.html || '<p>This is a test email</p>',
|
||||
attachments: options.attachments || [],
|
||||
date: new Date(),
|
||||
messageId: `<${Date.now()}@test.example.com>`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple test server for custom protocol testing
|
||||
*/
|
||||
export interface ISimpleTestServer {
|
||||
server: any;
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export async function createTestServer(options: {
|
||||
onConnection?: (socket: any) => void | Promise<void>;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
}): Promise<ISimpleTestServer> {
|
||||
const hostname = options.hostname || 'localhost';
|
||||
const port = options.port || await getAvailablePort();
|
||||
|
||||
const server = plugins.net.createServer((socket) => {
|
||||
if (options.onConnection) {
|
||||
const result = options.onConnection(socket);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.catch(error => {
|
||||
console.error('Error in onConnection handler:', error);
|
||||
socket.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(port, hostname, () => {
|
||||
resolve({
|
||||
server,
|
||||
hostname,
|
||||
port
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { smtpClientMod } from '../../ts/mail/delivery/index.js';
|
||||
import type { ISmtpClientOptions, SmtpClient } from '../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../ts/mail/core/classes.email.js';
|
||||
|
||||
/**
|
||||
* Create a test SMTP client
|
||||
*/
|
||||
export function createTestSmtpClient(options: Partial<ISmtpClientOptions> = {}): SmtpClient {
|
||||
const defaultOptions: ISmtpClientOptions = {
|
||||
host: options.host || 'localhost',
|
||||
port: options.port || 2525,
|
||||
secure: options.secure || false,
|
||||
auth: options.auth,
|
||||
connectionTimeout: options.connectionTimeout || 5000,
|
||||
socketTimeout: options.socketTimeout || 5000,
|
||||
maxConnections: options.maxConnections || 5,
|
||||
maxMessages: options.maxMessages || 100,
|
||||
debug: options.debug || false,
|
||||
tls: options.tls || {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
};
|
||||
|
||||
return smtpClientMod.createSmtpClient(defaultOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test email using SMTP client
|
||||
*/
|
||||
export async function sendTestEmail(
|
||||
client: SmtpClient,
|
||||
options: {
|
||||
from?: string;
|
||||
to?: string | string[];
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
} = {}
|
||||
): Promise<any> {
|
||||
const mailOptions = {
|
||||
from: options.from || 'test@example.com',
|
||||
to: options.to || 'recipient@example.com',
|
||||
subject: options.subject || 'Test Email',
|
||||
text: options.text || 'This is a test email',
|
||||
html: options.html
|
||||
};
|
||||
|
||||
const email = new Email({
|
||||
from: mailOptions.from,
|
||||
to: mailOptions.to,
|
||||
subject: mailOptions.subject,
|
||||
text: mailOptions.text,
|
||||
html: mailOptions.html
|
||||
});
|
||||
return client.sendMail(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SMTP client connection
|
||||
*/
|
||||
export async function testClientConnection(
|
||||
host: string,
|
||||
port: number,
|
||||
timeout: number = 5000
|
||||
): Promise<boolean> {
|
||||
const client = createTestSmtpClient({
|
||||
host,
|
||||
port,
|
||||
connectionTimeout: timeout
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.verify();
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
if (client.close) {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create authenticated SMTP client
|
||||
*/
|
||||
export function createAuthenticatedClient(
|
||||
host: string,
|
||||
port: number,
|
||||
username: string,
|
||||
password: string,
|
||||
authMethod: 'PLAIN' | 'LOGIN' = 'PLAIN'
|
||||
): SmtpClient {
|
||||
return createTestSmtpClient({
|
||||
host,
|
||||
port,
|
||||
auth: {
|
||||
user: username,
|
||||
pass: password,
|
||||
method: authMethod
|
||||
},
|
||||
secure: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create TLS-enabled SMTP client
|
||||
*/
|
||||
export function createTlsClient(
|
||||
host: string,
|
||||
port: number,
|
||||
options: {
|
||||
secure?: boolean;
|
||||
rejectUnauthorized?: boolean;
|
||||
} = {}
|
||||
): SmtpClient {
|
||||
return createTestSmtpClient({
|
||||
host,
|
||||
port,
|
||||
secure: options.secure || false,
|
||||
tls: {
|
||||
rejectUnauthorized: options.rejectUnauthorized || false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test client pool status
|
||||
*/
|
||||
export async function testClientPoolStatus(client: SmtpClient): Promise<any> {
|
||||
if (typeof client.getPoolStatus === 'function') {
|
||||
return client.getPoolStatus();
|
||||
}
|
||||
|
||||
// Fallback for clients without pool status
|
||||
return {
|
||||
size: 1,
|
||||
available: 1,
|
||||
pending: 0,
|
||||
connecting: 0,
|
||||
active: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send multiple emails concurrently
|
||||
*/
|
||||
export async function sendConcurrentEmails(
|
||||
client: SmtpClient,
|
||||
count: number,
|
||||
emailOptions: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
} = {}
|
||||
): Promise<any[]> {
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
promises.push(
|
||||
sendTestEmail(client, {
|
||||
...emailOptions,
|
||||
subject: `${emailOptions.subject || 'Test Email'} ${i + 1}`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure client throughput
|
||||
*/
|
||||
export async function measureClientThroughput(
|
||||
client: SmtpClient,
|
||||
duration: number = 10000,
|
||||
emailOptions: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
} = {}
|
||||
): Promise<{ totalSent: number; successCount: number; errorCount: number; throughput: number }> {
|
||||
const startTime = Date.now();
|
||||
let totalSent = 0;
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
while (Date.now() - startTime < duration) {
|
||||
try {
|
||||
await sendTestEmail(client, emailOptions);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
}
|
||||
totalSent++;
|
||||
}
|
||||
|
||||
const actualDuration = (Date.now() - startTime) / 1000; // in seconds
|
||||
const throughput = totalSent / actualDuration;
|
||||
|
||||
return {
|
||||
totalSent,
|
||||
successCount,
|
||||
errorCount,
|
||||
throughput
|
||||
};
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
|
||||
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();
|
||||
@@ -1,277 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,283 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,274 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,306 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,233 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,243 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,333 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,339 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,457 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
import { EmailValidator } from '../../../ts/mail/core/classes.emailvalidator.js';
|
||||
|
||||
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();
|
||||
@@ -1,409 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,150 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
|
||||
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();
|
||||
@@ -1,140 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,208 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,250 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createPooledSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,288 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,267 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,324 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient, createPooledSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,139 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
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();
|
||||
@@ -1,167 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
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();
|
||||
@@ -1,305 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
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();
|
||||
@@ -1,299 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,529 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,438 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,446 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,530 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,145 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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é, naï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();
|
||||
@@ -1,180 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,204 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,245 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,321 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,334 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,187 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,277 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,235 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,489 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,293 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,314 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,411 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,232 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,309 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,299 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,255 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,273 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,320 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,320 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,261 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,299 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,373 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
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();
|
||||
@@ -1,332 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createBulkSmtpClient, createPooledSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
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();
|
||||
@@ -1,304 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient, createPooledSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
import * as net from 'net';
|
||||
|
||||
let testServer: ITestServer;
|
||||
|
||||
tap.test('setup - start SMTP server for throughput tests', async () => {
|
||||
testServer = await startTestServer({
|
||||
port: 0,
|
||||
enableStarttls: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
expect(testServer.port).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('CPERF-02: Sequential message throughput', async (tools) => {
|
||||
tools.timeout(60000);
|
||||
|
||||
const smtpClient = await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
debug: false
|
||||
});
|
||||
|
||||
const messageCount = 10;
|
||||
const messages = Array(messageCount).fill(null).map((_, i) =>
|
||||
new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${i + 1}@example.com`],
|
||||
subject: `Sequential throughput test ${i + 1}`,
|
||||
text: `Testing sequential message sending - message ${i + 1}`
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`Sending ${messageCount} messages sequentially...`);
|
||||
const sequentialStart = Date.now();
|
||||
let successCount = 0;
|
||||
|
||||
for (const message of messages) {
|
||||
try {
|
||||
const result = await smtpClient.sendMail(message);
|
||||
if (result.success) successCount++;
|
||||
} catch (error) {
|
||||
console.log('Failed to send:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const sequentialTime = Date.now() - sequentialStart;
|
||||
const sequentialRate = (successCount / sequentialTime) * 1000;
|
||||
|
||||
console.log(`Sequential throughput: ${sequentialRate.toFixed(2)} messages/second`);
|
||||
console.log(`Successfully sent: ${successCount}/${messageCount} messages`);
|
||||
console.log(`Total time: ${sequentialTime}ms`);
|
||||
|
||||
expect(successCount).toBeGreaterThan(0);
|
||||
expect(sequentialRate).toBeGreaterThan(0.1); // At least 0.1 message per second
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('CPERF-02: Concurrent message throughput', async (tools) => {
|
||||
tools.timeout(60000);
|
||||
|
||||
const smtpClient = await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
debug: false
|
||||
});
|
||||
|
||||
const messageCount = 10;
|
||||
const messages = Array(messageCount).fill(null).map((_, i) =>
|
||||
new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${i + 1}@example.com`],
|
||||
subject: `Concurrent throughput test ${i + 1}`,
|
||||
text: `Testing concurrent message sending - message ${i + 1}`
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`Sending ${messageCount} messages concurrently...`);
|
||||
const concurrentStart = Date.now();
|
||||
|
||||
// Send in small batches to avoid overwhelming
|
||||
const batchSize = 3;
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i += batchSize) {
|
||||
const batch = messages.slice(i, i + batchSize);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(message => smtpClient.sendMail(message).catch(err => ({ success: false, error: err })))
|
||||
);
|
||||
results.push(...batchResults);
|
||||
|
||||
// Small delay between batches
|
||||
if (i + batchSize < messages.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const concurrentTime = Date.now() - concurrentStart;
|
||||
const concurrentRate = (successCount / concurrentTime) * 1000;
|
||||
|
||||
console.log(`Concurrent throughput: ${concurrentRate.toFixed(2)} messages/second`);
|
||||
console.log(`Successfully sent: ${successCount}/${messageCount} messages`);
|
||||
console.log(`Total time: ${concurrentTime}ms`);
|
||||
|
||||
expect(successCount).toBeGreaterThan(0);
|
||||
expect(concurrentRate).toBeGreaterThan(0.1);
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('CPERF-02: Connection pooling throughput', async (tools) => {
|
||||
tools.timeout(60000);
|
||||
|
||||
const pooledClient = await createPooledSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
maxConnections: 3,
|
||||
debug: false
|
||||
});
|
||||
|
||||
const messageCount = 15;
|
||||
const messages = Array(messageCount).fill(null).map((_, i) =>
|
||||
new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${i + 1}@example.com`],
|
||||
subject: `Pooled throughput test ${i + 1}`,
|
||||
text: `Testing connection pooling - message ${i + 1}`
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`Sending ${messageCount} messages with connection pooling...`);
|
||||
const poolStart = Date.now();
|
||||
|
||||
// Send in small batches
|
||||
const batchSize = 5;
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i += batchSize) {
|
||||
const batch = messages.slice(i, i + batchSize);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(message => pooledClient.sendMail(message).catch(err => ({ success: false, error: err })))
|
||||
);
|
||||
results.push(...batchResults);
|
||||
|
||||
// Small delay between batches
|
||||
if (i + batchSize < messages.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const poolTime = Date.now() - poolStart;
|
||||
const poolRate = (successCount / poolTime) * 1000;
|
||||
|
||||
console.log(`Pooled throughput: ${poolRate.toFixed(2)} messages/second`);
|
||||
console.log(`Successfully sent: ${successCount}/${messageCount} messages`);
|
||||
console.log(`Total time: ${poolTime}ms`);
|
||||
|
||||
expect(successCount).toBeGreaterThan(0);
|
||||
expect(poolRate).toBeGreaterThan(0.1);
|
||||
|
||||
await pooledClient.close();
|
||||
});
|
||||
|
||||
tap.test('CPERF-02: Variable message size throughput', async (tools) => {
|
||||
tools.timeout(60000);
|
||||
|
||||
const smtpClient = await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
debug: false
|
||||
});
|
||||
|
||||
// Create messages of varying sizes
|
||||
const messageSizes = [
|
||||
{ size: 'small', content: 'Short message' },
|
||||
{ size: 'medium', content: 'Medium message: ' + 'x'.repeat(500) },
|
||||
{ size: 'large', content: 'Large message: ' + 'x'.repeat(5000) }
|
||||
];
|
||||
|
||||
const messages = [];
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const sizeType = messageSizes[i % messageSizes.length];
|
||||
messages.push(new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${i + 1}@example.com`],
|
||||
subject: `Variable size test ${i + 1} (${sizeType.size})`,
|
||||
text: sizeType.content
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(`Sending ${messages.length} messages of varying sizes...`);
|
||||
const variableStart = Date.now();
|
||||
let successCount = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const message of messages) {
|
||||
try {
|
||||
const result = await smtpClient.sendMail(message);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
// Estimate message size
|
||||
totalBytes += message.text ? message.text.length : 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Failed to send:', error.message);
|
||||
}
|
||||
|
||||
// Small delay between messages
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const variableTime = Date.now() - variableStart;
|
||||
const variableRate = (successCount / variableTime) * 1000;
|
||||
const bytesPerSecond = (totalBytes / variableTime) * 1000;
|
||||
|
||||
console.log(`Variable size throughput: ${variableRate.toFixed(2)} messages/second`);
|
||||
console.log(`Data throughput: ${(bytesPerSecond / 1024).toFixed(2)} KB/second`);
|
||||
console.log(`Successfully sent: ${successCount}/${messages.length} messages`);
|
||||
|
||||
expect(successCount).toBeGreaterThan(0);
|
||||
expect(variableRate).toBeGreaterThan(0.1);
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('CPERF-02: Sustained throughput over time', async (tools) => {
|
||||
tools.timeout(60000);
|
||||
|
||||
const smtpClient = await createPooledSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
maxConnections: 2,
|
||||
debug: false
|
||||
});
|
||||
|
||||
const totalMessages = 12;
|
||||
const batchSize = 3;
|
||||
const batchDelay = 1000; // 1 second between batches
|
||||
|
||||
console.log(`Sending ${totalMessages} messages in batches of ${batchSize}...`);
|
||||
const sustainedStart = Date.now();
|
||||
let totalSuccess = 0;
|
||||
const timestamps: number[] = [];
|
||||
|
||||
for (let batch = 0; batch < totalMessages / batchSize; batch++) {
|
||||
const batchMessages = Array(batchSize).fill(null).map((_, i) => {
|
||||
const msgIndex = batch * batchSize + i + 1;
|
||||
return new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${msgIndex}@example.com`],
|
||||
subject: `Sustained test batch ${batch + 1} message ${i + 1}`,
|
||||
text: `Testing sustained throughput - message ${msgIndex}`
|
||||
});
|
||||
});
|
||||
|
||||
// Send batch
|
||||
const batchStart = Date.now();
|
||||
const results = await Promise.all(
|
||||
batchMessages.map(message => smtpClient.sendMail(message).catch(err => ({ success: false })))
|
||||
);
|
||||
|
||||
const batchSuccess = results.filter(r => r.success).length;
|
||||
totalSuccess += batchSuccess;
|
||||
timestamps.push(Date.now());
|
||||
|
||||
console.log(` Batch ${batch + 1} completed: ${batchSuccess}/${batchSize} successful`);
|
||||
|
||||
// Delay between batches (except last)
|
||||
if (batch < (totalMessages / batchSize) - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, batchDelay));
|
||||
}
|
||||
}
|
||||
|
||||
const sustainedTime = Date.now() - sustainedStart;
|
||||
const sustainedRate = (totalSuccess / sustainedTime) * 1000;
|
||||
|
||||
console.log(`Sustained throughput: ${sustainedRate.toFixed(2)} messages/second`);
|
||||
console.log(`Successfully sent: ${totalSuccess}/${totalMessages} messages`);
|
||||
console.log(`Total time: ${sustainedTime}ms`);
|
||||
|
||||
expect(totalSuccess).toBeGreaterThan(0);
|
||||
expect(sustainedRate).toBeGreaterThan(0.05); // Very relaxed for sustained test
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop SMTP server', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,332 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient, createPooledSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
let testServer: ITestServer;
|
||||
|
||||
// Helper function to get memory usage
|
||||
const getMemoryUsage = () => {
|
||||
if (process.memoryUsage) {
|
||||
const usage = process.memoryUsage();
|
||||
return {
|
||||
heapUsed: usage.heapUsed,
|
||||
heapTotal: usage.heapTotal,
|
||||
external: usage.external,
|
||||
rss: usage.rss
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper function to format bytes
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
tap.test('setup - start SMTP server for memory tests', async () => {
|
||||
testServer = await startTestServer({
|
||||
port: 0,
|
||||
enableStarttls: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
expect(testServer.port).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('CPERF-03: Memory usage during connection lifecycle', async (tools) => {
|
||||
tools.timeout(30000);
|
||||
|
||||
const memoryBefore = getMemoryUsage();
|
||||
console.log('Initial memory usage:', {
|
||||
heapUsed: formatBytes(memoryBefore.heapUsed),
|
||||
heapTotal: formatBytes(memoryBefore.heapTotal),
|
||||
rss: formatBytes(memoryBefore.rss)
|
||||
});
|
||||
|
||||
// Create and close multiple connections
|
||||
const connectionCount = 10;
|
||||
|
||||
for (let i = 0; i < connectionCount; i++) {
|
||||
const client = await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
debug: false
|
||||
});
|
||||
|
||||
// Send a test email
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: `Memory test ${i + 1}`,
|
||||
text: 'Testing memory usage'
|
||||
});
|
||||
|
||||
await client.sendMail(email);
|
||||
await client.close();
|
||||
|
||||
// Small delay between connections
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const memoryAfter = getMemoryUsage();
|
||||
const memoryIncrease = memoryAfter.heapUsed - memoryBefore.heapUsed;
|
||||
|
||||
console.log(`Memory after ${connectionCount} connections:`, {
|
||||
heapUsed: formatBytes(memoryAfter.heapUsed),
|
||||
heapTotal: formatBytes(memoryAfter.heapTotal),
|
||||
rss: formatBytes(memoryAfter.rss)
|
||||
});
|
||||
console.log(`Memory increase: ${formatBytes(memoryIncrease)}`);
|
||||
console.log(`Average per connection: ${formatBytes(memoryIncrease / connectionCount)}`);
|
||||
|
||||
// Memory increase should be reasonable
|
||||
expect(memoryIncrease / connectionCount).toBeLessThan(1024 * 1024); // Less than 1MB per connection
|
||||
});
|
||||
|
||||
tap.test('CPERF-03: Memory usage with large messages', async (tools) => {
|
||||
tools.timeout(30000);
|
||||
|
||||
const client = await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
debug: false
|
||||
});
|
||||
|
||||
const memoryBefore = getMemoryUsage();
|
||||
console.log('Memory before large messages:', {
|
||||
heapUsed: formatBytes(memoryBefore.heapUsed)
|
||||
});
|
||||
|
||||
// Send messages of increasing size
|
||||
const sizes = [1024, 10240, 102400]; // 1KB, 10KB, 100KB
|
||||
|
||||
for (const size of sizes) {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: `Large message test (${formatBytes(size)})`,
|
||||
text: 'x'.repeat(size)
|
||||
});
|
||||
|
||||
await client.sendMail(email);
|
||||
|
||||
const memoryAfter = getMemoryUsage();
|
||||
console.log(`Memory after ${formatBytes(size)} message:`, {
|
||||
heapUsed: formatBytes(memoryAfter.heapUsed),
|
||||
increase: formatBytes(memoryAfter.heapUsed - memoryBefore.heapUsed)
|
||||
});
|
||||
|
||||
// Small delay
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
await client.close();
|
||||
|
||||
const memoryFinal = getMemoryUsage();
|
||||
const totalIncrease = memoryFinal.heapUsed - memoryBefore.heapUsed;
|
||||
|
||||
console.log(`Total memory increase: ${formatBytes(totalIncrease)}`);
|
||||
|
||||
// Memory should not grow excessively
|
||||
expect(totalIncrease).toBeLessThan(10 * 1024 * 1024); // Less than 10MB total
|
||||
});
|
||||
|
||||
tap.test('CPERF-03: Memory usage with connection pooling', async (tools) => {
|
||||
tools.timeout(30000);
|
||||
|
||||
const memoryBefore = getMemoryUsage();
|
||||
console.log('Memory before pooling test:', {
|
||||
heapUsed: formatBytes(memoryBefore.heapUsed)
|
||||
});
|
||||
|
||||
const pooledClient = await createPooledSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
maxConnections: 3,
|
||||
debug: false
|
||||
});
|
||||
|
||||
// Send multiple emails through the pool
|
||||
const emailCount = 15;
|
||||
const emails = Array(emailCount).fill(null).map((_, i) =>
|
||||
new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${i}@example.com`],
|
||||
subject: `Pooled memory test ${i + 1}`,
|
||||
text: 'Testing memory with connection pooling'
|
||||
})
|
||||
);
|
||||
|
||||
// Send in batches
|
||||
for (let i = 0; i < emails.length; i += 3) {
|
||||
const batch = emails.slice(i, i + 3);
|
||||
await Promise.all(batch.map(email =>
|
||||
pooledClient.sendMail(email).catch(err => console.log('Send error:', err.message))
|
||||
));
|
||||
|
||||
// Check memory after each batch
|
||||
const memoryNow = getMemoryUsage();
|
||||
console.log(`Memory after batch ${Math.floor(i/3) + 1}:`, {
|
||||
heapUsed: formatBytes(memoryNow.heapUsed),
|
||||
increase: formatBytes(memoryNow.heapUsed - memoryBefore.heapUsed)
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await pooledClient.close();
|
||||
|
||||
const memoryFinal = getMemoryUsage();
|
||||
const totalIncrease = memoryFinal.heapUsed - memoryBefore.heapUsed;
|
||||
|
||||
console.log(`Total memory increase with pooling: ${formatBytes(totalIncrease)}`);
|
||||
console.log(`Average per email: ${formatBytes(totalIncrease / emailCount)}`);
|
||||
|
||||
// Pooling should be memory efficient
|
||||
expect(totalIncrease / emailCount).toBeLessThan(500 * 1024); // Less than 500KB per email
|
||||
});
|
||||
|
||||
tap.test('CPERF-03: Memory cleanup after errors', async (tools) => {
|
||||
tools.timeout(30000);
|
||||
|
||||
const memoryBefore = getMemoryUsage();
|
||||
console.log('Memory before error test:', {
|
||||
heapUsed: formatBytes(memoryBefore.heapUsed)
|
||||
});
|
||||
|
||||
// Try to send emails that might fail
|
||||
const errorCount = 5;
|
||||
|
||||
for (let i = 0; i < errorCount; i++) {
|
||||
try {
|
||||
const client = await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 1000, // Short timeout
|
||||
debug: false
|
||||
});
|
||||
|
||||
// Create a large email that might cause issues
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: `Error test ${i + 1}`,
|
||||
text: 'x'.repeat(100000), // 100KB
|
||||
attachments: [{
|
||||
filename: 'test.txt',
|
||||
content: Buffer.alloc(50000).toString('base64'), // 50KB attachment
|
||||
encoding: 'base64'
|
||||
}]
|
||||
});
|
||||
|
||||
await client.sendMail(email);
|
||||
await client.close();
|
||||
} catch (error) {
|
||||
console.log(`Error ${i + 1} handled: ${error.message}`);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const memoryAfter = getMemoryUsage();
|
||||
const memoryIncrease = memoryAfter.heapUsed - memoryBefore.heapUsed;
|
||||
|
||||
console.log(`Memory after ${errorCount} error scenarios:`, {
|
||||
heapUsed: formatBytes(memoryAfter.heapUsed),
|
||||
increase: formatBytes(memoryIncrease)
|
||||
});
|
||||
|
||||
// Memory should be properly cleaned up after errors
|
||||
expect(memoryIncrease).toBeLessThan(5 * 1024 * 1024); // Less than 5MB increase
|
||||
});
|
||||
|
||||
tap.test('CPERF-03: Long-running memory stability', async (tools) => {
|
||||
tools.timeout(60000);
|
||||
|
||||
const client = await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
debug: false
|
||||
});
|
||||
|
||||
const memorySnapshots = [];
|
||||
const duration = 10000; // 10 seconds
|
||||
const interval = 2000; // Check every 2 seconds
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log('Testing memory stability over time...');
|
||||
|
||||
let emailsSent = 0;
|
||||
|
||||
while (Date.now() - startTime < duration) {
|
||||
// Send an email
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: `Stability test ${++emailsSent}`,
|
||||
text: `Testing memory stability at ${new Date().toISOString()}`
|
||||
});
|
||||
|
||||
try {
|
||||
await client.sendMail(email);
|
||||
} catch (error) {
|
||||
console.log('Send error:', error.message);
|
||||
}
|
||||
|
||||
// Take memory snapshot
|
||||
const memory = getMemoryUsage();
|
||||
const elapsed = Date.now() - startTime;
|
||||
memorySnapshots.push({
|
||||
time: elapsed,
|
||||
heapUsed: memory.heapUsed
|
||||
});
|
||||
|
||||
console.log(`[${elapsed}ms] Heap: ${formatBytes(memory.heapUsed)}, Emails sent: ${emailsSent}`);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
await client.close();
|
||||
|
||||
// Analyze memory growth
|
||||
const firstSnapshot = memorySnapshots[0];
|
||||
const lastSnapshot = memorySnapshots[memorySnapshots.length - 1];
|
||||
const memoryGrowth = lastSnapshot.heapUsed - firstSnapshot.heapUsed;
|
||||
const growthRate = memoryGrowth / (lastSnapshot.time / 1000); // bytes per second
|
||||
|
||||
console.log(`\nMemory stability results:`);
|
||||
console.log(` Duration: ${lastSnapshot.time}ms`);
|
||||
console.log(` Emails sent: ${emailsSent}`);
|
||||
console.log(` Memory growth: ${formatBytes(memoryGrowth)}`);
|
||||
console.log(` Growth rate: ${formatBytes(growthRate)}/second`);
|
||||
|
||||
// Memory growth should be minimal over time
|
||||
expect(growthRate).toBeLessThan(150 * 1024); // Less than 150KB/second growth
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop SMTP server', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,373 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient, createPooledSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
let testServer: ITestServer;
|
||||
|
||||
// Helper function to measure CPU usage
|
||||
const measureCpuUsage = async (duration: number) => {
|
||||
const start = process.cpuUsage();
|
||||
const startTime = Date.now();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, duration));
|
||||
|
||||
const end = process.cpuUsage(start);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Ensure minimum elapsed time to avoid division issues
|
||||
const actualElapsed = Math.max(elapsed, 1);
|
||||
|
||||
return {
|
||||
user: end.user / 1000, // Convert to milliseconds
|
||||
system: end.system / 1000,
|
||||
total: (end.user + end.system) / 1000,
|
||||
elapsed: actualElapsed,
|
||||
userPercent: (end.user / 1000) / actualElapsed * 100,
|
||||
systemPercent: (end.system / 1000) / actualElapsed * 100,
|
||||
totalPercent: Math.min(((end.user + end.system) / 1000) / actualElapsed * 100, 100)
|
||||
};
|
||||
};
|
||||
|
||||
tap.test('setup - start SMTP server for CPU tests', async () => {
|
||||
testServer = await startTestServer({
|
||||
port: 0,
|
||||
enableStarttls: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
expect(testServer.port).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('CPERF-04: CPU usage during connection establishment', async (tools) => {
|
||||
tools.timeout(30000);
|
||||
|
||||
console.log('Testing CPU usage during connection establishment...');
|
||||
|
||||
// Measure baseline CPU
|
||||
const baseline = await measureCpuUsage(1000);
|
||||
console.log(`Baseline CPU: ${baseline.totalPercent.toFixed(2)}%`);
|
||||
|
||||
// Ensure we have a meaningful duration for measurement
|
||||
const connectionCount = 5;
|
||||
const startTime = Date.now();
|
||||
const cpuStart = process.cpuUsage();
|
||||
|
||||
for (let i = 0; i < connectionCount; i++) {
|
||||
const client = await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
debug: false
|
||||
});
|
||||
|
||||
await client.close();
|
||||
|
||||
// Small delay to ensure measurable duration
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const cpuEnd = process.cpuUsage(cpuStart);
|
||||
|
||||
// Ensure minimum elapsed time
|
||||
const actualElapsed = Math.max(elapsed, 100);
|
||||
const cpuPercent = Math.min(((cpuEnd.user + cpuEnd.system) / 1000) / actualElapsed * 100, 100);
|
||||
|
||||
console.log(`CPU usage for ${connectionCount} connections:`);
|
||||
console.log(` Total time: ${actualElapsed}ms`);
|
||||
console.log(` CPU time: ${(cpuEnd.user + cpuEnd.system) / 1000}ms`);
|
||||
console.log(` CPU usage: ${cpuPercent.toFixed(2)}%`);
|
||||
console.log(` Average per connection: ${(cpuPercent / connectionCount).toFixed(2)}%`);
|
||||
|
||||
// CPU usage should be reasonable (relaxed for test environment)
|
||||
expect(cpuPercent).toBeLessThan(100); // Must be less than 100%
|
||||
});
|
||||
|
||||
tap.test('CPERF-04: CPU usage during message sending', async (tools) => {
|
||||
tools.timeout(30000);
|
||||
|
||||
console.log('\nTesting CPU usage during message sending...');
|
||||
|
||||
const client = await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
debug: false
|
||||
});
|
||||
|
||||
const messageCount = 10; // Reduced for more stable measurement
|
||||
|
||||
// Measure CPU during message sending
|
||||
const cpuStart = process.cpuUsage();
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < messageCount; i++) {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${i}@example.com`],
|
||||
subject: `CPU test message ${i + 1}`,
|
||||
text: `Testing CPU usage during message ${i + 1}`
|
||||
});
|
||||
|
||||
await client.sendMail(email);
|
||||
|
||||
// Small delay between messages
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const cpuEnd = process.cpuUsage(cpuStart);
|
||||
const actualElapsed = Math.max(elapsed, 100);
|
||||
const cpuPercent = Math.min(((cpuEnd.user + cpuEnd.system) / 1000) / actualElapsed * 100, 100);
|
||||
|
||||
await client.close();
|
||||
|
||||
console.log(`CPU usage for ${messageCount} messages:`);
|
||||
console.log(` Total time: ${actualElapsed}ms`);
|
||||
console.log(` CPU time: ${(cpuEnd.user + cpuEnd.system) / 1000}ms`);
|
||||
console.log(` CPU usage: ${cpuPercent.toFixed(2)}%`);
|
||||
console.log(` Messages per second: ${(messageCount / (actualElapsed / 1000)).toFixed(2)}`);
|
||||
console.log(` CPU per message: ${(cpuPercent / messageCount).toFixed(2)}%`);
|
||||
|
||||
// CPU usage should be efficient (relaxed for test environment)
|
||||
expect(cpuPercent).toBeLessThan(100);
|
||||
});
|
||||
|
||||
tap.test('CPERF-04: CPU usage with parallel operations', async (tools) => {
|
||||
tools.timeout(30000);
|
||||
|
||||
console.log('\nTesting CPU usage with parallel operations...');
|
||||
|
||||
// Create multiple clients for parallel operations
|
||||
const clientCount = 2; // Reduced
|
||||
const messagesPerClient = 3; // Reduced
|
||||
|
||||
const clients = [];
|
||||
for (let i = 0; i < clientCount; i++) {
|
||||
clients.push(await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
debug: false
|
||||
}));
|
||||
}
|
||||
|
||||
// Measure CPU during parallel operations
|
||||
const cpuStart = process.cpuUsage();
|
||||
const startTime = Date.now();
|
||||
|
||||
const promises = [];
|
||||
for (let clientIndex = 0; clientIndex < clientCount; clientIndex++) {
|
||||
for (let msgIndex = 0; msgIndex < messagesPerClient; msgIndex++) {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${clientIndex}-${msgIndex}@example.com`],
|
||||
subject: `Parallel CPU test ${clientIndex}-${msgIndex}`,
|
||||
text: 'Testing CPU with parallel operations'
|
||||
});
|
||||
|
||||
promises.push(clients[clientIndex].sendMail(email));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const cpuEnd = process.cpuUsage(cpuStart);
|
||||
const actualElapsed = Math.max(elapsed, 100);
|
||||
const cpuPercent = Math.min(((cpuEnd.user + cpuEnd.system) / 1000) / actualElapsed * 100, 100);
|
||||
|
||||
// Close all clients
|
||||
await Promise.all(clients.map(client => client.close()));
|
||||
|
||||
const totalMessages = clientCount * messagesPerClient;
|
||||
console.log(`CPU usage for ${totalMessages} messages across ${clientCount} clients:`);
|
||||
console.log(` Total time: ${actualElapsed}ms`);
|
||||
console.log(` CPU time: ${(cpuEnd.user + cpuEnd.system) / 1000}ms`);
|
||||
console.log(` CPU usage: ${cpuPercent.toFixed(2)}%`);
|
||||
|
||||
// Parallel operations should complete successfully
|
||||
expect(cpuPercent).toBeLessThan(100);
|
||||
});
|
||||
|
||||
tap.test('CPERF-04: CPU usage with large messages', async (tools) => {
|
||||
tools.timeout(30000);
|
||||
|
||||
console.log('\nTesting CPU usage with large messages...');
|
||||
|
||||
const client = await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
debug: false
|
||||
});
|
||||
|
||||
const messageSizes = [
|
||||
{ name: 'small', size: 1024 }, // 1KB
|
||||
{ name: 'medium', size: 10240 }, // 10KB
|
||||
{ name: 'large', size: 51200 } // 50KB (reduced from 100KB)
|
||||
];
|
||||
|
||||
for (const { name, size } of messageSizes) {
|
||||
const cpuStart = process.cpuUsage();
|
||||
const startTime = Date.now();
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: `Large message test (${name})`,
|
||||
text: 'x'.repeat(size)
|
||||
});
|
||||
|
||||
await client.sendMail(email);
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const cpuEnd = process.cpuUsage(cpuStart);
|
||||
const actualElapsed = Math.max(elapsed, 1);
|
||||
const cpuPercent = Math.min(((cpuEnd.user + cpuEnd.system) / 1000) / actualElapsed * 100, 100);
|
||||
|
||||
console.log(`CPU usage for ${name} message (${size} bytes):`);
|
||||
console.log(` Time: ${actualElapsed}ms`);
|
||||
console.log(` CPU: ${cpuPercent.toFixed(2)}%`);
|
||||
console.log(` Throughput: ${(size / 1024 / (actualElapsed / 1000)).toFixed(2)} KB/s`);
|
||||
|
||||
// Small delay between messages
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
||||
tap.test('CPERF-04: CPU usage with connection pooling', async (tools) => {
|
||||
tools.timeout(30000);
|
||||
|
||||
console.log('\nTesting CPU usage with connection pooling...');
|
||||
|
||||
const pooledClient = await createPooledSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
maxConnections: 2, // Reduced
|
||||
debug: false
|
||||
});
|
||||
|
||||
const messageCount = 8; // Reduced
|
||||
|
||||
// Measure CPU with pooling
|
||||
const cpuStart = process.cpuUsage();
|
||||
const startTime = Date.now();
|
||||
|
||||
const promises = [];
|
||||
for (let i = 0; i < messageCount; i++) {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${i}@example.com`],
|
||||
subject: `Pooled CPU test ${i + 1}`,
|
||||
text: 'Testing CPU usage with connection pooling'
|
||||
});
|
||||
|
||||
promises.push(pooledClient.sendMail(email));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const cpuEnd = process.cpuUsage(cpuStart);
|
||||
const actualElapsed = Math.max(elapsed, 100);
|
||||
const cpuPercent = Math.min(((cpuEnd.user + cpuEnd.system) / 1000) / actualElapsed * 100, 100);
|
||||
|
||||
await pooledClient.close();
|
||||
|
||||
console.log(`CPU usage for ${messageCount} messages with pooling:`);
|
||||
console.log(` Total time: ${actualElapsed}ms`);
|
||||
console.log(` CPU time: ${(cpuEnd.user + cpuEnd.system) / 1000}ms`);
|
||||
console.log(` CPU usage: ${cpuPercent.toFixed(2)}%`);
|
||||
|
||||
// Pooling should complete successfully
|
||||
expect(cpuPercent).toBeLessThan(100);
|
||||
});
|
||||
|
||||
tap.test('CPERF-04: CPU profile over time', async (tools) => {
|
||||
tools.timeout(30000);
|
||||
|
||||
console.log('\nTesting CPU profile over time...');
|
||||
|
||||
const client = await createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
debug: false
|
||||
});
|
||||
|
||||
const duration = 8000; // 8 seconds (reduced)
|
||||
const interval = 2000; // Sample every 2 seconds
|
||||
const samples = [];
|
||||
|
||||
const endTime = Date.now() + duration;
|
||||
let emailsSent = 0;
|
||||
|
||||
while (Date.now() < endTime) {
|
||||
const sampleStart = Date.now();
|
||||
const cpuStart = process.cpuUsage();
|
||||
|
||||
// Send some emails
|
||||
for (let i = 0; i < 2; i++) { // Reduced from 3
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: `CPU profile test ${++emailsSent}`,
|
||||
text: `Testing CPU profile at ${new Date().toISOString()}`
|
||||
});
|
||||
|
||||
await client.sendMail(email);
|
||||
|
||||
// Small delay between emails
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const sampleElapsed = Date.now() - sampleStart;
|
||||
const cpuEnd = process.cpuUsage(cpuStart);
|
||||
const actualElapsed = Math.max(sampleElapsed, 100);
|
||||
const cpuPercent = Math.min(((cpuEnd.user + cpuEnd.system) / 1000) / actualElapsed * 100, 100);
|
||||
|
||||
samples.push({
|
||||
time: Date.now() - (endTime - duration),
|
||||
cpu: cpuPercent,
|
||||
emails: 2
|
||||
});
|
||||
|
||||
console.log(`[${samples[samples.length - 1].time}ms] CPU: ${cpuPercent.toFixed(2)}%, Emails sent: ${emailsSent}`);
|
||||
|
||||
// Wait for next interval
|
||||
const waitTime = interval - sampleElapsed;
|
||||
if (waitTime > 0 && Date.now() + waitTime < endTime) {
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
}
|
||||
}
|
||||
|
||||
await client.close();
|
||||
|
||||
// Calculate average CPU
|
||||
const avgCpu = samples.reduce((sum, s) => sum + s.cpu, 0) / samples.length;
|
||||
const maxCpu = Math.max(...samples.map(s => s.cpu));
|
||||
const minCpu = Math.min(...samples.map(s => s.cpu));
|
||||
|
||||
console.log(`\nCPU profile summary:`);
|
||||
console.log(` Samples: ${samples.length}`);
|
||||
console.log(` Average CPU: ${avgCpu.toFixed(2)}%`);
|
||||
console.log(` Min CPU: ${minCpu.toFixed(2)}%`);
|
||||
console.log(` Max CPU: ${maxCpu.toFixed(2)}%`);
|
||||
console.log(` Total emails: ${emailsSent}`);
|
||||
|
||||
// CPU should be bounded
|
||||
expect(avgCpu).toBeLessThan(100); // Average CPU less than 100%
|
||||
expect(maxCpu).toBeLessThan(100); // Max CPU less than 100%
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop SMTP server', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,181 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
tap.test('setup - start SMTP server for network efficiency tests', async () => {
|
||||
// Just a placeholder to ensure server starts properly
|
||||
});
|
||||
|
||||
tap.test('CPERF-05: network efficiency - connection reuse', async () => {
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
console.log('Testing connection reuse efficiency...');
|
||||
|
||||
// Test 1: Individual connections (2 messages)
|
||||
console.log('Sending 2 messages with individual connections...');
|
||||
const individualStart = Date.now();
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const client = createSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2525,
|
||||
secure: false
|
||||
});
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${i}@example.com`],
|
||||
subject: `Test ${i}`,
|
||||
text: `Message ${i}`,
|
||||
});
|
||||
|
||||
const result = await client.sendMail(email);
|
||||
expect(result.success).toBeTrue();
|
||||
await client.close();
|
||||
}
|
||||
|
||||
const individualTime = Date.now() - individualStart;
|
||||
console.log(`Individual connections: 2 connections, ${individualTime}ms`);
|
||||
|
||||
// Test 2: Connection reuse (2 messages)
|
||||
console.log('Sending 2 messages with connection reuse...');
|
||||
const reuseStart = Date.now();
|
||||
|
||||
const reuseClient = createSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2525,
|
||||
secure: false
|
||||
});
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`reuse${i}@example.com`],
|
||||
subject: `Reuse ${i}`,
|
||||
text: `Message ${i}`,
|
||||
});
|
||||
|
||||
const result = await reuseClient.sendMail(email);
|
||||
expect(result.success).toBeTrue();
|
||||
}
|
||||
|
||||
await reuseClient.close();
|
||||
|
||||
const reuseTime = Date.now() - reuseStart;
|
||||
console.log(`Connection reuse: 1 connection, ${reuseTime}ms`);
|
||||
|
||||
// Connection reuse should complete reasonably quickly
|
||||
expect(reuseTime).toBeLessThan(5000); // Less than 5 seconds
|
||||
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.test('CPERF-05: network efficiency - message throughput', async () => {
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
console.log('Testing message throughput...');
|
||||
|
||||
const client = createSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2525,
|
||||
secure: false,
|
||||
connectionTimeout: 10000,
|
||||
socketTimeout: 10000
|
||||
});
|
||||
|
||||
// Test with smaller message sizes to avoid timeout
|
||||
const sizes = [512, 1024]; // 512B, 1KB
|
||||
let totalBytes = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
for (const size of sizes) {
|
||||
const content = 'x'.repeat(size);
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: `Test ${size} bytes`,
|
||||
text: content,
|
||||
});
|
||||
|
||||
const result = await client.sendMail(email);
|
||||
expect(result.success).toBeTrue();
|
||||
totalBytes += size;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const throughput = (totalBytes / elapsed) * 1000; // bytes per second
|
||||
|
||||
console.log(`Total bytes sent: ${totalBytes}`);
|
||||
console.log(`Time elapsed: ${elapsed}ms`);
|
||||
console.log(`Throughput: ${(throughput / 1024).toFixed(1)} KB/s`);
|
||||
|
||||
// Should achieve reasonable throughput (lowered expectation)
|
||||
expect(throughput).toBeGreaterThan(100); // At least 100 bytes/s
|
||||
|
||||
await client.close();
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.test('CPERF-05: network efficiency - batch sending', async () => {
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
console.log('Testing batch email sending...');
|
||||
|
||||
const client = createSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2525,
|
||||
secure: false,
|
||||
connectionTimeout: 10000,
|
||||
socketTimeout: 10000
|
||||
});
|
||||
|
||||
// Send 3 emails in batch
|
||||
const emails = Array(3).fill(null).map((_, i) =>
|
||||
new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`batch${i}@example.com`],
|
||||
subject: `Batch ${i}`,
|
||||
text: `Testing batch sending - message ${i}`,
|
||||
})
|
||||
);
|
||||
|
||||
console.log('Sending 3 emails in batch...');
|
||||
const batchStart = Date.now();
|
||||
|
||||
// Send emails sequentially
|
||||
for (let i = 0; i < emails.length; i++) {
|
||||
const result = await client.sendMail(emails[i]);
|
||||
expect(result.success).toBeTrue();
|
||||
console.log(`Email ${i + 1} sent`);
|
||||
}
|
||||
|
||||
const batchTime = Date.now() - batchStart;
|
||||
|
||||
console.log(`\nBatch complete: 3 emails in ${batchTime}ms`);
|
||||
console.log(`Average time per email: ${(batchTime / 3).toFixed(1)}ms`);
|
||||
|
||||
// Batch should complete reasonably quickly
|
||||
expect(batchTime).toBeLessThan(5000); // Less than 5 seconds total
|
||||
|
||||
await client.close();
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop SMTP server', async () => {
|
||||
// Cleanup is handled in individual tests
|
||||
});
|
||||
|
||||
tap.start();
|
||||
@@ -1,190 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
tap.test('setup - start SMTP server for caching tests', async () => {
|
||||
// Just a placeholder to ensure server starts properly
|
||||
});
|
||||
|
||||
tap.test('CPERF-06: caching strategies - connection caching', async () => {
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
console.log('Testing connection caching strategies...');
|
||||
|
||||
// Create client for testing connection reuse
|
||||
const client = createSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2525,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// First batch - establish connections
|
||||
console.log('Sending first batch to establish connections...');
|
||||
const firstBatchStart = Date.now();
|
||||
|
||||
const firstBatch = Array(3).fill(null).map((_, i) =>
|
||||
new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`cached${i}@example.com`],
|
||||
subject: `Cache test ${i}`,
|
||||
text: `Testing connection caching - message ${i}`,
|
||||
})
|
||||
);
|
||||
|
||||
// Send emails sequentially
|
||||
for (const email of firstBatch) {
|
||||
const result = await client.sendMail(email);
|
||||
expect(result.success).toBeTrue();
|
||||
}
|
||||
|
||||
const firstBatchTime = Date.now() - firstBatchStart;
|
||||
|
||||
// Second batch - should reuse connection
|
||||
console.log('Sending second batch using same connection...');
|
||||
const secondBatchStart = Date.now();
|
||||
|
||||
const secondBatch = Array(3).fill(null).map((_, i) =>
|
||||
new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`cached2-${i}@example.com`],
|
||||
subject: `Cache test 2-${i}`,
|
||||
text: `Testing cached connections - message ${i}`,
|
||||
})
|
||||
);
|
||||
|
||||
// Send emails sequentially
|
||||
for (const email of secondBatch) {
|
||||
const result = await client.sendMail(email);
|
||||
expect(result.success).toBeTrue();
|
||||
}
|
||||
|
||||
const secondBatchTime = Date.now() - secondBatchStart;
|
||||
|
||||
console.log(`First batch: ${firstBatchTime}ms`);
|
||||
console.log(`Second batch: ${secondBatchTime}ms`);
|
||||
|
||||
// Both batches should complete successfully
|
||||
expect(firstBatchTime).toBeGreaterThan(0);
|
||||
expect(secondBatchTime).toBeGreaterThan(0);
|
||||
|
||||
await client.close();
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.test('CPERF-06: caching strategies - server capability caching', async () => {
|
||||
const testServer = await startTestServer({
|
||||
port: 2526,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
console.log('Testing server capability caching...');
|
||||
|
||||
const client = createSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2526,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// First email - discovers capabilities
|
||||
console.log('First email - discovering server capabilities...');
|
||||
const firstStart = Date.now();
|
||||
|
||||
const email1 = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient1@example.com'],
|
||||
subject: 'Capability test 1',
|
||||
text: 'Testing capability discovery',
|
||||
});
|
||||
|
||||
const result1 = await client.sendMail(email1);
|
||||
expect(result1.success).toBeTrue();
|
||||
const firstTime = Date.now() - firstStart;
|
||||
|
||||
// Second email - uses cached capabilities
|
||||
console.log('Second email - using cached capabilities...');
|
||||
const secondStart = Date.now();
|
||||
|
||||
const email2 = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient2@example.com'],
|
||||
subject: 'Capability test 2',
|
||||
text: 'Testing cached capabilities',
|
||||
});
|
||||
|
||||
const result2 = await client.sendMail(email2);
|
||||
expect(result2.success).toBeTrue();
|
||||
const secondTime = Date.now() - secondStart;
|
||||
|
||||
console.log(`First email (capability discovery): ${firstTime}ms`);
|
||||
console.log(`Second email (cached capabilities): ${secondTime}ms`);
|
||||
|
||||
// Both should complete quickly
|
||||
expect(firstTime).toBeLessThan(1000);
|
||||
expect(secondTime).toBeLessThan(1000);
|
||||
|
||||
await client.close();
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.test('CPERF-06: caching strategies - message batching', async () => {
|
||||
const testServer = await startTestServer({
|
||||
port: 2527,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
console.log('Testing message batching for cache efficiency...');
|
||||
|
||||
const client = createSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2527,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test sending messages in batches
|
||||
const batchSizes = [2, 3, 4];
|
||||
|
||||
for (const batchSize of batchSizes) {
|
||||
console.log(`\nTesting batch size: ${batchSize}`);
|
||||
const batchStart = Date.now();
|
||||
|
||||
const emails = Array(batchSize).fill(null).map((_, i) =>
|
||||
new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`batch${batchSize}-${i}@example.com`],
|
||||
subject: `Batch ${batchSize} message ${i}`,
|
||||
text: `Testing batching strategies - batch size ${batchSize}`,
|
||||
})
|
||||
);
|
||||
|
||||
// Send emails sequentially
|
||||
for (const email of emails) {
|
||||
const result = await client.sendMail(email);
|
||||
expect(result.success).toBeTrue();
|
||||
}
|
||||
|
||||
const batchTime = Date.now() - batchStart;
|
||||
const avgTime = batchTime / batchSize;
|
||||
|
||||
console.log(` Batch completed in ${batchTime}ms`);
|
||||
console.log(` Average time per message: ${avgTime.toFixed(1)}ms`);
|
||||
|
||||
// All batches should complete efficiently
|
||||
expect(avgTime).toBeLessThan(1000);
|
||||
}
|
||||
|
||||
await client.close();
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop SMTP server', async () => {
|
||||
// Cleanup is handled in individual tests
|
||||
});
|
||||
|
||||
tap.start();
|
||||
@@ -1,171 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
tap.test('setup - start SMTP server for queue management tests', async () => {
|
||||
// Just a placeholder to ensure server starts properly
|
||||
});
|
||||
|
||||
tap.test('CPERF-07: queue management - basic queue processing', async () => {
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
console.log('Testing basic queue processing...');
|
||||
|
||||
const client = createSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2525,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Queue up 5 emails (reduced from 10)
|
||||
const emailCount = 5;
|
||||
const emails = Array(emailCount).fill(null).map((_, i) =>
|
||||
new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`queue${i}@example.com`],
|
||||
subject: `Queue test ${i}`,
|
||||
text: `Testing queue management - message ${i}`,
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`Sending ${emailCount} emails...`);
|
||||
const queueStart = Date.now();
|
||||
|
||||
// Send all emails sequentially
|
||||
const results = [];
|
||||
for (let i = 0; i < emails.length; i++) {
|
||||
const result = await client.sendMail(emails[i]);
|
||||
console.log(` Email ${i} sent`);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const queueTime = Date.now() - queueStart;
|
||||
|
||||
// Verify all succeeded
|
||||
results.forEach((result, index) => {
|
||||
expect(result.success).toBeTrue();
|
||||
});
|
||||
|
||||
console.log(`All ${emailCount} emails processed in ${queueTime}ms`);
|
||||
console.log(`Average time per email: ${(queueTime / emailCount).toFixed(1)}ms`);
|
||||
|
||||
// Should complete within reasonable time
|
||||
expect(queueTime).toBeLessThan(10000); // Less than 10 seconds for 5 emails
|
||||
|
||||
await client.close();
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.test('CPERF-07: queue management - queue with rate limiting', async () => {
|
||||
const testServer = await startTestServer({
|
||||
port: 2526,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
console.log('Testing queue with rate limiting...');
|
||||
|
||||
const client = createSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2526,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Send 5 emails sequentially (simulating rate limiting)
|
||||
const emailCount = 5;
|
||||
const rateLimitDelay = 200; // 200ms between emails
|
||||
|
||||
console.log(`Sending ${emailCount} emails with ${rateLimitDelay}ms rate limit...`);
|
||||
const rateStart = Date.now();
|
||||
|
||||
for (let i = 0; i < emailCount; i++) {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`ratelimit${i}@example.com`],
|
||||
subject: `Rate limit test ${i}`,
|
||||
text: `Testing rate limited queue - message ${i}`,
|
||||
});
|
||||
|
||||
const result = await client.sendMail(email);
|
||||
expect(result.success).toBeTrue();
|
||||
|
||||
console.log(` Email ${i} sent`);
|
||||
|
||||
// Simulate rate limiting delay
|
||||
if (i < emailCount - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, rateLimitDelay));
|
||||
}
|
||||
}
|
||||
|
||||
const rateTime = Date.now() - rateStart;
|
||||
const expectedMinTime = (emailCount - 1) * rateLimitDelay;
|
||||
|
||||
console.log(`Rate limited emails sent in ${rateTime}ms`);
|
||||
console.log(`Expected minimum time: ${expectedMinTime}ms`);
|
||||
|
||||
// Should respect rate limiting
|
||||
expect(rateTime).toBeGreaterThanOrEqual(expectedMinTime);
|
||||
|
||||
await client.close();
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.test('CPERF-07: queue management - sequential processing', async () => {
|
||||
const testServer = await startTestServer({
|
||||
port: 2527,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
console.log('Testing sequential email processing...');
|
||||
|
||||
const client = createSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2527,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Send multiple emails sequentially
|
||||
const emails = Array(3).fill(null).map((_, i) =>
|
||||
new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`sequential${i}@example.com`],
|
||||
subject: `Sequential test ${i}`,
|
||||
text: `Testing sequential processing - message ${i}`,
|
||||
})
|
||||
);
|
||||
|
||||
console.log('Sending 3 emails sequentially...');
|
||||
const sequentialStart = Date.now();
|
||||
|
||||
const results = [];
|
||||
for (const email of emails) {
|
||||
const result = await client.sendMail(email);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const sequentialTime = Date.now() - sequentialStart;
|
||||
|
||||
// All should succeed
|
||||
results.forEach((result, index) => {
|
||||
expect(result.success).toBeTrue();
|
||||
console.log(` Email ${index} processed`);
|
||||
});
|
||||
|
||||
console.log(`Sequential processing completed in ${sequentialTime}ms`);
|
||||
console.log(`Average time per email: ${(sequentialTime / 3).toFixed(1)}ms`);
|
||||
|
||||
await client.close();
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop SMTP server', async () => {
|
||||
// Cleanup is handled in individual tests
|
||||
});
|
||||
|
||||
tap.start();
|
||||
@@ -1,50 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { createTestServer } from '../../helpers/server.loader.js';
|
||||
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
tap.test('CPERF-08: DNS Caching Tests', async () => {
|
||||
console.log('\n🌐 Testing SMTP Client DNS Caching');
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
const testServer = await createTestServer({});
|
||||
|
||||
try {
|
||||
console.log('\nTest: DNS caching with multiple connections');
|
||||
|
||||
// Create multiple clients to test DNS caching
|
||||
const clients = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port
|
||||
});
|
||||
clients.push(smtpClient);
|
||||
console.log(` ✓ Client ${i + 1} created (DNS should be cached)`);
|
||||
}
|
||||
|
||||
// Send email with first client
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'DNS Caching Test',
|
||||
text: 'Testing DNS caching efficiency'
|
||||
});
|
||||
|
||||
const result = await clients[0].sendMail(email);
|
||||
console.log(' ✓ Email sent successfully');
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Clean up all clients
|
||||
clients.forEach(client => client.close());
|
||||
console.log(' ✓ All clients closed');
|
||||
|
||||
console.log('\n✅ CPERF-08: DNS caching tests completed');
|
||||
|
||||
} finally {
|
||||
testServer.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.start();
|
||||
@@ -1,305 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
let testServer: ITestServer;
|
||||
|
||||
tap.test('setup test SMTP server', async () => {
|
||||
testServer = await startTestServer({
|
||||
port: 2600,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
expect(testServer).toBeTruthy();
|
||||
expect(testServer.port).toEqual(2600);
|
||||
});
|
||||
|
||||
tap.test('CREL-01: Basic reconnection after close', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// First verify connection works
|
||||
const result1 = await smtpClient.verify();
|
||||
expect(result1).toBeTrue();
|
||||
console.log('Initial connection verified');
|
||||
|
||||
// Close connection
|
||||
await smtpClient.close();
|
||||
console.log('Connection closed');
|
||||
|
||||
// Verify again - should reconnect automatically
|
||||
const result2 = await smtpClient.verify();
|
||||
expect(result2).toBeTrue();
|
||||
console.log('Reconnection successful');
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('CREL-01: Multiple sequential connections', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// Send multiple emails with closes in between
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: `Sequential Test ${i + 1}`,
|
||||
text: 'Testing sequential connections'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
expect(result.success).toBeTrue();
|
||||
console.log(`Email ${i + 1} sent successfully`);
|
||||
|
||||
// Close connection after each send
|
||||
await smtpClient.close();
|
||||
console.log(`Connection closed after email ${i + 1}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-01: Recovery from server restart', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// Send first email
|
||||
const email1 = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Before Server Restart',
|
||||
text: 'Testing server restart recovery'
|
||||
});
|
||||
|
||||
const result1 = await smtpClient.sendMail(email1);
|
||||
expect(result1.success).toBeTrue();
|
||||
console.log('First email sent successfully');
|
||||
|
||||
// Simulate server restart by creating a brief interruption
|
||||
console.log('Simulating server restart...');
|
||||
|
||||
// The SMTP client should handle the disconnection gracefully
|
||||
// and reconnect for the next operation
|
||||
|
||||
// Wait a moment
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Try to send another email
|
||||
const email2 = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'After Server Restart',
|
||||
text: 'Testing recovery after restart'
|
||||
});
|
||||
|
||||
const result2 = await smtpClient.sendMail(email2);
|
||||
expect(result2.success).toBeTrue();
|
||||
console.log('Second email sent successfully after simulated restart');
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('CREL-01: Connection pool reliability', async () => {
|
||||
const pooledClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
pool: true,
|
||||
maxConnections: 3,
|
||||
maxMessages: 10,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// Send multiple emails concurrently
|
||||
const emails = Array.from({ length: 10 }, (_, i) => new Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${i}@example.com`],
|
||||
subject: `Pool Test ${i}`,
|
||||
text: 'Testing connection pool'
|
||||
}));
|
||||
|
||||
console.log('Sending 10 emails through connection pool...');
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
emails.map(email => pooledClient.sendMail(email))
|
||||
);
|
||||
|
||||
const successful = results.filter(r => r.status === 'fulfilled').length;
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
|
||||
console.log(`Pool results: ${successful} successful, ${failed} failed`);
|
||||
expect(successful).toBeGreaterThan(0);
|
||||
|
||||
// Most should succeed
|
||||
expect(successful).toBeGreaterThanOrEqual(8);
|
||||
|
||||
await pooledClient.close();
|
||||
});
|
||||
|
||||
tap.test('CREL-01: Rapid connection cycling', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// Rapidly open and close connections
|
||||
console.log('Testing rapid connection cycling...');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const result = await smtpClient.verify();
|
||||
expect(result).toBeTrue();
|
||||
await smtpClient.close();
|
||||
console.log(`Cycle ${i + 1} completed`);
|
||||
}
|
||||
|
||||
console.log('Rapid cycling completed successfully');
|
||||
});
|
||||
|
||||
tap.test('CREL-01: Error recovery', async () => {
|
||||
// Test with invalid server first
|
||||
const smtpClient = createSmtpClient({
|
||||
host: 'invalid.host.local',
|
||||
port: 9999,
|
||||
secure: false,
|
||||
connectionTimeout: 1000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// First attempt should fail
|
||||
const result1 = await smtpClient.verify();
|
||||
expect(result1).toBeFalse();
|
||||
console.log('Connection to invalid host failed as expected');
|
||||
|
||||
// Now update to valid server (simulating failover)
|
||||
// Since we can't update options, create a new client
|
||||
const recoveredClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// Should connect successfully
|
||||
const result2 = await recoveredClient.verify();
|
||||
expect(result2).toBeTrue();
|
||||
console.log('Connection to valid host succeeded');
|
||||
|
||||
// Send email to verify full functionality
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Recovery Test',
|
||||
text: 'Testing error recovery'
|
||||
});
|
||||
|
||||
const sendResult = await recoveredClient.sendMail(email);
|
||||
expect(sendResult.success).toBeTrue();
|
||||
console.log('Email sent successfully after recovery');
|
||||
|
||||
await recoveredClient.close();
|
||||
});
|
||||
|
||||
tap.test('CREL-01: Long-lived connection', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 30000, // 30 second timeout
|
||||
socketTimeout: 30000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
console.log('Testing long-lived connection...');
|
||||
|
||||
// Send emails over time
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: `Long-lived Test ${i + 1}`,
|
||||
text: `Email ${i + 1} over long-lived connection`
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
expect(result.success).toBeTrue();
|
||||
console.log(`Email ${i + 1} sent at ${new Date().toISOString()}`);
|
||||
|
||||
// Wait between sends
|
||||
if (i < 2) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Long-lived connection test completed');
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('CREL-01: Concurrent operations', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
pool: true,
|
||||
maxConnections: 5,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
console.log('Testing concurrent operations...');
|
||||
|
||||
// Mix verify and send operations
|
||||
const operations = [
|
||||
smtpClient.verify(),
|
||||
smtpClient.sendMail(new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient1@example.com'],
|
||||
subject: 'Concurrent 1',
|
||||
text: 'First concurrent email'
|
||||
})),
|
||||
smtpClient.verify(),
|
||||
smtpClient.sendMail(new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient2@example.com'],
|
||||
subject: 'Concurrent 2',
|
||||
text: 'Second concurrent email'
|
||||
})),
|
||||
smtpClient.verify()
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(operations);
|
||||
|
||||
const successful = results.filter(r => r.status === 'fulfilled').length;
|
||||
console.log(`Concurrent operations: ${successful}/${results.length} successful`);
|
||||
|
||||
expect(successful).toEqual(results.length);
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('cleanup test SMTP server', async () => {
|
||||
if (testServer) {
|
||||
await stopTestServer(testServer);
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,207 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
import * as net from 'net';
|
||||
|
||||
let testServer: ITestServer;
|
||||
|
||||
tap.test('setup test SMTP server', async () => {
|
||||
testServer = await startTestServer({
|
||||
port: 2601,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
expect(testServer).toBeTruthy();
|
||||
expect(testServer.port).toEqual(2601);
|
||||
});
|
||||
|
||||
tap.test('CREL-02: Handle network interruption during verification', async () => {
|
||||
// Create a server that drops connections mid-session
|
||||
const interruptServer = net.createServer((socket) => {
|
||||
socket.write('220 Interrupt Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(`Server received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
// Start sending multi-line response then drop
|
||||
socket.write('250-test.server\r\n');
|
||||
socket.write('250-PIPELINING\r\n');
|
||||
|
||||
// Simulate network interruption
|
||||
setTimeout(() => {
|
||||
console.log('Simulating network interruption...');
|
||||
socket.destroy();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interruptServer.listen(2602, () => resolve());
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: 2602,
|
||||
secure: false,
|
||||
connectionTimeout: 2000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// Should handle the interruption gracefully
|
||||
const result = await smtpClient.verify();
|
||||
expect(result).toBeFalse();
|
||||
console.log('✅ Handled network interruption during verification');
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interruptServer.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('CREL-02: Recovery after brief network glitch', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// Send email successfully
|
||||
const email1 = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Before Glitch',
|
||||
text: 'First email before network glitch'
|
||||
});
|
||||
|
||||
const result1 = await smtpClient.sendMail(email1);
|
||||
expect(result1.success).toBeTrue();
|
||||
console.log('First email sent successfully');
|
||||
|
||||
// Close to simulate brief network issue
|
||||
await smtpClient.close();
|
||||
console.log('Simulating brief network glitch...');
|
||||
|
||||
// Wait a moment
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Try to send another email - should reconnect automatically
|
||||
const email2 = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'After Glitch',
|
||||
text: 'Second email after network recovery'
|
||||
});
|
||||
|
||||
const result2 = await smtpClient.sendMail(email2);
|
||||
expect(result2.success).toBeTrue();
|
||||
console.log('✅ Recovered from network glitch successfully');
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('CREL-02: Handle server becoming unresponsive', async () => {
|
||||
// Create a server that stops responding
|
||||
const unresponsiveServer = net.createServer((socket) => {
|
||||
socket.write('220 Unresponsive Server\r\n');
|
||||
let commandCount = 0;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
commandCount++;
|
||||
console.log(`Command ${commandCount}: ${command}`);
|
||||
|
||||
// Stop responding after first command
|
||||
if (commandCount === 1 && command.startsWith('EHLO')) {
|
||||
console.log('Server becoming unresponsive...');
|
||||
// Don't send any response - simulate hung server
|
||||
}
|
||||
});
|
||||
|
||||
// Don't close the socket, just stop responding
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
unresponsiveServer.listen(2604, () => resolve());
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: 2604,
|
||||
secure: false,
|
||||
connectionTimeout: 2000, // Short timeout to detect unresponsiveness
|
||||
debug: true
|
||||
});
|
||||
|
||||
// Should timeout when server doesn't respond
|
||||
const result = await smtpClient.verify();
|
||||
expect(result).toBeFalse();
|
||||
console.log('✅ Detected unresponsive server');
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
unresponsiveServer.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('CREL-02: Handle large email successfully', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 10000,
|
||||
socketTimeout: 10000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// Create a large email
|
||||
const largeText = 'x'.repeat(10000); // 10KB of text
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Large Email Test',
|
||||
text: largeText
|
||||
});
|
||||
|
||||
// Should complete successfully despite size
|
||||
const result = await smtpClient.sendMail(email);
|
||||
expect(result.success).toBeTrue();
|
||||
console.log('✅ Large email sent successfully');
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('CREL-02: Rapid reconnection after interruption', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// Rapid cycle of verify, close, verify
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = await smtpClient.verify();
|
||||
expect(result).toBeTrue();
|
||||
|
||||
await smtpClient.close();
|
||||
console.log(`Rapid cycle ${i + 1} completed`);
|
||||
|
||||
// Very short delay
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
console.log('✅ Rapid reconnection handled successfully');
|
||||
});
|
||||
|
||||
tap.test('cleanup test SMTP server', async () => {
|
||||
if (testServer) {
|
||||
await stopTestServer(testServer);
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,469 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
let messageCount = 0;
|
||||
let processedMessages: string[] = [];
|
||||
|
||||
tap.test('CREL-03: Basic Email Persistence Through Client Lifecycle', async () => {
|
||||
console.log('\n💾 Testing SMTP Client Queue Persistence Reliability');
|
||||
console.log('=' .repeat(60));
|
||||
console.log('\n🔄 Testing email handling through client lifecycle...');
|
||||
|
||||
messageCount = 0;
|
||||
processedMessages = [];
|
||||
|
||||
// Create test server
|
||||
const server = net.createServer(socket => {
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250-SIZE 10485760\r\n');
|
||||
socket.write('250 AUTH PLAIN LOGIN\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');
|
||||
} else if (line === '.') {
|
||||
messageCount++;
|
||||
socket.write(`250 OK Message ${messageCount} accepted\r\n`);
|
||||
console.log(` [Server] Processed message ${messageCount}`);
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Phase 1: Creating first client instance...');
|
||||
const smtpClient1 = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 2,
|
||||
maxMessages: 10
|
||||
});
|
||||
|
||||
console.log(' Creating emails for persistence test...');
|
||||
const emails = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
emails.push(new Email({
|
||||
from: 'sender@persistence.test',
|
||||
to: [`recipient${i}@persistence.test`],
|
||||
subject: `Persistence Test Email ${i + 1}`,
|
||||
text: `Testing queue persistence, email ${i + 1}`
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(' Sending emails to test persistence...');
|
||||
const sendPromises = emails.map((email, index) => {
|
||||
return smtpClient1.sendMail(email).then(result => {
|
||||
console.log(` 📤 Email ${index + 1} sent successfully`);
|
||||
processedMessages.push(`email-${index + 1}`);
|
||||
return { success: true, result, index };
|
||||
}).catch(error => {
|
||||
console.log(` ❌ Email ${index + 1} failed: ${error.message}`);
|
||||
return { success: false, error, index };
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for emails to be processed
|
||||
const results = await Promise.allSettled(sendPromises);
|
||||
|
||||
// Wait a bit for all messages to be processed by the server
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
console.log(' Phase 2: Verifying results...');
|
||||
const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length;
|
||||
console.log(` Total messages processed by server: ${messageCount}`);
|
||||
console.log(` Successful sends: ${successful}/${emails.length}`);
|
||||
|
||||
// With connection pooling, not all messages may be immediately processed
|
||||
expect(messageCount).toBeGreaterThanOrEqual(1);
|
||||
expect(successful).toEqual(emails.length);
|
||||
|
||||
smtpClient1.close();
|
||||
|
||||
// Wait for connections to close
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-03: Email Recovery After Connection Failure', async () => {
|
||||
console.log('\n🛠️ Testing email recovery after connection failure...');
|
||||
|
||||
let connectionCount = 0;
|
||||
let shouldReject = false;
|
||||
|
||||
// Create test server that can simulate failures
|
||||
const server = net.createServer(socket => {
|
||||
connectionCount++;
|
||||
|
||||
if (shouldReject) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Testing client behavior with connection failures...');
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
connectionTimeout: 2000,
|
||||
maxConnections: 1
|
||||
});
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@recovery.test',
|
||||
to: ['recipient@recovery.test'],
|
||||
subject: 'Recovery Test',
|
||||
text: 'Testing recovery from connection failure'
|
||||
});
|
||||
|
||||
console.log(' Sending email with potential connection issues...');
|
||||
|
||||
// First attempt should succeed
|
||||
try {
|
||||
await smtpClient.sendMail(email);
|
||||
console.log(' ✓ First email sent successfully');
|
||||
} catch (error) {
|
||||
console.log(' ✗ First email failed unexpectedly');
|
||||
}
|
||||
|
||||
// Simulate connection issues
|
||||
shouldReject = true;
|
||||
console.log(' Simulating connection failure...');
|
||||
|
||||
try {
|
||||
await smtpClient.sendMail(email);
|
||||
console.log(' ✗ Email sent when it should have failed');
|
||||
} catch (error) {
|
||||
console.log(' ✓ Email failed as expected during connection issue');
|
||||
}
|
||||
|
||||
// Restore connection
|
||||
shouldReject = false;
|
||||
console.log(' Connection restored, attempting recovery...');
|
||||
|
||||
try {
|
||||
await smtpClient.sendMail(email);
|
||||
console.log(' ✓ Email sent successfully after recovery');
|
||||
} catch (error) {
|
||||
console.log(' ✗ Email failed after recovery');
|
||||
}
|
||||
|
||||
console.log(` Total connection attempts: ${connectionCount}`);
|
||||
expect(connectionCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
smtpClient.close();
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-03: Concurrent Email Handling', async () => {
|
||||
console.log('\n🔒 Testing concurrent email handling...');
|
||||
|
||||
let processedEmails = 0;
|
||||
|
||||
// Create test server
|
||||
const server = net.createServer(socket => {
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
processedEmails++;
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Creating multiple clients for concurrent access...');
|
||||
|
||||
const clients = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
clients.push(createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 2
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(' Creating emails for concurrent test...');
|
||||
const allEmails = [];
|
||||
for (let clientIndex = 0; clientIndex < clients.length; clientIndex++) {
|
||||
for (let emailIndex = 0; emailIndex < 4; emailIndex++) {
|
||||
allEmails.push({
|
||||
client: clients[clientIndex],
|
||||
email: new Email({
|
||||
from: `sender${clientIndex}@concurrent.test`,
|
||||
to: [`recipient${clientIndex}-${emailIndex}@concurrent.test`],
|
||||
subject: `Concurrent Test Client ${clientIndex + 1} Email ${emailIndex + 1}`,
|
||||
text: `Testing concurrent access from client ${clientIndex + 1}`
|
||||
}),
|
||||
clientId: clientIndex,
|
||||
emailId: emailIndex
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' Sending emails concurrently from multiple clients...');
|
||||
const startTime = Date.now();
|
||||
|
||||
const promises = allEmails.map(({ client, email, clientId, emailId }) => {
|
||||
return client.sendMail(email).then(result => {
|
||||
console.log(` ✓ Client ${clientId + 1} Email ${emailId + 1} sent`);
|
||||
return { success: true, clientId, emailId, result };
|
||||
}).catch(error => {
|
||||
console.log(` ✗ Client ${clientId + 1} Email ${emailId + 1} failed: ${error.message}`);
|
||||
return { success: false, clientId, emailId, error };
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const endTime = Date.now();
|
||||
|
||||
const successful = results.filter(r => r.success).length;
|
||||
const failed = results.filter(r => !r.success).length;
|
||||
|
||||
console.log(` Concurrent operations completed in ${endTime - startTime}ms`);
|
||||
console.log(` Total emails: ${allEmails.length}`);
|
||||
console.log(` Successful: ${successful}, Failed: ${failed}`);
|
||||
console.log(` Emails processed by server: ${processedEmails}`);
|
||||
console.log(` Success rate: ${((successful / allEmails.length) * 100).toFixed(1)}%`);
|
||||
|
||||
expect(successful).toBeGreaterThanOrEqual(allEmails.length - 2);
|
||||
|
||||
// Close all clients
|
||||
for (const client of clients) {
|
||||
client.close();
|
||||
}
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-03: Email Integrity During High Load', async () => {
|
||||
console.log('\n🔍 Testing email integrity during high load...');
|
||||
|
||||
const receivedSubjects = new Set<string>();
|
||||
|
||||
// Create test server
|
||||
const server = net.createServer(socket => {
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
let inData = false;
|
||||
let currentData = '';
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (inData) {
|
||||
if (line === '.') {
|
||||
// Extract subject from email data
|
||||
const subjectMatch = currentData.match(/Subject: (.+)/);
|
||||
if (subjectMatch) {
|
||||
receivedSubjects.add(subjectMatch[1]);
|
||||
}
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
inData = false;
|
||||
currentData = '';
|
||||
} else {
|
||||
if (line.trim() !== '') {
|
||||
currentData += line + '\r\n';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Creating client for high load test...');
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 5,
|
||||
maxMessages: 100
|
||||
});
|
||||
|
||||
console.log(' Creating test emails with various content types...');
|
||||
const emails = [
|
||||
new Email({
|
||||
from: 'sender@integrity.test',
|
||||
to: ['recipient1@integrity.test'],
|
||||
subject: 'Integrity Test - Plain Text',
|
||||
text: 'Plain text email for integrity testing'
|
||||
}),
|
||||
new Email({
|
||||
from: 'sender@integrity.test',
|
||||
to: ['recipient2@integrity.test'],
|
||||
subject: 'Integrity Test - HTML',
|
||||
html: '<h1>HTML Email</h1><p>Testing integrity with HTML content</p>',
|
||||
text: 'Testing integrity with HTML content'
|
||||
}),
|
||||
new Email({
|
||||
from: 'sender@integrity.test',
|
||||
to: ['recipient3@integrity.test'],
|
||||
subject: 'Integrity Test - Special Characters',
|
||||
text: 'Testing with special characters: ñáéíóú, 中文, العربية, русский'
|
||||
})
|
||||
];
|
||||
|
||||
console.log(' Sending emails rapidly to test integrity...');
|
||||
const sendPromises = [];
|
||||
|
||||
// Send each email multiple times
|
||||
for (let round = 0; round < 3; round++) {
|
||||
for (let i = 0; i < emails.length; i++) {
|
||||
sendPromises.push(
|
||||
smtpClient.sendMail(emails[i]).then(() => {
|
||||
console.log(` ✓ Round ${round + 1} Email ${i + 1} sent`);
|
||||
return { success: true, round, emailIndex: i };
|
||||
}).catch(error => {
|
||||
console.log(` ✗ Round ${round + 1} Email ${i + 1} failed: ${error.message}`);
|
||||
return { success: false, round, emailIndex: i, error };
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(sendPromises);
|
||||
const successful = results.filter(r => r.success).length;
|
||||
|
||||
// Wait for all messages to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
console.log(` Total emails sent: ${sendPromises.length}`);
|
||||
console.log(` Successful: ${successful}`);
|
||||
console.log(` Unique subjects received: ${receivedSubjects.size}`);
|
||||
console.log(` Expected unique subjects: 3`);
|
||||
console.log(` Received subjects: ${Array.from(receivedSubjects).join(', ')}`);
|
||||
|
||||
// With connection pooling and timing, we may not receive all unique subjects
|
||||
expect(receivedSubjects.size).toBeGreaterThanOrEqual(1);
|
||||
expect(successful).toBeGreaterThanOrEqual(sendPromises.length - 2);
|
||||
|
||||
smtpClient.close();
|
||||
|
||||
// Wait for connections to close
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-03: Test Summary', async () => {
|
||||
console.log('\n✅ CREL-03: Queue Persistence Reliability Tests completed');
|
||||
console.log('💾 All queue persistence scenarios tested successfully');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
@@ -1,520 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
tap.test('CREL-04: Basic Connection Recovery from Server Issues', async () => {
|
||||
console.log('\n💥 Testing SMTP Client Connection Recovery');
|
||||
console.log('=' .repeat(60));
|
||||
console.log('\n🔌 Testing recovery from connection drops...');
|
||||
|
||||
let connectionCount = 0;
|
||||
let dropConnections = false;
|
||||
|
||||
// Create test server that can simulate connection drops
|
||||
const server = net.createServer(socket => {
|
||||
connectionCount++;
|
||||
console.log(` [Server] Connection ${connectionCount} established`);
|
||||
|
||||
if (dropConnections && connectionCount > 2) {
|
||||
console.log(` [Server] Simulating connection drop for connection ${connectionCount}`);
|
||||
setTimeout(() => {
|
||||
socket.destroy();
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Creating SMTP client with connection recovery settings...');
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 2,
|
||||
maxMessages: 50,
|
||||
connectionTimeout: 2000
|
||||
});
|
||||
|
||||
const emails = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
emails.push(new Email({
|
||||
from: 'sender@crashtest.example',
|
||||
to: [`recipient${i}@crashtest.example`],
|
||||
subject: `Connection Recovery Test ${i + 1}`,
|
||||
text: `Testing connection recovery, email ${i + 1}`
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(' Phase 1: Sending initial emails (connections should succeed)...');
|
||||
const results1 = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
await smtpClient.sendMail(emails[i]);
|
||||
results1.push({ success: true, index: i });
|
||||
console.log(` ✓ Email ${i + 1} sent successfully`);
|
||||
} catch (error) {
|
||||
results1.push({ success: false, index: i, error });
|
||||
console.log(` ✗ Email ${i + 1} failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' Phase 2: Enabling connection drops...');
|
||||
dropConnections = true;
|
||||
|
||||
console.log(' Sending emails during connection instability...');
|
||||
const results2 = [];
|
||||
const promises = emails.slice(3).map((email, index) => {
|
||||
const actualIndex = index + 3;
|
||||
return smtpClient.sendMail(email).then(result => {
|
||||
console.log(` ✓ Email ${actualIndex + 1} recovered and sent`);
|
||||
return { success: true, index: actualIndex, result };
|
||||
}).catch(error => {
|
||||
console.log(` ✗ Email ${actualIndex + 1} failed permanently: ${error.message}`);
|
||||
return { success: false, index: actualIndex, error };
|
||||
});
|
||||
});
|
||||
|
||||
const results2Resolved = await Promise.all(promises);
|
||||
results2.push(...results2Resolved);
|
||||
|
||||
const totalSuccessful = [...results1, ...results2].filter(r => r.success).length;
|
||||
const totalFailed = [...results1, ...results2].filter(r => !r.success).length;
|
||||
|
||||
console.log(` Connection attempts: ${connectionCount}`);
|
||||
console.log(` Emails sent successfully: ${totalSuccessful}/${emails.length}`);
|
||||
console.log(` Failed emails: ${totalFailed}`);
|
||||
console.log(` Recovery effectiveness: ${((totalSuccessful / emails.length) * 100).toFixed(1)}%`);
|
||||
|
||||
expect(totalSuccessful).toBeGreaterThanOrEqual(3); // At least initial emails should succeed
|
||||
expect(connectionCount).toBeGreaterThanOrEqual(2); // Should have made multiple connection attempts
|
||||
|
||||
smtpClient.close();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-04: Recovery from Server Restart', async () => {
|
||||
console.log('\n💀 Testing recovery from server restart...');
|
||||
|
||||
// Start first server instance
|
||||
let server1 = net.createServer(socket => {
|
||||
console.log(' [Server1] Connection established');
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server1.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server1.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Creating client...');
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 1,
|
||||
connectionTimeout: 3000
|
||||
});
|
||||
|
||||
const emails = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
emails.push(new Email({
|
||||
from: 'sender@serverrestart.test',
|
||||
to: [`recipient${i}@serverrestart.test`],
|
||||
subject: `Server Restart Recovery ${i + 1}`,
|
||||
text: `Testing server restart recovery, email ${i + 1}`
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(' Sending first batch of emails...');
|
||||
await smtpClient.sendMail(emails[0]);
|
||||
console.log(' ✓ Email 1 sent successfully');
|
||||
|
||||
await smtpClient.sendMail(emails[1]);
|
||||
console.log(' ✓ Email 2 sent successfully');
|
||||
|
||||
console.log(' Simulating server restart by closing server...');
|
||||
server1.close();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
console.log(' Starting new server instance on same port...');
|
||||
const server2 = net.createServer(socket => {
|
||||
console.log(' [Server2] Connection established after restart');
|
||||
socket.write('220 localhost SMTP Test Server Restarted\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server2.listen(port, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
console.log(' Sending emails after server restart...');
|
||||
const recoveryResults = [];
|
||||
|
||||
for (let i = 2; i < emails.length; i++) {
|
||||
try {
|
||||
await smtpClient.sendMail(emails[i]);
|
||||
recoveryResults.push({ success: true, index: i });
|
||||
console.log(` ✓ Email ${i + 1} sent after server recovery`);
|
||||
} catch (error) {
|
||||
recoveryResults.push({ success: false, index: i, error });
|
||||
console.log(` ✗ Email ${i + 1} failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const successfulRecovery = recoveryResults.filter(r => r.success).length;
|
||||
const totalSuccessful = 2 + successfulRecovery; // 2 from before restart + recovery
|
||||
|
||||
console.log(` Pre-restart emails: 2/2 successful`);
|
||||
console.log(` Post-restart emails: ${successfulRecovery}/${recoveryResults.length} successful`);
|
||||
console.log(` Overall success rate: ${((totalSuccessful / emails.length) * 100).toFixed(1)}%`);
|
||||
console.log(` Server restart recovery: ${successfulRecovery > 0 ? 'Successful' : 'Failed'}`);
|
||||
|
||||
expect(successfulRecovery).toBeGreaterThanOrEqual(1); // At least some emails should work after restart
|
||||
|
||||
smtpClient.close();
|
||||
server2.close();
|
||||
} finally {
|
||||
// Ensure cleanup
|
||||
try {
|
||||
server1.close();
|
||||
} catch (e) { /* Already closed */ }
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-04: Error Recovery and State Management', async () => {
|
||||
console.log('\n⚠️ Testing error recovery and state management...');
|
||||
|
||||
let errorInjectionEnabled = false;
|
||||
const server = net.createServer(socket => {
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (errorInjectionEnabled && line.startsWith('MAIL FROM')) {
|
||||
console.log(' [Server] Injecting error response');
|
||||
socket.write('550 Simulated server error\r\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else if (line === 'RSET') {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Creating client with error handling...');
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 1,
|
||||
connectionTimeout: 3000
|
||||
});
|
||||
|
||||
const emails = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
emails.push(new Email({
|
||||
from: 'sender@exception.test',
|
||||
to: [`recipient${i}@exception.test`],
|
||||
subject: `Error Recovery Test ${i + 1}`,
|
||||
text: `Testing error recovery, email ${i + 1}`
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(' Phase 1: Sending emails normally...');
|
||||
await smtpClient.sendMail(emails[0]);
|
||||
console.log(' ✓ Email 1 sent successfully');
|
||||
|
||||
await smtpClient.sendMail(emails[1]);
|
||||
console.log(' ✓ Email 2 sent successfully');
|
||||
|
||||
console.log(' Phase 2: Enabling error injection...');
|
||||
errorInjectionEnabled = true;
|
||||
|
||||
console.log(' Sending emails with error injection...');
|
||||
const recoveryResults = [];
|
||||
|
||||
for (let i = 2; i < 4; i++) {
|
||||
try {
|
||||
await smtpClient.sendMail(emails[i]);
|
||||
recoveryResults.push({ success: true, index: i });
|
||||
console.log(` ✓ Email ${i + 1} sent despite errors`);
|
||||
} catch (error) {
|
||||
recoveryResults.push({ success: false, index: i, error });
|
||||
console.log(` ✗ Email ${i + 1} failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' Phase 3: Disabling error injection...');
|
||||
errorInjectionEnabled = false;
|
||||
|
||||
console.log(' Sending final emails (recovery validation)...');
|
||||
for (let i = 4; i < emails.length; i++) {
|
||||
try {
|
||||
await smtpClient.sendMail(emails[i]);
|
||||
recoveryResults.push({ success: true, index: i });
|
||||
console.log(` ✓ Email ${i + 1} sent after recovery`);
|
||||
} catch (error) {
|
||||
recoveryResults.push({ success: false, index: i, error });
|
||||
console.log(` ✗ Email ${i + 1} failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const successful = recoveryResults.filter(r => r.success).length;
|
||||
const totalSuccessful = 2 + successful; // 2 initial + recovery phase
|
||||
|
||||
console.log(` Pre-error emails: 2/2 successful`);
|
||||
console.log(` Error/recovery phase emails: ${successful}/${recoveryResults.length} successful`);
|
||||
console.log(` Total success rate: ${((totalSuccessful / emails.length) * 100).toFixed(1)}%`);
|
||||
console.log(` Error recovery: ${successful >= recoveryResults.length - 2 ? 'Effective' : 'Partial'}`);
|
||||
|
||||
expect(totalSuccessful).toBeGreaterThanOrEqual(4); // At least initial + some recovery
|
||||
|
||||
smtpClient.close();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-04: Resource Management During Issues', async () => {
|
||||
console.log('\n🧠 Testing resource management during connection issues...');
|
||||
|
||||
let memoryBefore = process.memoryUsage();
|
||||
|
||||
const server = net.createServer(socket => {
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Creating client for resource management test...');
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 5,
|
||||
maxMessages: 100
|
||||
});
|
||||
|
||||
console.log(' Creating emails with various content types...');
|
||||
const emails = [
|
||||
new Email({
|
||||
from: 'sender@resource.test',
|
||||
to: ['recipient1@resource.test'],
|
||||
subject: 'Resource Test - Normal',
|
||||
text: 'Normal email content'
|
||||
}),
|
||||
new Email({
|
||||
from: 'sender@resource.test',
|
||||
to: ['recipient2@resource.test'],
|
||||
subject: 'Resource Test - Large Content',
|
||||
text: 'X'.repeat(50000) // Large content
|
||||
}),
|
||||
new Email({
|
||||
from: 'sender@resource.test',
|
||||
to: ['recipient3@resource.test'],
|
||||
subject: 'Resource Test - Unicode',
|
||||
text: '🎭🎪🎨🎯🎲🎸🎺🎻🎼🎵🎶🎷'.repeat(100)
|
||||
})
|
||||
];
|
||||
|
||||
console.log(' Sending emails and monitoring resource usage...');
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < emails.length; i++) {
|
||||
console.log(` Testing email ${i + 1} (${emails[i].subject.split(' - ')[1]})...`);
|
||||
|
||||
try {
|
||||
// Monitor memory usage before sending
|
||||
const memBefore = process.memoryUsage();
|
||||
console.log(` Memory before: ${Math.round(memBefore.heapUsed / 1024 / 1024)}MB`);
|
||||
|
||||
await smtpClient.sendMail(emails[i]);
|
||||
|
||||
const memAfter = process.memoryUsage();
|
||||
console.log(` Memory after: ${Math.round(memAfter.heapUsed / 1024 / 1024)}MB`);
|
||||
|
||||
const memIncrease = memAfter.heapUsed - memBefore.heapUsed;
|
||||
console.log(` Memory increase: ${Math.round(memIncrease / 1024)}KB`);
|
||||
|
||||
results.push({
|
||||
success: true,
|
||||
index: i,
|
||||
memoryIncrease: memIncrease
|
||||
});
|
||||
console.log(` ✓ Email ${i + 1} sent successfully`);
|
||||
|
||||
} catch (error) {
|
||||
results.push({ success: false, index: i, error });
|
||||
console.log(` ✗ Email ${i + 1} failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const successful = results.filter(r => r.success).length;
|
||||
const totalMemoryIncrease = results.reduce((sum, r) => sum + (r.memoryIncrease || 0), 0);
|
||||
|
||||
console.log(` Resource management: ${successful}/${emails.length} emails processed`);
|
||||
console.log(` Total memory increase: ${Math.round(totalMemoryIncrease / 1024)}KB`);
|
||||
console.log(` Resource efficiency: ${((successful / emails.length) * 100).toFixed(1)}%`);
|
||||
|
||||
expect(successful).toBeGreaterThanOrEqual(2); // Most emails should succeed
|
||||
expect(totalMemoryIncrease).toBeLessThan(100 * 1024 * 1024); // Less than 100MB increase
|
||||
|
||||
smtpClient.close();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-04: Test Summary', async () => {
|
||||
console.log('\n✅ CREL-04: Crash Recovery Reliability Tests completed');
|
||||
console.log('💥 All connection recovery scenarios tested successfully');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
@@ -1,503 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
// Helper function to get memory usage
|
||||
const getMemoryUsage = () => {
|
||||
const usage = process.memoryUsage();
|
||||
return {
|
||||
heapUsed: Math.round(usage.heapUsed / 1024 / 1024 * 100) / 100, // MB
|
||||
heapTotal: Math.round(usage.heapTotal / 1024 / 1024 * 100) / 100, // MB
|
||||
external: Math.round(usage.external / 1024 / 1024 * 100) / 100, // MB
|
||||
rss: Math.round(usage.rss / 1024 / 1024 * 100) / 100 // MB
|
||||
};
|
||||
};
|
||||
|
||||
// Force garbage collection if available
|
||||
const forceGC = () => {
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
global.gc(); // Run twice for thoroughness
|
||||
}
|
||||
};
|
||||
|
||||
tap.test('CREL-05: Connection Pool Memory Management', async () => {
|
||||
console.log('\n🧠 Testing SMTP Client Memory Leak Prevention');
|
||||
console.log('=' .repeat(60));
|
||||
console.log('\n🏊 Testing connection pool memory management...');
|
||||
|
||||
// Create test server
|
||||
const server = net.createServer(socket => {
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
const initialMemory = getMemoryUsage();
|
||||
console.log(` Initial memory: ${initialMemory.heapUsed}MB heap, ${initialMemory.rss}MB RSS`);
|
||||
|
||||
console.log(' Phase 1: Creating and using multiple connection pools...');
|
||||
const memorySnapshots = [];
|
||||
|
||||
for (let poolIndex = 0; poolIndex < 5; poolIndex++) {
|
||||
console.log(` Creating connection pool ${poolIndex + 1}...`);
|
||||
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 3,
|
||||
maxMessages: 20,
|
||||
connectionTimeout: 1000
|
||||
});
|
||||
|
||||
// Send emails through this pool
|
||||
const emails = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
emails.push(new Email({
|
||||
from: `sender${poolIndex}@memoryleak.test`,
|
||||
to: [`recipient${i}@memoryleak.test`],
|
||||
subject: `Memory Pool Test ${poolIndex + 1}-${i + 1}`,
|
||||
text: `Testing memory management in pool ${poolIndex + 1}, email ${i + 1}`
|
||||
}));
|
||||
}
|
||||
|
||||
// Send emails concurrently
|
||||
const promises = emails.map((email, index) => {
|
||||
return smtpClient.sendMail(email).then(result => {
|
||||
return { success: true, result };
|
||||
}).catch(error => {
|
||||
return { success: false, error };
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const successful = results.filter(r => r.success).length;
|
||||
console.log(` Pool ${poolIndex + 1}: ${successful}/${emails.length} emails sent`);
|
||||
|
||||
// Close the pool
|
||||
smtpClient.close();
|
||||
console.log(` Pool ${poolIndex + 1} closed`);
|
||||
|
||||
// Force garbage collection and measure memory
|
||||
forceGC();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const currentMemory = getMemoryUsage();
|
||||
memorySnapshots.push({
|
||||
pool: poolIndex + 1,
|
||||
heap: currentMemory.heapUsed,
|
||||
rss: currentMemory.rss,
|
||||
external: currentMemory.external
|
||||
});
|
||||
|
||||
console.log(` Memory after pool ${poolIndex + 1}: ${currentMemory.heapUsed}MB heap`);
|
||||
}
|
||||
|
||||
console.log('\n Memory analysis:');
|
||||
memorySnapshots.forEach((snapshot, index) => {
|
||||
const memoryIncrease = snapshot.heap - initialMemory.heapUsed;
|
||||
console.log(` Pool ${snapshot.pool}: +${memoryIncrease.toFixed(2)}MB heap increase`);
|
||||
});
|
||||
|
||||
// Check for memory leaks (memory should not continuously increase)
|
||||
const firstIncrease = memorySnapshots[0].heap - initialMemory.heapUsed;
|
||||
const lastIncrease = memorySnapshots[memorySnapshots.length - 1].heap - initialMemory.heapUsed;
|
||||
const leakGrowth = lastIncrease - firstIncrease;
|
||||
|
||||
console.log(` Memory leak assessment:`);
|
||||
console.log(` First pool increase: +${firstIncrease.toFixed(2)}MB`);
|
||||
console.log(` Final memory increase: +${lastIncrease.toFixed(2)}MB`);
|
||||
console.log(` Memory growth across pools: +${leakGrowth.toFixed(2)}MB`);
|
||||
console.log(` Memory management: ${leakGrowth < 3.0 ? 'Good (< 3MB growth)' : 'Potential leak detected'}`);
|
||||
|
||||
expect(leakGrowth).toBeLessThan(5.0); // Allow some memory growth but detect major leaks
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-05: Email Object Memory Lifecycle', async () => {
|
||||
console.log('\n📧 Testing email object memory lifecycle...');
|
||||
|
||||
// Create test server
|
||||
const server = net.createServer(socket => {
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 2
|
||||
});
|
||||
|
||||
const initialMemory = getMemoryUsage();
|
||||
console.log(` Initial memory: ${initialMemory.heapUsed}MB heap`);
|
||||
|
||||
console.log(' Phase 1: Creating large batches of email objects...');
|
||||
const batchSizes = [50, 100, 150, 100, 50]; // Varying batch sizes
|
||||
const memorySnapshots = [];
|
||||
|
||||
for (let batchIndex = 0; batchIndex < batchSizes.length; batchIndex++) {
|
||||
const batchSize = batchSizes[batchIndex];
|
||||
console.log(` Creating batch ${batchIndex + 1} with ${batchSize} emails...`);
|
||||
|
||||
const emails = [];
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
emails.push(new Email({
|
||||
from: 'sender@emailmemory.test',
|
||||
to: [`recipient${i}@emailmemory.test`],
|
||||
subject: `Memory Lifecycle Test Batch ${batchIndex + 1} Email ${i + 1}`,
|
||||
text: `Testing email object memory lifecycle. This is a moderately long email body to test memory usage patterns. Email ${i + 1} in batch ${batchIndex + 1} of ${batchSize} emails.`,
|
||||
html: `<h1>Email ${i + 1}</h1><p>Testing memory patterns with HTML content. Batch ${batchIndex + 1}.</p>`
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(` Sending batch ${batchIndex + 1}...`);
|
||||
const promises = emails.map((email, index) => {
|
||||
return smtpClient.sendMail(email).then(result => {
|
||||
return { success: true };
|
||||
}).catch(error => {
|
||||
return { success: false, error };
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const successful = results.filter(r => r.success).length;
|
||||
console.log(` Batch ${batchIndex + 1}: ${successful}/${batchSize} emails sent`);
|
||||
|
||||
// Clear email references
|
||||
emails.length = 0;
|
||||
|
||||
// Force garbage collection
|
||||
forceGC();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const currentMemory = getMemoryUsage();
|
||||
memorySnapshots.push({
|
||||
batch: batchIndex + 1,
|
||||
size: batchSize,
|
||||
heap: currentMemory.heapUsed,
|
||||
external: currentMemory.external
|
||||
});
|
||||
|
||||
console.log(` Memory after batch ${batchIndex + 1}: ${currentMemory.heapUsed}MB heap`);
|
||||
}
|
||||
|
||||
console.log('\n Email object memory analysis:');
|
||||
memorySnapshots.forEach((snapshot, index) => {
|
||||
const memoryIncrease = snapshot.heap - initialMemory.heapUsed;
|
||||
console.log(` Batch ${snapshot.batch} (${snapshot.size} emails): +${memoryIncrease.toFixed(2)}MB`);
|
||||
});
|
||||
|
||||
// Check if memory scales reasonably with email batch size
|
||||
const maxMemoryIncrease = Math.max(...memorySnapshots.map(s => s.heap - initialMemory.heapUsed));
|
||||
const avgBatchSize = batchSizes.reduce((a, b) => a + b, 0) / batchSizes.length;
|
||||
|
||||
console.log(` Maximum memory increase: +${maxMemoryIncrease.toFixed(2)}MB`);
|
||||
console.log(` Average batch size: ${avgBatchSize} emails`);
|
||||
console.log(` Memory per email: ~${(maxMemoryIncrease / avgBatchSize * 1024).toFixed(1)}KB`);
|
||||
console.log(` Email object lifecycle: ${maxMemoryIncrease < 10 ? 'Efficient' : 'Needs optimization'}`);
|
||||
|
||||
expect(maxMemoryIncrease).toBeLessThan(15); // Allow reasonable memory usage
|
||||
|
||||
smtpClient.close();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-05: Long-Running Client Memory Stability', async () => {
|
||||
console.log('\n⏱️ Testing long-running client memory stability...');
|
||||
|
||||
// Create test server
|
||||
const server = net.createServer(socket => {
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 2,
|
||||
maxMessages: 1000
|
||||
});
|
||||
|
||||
const initialMemory = getMemoryUsage();
|
||||
console.log(` Initial memory: ${initialMemory.heapUsed}MB heap`);
|
||||
|
||||
console.log(' Starting sustained email sending operation...');
|
||||
const memoryMeasurements = [];
|
||||
const totalEmails = 100; // Reduced for test efficiency
|
||||
const measurementInterval = 20; // Measure every 20 emails
|
||||
|
||||
let emailsSent = 0;
|
||||
let emailsFailed = 0;
|
||||
|
||||
for (let i = 0; i < totalEmails; i++) {
|
||||
const email = new Email({
|
||||
from: 'sender@longrunning.test',
|
||||
to: [`recipient${i}@longrunning.test`],
|
||||
subject: `Long Running Test ${i + 1}`,
|
||||
text: `Sustained operation test email ${i + 1}`
|
||||
});
|
||||
|
||||
try {
|
||||
await smtpClient.sendMail(email);
|
||||
emailsSent++;
|
||||
} catch (error) {
|
||||
emailsFailed++;
|
||||
}
|
||||
|
||||
// Measure memory at intervals
|
||||
if ((i + 1) % measurementInterval === 0) {
|
||||
forceGC();
|
||||
const currentMemory = getMemoryUsage();
|
||||
memoryMeasurements.push({
|
||||
emailCount: i + 1,
|
||||
heap: currentMemory.heapUsed,
|
||||
rss: currentMemory.rss,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
console.log(` ${i + 1}/${totalEmails} emails: ${currentMemory.heapUsed}MB heap`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n Long-running memory analysis:');
|
||||
console.log(` Emails sent: ${emailsSent}, Failed: ${emailsFailed}`);
|
||||
|
||||
memoryMeasurements.forEach((measurement, index) => {
|
||||
const memoryIncrease = measurement.heap - initialMemory.heapUsed;
|
||||
console.log(` After ${measurement.emailCount} emails: +${memoryIncrease.toFixed(2)}MB heap`);
|
||||
});
|
||||
|
||||
// Analyze memory growth trend
|
||||
if (memoryMeasurements.length >= 2) {
|
||||
const firstMeasurement = memoryMeasurements[0];
|
||||
const lastMeasurement = memoryMeasurements[memoryMeasurements.length - 1];
|
||||
|
||||
const memoryGrowth = lastMeasurement.heap - firstMeasurement.heap;
|
||||
const emailsProcessed = lastMeasurement.emailCount - firstMeasurement.emailCount;
|
||||
const growthRate = (memoryGrowth / emailsProcessed) * 1000; // KB per email
|
||||
|
||||
console.log(` Memory growth over operation: +${memoryGrowth.toFixed(2)}MB`);
|
||||
console.log(` Growth rate: ~${growthRate.toFixed(2)}KB per email`);
|
||||
console.log(` Memory stability: ${growthRate < 10 ? 'Excellent' : growthRate < 25 ? 'Good' : 'Concerning'}`);
|
||||
|
||||
expect(growthRate).toBeLessThan(50); // Allow reasonable growth but detect major leaks
|
||||
}
|
||||
|
||||
expect(emailsSent).toBeGreaterThanOrEqual(totalEmails - 5); // Most emails should succeed
|
||||
|
||||
smtpClient.close();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-05: Large Content Memory Management', async () => {
|
||||
console.log('\n🌊 Testing large content memory management...');
|
||||
|
||||
// Create test server
|
||||
const server = net.createServer(socket => {
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 1
|
||||
});
|
||||
|
||||
const initialMemory = getMemoryUsage();
|
||||
console.log(` Initial memory: ${initialMemory.heapUsed}MB heap`);
|
||||
|
||||
console.log(' Testing with various content sizes...');
|
||||
const contentSizes = [
|
||||
{ size: 1024, name: '1KB' },
|
||||
{ size: 10240, name: '10KB' },
|
||||
{ size: 102400, name: '100KB' },
|
||||
{ size: 256000, name: '250KB' }
|
||||
];
|
||||
|
||||
for (const contentTest of contentSizes) {
|
||||
console.log(` Testing ${contentTest.name} content size...`);
|
||||
|
||||
const beforeMemory = getMemoryUsage();
|
||||
|
||||
// Create large text content
|
||||
const largeText = 'X'.repeat(contentTest.size);
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@largemem.test',
|
||||
to: ['recipient@largemem.test'],
|
||||
subject: `Large Content Test - ${contentTest.name}`,
|
||||
text: largeText
|
||||
});
|
||||
|
||||
try {
|
||||
await smtpClient.sendMail(email);
|
||||
console.log(` ✓ ${contentTest.name} email sent successfully`);
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${contentTest.name} email failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// Force cleanup
|
||||
forceGC();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const afterMemory = getMemoryUsage();
|
||||
const memoryDiff = afterMemory.heapUsed - beforeMemory.heapUsed;
|
||||
|
||||
console.log(` Memory impact: ${memoryDiff > 0 ? '+' : ''}${memoryDiff.toFixed(2)}MB`);
|
||||
console.log(` Efficiency: ${Math.abs(memoryDiff) < (contentTest.size / 1024 / 1024) * 2 ? 'Good' : 'High memory usage'}`);
|
||||
}
|
||||
|
||||
const finalMemory = getMemoryUsage();
|
||||
const totalMemoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed;
|
||||
|
||||
console.log(`\n Large content memory summary:`);
|
||||
console.log(` Total memory increase: +${totalMemoryIncrease.toFixed(2)}MB`);
|
||||
console.log(` Memory management efficiency: ${totalMemoryIncrease < 5 ? 'Excellent' : 'Needs optimization'}`);
|
||||
|
||||
expect(totalMemoryIncrease).toBeLessThan(20); // Allow reasonable memory usage for large content
|
||||
|
||||
smtpClient.close();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-05: Test Summary', async () => {
|
||||
console.log('\n✅ CREL-05: Memory Leak Prevention Reliability Tests completed');
|
||||
console.log('🧠 All memory management scenarios tested successfully');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
@@ -1,558 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
tap.test('CREL-06: Simultaneous Connection Management', async () => {
|
||||
console.log('\n⚡ Testing SMTP Client Concurrent Operation Safety');
|
||||
console.log('=' .repeat(60));
|
||||
console.log('\n🔗 Testing simultaneous connection management safety...');
|
||||
|
||||
let connectionCount = 0;
|
||||
let activeConnections = 0;
|
||||
const connectionLog: string[] = [];
|
||||
|
||||
// Create test server that tracks connections
|
||||
const server = net.createServer(socket => {
|
||||
connectionCount++;
|
||||
activeConnections++;
|
||||
const connId = `CONN-${connectionCount}`;
|
||||
connectionLog.push(`${new Date().toISOString()}: ${connId} OPENED (active: ${activeConnections})`);
|
||||
console.log(` [Server] ${connId} opened (total: ${connectionCount}, active: ${activeConnections})`);
|
||||
|
||||
socket.on('close', () => {
|
||||
activeConnections--;
|
||||
connectionLog.push(`${new Date().toISOString()}: ${connId} CLOSED (active: ${activeConnections})`);
|
||||
console.log(` [Server] ${connId} closed (active: ${activeConnections})`);
|
||||
});
|
||||
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Creating multiple SMTP clients with shared connection pool settings...');
|
||||
const clients = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
clients.push(createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 3, // Allow up to 3 connections
|
||||
maxMessages: 10,
|
||||
connectionTimeout: 2000
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(' Launching concurrent email sending operations...');
|
||||
const emailBatches = clients.map((client, clientIndex) => {
|
||||
return Array.from({ length: 8 }, (_, emailIndex) => {
|
||||
return new Email({
|
||||
from: `sender${clientIndex}@concurrent.test`,
|
||||
to: [`recipient${clientIndex}-${emailIndex}@concurrent.test`],
|
||||
subject: `Concurrent Safety Test Client ${clientIndex + 1} Email ${emailIndex + 1}`,
|
||||
text: `Testing concurrent operation safety from client ${clientIndex + 1}, email ${emailIndex + 1}`
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
const allPromises: Promise<any>[] = [];
|
||||
|
||||
// Launch all email operations simultaneously
|
||||
emailBatches.forEach((emails, clientIndex) => {
|
||||
emails.forEach((email, emailIndex) => {
|
||||
const promise = clients[clientIndex].sendMail(email).then(result => {
|
||||
console.log(` ✓ Client ${clientIndex + 1} Email ${emailIndex + 1} sent`);
|
||||
return { success: true, clientIndex, emailIndex, result };
|
||||
}).catch(error => {
|
||||
console.log(` ✗ Client ${clientIndex + 1} Email ${emailIndex + 1} failed: ${error.message}`);
|
||||
return { success: false, clientIndex, emailIndex, error };
|
||||
});
|
||||
allPromises.push(promise);
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.all(allPromises);
|
||||
const endTime = Date.now();
|
||||
|
||||
// Close all clients
|
||||
clients.forEach(client => client.close());
|
||||
|
||||
// Wait for connections to close
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const successful = results.filter(r => r.success).length;
|
||||
const failed = results.filter(r => !r.success).length;
|
||||
const totalEmails = emailBatches.flat().length;
|
||||
|
||||
console.log(`\n Concurrent operation results:`);
|
||||
console.log(` Total operations: ${totalEmails}`);
|
||||
console.log(` Successful: ${successful}, Failed: ${failed}`);
|
||||
console.log(` Success rate: ${((successful / totalEmails) * 100).toFixed(1)}%`);
|
||||
console.log(` Execution time: ${endTime - startTime}ms`);
|
||||
console.log(` Peak connections: ${Math.max(...connectionLog.map(log => {
|
||||
const match = log.match(/active: (\d+)/);
|
||||
return match ? parseInt(match[1]) : 0;
|
||||
}))}`);
|
||||
console.log(` Connection management: ${activeConnections === 0 ? 'Clean' : 'Connections remaining'}`);
|
||||
|
||||
expect(successful).toBeGreaterThanOrEqual(totalEmails - 5); // Allow some failures
|
||||
expect(activeConnections).toEqual(0); // All connections should be closed
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-06: Concurrent Queue Operations', async () => {
|
||||
console.log('\n🔒 Testing concurrent queue operations...');
|
||||
|
||||
let messageProcessingOrder: string[] = [];
|
||||
|
||||
// Create test server that tracks message processing order
|
||||
const server = net.createServer(socket => {
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
let inData = false;
|
||||
let currentData = '';
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (inData) {
|
||||
if (line === '.') {
|
||||
// Extract Message-ID from email data
|
||||
const messageIdMatch = currentData.match(/Message-ID:\s*<([^>]+)>/);
|
||||
if (messageIdMatch) {
|
||||
messageProcessingOrder.push(messageIdMatch[1]);
|
||||
console.log(` [Server] Processing: ${messageIdMatch[1]}`);
|
||||
}
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
inData = false;
|
||||
currentData = '';
|
||||
} else {
|
||||
currentData += line + '\r\n';
|
||||
}
|
||||
} else {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Creating SMTP client for concurrent queue operations...');
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 2,
|
||||
maxMessages: 50
|
||||
});
|
||||
|
||||
console.log(' Launching concurrent queue operations...');
|
||||
const operations: Promise<any>[] = [];
|
||||
const emailGroups = ['A', 'B', 'C', 'D'];
|
||||
|
||||
// Create concurrent operations that use the queue
|
||||
emailGroups.forEach((group, groupIndex) => {
|
||||
// Add multiple emails per group concurrently
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const email = new Email({
|
||||
from: `sender${group}@queuetest.example`,
|
||||
to: [`recipient${group}${i}@queuetest.example`],
|
||||
subject: `Queue Safety Test Group ${group} Email ${i + 1}`,
|
||||
text: `Testing queue safety for group ${group}, email ${i + 1}`
|
||||
});
|
||||
|
||||
const operation = smtpClient.sendMail(email).then(result => {
|
||||
return {
|
||||
success: true,
|
||||
group,
|
||||
index: i,
|
||||
messageId: result.messageId,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}).catch(error => {
|
||||
return {
|
||||
success: false,
|
||||
group,
|
||||
index: i,
|
||||
error: error.message
|
||||
};
|
||||
});
|
||||
|
||||
operations.push(operation);
|
||||
}
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
const results = await Promise.all(operations);
|
||||
const endTime = Date.now();
|
||||
|
||||
// Wait for all processing to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const successful = results.filter(r => r.success).length;
|
||||
const failed = results.filter(r => !r.success).length;
|
||||
|
||||
console.log(`\n Queue safety results:`);
|
||||
console.log(` Total queue operations: ${operations.length}`);
|
||||
console.log(` Successful: ${successful}, Failed: ${failed}`);
|
||||
console.log(` Success rate: ${((successful / operations.length) * 100).toFixed(1)}%`);
|
||||
console.log(` Processing time: ${endTime - startTime}ms`);
|
||||
|
||||
// Analyze processing order
|
||||
const groupCounts = emailGroups.reduce((acc, group) => {
|
||||
acc[group] = messageProcessingOrder.filter(id => id && id.includes(`${group}`)).length;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
console.log(` Processing distribution:`);
|
||||
Object.entries(groupCounts).forEach(([group, count]) => {
|
||||
console.log(` Group ${group}: ${count} emails processed`);
|
||||
});
|
||||
|
||||
const totalProcessed = Object.values(groupCounts).reduce((a, b) => a + b, 0);
|
||||
console.log(` Queue integrity: ${totalProcessed === successful ? 'Maintained' : 'Some messages lost'}`);
|
||||
|
||||
expect(successful).toBeGreaterThanOrEqual(operations.length - 2); // Allow minimal failures
|
||||
|
||||
smtpClient.close();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-06: Concurrent Error Handling', async () => {
|
||||
console.log('\n❌ Testing concurrent error handling safety...');
|
||||
|
||||
let errorInjectionPhase = false;
|
||||
let connectionAttempts = 0;
|
||||
|
||||
// Create test server that can inject errors
|
||||
const server = net.createServer(socket => {
|
||||
connectionAttempts++;
|
||||
console.log(` [Server] Connection attempt ${connectionAttempts}`);
|
||||
|
||||
if (errorInjectionPhase && Math.random() < 0.4) {
|
||||
console.log(` [Server] Injecting connection error ${connectionAttempts}`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (errorInjectionPhase && line.startsWith('MAIL FROM') && Math.random() < 0.3) {
|
||||
console.log(' [Server] Injecting SMTP error');
|
||||
socket.write('450 Temporary failure, please retry\r\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Creating multiple clients for concurrent error testing...');
|
||||
const clients = [];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
clients.push(createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 2,
|
||||
connectionTimeout: 3000
|
||||
}));
|
||||
}
|
||||
|
||||
const emails = [];
|
||||
for (let clientIndex = 0; clientIndex < clients.length; clientIndex++) {
|
||||
for (let emailIndex = 0; emailIndex < 5; emailIndex++) {
|
||||
emails.push({
|
||||
client: clients[clientIndex],
|
||||
email: new Email({
|
||||
from: `sender${clientIndex}@errortest.example`,
|
||||
to: [`recipient${clientIndex}-${emailIndex}@errortest.example`],
|
||||
subject: `Concurrent Error Test Client ${clientIndex + 1} Email ${emailIndex + 1}`,
|
||||
text: `Testing concurrent error handling ${clientIndex + 1}-${emailIndex + 1}`
|
||||
}),
|
||||
clientIndex,
|
||||
emailIndex
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' Phase 1: Normal operation...');
|
||||
const phase1Results = [];
|
||||
const phase1Emails = emails.slice(0, 8); // First 8 emails
|
||||
|
||||
const phase1Promises = phase1Emails.map(({ client, email, clientIndex, emailIndex }) => {
|
||||
return client.sendMail(email).then(result => {
|
||||
console.log(` ✓ Phase 1: Client ${clientIndex + 1} Email ${emailIndex + 1} sent`);
|
||||
return { success: true, phase: 1, clientIndex, emailIndex };
|
||||
}).catch(error => {
|
||||
console.log(` ✗ Phase 1: Client ${clientIndex + 1} Email ${emailIndex + 1} failed`);
|
||||
return { success: false, phase: 1, clientIndex, emailIndex, error: error.message };
|
||||
});
|
||||
});
|
||||
|
||||
const phase1Resolved = await Promise.all(phase1Promises);
|
||||
phase1Results.push(...phase1Resolved);
|
||||
|
||||
console.log(' Phase 2: Error injection enabled...');
|
||||
errorInjectionPhase = true;
|
||||
|
||||
const phase2Results = [];
|
||||
const phase2Emails = emails.slice(8); // Remaining emails
|
||||
|
||||
const phase2Promises = phase2Emails.map(({ client, email, clientIndex, emailIndex }) => {
|
||||
return client.sendMail(email).then(result => {
|
||||
console.log(` ✓ Phase 2: Client ${clientIndex + 1} Email ${emailIndex + 1} recovered`);
|
||||
return { success: true, phase: 2, clientIndex, emailIndex };
|
||||
}).catch(error => {
|
||||
console.log(` ✗ Phase 2: Client ${clientIndex + 1} Email ${emailIndex + 1} failed permanently`);
|
||||
return { success: false, phase: 2, clientIndex, emailIndex, error: error.message };
|
||||
});
|
||||
});
|
||||
|
||||
const phase2Resolved = await Promise.all(phase2Promises);
|
||||
phase2Results.push(...phase2Resolved);
|
||||
|
||||
// Close all clients
|
||||
clients.forEach(client => client.close());
|
||||
|
||||
const phase1Success = phase1Results.filter(r => r.success).length;
|
||||
const phase2Success = phase2Results.filter(r => r.success).length;
|
||||
const totalSuccess = phase1Success + phase2Success;
|
||||
const totalEmails = emails.length;
|
||||
|
||||
console.log(`\n Concurrent error handling results:`);
|
||||
console.log(` Phase 1 (normal): ${phase1Success}/${phase1Results.length} successful`);
|
||||
console.log(` Phase 2 (errors): ${phase2Success}/${phase2Results.length} successful`);
|
||||
console.log(` Overall success: ${totalSuccess}/${totalEmails} (${((totalSuccess / totalEmails) * 100).toFixed(1)}%)`);
|
||||
console.log(` Error resilience: ${phase2Success > 0 ? 'Good' : 'Poor'}`);
|
||||
console.log(` Concurrent error safety: ${phase1Success === phase1Results.length ? 'Maintained' : 'Some failures'}`);
|
||||
|
||||
expect(phase1Success).toBeGreaterThanOrEqual(phase1Results.length - 1); // Most should succeed
|
||||
expect(phase2Success).toBeGreaterThanOrEqual(1); // Some should succeed despite errors
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-06: Resource Contention Management', async () => {
|
||||
console.log('\n🏁 Testing resource contention management...');
|
||||
|
||||
// Create test server with limited capacity
|
||||
const server = net.createServer(socket => {
|
||||
console.log(' [Server] New connection established');
|
||||
|
||||
socket.write('220 localhost SMTP Test Server\r\n');
|
||||
|
||||
// Add some delay to simulate slow server
|
||||
socket.on('data', (data) => {
|
||||
setTimeout(() => {
|
||||
const lines = data.toString().split('\r\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('EHLO') || line.startsWith('HELO')) {
|
||||
socket.write('250-localhost\r\n');
|
||||
socket.write('250 SIZE 10485760\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');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK Message accepted\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}, 20); // Add 20ms delay to responses
|
||||
});
|
||||
});
|
||||
|
||||
server.maxConnections = 3; // Limit server connections
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
try {
|
||||
console.log(' Creating high-contention scenario with limited resources...');
|
||||
const clients = [];
|
||||
|
||||
// Create more clients than server can handle simultaneously
|
||||
for (let i = 0; i < 8; i++) {
|
||||
clients.push(createTestSmtpClient({
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
secure: false,
|
||||
maxConnections: 1, // Force contention
|
||||
maxMessages: 10,
|
||||
connectionTimeout: 3000
|
||||
}));
|
||||
}
|
||||
|
||||
const emails = [];
|
||||
clients.forEach((client, clientIndex) => {
|
||||
for (let emailIndex = 0; emailIndex < 4; emailIndex++) {
|
||||
emails.push({
|
||||
client,
|
||||
email: new Email({
|
||||
from: `sender${clientIndex}@contention.test`,
|
||||
to: [`recipient${clientIndex}-${emailIndex}@contention.test`],
|
||||
subject: `Resource Contention Test ${clientIndex + 1}-${emailIndex + 1}`,
|
||||
text: `Testing resource contention management ${clientIndex + 1}-${emailIndex + 1}`
|
||||
}),
|
||||
clientIndex,
|
||||
emailIndex
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(' Launching high-contention operations...');
|
||||
const startTime = Date.now();
|
||||
const promises = emails.map(({ client, email, clientIndex, emailIndex }) => {
|
||||
return client.sendMail(email).then(result => {
|
||||
console.log(` ✓ Client ${clientIndex + 1} Email ${emailIndex + 1} sent`);
|
||||
return {
|
||||
success: true,
|
||||
clientIndex,
|
||||
emailIndex,
|
||||
completionTime: Date.now() - startTime
|
||||
};
|
||||
}).catch(error => {
|
||||
console.log(` ✗ Client ${clientIndex + 1} Email ${emailIndex + 1} failed: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
clientIndex,
|
||||
emailIndex,
|
||||
error: error.message,
|
||||
completionTime: Date.now() - startTime
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const endTime = Date.now();
|
||||
|
||||
// Close all clients
|
||||
clients.forEach(client => client.close());
|
||||
|
||||
const successful = results.filter(r => r.success).length;
|
||||
const failed = results.filter(r => !r.success).length;
|
||||
const avgCompletionTime = results
|
||||
.filter(r => r.success)
|
||||
.reduce((sum, r) => sum + r.completionTime, 0) / successful || 0;
|
||||
|
||||
console.log(`\n Resource contention results:`);
|
||||
console.log(` Total operations: ${emails.length}`);
|
||||
console.log(` Successful: ${successful}, Failed: ${failed}`);
|
||||
console.log(` Success rate: ${((successful / emails.length) * 100).toFixed(1)}%`);
|
||||
console.log(` Total execution time: ${endTime - startTime}ms`);
|
||||
console.log(` Average completion time: ${avgCompletionTime.toFixed(0)}ms`);
|
||||
console.log(` Resource management: ${successful > emails.length * 0.8 ? 'Effective' : 'Needs improvement'}`);
|
||||
|
||||
expect(successful).toBeGreaterThanOrEqual(emails.length * 0.7); // At least 70% should succeed
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CREL-06: Test Summary', async () => {
|
||||
console.log('\n✅ CREL-06: Concurrent Operation Safety Reliability Tests completed');
|
||||
console.log('⚡ All concurrency safety scenarios tested successfully');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
@@ -1,52 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { createTestServer } from '../../helpers/server.loader.js';
|
||||
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||
|
||||
tap.test('CREL-07: Resource Cleanup Tests', async () => {
|
||||
console.log('\n🧹 Testing SMTP Client Resource Cleanup');
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
const testServer = await createTestServer({});
|
||||
|
||||
try {
|
||||
console.log('\nTest 1: Basic client creation and cleanup');
|
||||
|
||||
// Create a client
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port
|
||||
});
|
||||
console.log(' ✓ Client created');
|
||||
|
||||
// Verify connection
|
||||
try {
|
||||
const verifyResult = await smtpClient.verify();
|
||||
console.log(' ✓ Connection verified:', verifyResult);
|
||||
} catch (error) {
|
||||
console.log(' ⚠️ Verify failed:', error.message);
|
||||
}
|
||||
|
||||
// Close the client
|
||||
smtpClient.close();
|
||||
console.log(' ✓ Client closed');
|
||||
|
||||
console.log('\nTest 2: Multiple close calls');
|
||||
const testClient = createTestSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port
|
||||
});
|
||||
|
||||
// Close multiple times - should not throw
|
||||
testClient.close();
|
||||
testClient.close();
|
||||
testClient.close();
|
||||
console.log(' ✓ Multiple close calls handled safely');
|
||||
|
||||
console.log('\n✅ CREL-07: Resource cleanup tests completed');
|
||||
|
||||
} finally {
|
||||
testServer.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.start();
|
||||
@@ -1,283 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../../ts/mail/delivery/smtpclient/index.js';
|
||||
import type { SmtpClient } from '../../../ts/mail/delivery/smtpclient/smtp-client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
let testServer: ITestServer;
|
||||
let smtpClient: SmtpClient;
|
||||
|
||||
tap.test('setup - start SMTP server for RFC 5321 compliance tests', async () => {
|
||||
testServer = await startTestServer({
|
||||
port: 2590,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
|
||||
expect(testServer.port).toEqual(2590);
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §3.1 - Client MUST send EHLO/HELO first', async () => {
|
||||
smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
domain: 'client.example.com',
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
// verify() establishes connection and sends EHLO
|
||||
const isConnected = await smtpClient.verify();
|
||||
expect(isConnected).toBeTrue();
|
||||
|
||||
console.log('✅ RFC 5321 §3.1: Client sends EHLO as first command');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §3.2 - Client MUST use CRLF line endings', async () => {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'CRLF Test',
|
||||
text: 'Line 1\nLine 2\nLine 3' // LF only in input
|
||||
});
|
||||
|
||||
// Client should convert to CRLF for transmission
|
||||
const result = await smtpClient.sendMail(email);
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
console.log('✅ RFC 5321 §3.2: Client converts line endings to CRLF');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §4.1.1.1 - EHLO parameter MUST be valid domain', async () => {
|
||||
const domainClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
domain: 'valid-domain.example.com', // Valid domain format
|
||||
connectionTimeout: 5000
|
||||
});
|
||||
|
||||
const isConnected = await domainClient.verify();
|
||||
expect(isConnected).toBeTrue();
|
||||
|
||||
await domainClient.close();
|
||||
console.log('✅ RFC 5321 §4.1.1.1: EHLO uses valid domain name');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §4.1.1.2 - Client MUST handle HELO fallback', async () => {
|
||||
// Modern servers support EHLO, but client must be able to fall back
|
||||
const heloClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000
|
||||
});
|
||||
|
||||
const isConnected = await heloClient.verify();
|
||||
expect(isConnected).toBeTrue();
|
||||
|
||||
await heloClient.close();
|
||||
console.log('✅ RFC 5321 §4.1.1.2: Client supports HELO fallback capability');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §4.1.1.4 - MAIL FROM MUST use angle brackets', async () => {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'MAIL FROM Format Test',
|
||||
text: 'Testing MAIL FROM command format'
|
||||
});
|
||||
|
||||
// Client should format as MAIL FROM:<sender@example.com>
|
||||
const result = await smtpClient.sendMail(email);
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(result.envelope?.from).toEqual('sender@example.com');
|
||||
|
||||
console.log('✅ RFC 5321 §4.1.1.4: MAIL FROM uses angle bracket format');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §4.1.1.5 - RCPT TO MUST use angle brackets', async () => {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient1@example.com', 'recipient2@example.com'],
|
||||
subject: 'RCPT TO Format Test',
|
||||
text: 'Testing RCPT TO command format'
|
||||
});
|
||||
|
||||
// Client should format as RCPT TO:<recipient@example.com>
|
||||
const result = await smtpClient.sendMail(email);
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(result.acceptedRecipients.length).toEqual(2);
|
||||
|
||||
console.log('✅ RFC 5321 §4.1.1.5: RCPT TO uses angle bracket format');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §4.1.1.9 - DATA termination sequence', async () => {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'DATA Termination Test',
|
||||
text: 'This tests the <CRLF>.<CRLF> termination sequence'
|
||||
});
|
||||
|
||||
// Client MUST terminate DATA with <CRLF>.<CRLF>
|
||||
const result = await smtpClient.sendMail(email);
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
console.log('✅ RFC 5321 §4.1.1.9: DATA terminated with <CRLF>.<CRLF>');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §4.1.1.10 - QUIT command usage', async () => {
|
||||
// Create new client for clean test
|
||||
const quitClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000
|
||||
});
|
||||
|
||||
await quitClient.verify();
|
||||
|
||||
// Client SHOULD send QUIT before closing
|
||||
await quitClient.close();
|
||||
|
||||
console.log('✅ RFC 5321 §4.1.1.10: Client sends QUIT before closing');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §4.5.3.1.1 - Line length limit (998 chars)', async () => {
|
||||
// Create a line with 995 characters (leaving room for CRLF)
|
||||
const longLine = 'a'.repeat(995);
|
||||
|
||||
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('✅ RFC 5321 §4.5.3.1.1: Lines limited to 998 characters');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §4.5.3.1.2 - Dot stuffing implementation', async () => {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Dot Stuffing Test',
|
||||
text: '.This line starts with a dot\n..This has two dots\n...This has three'
|
||||
});
|
||||
|
||||
// Client MUST add extra dot to lines starting with dot
|
||||
const result = await smtpClient.sendMail(email);
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
console.log('✅ RFC 5321 §4.5.3.1.2: Dot stuffing implemented correctly');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §5.1 - Reply code handling', async () => {
|
||||
// Test various reply code scenarios
|
||||
const scenarios = [
|
||||
{
|
||||
email: new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Success Test',
|
||||
text: 'Should succeed'
|
||||
}),
|
||||
expectSuccess: true
|
||||
}
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const result = await smtpClient.sendMail(scenario.email);
|
||||
expect(result.success).toEqual(scenario.expectSuccess);
|
||||
}
|
||||
|
||||
console.log('✅ RFC 5321 §5.1: Client handles reply codes correctly');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §4.1.4 - Order of commands', async () => {
|
||||
// Commands must be in order: EHLO, MAIL, RCPT, DATA
|
||||
const orderClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000
|
||||
});
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Command Order Test',
|
||||
text: 'Testing proper command sequence'
|
||||
});
|
||||
|
||||
const result = await orderClient.sendMail(email);
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
|
||||
await orderClient.close();
|
||||
console.log('✅ RFC 5321 §4.1.4: Commands sent in correct order');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §4.2.1 - Reply code categories', async () => {
|
||||
// Client must understand reply code categories:
|
||||
// 2xx = Success
|
||||
// 3xx = Intermediate
|
||||
// 4xx = Temporary failure
|
||||
// 5xx = Permanent failure
|
||||
|
||||
console.log('✅ RFC 5321 §4.2.1: Client understands reply code categories');
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §4.1.1.4 - Null reverse-path handling', async () => {
|
||||
// Test bounce message with null sender
|
||||
try {
|
||||
const bounceEmail = new Email({
|
||||
from: '<>', // Null reverse-path
|
||||
to: 'postmaster@example.com',
|
||||
subject: 'Bounce Message',
|
||||
text: 'This is a bounce notification'
|
||||
});
|
||||
|
||||
await smtpClient.sendMail(bounceEmail);
|
||||
console.log('✅ RFC 5321 §4.1.1.4: Null reverse-path handled');
|
||||
} catch (error) {
|
||||
// Email class might reject empty from
|
||||
console.log('ℹ️ Email class enforces non-empty sender');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CRFC-01: RFC 5321 §2.3.5 - Domain literals', async () => {
|
||||
// Test IP address literal
|
||||
try {
|
||||
const email = new Email({
|
||||
from: 'sender@[127.0.0.1]',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Domain Literal Test',
|
||||
text: 'Testing IP literal in email address'
|
||||
});
|
||||
|
||||
await smtpClient.sendMail(email);
|
||||
console.log('✅ RFC 5321 §2.3.5: Domain literals supported');
|
||||
} catch (error) {
|
||||
console.log('ℹ️ Domain literals not supported by Email class');
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -1,77 +0,0 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createTestServer } from '../../helpers/server.loader.js';
|
||||
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
|
||||
tap.test('CRFC-02: Basic ESMTP Compliance', async () => {
|
||||
console.log('\n📧 Testing SMTP Client ESMTP Compliance');
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
const testServer = await createTestServer({});
|
||||
|
||||
try {
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port
|
||||
});
|
||||
|
||||
console.log('\nTest 1: Basic EHLO negotiation');
|
||||
const email1 = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'ESMTP test',
|
||||
text: 'Testing ESMTP'
|
||||
});
|
||||
|
||||
const result1 = await smtpClient.sendMail(email1);
|
||||
console.log(' ✓ EHLO negotiation successful');
|
||||
expect(result1).toBeDefined();
|
||||
|
||||
console.log('\nTest 2: Multiple recipients');
|
||||
const email2 = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient1@example.com', 'recipient2@example.com'],
|
||||
cc: ['cc@example.com'],
|
||||
bcc: ['bcc@example.com'],
|
||||
subject: 'Multiple recipients',
|
||||
text: 'Testing multiple recipients'
|
||||
});
|
||||
|
||||
const result2 = await smtpClient.sendMail(email2);
|
||||
console.log(' ✓ Multiple recipients handled');
|
||||
expect(result2).toBeDefined();
|
||||
|
||||
console.log('\nTest 3: UTF-8 content');
|
||||
const email3 = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'UTF-8: café ☕ 测试',
|
||||
text: 'International text: émojis 🎉, 日本語',
|
||||
html: '<p>HTML: <strong>Zürich</strong></p>'
|
||||
});
|
||||
|
||||
const result3 = await smtpClient.sendMail(email3);
|
||||
console.log(' ✓ UTF-8 content accepted');
|
||||
expect(result3).toBeDefined();
|
||||
|
||||
console.log('\nTest 4: Long headers');
|
||||
const longSubject = 'This is a very long subject line that exceeds 78 characters and should be properly folded according to RFC 2822';
|
||||
const email4 = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: longSubject,
|
||||
text: 'Testing header folding'
|
||||
});
|
||||
|
||||
const result4 = await smtpClient.sendMail(email4);
|
||||
console.log(' ✓ Long headers handled');
|
||||
expect(result4).toBeDefined();
|
||||
|
||||
console.log('\n✅ CRFC-02: ESMTP compliance tests completed');
|
||||
|
||||
} finally {
|
||||
testServer.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.start();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user